diff --git a/.github/workflows/contract-tests.yml b/.github/workflows/contract-tests.yml index fb5c5b4..7aebfa8 100644 --- a/.github/workflows/contract-tests.yml +++ b/.github/workflows/contract-tests.yml @@ -42,12 +42,17 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.13" + - uses: actions/setup-node@v4 + with: + node-version: "24" - name: Check Rust formatting run: cargo fmt --all --check - name: Run Rust unit tests run: cargo test --locked - name: Run control-server session contracts run: npm test --prefix control-server + - name: Run independent logger contracts + run: cargo test --locked -p multiagent-logger - name: Install terminal-client dependencies run: npm ci --prefix client - name: Run terminal-client contracts diff --git a/AGENTS.md b/AGENTS.md index 436ac42..6625b8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,3 +31,17 @@ In particular: Pull requests that change an architecture boundary must update `docs/architecture/system-architecture.md` in the same pull request. + +## Deferred Work Tracking + +`docs/TODO.md` is the canonical repository backlog. When a change identifies +accepted but unfinished work, add a concrete unchecked item there in the same +pull request. Do not leave the only record of deferred work in review comments, +commit messages, or scattered documentation. + +A TODO must state the missing outcome and enough completion evidence to remove +it. Do not add TODOs for work completed by the current change, and do not remove +or mark an item complete until its implementation, applicable deployment +integration, and relevant tests or operational evidence exist. A TODO never +substitutes for an architecture decision: changes to ownership or trust +boundaries must still update `docs/architecture/system-architecture.md`. diff --git a/Cargo.lock b/Cargo.lock index 92eef74..ead788d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "android_system_properties" @@ -11,12 +11,88 @@ dependencies = [ "libc", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + [[package]] name = "block-buffer" version = "0.10.4" @@ -32,6 +108,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cc" version = "1.3.0" @@ -48,6 +130,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -56,9 +155,16 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", + "serde", "windows-link", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -74,6 +180,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -84,6 +199,44 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -94,12 +247,78 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fs2" version = "0.4.3" @@ -110,6 +329,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.33" @@ -145,186 +373,826 @@ dependencies = [ ] [[package]] -name = "iana-time-zone" -version = "0.1.65" +name = "getrandom" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", + "cfg-if", "js-sys", - "log", + "libc", + "wasi", "wasm-bindgen", - "windows-core", ] [[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ - "cc", + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "http" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] [[package]] -name = "js-sys" -version = "0.3.103" +name = "http-body" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", + "bytes", + "http", ] [[package]] -name = "libc" -version = "0.2.189" +name = "http-body-util" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] [[package]] -name = "log" -version = "0.4.33" +name = "httparse" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "memchr" -version = "2.8.3" +name = "httpdate" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "multiagent" -version = "0.1.0" +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ - "chrono", - "fs2", - "libc", - "serde", - "serde_json", - "sha2", + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "hyper-rustls" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "autocfg", + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "hyper-util" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "iana-time-zone" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "unicode-ident", + "cc", ] [[package]] -name = "quote" -version = "1.0.47" +name = "icu_collections" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ - "proc-macro2", + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", ] [[package]] -name = "rustversion" -version = "1.0.23" +name = "icu_locale_core" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] [[package]] -name = "serde" -version = "1.0.229" +name = "icu_normalizer" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ - "serde_core", - "serde_derive", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", ] [[package]] -name = "serde_core" -version = "1.0.229" +name = "icu_normalizer_data" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ - "serde_derive", + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", ] [[package]] -name = "serde_derive" -version = "1.0.229" +name = "icu_properties_data" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] -name = "serde_json" -version = "1.0.151" +name = "idna" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "idna_adapter", + "smallvec", + "utf8_iter", ] [[package]] -name = "sha2" -version = "0.10.9" +name = "idna_adapter" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "icu_normalizer", + "icu_properties", ] [[package]] -name = "shlex" -version = "2.0.1" +name = "ipnet" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiagent" +version = "0.1.0" +dependencies = [ + "chrono", + "fs2", + "libc", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "multiagent-logger" +version = "0.1.0" +dependencies = [ + "axum", + "base64", + "chrono", + "ed25519-dalek", + "libc", + "reqwest", + "serde", + "serde_json", + "sha2", + "subtle", + "tempfile", + "tokio", + "tower", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" @@ -348,6 +1216,193 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -360,12 +1415,51 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -379,6 +1473,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.126" @@ -411,6 +1515,35 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -492,6 +1625,177 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index cd47ed9..aa7c5f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,4 @@ [workspace] -members = ["runtime"] +members = ["runtime", "logger"] default-members = ["runtime"] resolver = "2" diff --git a/README.md b/README.md index fe48e2a..42bd724 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,10 @@ plane. - `client/` contains the independently distributed terminal client. - `control-server/` contains the authenticated thread gateway. - `runtime/` contains the Rust session runtime and supervisor package. -- `audit-log/` reserves the independent audit-service boundary for the next - implementation phase; phase 1 contains no audit-service behavior. +- `logger/` contains the independently deployed, append-only-file single writer + for the authoritative structural audit chain, signed periodic checkpoints, + and integrity verification. It records evidence but does not control workflow + progress. - `docker/` contains component image definitions and container entrypoints. - `gitops/` documents the deployment integration boundary. Production GitOps resources remain owned by the separate `InternalServices` repository. diff --git a/audit-log/README.md b/audit-log/README.md deleted file mode 100644 index d12f9b0..0000000 --- a/audit-log/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Audit log service - -This directory reserves an independent component boundary for the audit log -service planned for phase 2. - -Phase 1 contains no audit-service executable, storage implementation, network -API, identity, signing authority, or image pipeline. Those behaviors require a -separate architecture decision and will be implemented only after the -repository-layout pull request is merged. diff --git a/contracts/logger-event-v1.schema.json b/contracts/logger-event-v1.schema.json new file mode 100644 index 0000000..fd460d1 --- /dev/null +++ b/contracts/logger-event-v1.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://multiagent.dev/contracts/logger-event-v1.schema.json", + "title": "LoggerEventV1", + "type": "object", + "additionalProperties": false, + "required": ["eventId", "sessionId", "eventType", "payloadDigest", "artifactReferences"], + "properties": { + "eventId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"}, + "sessionId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"}, + "eventType": {"type": "string", "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+){0,15}$"}, + "payloadDigest": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}, + "artifactReferences": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["uri"], + "properties": { + "uri": {"type": "string", "minLength": 1, "maxLength": 2048, "pattern": "^[^\\r\\n?#]+$"}, + "digest": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}, + "size": {"type": "integer", "minimum": 0, "maximum": 9007199254740991}, + "mediaType": {"type": "string", "minLength": 1, "maxLength": 255} + } + } + } + } +} diff --git a/control-server/src/server.mjs b/control-server/src/server.mjs index ec74d49..6476ec3 100644 --- a/control-server/src/server.mjs +++ b/control-server/src/server.mjs @@ -258,6 +258,7 @@ function workflowCompletionRoute(id) { if (result.startsWith("direct-response:")) return "direct-response"; if (result.startsWith("read-only:")) return "read-only"; if (result.startsWith("external-only:")) return "external-only"; + if (result.startsWith("human-review:")) return "human-review"; return result ? "source" : null; } diff --git a/control-server/src/session-runtime.mjs b/control-server/src/session-runtime.mjs index 1f74660..1c2a74f 100644 --- a/control-server/src/session-runtime.mjs +++ b/control-server/src/session-runtime.mjs @@ -60,7 +60,7 @@ export function selectFinalMessage(result, fallback) { } export function responseTypeForMessage(message, completionRoute = "") { - if (completionRoute !== "direct-response") return "assistant_message"; + if (!["direct-response", "human-review"].includes(completionRoute)) return "assistant_message"; const text = String(message || "").trim(); const questions = [...text].filter((character) => character === "?" || character === "?").length; const tail = text.replace(/[\s*_`"')\]]+$/g, ""); @@ -87,7 +87,7 @@ export function normalizeWorkerReport(value) { if (Buffer.byteLength(JSON.stringify(transcript), "utf8") > 64 * 1024) return null; const message = typeof value.message === "string" && value.message.trim() ? value.message.trim() : null; if (message && Buffer.byteLength(message, "utf8") > 6000) return null; - const completionRoute = new Set(["direct-response", "read-only", "external-only", "source"]) + const completionRoute = new Set(["direct-response", "read-only", "external-only", "human-review", "source"]) .has(value.completionRoute) ? value.completionRoute : null; return { report: value.report, diff --git a/docker/README.md b/docker/README.md index 8ba6124..431ef18 100644 --- a/docker/README.md +++ b/docker/README.md @@ -12,3 +12,15 @@ The repository root remains the build context so the image can consume the runtime package, control server, portable framework assets, and shared contracts. Environment-specific deployment configuration is intentionally not part of these image definitions. + +Build the independently isolated Logger image from the same repository +root with: + +```bash +docker build -f docker/logger/Dockerfile -t multiagent-logger:local . +``` + +The Logger image contains only the Rust `logger` package. It runs as UID +10020, uses a dedicated volume at `/var/lib/logger`, and expects its +Ed25519 signing key and producer-client authorization file to be mounted by the +deployment. It contains neither the session runtime nor the control server. diff --git a/docker/logger/Dockerfile b/docker/logger/Dockerfile new file mode 100644 index 0000000..0d9339a --- /dev/null +++ b/docker/logger/Dockerfile @@ -0,0 +1,22 @@ +FROM rust:1.98-bookworm AS builder +WORKDIR /src +COPY Cargo.toml Cargo.lock ./ +COPY runtime runtime +COPY logger logger +RUN cargo build --locked --release -p multiagent-logger + +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 10020 logger \ + && useradd --no-create-home --uid 10020 --gid 10020 logger \ + && mkdir -p /var/lib/logger \ + && chown 10020:10020 /var/lib/logger +COPY --from=builder /src/target/release/logger /usr/local/bin/logger +USER 10020:10020 +ENV LOGGER_LEDGER_FILE=/var/lib/logger/ledger.jsonl PORT=8090 +EXPOSE 8090 +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD ["curl", "--fail", "--silent", "http://127.0.0.1:8090/readyz"] +ENTRYPOINT ["/usr/local/bin/logger", "serve"] diff --git a/docker/logger/Dockerfile.dockerignore b/docker/logger/Dockerfile.dockerignore new file mode 100644 index 0000000..8e85603 --- /dev/null +++ b/docker/logger/Dockerfile.dockerignore @@ -0,0 +1,6 @@ +.git +.worktrees +target +**/node_modules +**/*.jsonl +**/*.pem diff --git a/docker/runtime/Dockerfile b/docker/runtime/Dockerfile index fbdebc4..d3e2878 100644 --- a/docker/runtime/Dockerfile +++ b/docker/runtime/Dockerfile @@ -31,6 +31,7 @@ RUN install -m 0755 docker/runtime/container-entrypoint.sh /opt/multiagent/bin/c && useradd --no-create-home --uid 10003 --gid 10001 multiagent-reader \ && useradd --no-create-home --uid 10004 --gid 10001 --groups 10004 multiagent-supervisor \ && useradd --no-create-home --uid 10005 --gid 10001 multiagent-ops \ + && useradd --no-create-home --uid 10007 --gid 10001 multiagent-reviewer \ && chown root:multiagent-role /opt/multiagent/bin/multiagent \ && chmod 4755 /opt/multiagent/bin/multiagent \ && ln -s /opt/multiagent/bin/multiagent /usr/local/bin/multiagent \ @@ -38,11 +39,13 @@ RUN install -m 0755 docker/runtime/container-entrypoint.sh /opt/multiagent/bin/c /var/lib/multiagent/role-homes/orchestrator \ /var/lib/multiagent/role-homes/writer \ /var/lib/multiagent/role-homes/reader \ + /var/lib/multiagent/role-homes/reviewer \ /var/lib/multiagent/role-homes/supervisor \ /var/lib/multiagent/role-homes/ops \ && chown -R 10001:10001 /var/lib/multiagent/repositories /var/lib/multiagent/role-homes/orchestrator \ && chown -R 10002:10001 /var/lib/multiagent/role-homes/writer \ && chown -R 10003:10001 /var/lib/multiagent/role-homes/reader \ + && chown -R 10007:10001 /var/lib/multiagent/role-homes/reviewer \ && chown -R 10004:10001 /var/lib/multiagent/role-homes/supervisor \ && chown -R 10005:10001 /var/lib/multiagent/role-homes/ops \ && chmod 0700 /var/lib/multiagent/role-homes/* diff --git a/TODO.md b/docs/TODO.md similarity index 74% rename from TODO.md rename to docs/TODO.md index db32182..41051dd 100644 --- a/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,47 @@ # Project TODO +This is the canonical backlog for deferred repository work. Architecture +decisions remain in `docs/architecture/system-architecture.md`; this file tracks +work that is accepted by those decisions but not yet implemented or proven. + +Add newly discovered deferred work here in the same pull request that identifies +it. Keep an item open until its implementation, deployment integration when +applicable, and relevant evidence are complete. + +## Architecture Target-State Work + +- [ ] Add stale runtime cleanup, artifact materialization, and child-process + reaping around the file-backed thread manifest and existing S3 trace + lifecycle. +- [ ] Separate gateway and session S3 identities or add independently verified + manifest integrity before enabling automatic S3 bootstrap. +- [ ] Harden filesystem operations against descriptor-relative path and race + attacks where pathname policy is insufficient. +- [ ] Complete cross-account Route53, ACM validation, load balancer routing, + and health verification in deployment code. +- [ ] Move from shared contract fixtures toward one canonical generated or + machine-validated permit schema. +- [ ] Strengthen deterministic assignment/result binding and immutable + pre-execution evidence. +- [ ] Enforce provider-native tool capability restrictions where + operating-system confinement cannot express the required boundary. +- [ ] Define retention, redaction, migration, and replay policy for historical + S3 evidence. +- [ ] Complete Logger storage rollback detection: publish signed checkpoints to + an independently retained witness, allow verification against the last + trusted checkpoint, require scheduled checkpoints during replay, and add + restart tests for newline-aligned tail truncation and missing checkpoints. + This does not require producer-assigned sequence numbers; the Logger remains + the sole sequencer. +- [ ] Integrate producer outboxes or a deployment-owned durable queue so Logger + delivery retries independently and backlog alerts are testable. +- [ ] Add deployment-owned Loki/OpenTelemetry projections if operational demand + justifies them; these must remain derived from the authoritative ledger. + +Security-specific deferred work that predates this canonical backlog remains +detailed in [Known Security TODOs](known-security-todos.md). New security TODOs +must also be represented or linked from this file. + ## Production MCP Joint Test - [x] Activate the existing Linux UID and Landlock boundary in the production container. diff --git a/docs/architecture/system-architecture.md b/docs/architecture/system-architecture.md index acb2629..9193117 100644 --- a/docs/architecture/system-architecture.md +++ b/docs/architecture/system-architecture.md @@ -55,14 +55,21 @@ Multiagent session runtime | | | Trace collector sidecar ------------------+ | +-------------------------------------------|---+ + +-----------> S3 + | v - S3 + Logger service + ^ + | +Control server / supervisor / reviewers ------+ Supervisor -- bearer token + KMS-signed permit --> prod-mcp | +--> Grafana/Loki +--> Kubernetes +--> AWS services/accounts + | + +--> Logger service InternalServices owns all Kubernetes, IAM, KMS, secret, ingress, DNS, and storage configuration shown above. @@ -77,9 +84,10 @@ storage configuration shown above. | Supervisor | One session's authority, role bootstrap, role confinement, privileged-request mediation, KMS signing | Service-specific operational procedures | | Orchestrator | Goal decomposition, role routing, workflow coordination | Grafana/Loki knowledge, concrete production operations, `prod-mcp` parameters, provider-specific prompts | | Ops agent | Reading a selected Markdown runbook, planning and requesting its steps, reporting evidence | Deployment secrets, KMS private authority, infrastructure provisioning | -| Ops reviewer | Comparing the proposed or observed operation with the authorized goal and runbook | Production credentials, independent execution authority | +| Ops reviewer | Independently reconstructing evidence and comparing the proposed or observed operation with the authorized goal and runbook | Production credentials, mutating production authority, or authority to widen the reviewed scope | | Other role agents | Their assigned reasoning or implementation role | Supervisor authority and unrelated role capabilities | | `prod-mcp` | Authentication verification, signed-permit validation, operation schemas, target allowlists, execution, receipts | Multiagent workflow orchestration and model-provider behavior | +| Logger | Authenticated structural event ingestion, authoritative ordering, canonical encoding, hash-chain construction, replay prevention, signed periodic checkpoints, ledger verification, and non-authoritative audit projections | Semantic review, workflow progression, runbook interpretation, model credentials, production credentials, permit issuance, or production execution | | `InternalServices` | Images, deployments, secrets, IAM, KMS, service accounts, endpoints, ingress, DNS, certificates, S3 trace export, and distribution of deployment-specific Markdown runbook artifacts | Agent reasoning, procedure logic embedded in deployment code, and environment-specific secrets inside runbooks | | Markdown runbooks | Human-readable operational procedure, operation version, allowed phase progression | Credentials and environment-specific secrets | @@ -96,9 +104,9 @@ top-level ownership boundaries: - `control-server/` owns the authenticated control gateway package. - `runtime/` owns the Rust session runtime, supervisor, and role-confinement package. -- `audit-log/` is reserved for the independent audit service planned for a - later architecture and implementation phase. Its presence in the phase-one - layout grants it no authority and changes no trace behavior. +- `logger/` owns the independent single-writer Logger executable, + canonical event contract, append-only JSONL ledger, integrity checks, and + producer client utilities. - `docker/` owns component image definitions and container entrypoints, but not deployment secrets or environment-specific configuration. - `gitops/` documents the application-to-deployment contract. Concrete GitOps @@ -336,10 +344,32 @@ the authenticated user's goal, the selected runbook, the current runbook phase, and collected evidence. The reviewer does not replace deterministic policy in `prod-mcp` and cannot expand an operation beyond the server allowlist. +The reviewer is not limited to evidence selected by the proposing agent. It +may read all session traces and immutable artifacts and may request fresh, +bounded read-only evidence through `prod-mcp`. Reviewer evidence requests use +the same supervisor-mediated ops execution command and `prod-mcp` +`operations_execute` surface as other operations; there is no parallel +evidence-read authority. The reviewer-specific operating-system identity is +preserved across the supervisor boundary. Before signing, the supervisor +mechanically binds the request to the same session, task, goal, runbook, and +target, requires the delegated observer subject to be the live reviewer, and +requires `prod-mcp` to advertise `access=read` and `mutation=false`. The +reviewer never receives transport credentials or KMS authority, and the shared +path cannot issue a mutating permit for a reviewer. + Reviewer approval is bound to the task, intent, history, runbook content, runbook context, operation, parameters, target, actor, and expiry through digests in the permit. +If independent reconstruction cannot establish that the next action is within +the authorized contract, the system uses a Simplex-style fallback: it issues no +next operation permit, persists a supervisor-verified human-review request, +ends the execution session in `human-review-required` state, and asks the user +one bounded question. This is the same terminal authority pattern used when a +decision-authority review detects a user-owned scope or risk choice. A later +user answer starts a new execution session; model prose alone cannot clear the +pending human boundary in the completed session. + ### AD-007: `prod-mcp` is the production execution boundary One centrally deployed `prod-mcp` serves the supported accounts. It executes @@ -440,6 +470,59 @@ retry, object naming, and status reporting. Trace export failures must be observable without preventing the session from retaining local evidence until the configured retention limit. +Bulk traces continue to use this path rather than passing through the Logger. +After a successful export, the sidecar submits a bounded +`trace.artifact_exported` event containing the artifact digest, storage +reference, size, and media type. The Logger commits the reference and +digest but does not fetch, interpret, or proxy the trace body. + +### AD-018: One independently isolated Logger advances authoritative audit history + +The Logger is a long-lived service in a security domain separate from +the control server, session runtimes, reviewers, trace sidecars, and +`prod-mcp`. Many authenticated producers may submit structural audit events, +but only the Logger assigns a sequence number and previous hash, appends +an entry, and advances the authoritative per-session chain head. + +For each append, the service authenticates the producer, authorizes the event +type and session, validates a bounded schema, rejects conflicting event-ID +replays, serializes each append as one canonical JSONL record, fsyncs it before +acknowledgement, hashes the entry, and then advances the in-memory head. Exact +idempotent replay is a no-op. The HTTP append endpoint returns `204 No Content`; +this is transport acknowledgement, not evidence that authorizes workflow progress. Periodic +signed checkpoints commit the current chain head. Startup and explicit +verification recompute the chain and verify checkpoint signatures; an +integrity failure makes the service unready and prevents further authoritative +appends. + +The append-only ledger file and its dedicated volume are internal implementation +details of the single writer, not a database or shared organizational storage. +The service takes an exclusive process lock, rejects truncated or non-canonical +records during startup replay, and rebuilds read indexes in memory. Deployment +must run at most one active writer for a ledger volume. A cold standby has no +write authority until deployment fencing transfers ownership. Producers may +call the append API but cannot update or delete entries, choose the chain head, +or read the logger signing key. + +The Logger signing identity, producer credentials, volume, network +policy, backups, retention, and concrete endpoints are deployment-owned. The +service receives no model, supervisor KMS, `prod-mcp`, Grafana, Kubernetes, or +repository credentials and cannot issue permits or perform production work. +Its verification is structural and cryptographic; independent reviewers retain +ownership of semantic correctness and scope review. + +Authoritative appends complete before derived exports. Optional JSONL +projections are non-authoritative and are rebuilt atomically from the ledger; +their outage must not invalidate or block a committed append. Producers retain +and retry undelivered events through a local outbox or deployment-owned durable +queue, and delivery backlog is observable. Neither logger availability, append +acknowledgement, nor a checkpoint grants or denies a workflow transition. The +supervisor remains the sole workflow authority, and independent reviewers use +the logger's read interface, original traces, and read-only `prod-mcp` evidence +to reconstruct and evaluate behavior. A missing or inconsistent audit event is +review evidence that may cause the supervisor to request human review; the +Logger itself does not perform that semantic decision. + ### AD-012: Public ingress is deployment-managed The control server is reached through a deployment-managed reverse proxy and @@ -480,8 +563,11 @@ Quality gates are not optional routing hints. The supervisor derives and stores review obligations from the artifacts and actions actually produced. The orchestrator may request additional review but cannot remove a pending obligation, and completion is denied until every applicable obligation has -passing evidence bound to the exact artifact. Reviewers receive only the goal, -their role instructions, and the immutable artifacts needed for their review. +passing evidence bound to the exact artifact. Reviewers receive the goal, their +role instructions, and the immutable artifacts needed for their review. They +also have read access to the session trace corpus and the supervisor-mediated, +read-only `prod-mcp` evidence path defined in AD-006, so evidence selection by +another agent is not a trust boundary. Before implementation, the supervisor generates an immutable decision capsule containing the workflow revision, committed decision, selected alternative, original-task digest, and contract digest. Decision-authority evidence and the @@ -547,21 +633,31 @@ authorized iteration without granting the runtime semantic decision authority. 4. The orchestrator delegates production work without encoding the procedure. 5. The ops agent selects and reads the exact versioned Markdown runbook. 6. The ops agent proposes the next operation and supplies runbook evidence. -7. The ops reviewer checks the proposal against the user goal and runbook. -8. The supervisor creates a short-lived permit containing all required digests, +7. The ops reviewer reconstructs evidence from immutable artifacts and session + traces and, when needed, requests a same-scope read-only query through the + supervisor and `prod-mcp`. +8. The ops reviewer checks the proposal against the user goal and runbook. If + it cannot safely accept, the supervisor persists a human-review request, + issues no next permit, and terminates the execution session with the bounded + question. +9. The supervisor creates a short-lived permit containing all required digests, target information, approvals, authority-proxy data, and expiry. -9. AWS KMS signs the permit under the supervisor's deployment-provided role. -10. The supervisor calls `prod-mcp` with the bearer token and signed permit. -11. `prod-mcp` authenticates transport, verifies the signature and permit, - applies operation and target allowlists, then executes the operation. -12. `prod-mcp` returns a digest-bound receipt and appropriately classified - output. -13. The reviewer and supervisor evaluate the result before another runbook - phase or operation is allowed. -14. The trace sidecar persists session evidence to S3. -15. The session runtime delivers its bounded result to the gateway with its - session-scoped token, and the gateway persists it before finalization. -16. The control server returns or streams user-safe progress and results to the terminal client. +10. AWS KMS signs the permit under the supervisor's deployment-provided role. +11. The supervisor calls `prod-mcp` with the bearer token and signed permit. +12. `prod-mcp` authenticates transport, verifies the signature and permit, + applies operation and target allowlists, then executes the operation. +13. `prod-mcp` returns a digest-bound receipt and appropriately classified + output. +14. The reviewer and supervisor evaluate the result before another runbook + phase or operation is allowed. +15. Authenticated producers submit bounded structural events to the Logger, + which independently advances the authoritative chain without + participating in workflow progression. +16. The trace sidecar persists session evidence to S3 and submits the exported + artifact commitment to the Logger without sending the trace body. +17. The session runtime delivers its bounded result to the gateway with its + session-scoped token, and the gateway persists it before finalization. +18. The control server returns or streams user-safe progress and results to the terminal client. ## Deployment topology @@ -574,8 +670,9 @@ The desired production topology is: | --- | --- | --- | --- | | Control server | Long-lived, one writer | Reverse proxy or approved private ingress | Client/session authentication only | | Session runtime | One per execution session | Private | Model keys as needed, supervisor KMS and `prod-mcp` client authority | -| Trace sidecar | Same lifetime as session | S3 egress | Narrow S3 write role | +| Trace sidecar | Same lifetime as session | S3 and Logger egress | Narrow S3 write role and a trace-commitment-only Logger producer identity | | `prod-mcp` | Long-lived central service | Private service endpoint | Grafana token and narrow cross-account execution roles | +| Logger | Long-lived, one active writer per ledger | Private append/read endpoints | Logger signing key and producer-authentication configuration only | If session runtimes initially share a deployment with the control server, that is a transitional implementation rather than a change to the one-supervisor- @@ -603,7 +700,7 @@ share mutable authority across sessions. | --- | --- | --- | | Orchestrator | Role routing, decomposition, workflow coordination, generic production delegation | Grafana queries, Loki labels, operation IDs, runbook steps, provider lifecycle, credentials | | Ops agent | How to interpret and follow a Markdown runbook, how to report evidence and blockers | Hard-coded service procedures, deployment secrets, direct KMS use | -| Ops reviewer | Goal-alignment criteria, runbook-phase verification, rejection behavior | Independent execution instructions and credentials | +| Ops reviewer | Goal-alignment criteria, independent evidence reconstruction, bounded read-only evidence-request protocol, runbook-phase verification, human-review fallback | Mutating execution instructions and credentials | | Other role prompts | Cross-cutting responsibility and safety boundaries needed by that role | Unrelated production operations, language/framework recipes, and scenario-specific procedures | Model-provider instructions belong in deployment or provider adapters. Prompts @@ -628,7 +725,8 @@ Prompts communicate responsibilities but are not trusted enforcement. Operational procedure belongs to runbooks, operation validation belongs to `prod-mcp`, coordination belongs to the orchestrator, authority belongs to the -supervisor, and infrastructure belongs to `InternalServices`. +supervisor, structural audit history belongs to the Logger, and +infrastructure belongs to `InternalServices`. ### Exact and inspectable authorization @@ -703,33 +801,19 @@ The deployment is working only when a real test jointly verifies: access. 10. The result and receipt return to the session and user. 11. The trace sidecar persists the complete redacted trace to S3. -12. Health and readiness endpoints report each integration accurately. +12. The trace sidecar submits a digest and storage-reference commitment to the + independently isolated Logger. +13. The Logger exposes the advanced chain and signed periodic checkpoints + through its read interface and survives a restart without losing or forking + history; its append response is not consumed as workflow authorization. +14. Health and readiness endpoints report each integration accurately. A test that only proves image startup, simulated execution, a log emitter, or a mock Grafana response does not satisfy this acceptance path. ## Known target-state work -The following items are compatible with the accepted architecture but may not -yet be fully implemented: - -- Add stale runtime cleanup, artifact materialization, and child-process reaping - around the file-backed thread manifest and existing S3 trace lifecycle. -- Separate gateway and session S3 identities or add independently verified - manifest integrity before enabling automatic S3 bootstrap. -- Harden filesystem operations against descriptor-relative path and race - attacks where pathname policy is insufficient. -- Complete cross-account Route53, ACM validation, load balancer routing, and - health verification in deployment code. -- Move from shared contract fixtures toward one canonical generated or - machine-validated permit schema. -- Strengthen deterministic assignment/result binding and immutable - pre-execution evidence. -- Enforce provider-native tool capability restrictions where operating-system - confinement cannot express the required boundary. -- Define retention, redaction, migration, and replay policy for historical S3 - evidence. - -Items must remain in the tracked security or architecture backlog until their -implementation and end-to-end behavior are complete. Remove a TODO only when -the enforcing code, deployment configuration, and relevant evidence all exist. +Deferred work compatible with this architecture is tracked in the canonical +[project TODO](../TODO.md). The TODO backlog records implementation status; it +does not replace or override the ownership and trust-boundary decisions in this +document. diff --git a/docs/internal-pilot-request.md b/docs/internal-pilot-request.md index 2c6321a..94fd446 100644 --- a/docs/internal-pilot-request.md +++ b/docs/internal-pilot-request.md @@ -43,7 +43,7 @@ log. The guide defines the final review and summary procedure. ## Completion Evidence Required -The Internal Validation items in [TODO.md](../TODO.md) stay unchecked until +The Internal Validation items in [TODO.md](TODO.md) stay unchecked until these artifacts exist: 1. A record that the pitch was actually delivered internally (date, audience). diff --git a/gitops/README.md b/gitops/README.md index 435553c..3ed473b 100644 --- a/gitops/README.md +++ b/gitops/README.md @@ -9,5 +9,27 @@ runbook artifacts. Application code may define configuration interfaces and image contracts here, but must not duplicate environment-specific deployment configuration. -Phase 2 will update that external GitOps source together with the independent -audit-log image and service deployment. +The application-owned Logger deployment contract is: + +- deploy `docker/logger/Dockerfile` as a separate long-lived workload; +- use a dedicated ServiceAccount/workload identity and private Service; +- mount one dedicated writable volume at `/var/lib/logger`; +- mount an Ed25519 private key read-only and set + `LOGGER_SIGNING_KEY_FILE` to its path; +- mount a client authorization file read-only and set + `LOGGER_CLIENTS_FILE` to its path; +- set a stable `LOGGER_ID` and signing-key ID; +- allow producers to reach only the append API and permit audit readers to + reach only the read/verify APIs through deployment network policy; +- do not mount model, KMS, `prod-mcp`, Grafana, Kubernetes, or repository + credentials; +- back up the append-only ledger, whose records include signed checkpoints, using the + deployment-owned retention controls; +- give producers a durable retry path or outbox and alert on delivery backlog; +- keep the existing trace sidecar and S3 data path, then submit a bounded + `trace.artifact_exported` commitment after a successful upload; +- configure at most one active Logger replica for a ledger volume. A + standby must not write until deployment fencing has transferred ownership. + +Concrete Kubernetes resources, secret names, PVC classes, S3 destinations, +network identities, and retention policy remain in `InternalServices`. diff --git a/logger/Cargo.toml b/logger/Cargo.toml new file mode 100644 index 0000000..7e50848 --- /dev/null +++ b/logger/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "multiagent-logger" +version = "0.1.0" +edition = "2021" +rust-version = "1.98" +description = "Independent append-only structural logger for multiagent" +license = "MIT" + +[[bin]] +name = "logger" +path = "src/main.rs" + +[dependencies] +axum = "0.8.4" +base64 = "0.22.1" +chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] } +ed25519-dalek = { version = "2.2.0", features = ["pem", "pkcs8"] } +libc = "0.2.175" +reqwest = { version = "0.12.23", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +sha2 = "0.10.9" +subtle = "2.6.1" +tokio = { version = "1.47.1", features = ["macros", "net", "rt-multi-thread", "signal", "time"] } + +[dev-dependencies] +tempfile = "3.22.0" +tower = { version = "0.5.2", features = ["util"] } diff --git a/logger/README.md b/logger/README.md new file mode 100644 index 0000000..ca3962f --- /dev/null +++ b/logger/README.md @@ -0,0 +1,77 @@ +# Logger service + +`logger` is the independently deployed Rust single-writer authority for +structural history. Producers submit bounded event commitments; only this +service assigns authoritative sequence numbers and parent hashes. + +It provides scoped bearer authentication, canonical JSON encoding, a durable +fsynced append-only JSONL ledger, exact event-id idempotency, per-session hash chains, +Ed25519-signed periodic checkpoints, startup/on-demand verification, and +optional retrying JSONL projections. It does not interpret reasoning, approve +workflow transitions, hold production credentials, or issue operation permits. + +`POST /v1/events` returns `204 No Content` for a new or exactly idempotent +append. The response is a transport acknowledgement, never an authorization +artifact. Producers must retain and retry undelivered events. + +## Run locally + +Generate an Ed25519 key and token, then put the printed digest in a client file: + +```bash +openssl genpkey -algorithm ED25519 -out /tmp/logger.pem +token="$(openssl rand -hex 32)" +cargo run -p multiagent-logger -- hash-token "$token" +``` + +```json +{"clients":[{"id":"local-producer","tokenSha256":"sha256:...","permissions":["append","read","verify"],"eventTypes":["*"],"sessions":["*"]}]} +``` + +```bash +LOGGER_SIGNING_KEY_FILE=/tmp/logger.pem \ +LOGGER_CLIENTS_FILE=/tmp/logger-clients.json \ +LOGGER_LEDGER_FILE=/tmp/logger.jsonl \ +LOGGER_ID=logger-local \ +cargo run -p multiagent-logger -- serve +``` + +Submit a JSON event on stdin: + +```bash +cargo run -p multiagent-logger -- submit-event \ + --url http://127.0.0.1:8090 --token-file /tmp/logger-token < event.json +``` + +Commit a trace artifact without sending the artifact body: + +```bash +cargo run -p multiagent-logger -- submit-trace-commitment \ + --url http://logger:8090 --token-file /run/secrets/logger/token \ + --event-id trace-export-session-123 --session-id session-123 \ + --file /traces/session-123.tar.gz \ + --storage-reference s3://audit-bucket/session-123.tar.gz \ + --media-type application/gzip +``` + +## API and configuration + +The API exposes event append, log heads/entries/checkpoints, the public key, +verification, health/readiness, and authenticated metrics endpoints. Event +bodies are limited to 64 KiB by default; artifact references are metadata only. + +| Variable | Default | +| --- | --- | +| `LOGGER_LEDGER_FILE` | `/var/lib/logger/ledger.jsonl` | +| `LOGGER_SIGNING_KEY_FILE` | required | +| `LOGGER_CLIENTS_FILE` | required | +| `LOGGER_ID` | required | +| `LOGGER_SIGNING_KEY_ID` | `logger-signing-key` | +| `LOGGER_CHECKPOINT_INTERVAL` | `100` | +| `LOGGER_MAX_EVENT_BYTES` | `65536` | +| `LOGGER_PROJECTION_DIR` | unset | +| `LOGGER_PROJECTION_INTERVAL_MS` | `1000` | +| `PORT` | `8090` | + +Signing keys, client tokens, storage, network policy, retention, and workload +identity remain deployment-owned. diff --git a/logger/src/auth.rs b/logger/src/auth.rs new file mode 100644 index 0000000..ef9b105 --- /dev/null +++ b/logger/src/auth.rs @@ -0,0 +1,218 @@ +use crate::model::validate_identifier; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::{collections::HashSet, fs, path::Path}; +use subtle::ConstantTimeEq; + +#[derive(Clone, Debug)] +pub struct Client { + pub id: String, + token_digest: [u8; 32], + permissions: HashSet, + event_types: Vec, + sessions: Vec, +} + +#[derive(Clone, Debug)] +pub struct Authorizer { + clients: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ClientsFile { + clients: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ClientSpec { + id: String, + token_sha256: String, + permissions: Vec, + event_types: Vec, + sessions: Vec, +} + +impl Authorizer { + pub fn load(path: &Path) -> Result { + let raw = fs::read(path).map_err(|error| format!("read logger clients file: {error}"))?; + let decoded: ClientsFile = serde_json::from_slice(&raw) + .map_err(|error| format!("decode logger clients file: {error}"))?; + if decoded.clients.is_empty() { + return Err("logger clients file must contain clients".into()); + } + let mut clients = Vec::with_capacity(decoded.clients.len()); + let mut ids = HashSet::new(); + let mut digests = HashSet::new(); + for spec in decoded.clients { + validate_identifier(&spec.id, "logger client ID")?; + if !ids.insert(spec.id.clone()) { + return Err("logger client IDs must be unique".into()); + } + let digest = decode_digest(&spec.token_sha256)?; + if !digests.insert(digest) { + return Err("logger client token digests must be unique".into()); + } + let permissions = validate_permissions(spec.permissions, &spec.id)?; + let event_types = validate_patterns( + spec.event_types, + &format!("logger client {} eventTypes", spec.id), + )?; + let sessions = validate_patterns( + spec.sessions, + &format!("logger client {} sessions", spec.id), + )?; + clients.push(Client { + id: spec.id, + token_digest: digest, + permissions, + event_types, + sessions, + }); + } + Ok(Self { clients }) + } + + pub fn authenticate(&self, header: Option<&str>) -> Result { + let token = header + .and_then(|value| value.strip_prefix("Bearer ")) + .filter(|value| (20..=512).contains(&value.len())) + .filter(|value| { + value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._~+/=-".contains(&byte)) + }) + .ok_or_else(|| AuthError::unauthorized("bearer authentication is required"))?; + let digest: [u8; 32] = Sha256::digest(token.as_bytes()).into(); + let selected = self.clients.iter().fold(None, |selected, client| { + if bool::from(digest.ct_eq(&client.token_digest)) { + Some(client.clone()) + } else { + selected + } + }); + selected.ok_or_else(|| AuthError::unauthorized("invalid bearer token")) + } +} + +impl Client { + pub fn require(&self, permission: &str) -> Result<(), AuthError> { + if self.permissions.contains(permission) { + Ok(()) + } else { + Err(AuthError::forbidden( + "forbidden", + format!("client {} lacks {permission} permission", self.id), + )) + } + } + pub fn authorize_session(&self, value: &str) -> Result<(), AuthError> { + if matches_any(&self.sessions, value) { + Ok(()) + } else { + Err(AuthError::forbidden( + "session_forbidden", + format!("client {} is not authorized for this session", self.id), + )) + } + } + pub fn authorize_event(&self, value: &str) -> Result<(), AuthError> { + if matches_any(&self.event_types, value) { + Ok(()) + } else { + Err(AuthError::forbidden( + "event_type_forbidden", + format!("client {} is not authorized for this event type", self.id), + )) + } + } +} + +#[derive(Debug)] +pub struct AuthError { + pub status: u16, + pub code: &'static str, + pub message: String, +} +impl AuthError { + fn unauthorized(message: &str) -> Self { + Self { + status: 401, + code: "unauthorized", + message: message.into(), + } + } + fn forbidden(code: &'static str, message: String) -> Self { + Self { + status: 403, + code, + message, + } + } +} + +fn decode_digest(value: &str) -> Result<[u8; 32], String> { + let hex = value + .strip_prefix("sha256:") + .ok_or("tokenSha256 must be a lowercase sha256 digest")?; + if hex.len() != 64 + || !hex + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err("tokenSha256 must be a lowercase sha256 digest".into()); + } + let mut output = [0; 32]; + for (index, pair) in hex.as_bytes().chunks_exact(2).enumerate() { + output[index] = (nibble(pair[0])? << 4) | nibble(pair[1])?; + } + Ok(output) +} +fn nibble(value: u8) -> Result { + match value { + b'0'..=b'9' => Ok(value - b'0'), + b'a'..=b'f' => Ok(value - b'a' + 10), + _ => Err("invalid digest".into()), + } +} +fn validate_permissions(values: Vec, id: &str) -> Result, String> { + if values.is_empty() { + return Err(format!("logger client {id} must have permissions")); + } + let values = values.into_iter().collect::>(); + if values + .iter() + .any(|value| !matches!(value.as_str(), "append" | "read" | "verify")) + { + return Err(format!("logger client {id} has unsupported permission")); + } + Ok(values) +} +fn validate_patterns(values: Vec, name: &str) -> Result, String> { + if values.is_empty() || values.len() > 128 { + return Err(format!( + "{name} must be a non-empty array of at most 128 patterns" + )); + } + for pattern in &values { + let stars = pattern.matches('*').count(); + if pattern.is_empty() + || pattern.len() > 128 + || stars > 1 + || (stars == 1 && !pattern.ends_with('*')) + { + return Err(format!("{name} contains an invalid pattern")); + } + } + Ok(values) +} +fn matches_any(patterns: &[String], value: &str) -> bool { + patterns.iter().any(|pattern| { + pattern == "*" + || pattern == value + || pattern + .strip_suffix('*') + .is_some_and(|prefix| value.starts_with(prefix)) + }) +} diff --git a/logger/src/canonical.rs b/logger/src/canonical.rs new file mode 100644 index 0000000..3348ceb --- /dev/null +++ b/logger/src/canonical.rs @@ -0,0 +1,40 @@ +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +pub fn bytes(value: &T) -> Result, String> { + let value = + serde_json::to_value(value).map_err(|error| format!("encode canonical value: {error}"))?; + serde_json::to_vec(&canonical_value(value)) + .map_err(|error| format!("encode canonical JSON: {error}")) +} + +pub fn string(value: &T) -> Result { + String::from_utf8(bytes(value)?) + .map_err(|error| format!("canonical JSON is not UTF-8: {error}")) +} + +pub fn sha256(value: &T) -> Result { + Ok(format!("sha256:{:x}", Sha256::digest(bytes(value)?))) +} + +pub fn sha256_bytes(value: &[u8]) -> String { + format!("sha256:{:x}", Sha256::digest(value)) +} + +fn canonical_value(value: Value) -> Value { + match value { + Value::Array(values) => Value::Array(values.into_iter().map(canonical_value).collect()), + Value::Object(values) => { + let mut pairs = values.into_iter().collect::>(); + pairs.sort_by(|left, right| left.0.cmp(&right.0)); + Value::Object( + pairs + .into_iter() + .map(|(key, value)| (key, canonical_value(value))) + .collect(), + ) + } + other => other, + } +} diff --git a/logger/src/config.rs b/logger/src/config.rs new file mode 100644 index 0000000..aef3003 --- /dev/null +++ b/logger/src/config.rs @@ -0,0 +1,68 @@ +use std::{env, path::PathBuf}; + +#[derive(Clone, Debug)] +pub struct Config { + pub ledger_file: PathBuf, + pub signing_key_file: PathBuf, + pub signing_key_id: String, + pub logger_id: String, + pub clients_file: PathBuf, + pub checkpoint_interval: u64, + pub max_event_bytes: usize, + pub projection_dir: Option, + pub projection_interval_ms: u64, + pub host: String, + pub port: u16, +} + +impl Config { + pub fn from_env() -> Result { + Ok(Self { + ledger_file: path("LOGGER_LEDGER_FILE", "/var/lib/logger/ledger.jsonl"), + signing_key_file: required_path("LOGGER_SIGNING_KEY_FILE")?, + signing_key_id: env::var("LOGGER_SIGNING_KEY_ID") + .unwrap_or_else(|_| "logger-signing-key".into()), + logger_id: required("LOGGER_ID")?, + clients_file: required_path("LOGGER_CLIENTS_FILE")?, + checkpoint_interval: integer("LOGGER_CHECKPOINT_INTERVAL", 100, 1, 1_000_000)?, + max_event_bytes: integer("LOGGER_MAX_EVENT_BYTES", 65_536, 1_024, 1_048_576)? as usize, + projection_dir: env::var_os("LOGGER_PROJECTION_DIR").map(PathBuf::from), + projection_interval_ms: integer("LOGGER_PROJECTION_INTERVAL_MS", 1_000, 100, 60_000)?, + host: env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into()), + port: integer("PORT", 8090, 1, 65_535)? as u16, + }) + } +} + +fn required(name: &str) -> Result { + env::var(name) + .map_err(|_| format!("{name} is required")) + .and_then(|value| { + if value.is_empty() { + Err(format!("{name} is required")) + } else { + Ok(value) + } + }) +} + +fn required_path(name: &str) -> Result { + required(name).map(PathBuf::from) +} + +fn path(name: &str, fallback: &str) -> PathBuf { + env::var_os(name) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(fallback)) +} + +fn integer(name: &str, fallback: u64, min: u64, max: u64) -> Result { + let value = env::var(name).ok().map_or(Ok(fallback), |raw| { + raw.parse::() + .map_err(|_| format!("{name} must be an integer between {min} and {max}")) + })?; + if !(min..=max).contains(&value) { + return Err(format!("{name} must be an integer between {min} and {max}")); + } + Ok(value) +} diff --git a/logger/src/lib.rs b/logger/src/lib.rs new file mode 100644 index 0000000..51132eb --- /dev/null +++ b/logger/src/lib.rs @@ -0,0 +1,9 @@ +pub mod auth; +pub mod canonical; +pub mod config; +pub mod model; +pub mod server; +pub mod signer; +pub mod store; + +pub use server::{router, AppState}; diff --git a/logger/src/main.rs b/logger/src/main.rs new file mode 100644 index 0000000..9c1bcbd --- /dev/null +++ b/logger/src/main.rs @@ -0,0 +1,155 @@ +use multiagent_logger::{ + config::Config, + model::{ArtifactReference, Event}, + AppState, +}; +use sha2::{Digest, Sha256}; +use std::{ + env, fs, + io::{self, Read}, +}; + +#[tokio::main] +async fn main() { + #[cfg(unix)] + unsafe { + libc::umask(0o077); + } + if let Err(error) = run().await { + eprintln!("logger failed: {error}"); + std::process::exit(1) + } +} +async fn run() -> Result<(), String> { + let mut args = env::args().skip(1); + match args.next().as_deref().unwrap_or("serve") { + "serve" => serve().await, + "hash-token" => { + let token = args.next().ok_or("usage: logger hash-token TOKEN")?; + if args.next().is_some() { + return Err("usage: logger hash-token TOKEN".into()); + } + println!("sha256:{:x}", Sha256::digest(token)); + Ok(()) + } + "submit-event" => submit(false, args.collect()).await, + "submit-trace-commitment" => submit(true, args.collect()).await, + other => Err(format!("unknown logger command {other}")), + } +} +async fn serve() -> Result<(), String> { + let config = Config::from_env()?; + let state = AppState::from_config(&config)?; + if config.projection_dir.is_some() { + let projection = state.clone(); + let interval = config.projection_interval_ms; + tokio::spawn(async move { + let mut timer = tokio::time::interval(std::time::Duration::from_millis(interval)); + loop { + timer.tick().await; + projection.flush_projections(); + } + }); + } + let address = format!("{}:{}", config.host, config.port); + let listener = tokio::net::TcpListener::bind(&address) + .await + .map_err(|e| format!("bind {address}: {e}"))?; + println!("logger listening on {address}"); + axum::serve(listener, multiagent_logger::router(state)) + .with_graceful_shutdown(async { + let _ = tokio::signal::ctrl_c().await; + }) + .await + .map_err(|e| e.to_string()) +} +async fn submit(trace: bool, args: Vec) -> Result<(), String> { + let ( + mut url, + mut token_file, + mut session, + mut event_id, + mut file, + mut storage_reference, + mut media_type, + ) = (None, None, None, None, None, None, None); + let mut index = 0; + while index < args.len() { + let target = match args[index].as_str() { + "--url" => &mut url, + "--token-file" => &mut token_file, + "--session-id" => &mut session, + "--event-id" => &mut event_id, + "--file" => &mut file, + "--storage-reference" => &mut storage_reference, + "--media-type" => &mut media_type, + flag => return Err(format!("unknown option {flag}")), + }; + index += 1; + *target = Some(args.get(index).ok_or("option value is required")?.clone()); + index += 1; + } + let url = url.ok_or("--url is required")?; + let token = fs::read_to_string(token_file.ok_or("--token-file is required")?) + .map_err(|e| e.to_string())? + .trim() + .to_string(); + let event = if trace { + let path = file.ok_or("--file is required")?; + let (digest, size) = digest_file(&path)?; + Event { + event_id: event_id.ok_or("--event-id is required")?, + session_id: session.ok_or("--session-id is required")?, + event_type: "trace.commitment".into(), + payload_digest: digest.clone(), + artifact_references: vec![ArtifactReference { + uri: storage_reference.ok_or("--storage-reference is required")?, + digest: Some(digest), + size: Some(size), + media_type: Some(media_type.ok_or("--media-type is required")?), + }], + } + } else { + let mut raw = Vec::new(); + io::stdin() + .read_to_end(&mut raw) + .map_err(|e| e.to_string())?; + serde_json::from_slice(&raw).map_err(|e| format!("decode event: {e}"))? + }; + let response = reqwest::Client::new() + .post(format!("{}/v1/events", url.trim_end_matches('/'))) + .bearer_auth(token) + .json(&event) + .send() + .await + .map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!( + "logger rejected event: {} {}", + response.status(), + response.text().await.unwrap_or_default() + )); + } + Ok(()) +} + +fn digest_file(path: &str) -> Result<(String, u64), String> { + let mut input = + fs::File::open(path).map_err(|error| format!("open trace artifact: {error}"))?; + let mut digest = Sha256::new(); + let mut size = 0u64; + let mut buffer = [0u8; 64 * 1024]; + loop { + let count = input + .read(&mut buffer) + .map_err(|error| format!("read trace artifact: {error}"))?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + size = size + .checked_add(count as u64) + .ok_or("trace artifact is too large")?; + } + Ok((format!("sha256:{:x}", digest.finalize()), size)) +} diff --git a/logger/src/model.rs b/logger/src/model.rs new file mode 100644 index 0000000..0f5e3f4 --- /dev/null +++ b/logger/src/model.rs @@ -0,0 +1,183 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Event { + pub event_id: String, + pub session_id: String, + pub event_type: String, + pub payload_digest: String, + pub artifact_references: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ArtifactReference { + pub uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub digest: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub media_type: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AcceptedEvent { + #[serde(flatten)] + pub event: Event, + pub producer_id: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EntryBody { + pub log_id: String, + pub sequence: u64, + pub previous_hash: String, + pub committed_at: String, + pub producer_id: String, + pub event_id: String, + pub event_type: String, + pub payload_digest: String, + pub artifact_references: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Entry { + #[serde(flatten)] + pub body: EntryBody, + pub entry_hash: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Signature { + pub algorithm: String, + pub key_id: String, + pub signature: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckpointBody { + pub api_version: String, + pub kind: String, + pub checkpoint_id: String, + pub log_id: String, + pub sequence: u64, + pub entry_hash: String, + pub created_at: String, + pub logger_identity: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Checkpoint { + #[serde(flatten)] + pub body: CheckpointBody, + pub logger_signature: Signature, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LogHead { + pub log_id: String, + pub sequence: u64, + pub entry_hash: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Verification { + pub ok: bool, + pub checked_logs: usize, + pub checked_entries: usize, + pub checked_checkpoints: usize, + pub errors: Vec, +} + +pub fn validate_event(event: &Event) -> Result<(), String> { + validate_identifier(&event.event_id, "eventId")?; + validate_identifier(&event.session_id, "sessionId")?; + if !valid_event_type(&event.event_type) { + return Err("eventType has an invalid format".into()); + } + validate_digest(&event.payload_digest, "payloadDigest")?; + if event.artifact_references.len() > 64 { + return Err("artifactReferences must contain at most 64 entries".into()); + } + for (index, reference) in event.artifact_references.iter().enumerate() { + if reference.uri.is_empty() + || reference.uri.len() > 2048 + || reference.uri.contains(['\r', '\n', '?', '#']) + { + return Err(format!("artifactReferences[{index}].uri is invalid")); + } + if let Some(digest) = &reference.digest { + validate_digest(digest, &format!("artifactReferences[{index}].digest"))?; + } + if reference + .media_type + .as_ref() + .is_some_and(|value| value.is_empty() || value.len() > 255) + { + return Err(format!("artifactReferences[{index}].mediaType is invalid")); + } + } + Ok(()) +} + +pub fn validate_identifier(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > 128 + || !value.chars().enumerate().all(|(index, ch)| { + ch.is_ascii_alphanumeric() || index > 0 && matches!(ch, '.' | '_' | ':' | '-') + }) + { + return Err(format!("{label} has an invalid format")); + } + Ok(()) +} + +pub fn validate_digest(value: &str, label: &str) -> Result<(), String> { + let Some(hex) = value.strip_prefix("sha256:") else { + return Err(format!("{label} must be a lowercase sha256 digest")); + }; + if hex.len() != 64 + || !hex + .chars() + .all(|ch| ch.is_ascii_hexdigit() && !ch.is_ascii_uppercase()) + { + return Err(format!("{label} must be a lowercase sha256 digest")); + } + Ok(()) +} + +fn valid_event_type(value: &str) -> bool { + if value.is_empty() + || value.len() > 128 + || !value.starts_with(|ch: char| ch.is_ascii_lowercase()) + { + return false; + } + let mut segments = 1; + let mut previous_separator = false; + for ch in value.chars() { + if matches!(ch, '.' | '_' | '-') { + if previous_separator { + return false; + } + previous_separator = true; + segments += 1; + } else if ch.is_ascii_lowercase() || ch.is_ascii_digit() { + previous_separator = false; + } else { + return false; + } + } + !previous_separator && segments <= 16 +} diff --git a/logger/src/server.rs b/logger/src/server.rs new file mode 100644 index 0000000..2d34e1e --- /dev/null +++ b/logger/src/server.rs @@ -0,0 +1,522 @@ +use crate::{ + auth::{AuthError, Authorizer, Client}, + config::Config, + model::{validate_event, validate_identifier, Event}, + signer::Ed25519Signer, + store::{AppendResult, Store, StoreError}, +}; +use axum::{ + body::Bytes, + extract::{Path, Query, State}, + http::{header::AUTHORIZATION, HeaderMap, Request, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::{ + collections::BTreeMap, + path::PathBuf, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, + }, +}; + +#[derive(Clone)] +pub struct AppState(Arc); +struct Inner { + store: Mutex, + authorizer: Authorizer, + public_key: Value, + ready: AtomicBool, + max_event_bytes: usize, + projection_dir: Option, + appends: AtomicU64, + duplicates: AtomicU64, + rejected: AtomicU64, + projection_success: AtomicU64, + projection_failures: AtomicU64, +} + +impl AppState { + pub fn from_config(config: &Config) -> Result { + let signer = Ed25519Signer::load( + &config.signing_key_file, + config.signing_key_id.clone(), + config.logger_id.clone(), + )?; + let public_key = signer.public_descriptor()?; + let store = Store::open( + &config.ledger_file, + signer, + config.checkpoint_interval, + config.projection_dir.is_some(), + )?; + let authorizer = Authorizer::load(&config.clients_file)?; + Ok(Self(Arc::new(Inner { + store: Mutex::new(store), + authorizer, + public_key, + ready: AtomicBool::new(true), + max_event_bytes: config.max_event_bytes, + projection_dir: config.projection_dir.clone(), + appends: 0.into(), + duplicates: 0.into(), + rejected: 0.into(), + projection_success: 0.into(), + projection_failures: 0.into(), + }))) + } + pub fn flush_projections(&self) { + let Some(directory) = &self.0.projection_dir else { + return; + }; + let result = self + .0 + .store + .lock() + .expect("logger store lock") + .flush_projections(directory); + match result { + Ok((ok, failed)) => { + self.0.projection_success.fetch_add(ok, Ordering::Relaxed); + self.0 + .projection_failures + .fetch_add(failed, Ordering::Relaxed); + } + Err(error) => { + self.0.projection_failures.fetch_add(1, Ordering::Relaxed); + eprintln!("logger projection failed: {error}") + } + } + } +} + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/healthz", get(health)) + .route("/readyz", get(ready)) + .route("/v1/public-key", get(public_key)) + .route("/metrics", get(metrics)) + .route("/v1/events", post(events)) + .route("/v1/logs/{log_id}/head", get(head)) + .route("/v1/logs/{log_id}/entries", get(entries)) + .route("/v1/logs/{log_id}/checkpoints", get(checkpoints)) + .route("/v1/checkpoints/{checkpoint_id}", get(checkpoint)) + .route("/v1/verify", post(verify)) + .layer(middleware::from_fn_with_state( + state.clone(), + response_policy, + )) + .with_state(state) +} + +async fn response_policy( + State(state): State, + request: Request, + next: Next, +) -> Response { + let mut response = next.run(request).await; + response.headers_mut().insert( + axum::http::header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("no-store"), + ); + response.headers_mut().insert( + axum::http::header::X_CONTENT_TYPE_OPTIONS, + axum::http::HeaderValue::from_static("nosniff"), + ); + if response.status().is_client_error() || response.status().is_server_error() { + state.0.rejected.fetch_add(1, Ordering::Relaxed); + } + response +} +async fn health() -> Json { + Json(json!({"live":true})) +} +async fn ready(State(state): State) -> Result, ApiError> { + if state.0.ready.load(Ordering::Relaxed) { + Ok(Json(json!({"ready":true}))) + } else { + Err(ApiError::new( + StatusCode::SERVICE_UNAVAILABLE, + "integrity_unavailable", + "logger integrity is unavailable", + )) + } +} +async fn public_key(State(state): State) -> Json { + Json(state.0.public_key.clone()) +} +async fn events( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result { + if !state.0.ready.load(Ordering::Relaxed) { + return Err(ApiError::new( + StatusCode::SERVICE_UNAVAILABLE, + "integrity_unavailable", + "authoritative append is disabled until ledger integrity is restored", + )); + } + let client = authorize(&state, &headers, "append")?; + if body.is_empty() { + return Err(ApiError::bad("request body is required")); + } + if body.len() > state.0.max_event_bytes { + return Err(ApiError::new( + StatusCode::PAYLOAD_TOO_LARGE, + "request_too_large", + "request body exceeds size limit", + )); + } + let event: Event = serde_json::from_slice(&body).map_err(|error| { + ApiError::bad(format!("request body must be valid event JSON: {error}")) + })?; + validate_event(&event).map_err(ApiError::bad)?; + client + .authorize_session(&event.session_id) + .map_err(ApiError::from_auth)?; + client + .authorize_event(&event.event_type) + .map_err(ApiError::from_auth)?; + let result = state + .0 + .store + .lock() + .expect("logger store lock") + .append(event, client.id); + if matches!(result, Err(StoreError::Internal(_))) { + state.0.ready.store(false, Ordering::Relaxed); + } + let result = result.map_err(ApiError::from_store)?; + match result { + AppendResult::Appended => { + state.0.appends.fetch_add(1, Ordering::Relaxed); + } + AppendResult::Duplicate => { + state.0.duplicates.fetch_add(1, Ordering::Relaxed); + } + } + Ok(StatusCode::NO_CONTENT) +} +async fn head( + State(state): State, + headers: HeaderMap, + Path(log_id): Path, +) -> Result, ApiError> { + let client = read_client(&state, &headers, &log_id)?; + drop(client); + let value = state + .0 + .store + .lock() + .expect("logger store lock") + .head(&log_id) + .map_err(ApiError::internal)? + .ok_or_else(ApiError::not_found)?; + Ok(Json( + serde_json::to_value(value).map_err(|e| ApiError::internal(e.to_string()))?, + )) +} +#[derive(Deserialize)] +struct ListQuery { + after: Option, + limit: Option, +} +async fn entries( + State(state): State, + headers: HeaderMap, + Path(log_id): Path, + Query(query): Query, +) -> Result, ApiError> { + read_client(&state, &headers, &log_id)?; + let (after, limit) = list_query(query)?; + let values = state + .0 + .store + .lock() + .expect("logger store lock") + .entries(&log_id, after, limit) + .map_err(ApiError::internal)?; + Ok(Json(json!({"logId":log_id,"entries":values}))) +} +async fn checkpoints( + State(state): State, + headers: HeaderMap, + Path(log_id): Path, + Query(query): Query, +) -> Result, ApiError> { + read_client(&state, &headers, &log_id)?; + let (after, limit) = list_query(query)?; + let values = state + .0 + .store + .lock() + .expect("logger store lock") + .checkpoints(&log_id, after, limit) + .map_err(ApiError::internal)?; + Ok(Json(json!({"logId":log_id,"checkpoints":values}))) +} +async fn checkpoint( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Result, ApiError> { + validate_identifier(&id, "checkpointId").map_err(ApiError::bad)?; + let client = authorize(&state, &headers, "read")?; + let store = state.0.store.lock().expect("logger store lock"); + let log_id = store + .checkpoint_session(&id) + .map_err(ApiError::internal)? + .ok_or_else(ApiError::not_found)?; + client + .authorize_session(&log_id) + .map_err(ApiError::from_auth)?; + let value = store + .checkpoint(&id) + .map_err(ApiError::internal)? + .ok_or_else(ApiError::not_found)?; + Ok(Json( + serde_json::to_value(value).map_err(|e| ApiError::internal(e.to_string()))?, + )) +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct VerifyRequest { + log_id: Option, +} +async fn verify( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result<(StatusCode, Json), ApiError> { + let client = authorize(&state, &headers, "verify")?; + let request: VerifyRequest = serde_json::from_slice(&body) + .map_err(|error| ApiError::bad(format!("invalid verify request: {error}")))?; + if let Some(id) = &request.log_id { + validate_identifier(id, "logId").map_err(ApiError::bad)?; + client.authorize_session(id).map_err(ApiError::from_auth)?; + } + let result = state + .0 + .store + .lock() + .expect("logger store lock") + .verify(request.log_id.as_deref()) + .map_err(ApiError::internal)?; + state.0.ready.store(result.ok, Ordering::Relaxed); + let status = if result.ok { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + Ok(( + status, + Json(serde_json::to_value(result).map_err(|e| ApiError::internal(e.to_string()))?), + )) +} +async fn metrics(State(state): State, headers: HeaderMap) -> Result { + authorize(&state, &headers, "read")?; + let counts: BTreeMap = state + .0 + .store + .lock() + .expect("logger store lock") + .projection_counts() + .map_err(ApiError::internal)?; + Ok(format!("logger_appends_total {}\nlogger_duplicate_appends_total {}\nlogger_rejected_requests_total {}\nlogger_integrity_ok {}\nlogger_projection_success_total {}\nlogger_projection_failures_total {}\nlogger_projection_pending {}\n",state.0.appends.load(Ordering::Relaxed),state.0.duplicates.load(Ordering::Relaxed),state.0.rejected.load(Ordering::Relaxed),u8::from(state.0.ready.load(Ordering::Relaxed)),state.0.projection_success.load(Ordering::Relaxed),state.0.projection_failures.load(Ordering::Relaxed),counts.get("pending").copied().unwrap_or(0))) +} +fn authorize(state: &AppState, headers: &HeaderMap, permission: &str) -> Result { + let value = headers.get(AUTHORIZATION).and_then(|v| v.to_str().ok()); + let client = state + .0 + .authorizer + .authenticate(value) + .map_err(ApiError::from_auth)?; + client.require(permission).map_err(ApiError::from_auth)?; + Ok(client) +} +fn read_client(state: &AppState, headers: &HeaderMap, log_id: &str) -> Result { + validate_identifier(log_id, "logId").map_err(ApiError::bad)?; + let client = authorize(state, headers, "read")?; + client + .authorize_session(log_id) + .map_err(ApiError::from_auth)?; + Ok(client) +} +fn list_query(query: ListQuery) -> Result<(u64, usize), ApiError> { + let limit = query.limit.unwrap_or(100); + if !(1..=1000).contains(&limit) { + return Err(ApiError::bad("limit is outside the allowed range")); + } + Ok((query.after.unwrap_or(0), limit)) +} + +#[derive(Debug)] +struct ApiError { + status: StatusCode, + code: &'static str, + message: String, +} +impl ApiError { + fn new(status: StatusCode, code: &'static str, message: impl Into) -> Self { + Self { + status, + code, + message: message.into(), + } + } + fn bad(message: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, "invalid_request", message) + } + fn internal(message: impl Into) -> Self { + eprintln!("logger request failed: {}", message.into()); + Self::new( + StatusCode::INTERNAL_SERVER_ERROR, + "internal_error", + "internal server error", + ) + } + fn not_found() -> Self { + Self::new(StatusCode::NOT_FOUND, "not_found", "resource not found") + } + fn from_auth(value: AuthError) -> Self { + Self::new( + StatusCode::from_u16(value.status).unwrap_or(StatusCode::UNAUTHORIZED), + value.code, + value.message, + ) + } + fn from_store(value: StoreError) -> Self { + match value { + StoreError::Conflict(message) => { + Self::new(StatusCode::CONFLICT, "event_id_conflict", message) + } + StoreError::Internal(message) => Self::internal(message), + } + } +} +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + ( + self.status, + Json(json!({"error":{"code":self.code,"message":self.message}})), + ) + .into_response() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::Body, http::Request}; + use ed25519_dalek::{ + pkcs8::{spki::der::pem::LineEnding, EncodePrivateKey}, + SigningKey, + }; + use sha2::{Digest, Sha256}; + use std::fs; + use tower::ServiceExt; + + fn application() -> (tempfile::TempDir, Router) { + let directory = tempfile::tempdir().unwrap(); + let key = SigningKey::from_bytes(&[9; 32]) + .to_pkcs8_pem(LineEnding::LF) + .unwrap(); + let key_file = directory.path().join("signing-key.pem"); + fs::write(&key_file, key.as_bytes()).unwrap(); + let token = "test-token-0123456789abcdef"; + let clients_file = directory.path().join("clients.json"); + fs::write( + &clients_file, + serde_json::to_vec(&json!({"clients":[{ + "id":"test-client", + "tokenSha256":format!("sha256:{:x}", Sha256::digest(token)), + "permissions":["append","read","verify"], + "eventTypes":["*"], + "sessions":["session-*"] + }]})) + .unwrap(), + ) + .unwrap(); + let config = Config { + ledger_file: directory.path().join("ledger.jsonl"), + signing_key_file: key_file, + signing_key_id: "test-key".into(), + logger_id: "test-logger".into(), + clients_file, + checkpoint_interval: 1, + max_event_bytes: 65_536, + projection_dir: None, + projection_interval_ms: 1_000, + host: "127.0.0.1".into(), + port: 8090, + }; + let state = AppState::from_config(&config).unwrap(); + (directory, router(state)) + } + + #[tokio::test] + async fn append_and_read_require_scoped_auth_and_return_no_receipt() { + let (_directory, app) = application(); + let event = json!({ + "eventId":"event-1", + "sessionId":"session-1", + "eventType":"reviewer.verdict", + "payloadDigest":format!("sha256:{}", "1".repeat(64)), + "artifactReferences":[] + }); + let unauthorized = app + .clone() + .oneshot( + Request::post("/v1/events") + .header("content-type", "application/json") + .body(Body::from(event.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); + + let appended = app + .clone() + .oneshot( + Request::post("/v1/events") + .header("authorization", "Bearer test-token-0123456789abcdef") + .header("content-type", "application/json") + .body(Body::from(event.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(appended.status(), StatusCode::NO_CONTENT); + assert_eq!(appended.headers()["cache-control"], "no-store"); + assert_eq!( + axum::body::to_bytes(appended.into_body(), 1024) + .await + .unwrap() + .len(), + 0 + ); + + let head = app + .oneshot( + Request::get("/v1/logs/session-1/head") + .header("authorization", "Bearer test-token-0123456789abcdef") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(head.status(), StatusCode::OK); + let body = axum::body::to_bytes(head.into_body(), 4096).await.unwrap(); + assert_eq!( + serde_json::from_slice::(&body).unwrap()["sequence"], + 1 + ); + } +} diff --git a/logger/src/signer.rs b/logger/src/signer.rs new file mode 100644 index 0000000..85f4359 --- /dev/null +++ b/logger/src/signer.rs @@ -0,0 +1,80 @@ +use crate::{ + canonical, + model::validate_identifier, + model::{CheckpointBody, Signature}, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use ed25519_dalek::{ + pkcs8::{spki::der::pem::LineEnding, DecodePrivateKey, EncodePublicKey}, + Signature as DalekSignature, Signer as _, SigningKey, Verifier as _, +}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::{fs, path::Path}; + +#[derive(Clone)] +pub struct Ed25519Signer { + key: SigningKey, + pub key_id: String, + pub logger_id: String, +} + +impl Ed25519Signer { + pub fn load(path: &Path, key_id: String, logger_id: String) -> Result { + validate_identifier(&key_id, "logger signing key ID")?; + validate_identifier(&logger_id, "logger identity")?; + let pem = fs::read_to_string(path) + .map_err(|error| format!("read logger signing key: {error}"))?; + let key = SigningKey::from_pkcs8_pem(&pem) + .map_err(|error| format!("decode Ed25519 PKCS#8 signing key: {error}"))?; + Ok(Self { + key, + key_id, + logger_id, + }) + } + pub fn sign(&self, body: &T) -> Result { + let bytes = canonical::bytes(body)?; + Ok(Signature { + algorithm: "Ed25519".into(), + key_id: self.key_id.clone(), + signature: URL_SAFE_NO_PAD.encode(self.key.sign(&bytes).to_bytes()), + }) + } + pub fn verify(&self, body: &CheckpointBody, signature: &Signature) -> bool { + if signature.algorithm != "Ed25519" || signature.key_id != self.key_id { + return false; + } + let Ok(bytes) = canonical::bytes(body) else { + return false; + }; + let Ok(raw) = URL_SAFE_NO_PAD.decode(&signature.signature) else { + return false; + }; + let Ok(signature) = DalekSignature::from_slice(&raw) else { + return false; + }; + self.key.verifying_key().verify(&bytes, &signature).is_ok() + } + pub fn public_descriptor(&self) -> Result { + let pem = self + .key + .verifying_key() + .to_public_key_pem(LineEnding::LF) + .map_err(|error| format!("encode logger public key: {error}"))?; + Ok( + json!({"algorithm":"Ed25519","keyId":self.key_id,"loggerIdentity":self.logger_id,"publicKeyPem":pem}), + ) + } +} + +#[cfg(test)] +impl Ed25519Signer { + pub(crate) fn from_seed(seed: [u8; 32]) -> Self { + Self { + key: SigningKey::from_bytes(&seed), + key_id: "test-key".into(), + logger_id: "test-logger".into(), + } + } +} diff --git a/logger/src/store.rs b/logger/src/store.rs new file mode 100644 index 0000000..e7f4122 --- /dev/null +++ b/logger/src/store.rs @@ -0,0 +1,694 @@ +use crate::{ + canonical, + model::{ + validate_event, validate_identifier, AcceptedEvent, Checkpoint, CheckpointBody, Entry, + EntryBody, Event, LogHead, Verification, + }, + signer::Ed25519Signer, +}; +use chrono::{DateTime, SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet, HashMap}, + fs::{self, File, OpenOptions}, + io::{BufRead, BufReader, Read, Write}, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +pub const GENESIS_HASH: &str = + "sha256:0000000000000000000000000000000000000000000000000000000000000000"; +const MAX_LEDGER_RECORD_BYTES: usize = 2 * 1024 * 1024; + +pub struct Store { + ledger_path: PathBuf, + ledger: File, + signer: Ed25519Signer, + checkpoint_interval: u64, + projection_enabled: bool, + projection_dirty: BTreeSet, + state: LedgerState, + writable: bool, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct LedgerState { + logs: BTreeMap, + events: HashMap, + entries: BTreeMap>, + checkpoints: HashMap, + checkpoints_by_log: BTreeMap>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct StoredEvent { + request_digest: String, + accepted_event: AcceptedEvent, + entry: Entry, +} + +/// One newline-delimited record is one authoritative transaction. Keeping an +/// optional checkpoint in the same record prevents a crash from committing an +/// entry without its scheduled checkpoint. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LedgerRecord { + api_version: String, + kind: String, + request_digest: String, + accepted_event: AcceptedEvent, + entry: Entry, + #[serde(skip_serializing_if = "Option::is_none")] + checkpoint: Option, +} + +#[derive(Debug, Eq, PartialEq)] +pub enum AppendResult { + Appended, + Duplicate, +} + +#[derive(Debug)] +pub enum StoreError { + Conflict(String), + Internal(String), +} + +impl From for StoreError { + fn from(value: String) -> Self { + Self::Internal(value) + } +} + +impl Store { + pub fn open( + ledger_path: &Path, + signer: Ed25519Signer, + checkpoint_interval: u64, + projection_enabled: bool, + ) -> Result { + if let Some(parent) = ledger_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + { + fs::create_dir_all(parent) + .map_err(|error| format!("create logger data directory: {error}"))?; + } + let ledger = secure_append_file(ledger_path)?; + lock_single_writer(&ledger)?; + let state = replay(ledger_path, &signer)?; + let projection_dirty = if projection_enabled { + state.logs.keys().cloned().collect() + } else { + BTreeSet::new() + }; + Ok(Self { + ledger_path: ledger_path.to_path_buf(), + ledger, + signer, + checkpoint_interval, + projection_enabled, + projection_dirty, + state, + writable: true, + }) + } + + pub fn append( + &mut self, + event: Event, + producer_id: String, + ) -> Result { + if !self.writable { + return Err(StoreError::Internal( + "logger ledger is unavailable after a durability failure".into(), + )); + } + let accepted_event = AcceptedEvent { + event: event.clone(), + producer_id: producer_id.clone(), + }; + let request_digest = canonical::sha256(&accepted_event)?; + if let Some(existing) = self.state.events.get(&event.event_id) { + if existing.request_digest == request_digest { + return Ok(AppendResult::Duplicate); + } + return Err(StoreError::Conflict( + "eventId already commits different content".into(), + )); + } + + let head = self.state.logs.get(&event.session_id); + let sequence = head.map_or(1, |value| value.sequence + 1); + let previous_hash = head + .map(|value| value.entry_hash.clone()) + .unwrap_or_else(|| GENESIS_HASH.into()); + let committed_at = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + let body = EntryBody { + log_id: event.session_id.clone(), + sequence, + previous_hash, + committed_at: committed_at.clone(), + producer_id, + event_id: event.event_id.clone(), + event_type: event.event_type.clone(), + payload_digest: event.payload_digest.clone(), + artifact_references: event.artifact_references.clone(), + }; + let entry = Entry { + entry_hash: canonical::sha256(&body)?, + body, + }; + let checkpoint = if sequence % self.checkpoint_interval == 0 { + Some(create_checkpoint(&self.signer, &entry)?) + } else { + None + }; + let record = LedgerRecord { + api_version: "logger.multiagent.dev/v1".into(), + kind: "LoggerLedgerRecord".into(), + request_digest, + accepted_event, + entry, + checkpoint, + }; + validate_next(&self.state, &self.signer, &record)?; + let mut encoded = canonical::bytes(&record)?; + if encoded.len() > MAX_LEDGER_RECORD_BYTES { + return Err(StoreError::Internal( + "encoded logger record exceeds the ledger limit".into(), + )); + } + encoded.push(b'\n'); + if let Err(error) = self + .ledger + .write_all(&encoded) + .and_then(|()| self.ledger.sync_data()) + { + self.writable = false; + return Err(StoreError::Internal(format!( + "durably append logger ledger: {error}" + ))); + } + let log_id = record.entry.body.log_id.clone(); + insert_record(&mut self.state, record); + if self.projection_enabled { + self.projection_dirty.insert(log_id); + } + Ok(AppendResult::Appended) + } + + pub fn head(&self, log_id: &str) -> Result, String> { + Ok(self.state.logs.get(log_id).cloned()) + } + + pub fn entries(&self, log_id: &str, after: u64, limit: usize) -> Result, String> { + Ok(self + .state + .entries + .get(log_id) + .into_iter() + .flatten() + .filter(|entry| entry.body.sequence > after) + .take(limit) + .cloned() + .collect()) + } + + pub fn checkpoints( + &self, + log_id: &str, + after: u64, + limit: usize, + ) -> Result, String> { + Ok(self + .state + .checkpoints_by_log + .get(log_id) + .into_iter() + .flatten() + .filter(|checkpoint| checkpoint.body.sequence > after) + .take(limit) + .cloned() + .collect()) + } + + pub fn checkpoint(&self, id: &str) -> Result, String> { + Ok(self.state.checkpoints.get(id).cloned()) + } + + pub fn checkpoint_session(&self, id: &str) -> Result, String> { + Ok(self + .state + .checkpoints + .get(id) + .map(|checkpoint| checkpoint.body.log_id.clone())) + } + + pub fn verify(&self, log_id: Option<&str>) -> Result { + let replayed = match replay(&self.ledger_path, &self.signer) { + Ok(state) => state, + Err(error) => { + return Ok(Verification { + ok: false, + checked_logs: 0, + checked_entries: 0, + checked_checkpoints: 0, + errors: vec![error], + }); + } + }; + let mut errors = Vec::new(); + if !self.writable { + errors.push("ledger writes are disabled after a durability failure".into()); + } + if replayed != self.state { + errors.push("on-disk ledger differs from the active in-memory index".into()); + } + let logs = replayed + .logs + .keys() + .filter(|candidate| log_id.is_none_or(|selected| candidate.as_str() == selected)) + .collect::>(); + let checked_entries = logs + .iter() + .map(|id| replayed.entries.get(id.as_str()).map_or(0, Vec::len)) + .sum(); + let checked_checkpoints = logs + .iter() + .map(|id| { + replayed + .checkpoints_by_log + .get(id.as_str()) + .map_or(0, Vec::len) + }) + .sum(); + Ok(Verification { + ok: errors.is_empty(), + checked_logs: logs.len(), + checked_entries, + checked_checkpoints, + errors, + }) + } + + pub fn projection_counts(&self) -> Result, String> { + Ok(BTreeMap::from([( + "pending".into(), + self.projection_dirty.len() as u64, + )])) + } + + /// Projections are rebuilt atomically from the authoritative ledger index. + /// This avoids a second state store and makes retries naturally idempotent. + pub fn flush_projections(&mut self, directory: &Path) -> Result<(u64, u64), String> { + fs::create_dir_all(directory).map_err(|error| error.to_string())?; + let pending = self.projection_dirty.iter().cloned().collect::>(); + let (mut success, mut failed) = (0, 0); + for log_id in pending { + match write_projection( + directory, + &log_id, + self.state.entries.get(&log_id).map_or(&[], Vec::as_slice), + ) { + Ok(()) => { + self.projection_dirty.remove(&log_id); + success += 1; + } + Err(error) => { + eprintln!("logger projection for {log_id} failed: {error}"); + failed += 1; + } + } + } + Ok((success, failed)) + } +} + +fn replay(path: &Path, signer: &Ed25519Signer) -> Result { + let file = + File::open(path).map_err(|error| format!("open logger ledger for replay: {error}"))?; + let mut reader = BufReader::new(file); + let mut state = LedgerState::default(); + let mut line = Vec::new(); + let mut record_number = 0usize; + loop { + line.clear(); + let count = reader + .by_ref() + .take((MAX_LEDGER_RECORD_BYTES + 2) as u64) + .read_until(b'\n', &mut line) + .map_err(|error| format!("read logger ledger: {error}"))?; + if count == 0 { + break; + } + record_number += 1; + if line.len() > MAX_LEDGER_RECORD_BYTES + 1 { + return Err(format!( + "logger ledger record {record_number} exceeds the size limit" + )); + } + if line.last() != Some(&b'\n') { + return Err(format!( + "logger ledger record {record_number} is truncated; manual recovery is required" + )); + } + line.pop(); + if line.is_empty() { + return Err(format!("logger ledger record {record_number} is empty")); + } + let record: LedgerRecord = serde_json::from_slice(&line) + .map_err(|error| format!("decode logger ledger record {record_number}: {error}"))?; + if canonical::bytes(&record)? != line { + return Err(format!( + "logger ledger record {record_number} is not canonical JSON" + )); + } + validate_next(&state, signer, &record) + .map_err(|error| format!("verify logger ledger record {record_number}: {error}"))?; + insert_record(&mut state, record); + } + Ok(state) +} + +fn validate_next( + state: &LedgerState, + signer: &Ed25519Signer, + record: &LedgerRecord, +) -> Result<(), String> { + if record.api_version != "logger.multiagent.dev/v1" || record.kind != "LoggerLedgerRecord" { + return Err("ledger record has an unsupported contract".into()); + } + validate_event(&record.accepted_event.event)?; + validate_identifier(&record.accepted_event.producer_id, "producer ID")?; + if canonical::sha256(&record.accepted_event)? != record.request_digest { + return Err("accepted event digest mismatch".into()); + } + if state + .events + .contains_key(&record.accepted_event.event.event_id) + { + return Err("ledger contains a duplicate event ID".into()); + } + let event = &record.accepted_event.event; + let entry = &record.entry; + if entry.body.log_id != event.session_id + || entry.body.event_id != event.event_id + || entry.body.producer_id != record.accepted_event.producer_id + || entry.body.event_type != event.event_type + || entry.body.payload_digest != event.payload_digest + || entry.body.artifact_references != event.artifact_references + { + return Err("accepted event does not match the chain entry".into()); + } + DateTime::parse_from_rfc3339(&entry.body.committed_at) + .map_err(|_| "entry committedAt is not RFC3339")?; + let head = state.logs.get(&event.session_id); + let expected_sequence = head.map_or(1, |value| value.sequence + 1); + let expected_previous = head + .map(|value| value.entry_hash.as_str()) + .unwrap_or(GENESIS_HASH); + if entry.body.sequence != expected_sequence || entry.body.previous_hash != expected_previous { + return Err("entry does not extend the current log head".into()); + } + if canonical::sha256(&entry.body)? != entry.entry_hash { + return Err("entry hash mismatch".into()); + } + if let Some(checkpoint) = &record.checkpoint { + validate_checkpoint(state, signer, checkpoint, entry)?; + } + Ok(()) +} + +fn validate_checkpoint( + state: &LedgerState, + signer: &Ed25519Signer, + checkpoint: &Checkpoint, + entry: &Entry, +) -> Result<(), String> { + let body = &checkpoint.body; + if body.api_version != "logger.multiagent.dev/v1" + || body.kind != "LoggerCheckpoint" + || body.logger_identity != signer.logger_id + || body.log_id != entry.body.log_id + || body.sequence != entry.body.sequence + || body.entry_hash != entry.entry_hash + || body.created_at != entry.body.committed_at + { + return Err("checkpoint does not match its ledger entry".into()); + } + if state.checkpoints.contains_key(&body.checkpoint_id) + || state + .checkpoints_by_log + .get(&body.log_id) + .is_some_and(|values| { + values + .iter() + .any(|value| value.body.sequence == body.sequence) + }) + { + return Err("ledger contains a duplicate checkpoint".into()); + } + if !signer.verify(body, &checkpoint.logger_signature) { + return Err("checkpoint signature is invalid".into()); + } + Ok(()) +} + +fn insert_record(state: &mut LedgerState, record: LedgerRecord) { + let log_id = record.entry.body.log_id.clone(); + let event_id = record.accepted_event.event.event_id.clone(); + state.logs.insert( + log_id.clone(), + LogHead { + log_id: log_id.clone(), + sequence: record.entry.body.sequence, + entry_hash: record.entry.entry_hash.clone(), + updated_at: record.entry.body.committed_at.clone(), + }, + ); + state + .entries + .entry(log_id.clone()) + .or_default() + .push(record.entry.clone()); + state.events.insert( + event_id, + StoredEvent { + request_digest: record.request_digest, + accepted_event: record.accepted_event, + entry: record.entry, + }, + ); + if let Some(checkpoint) = record.checkpoint { + state + .checkpoints_by_log + .entry(log_id) + .or_default() + .push(checkpoint.clone()); + state + .checkpoints + .insert(checkpoint.body.checkpoint_id.clone(), checkpoint); + } +} + +fn create_checkpoint(signer: &Ed25519Signer, entry: &Entry) -> Result { + let digest = format!( + "{:x}", + Sha256::digest(format!( + "{}:{}:{}", + entry.body.log_id, entry.body.sequence, entry.entry_hash + )) + ); + let body = CheckpointBody { + api_version: "logger.multiagent.dev/v1".into(), + kind: "LoggerCheckpoint".into(), + checkpoint_id: format!("checkpoint-{}", &digest[..48]), + log_id: entry.body.log_id.clone(), + sequence: entry.body.sequence, + entry_hash: entry.entry_hash.clone(), + created_at: entry.body.committed_at.clone(), + logger_identity: signer.logger_id.clone(), + }; + Ok(Checkpoint { + logger_signature: signer.sign(&body)?, + body, + }) +} + +fn write_projection(directory: &Path, log_id: &str, entries: &[Entry]) -> Result<(), String> { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| error.to_string())? + .as_nanos(); + let temporary = directory.join(format!(".{log_id}.{}.{unique}.tmp", std::process::id())); + let destination = directory.join(format!("{log_id}.jsonl")); + let result = (|| { + let mut file = secure_create_new(&temporary)?; + for entry in entries { + file.write_all(&canonical::bytes(entry)?) + .and_then(|()| file.write_all(b"\n")) + .map_err(|error| format!("write logger projection: {error}"))?; + } + file.sync_all() + .map_err(|error| format!("sync logger projection: {error}"))?; + fs::rename(&temporary, &destination) + .map_err(|error| format!("publish logger projection: {error}"))?; + protect(&destination, 0o600)?; + sync_directory(directory) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result +} + +fn secure_append_file(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.create(true).read(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let file = options + .open(path) + .map_err(|error| format!("open logger ledger: {error}"))?; + if !file + .metadata() + .map_err(|error| format!("inspect logger ledger: {error}"))? + .is_file() + { + return Err("logger ledger must be a regular file".into()); + } + protect_file(&file, 0o600)?; + Ok(file) +} + +fn secure_create_new(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options + .open(path) + .map_err(|error| format!("create logger projection: {error}")) +} + +#[cfg(unix)] +fn protect(path: &Path, mode: u32) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(mode)) + .map_err(|error| format!("protect {}: {error}", path.display())) +} + +#[cfg(unix)] +fn protect_file(file: &File, mode: u32) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(fs::Permissions::from_mode(mode)) + .map_err(|error| format!("protect logger ledger: {error}")) +} + +#[cfg(not(unix))] +fn protect(_path: &Path, _mode: u32) -> Result<(), String> { + Ok(()) +} + +#[cfg(not(unix))] +fn protect_file(_file: &File, _mode: u32) -> Result<(), String> { + Ok(()) +} + +#[cfg(unix)] +fn lock_single_writer(file: &File) -> Result<(), String> { + use std::os::fd::AsRawFd; + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if result == 0 { + Ok(()) + } else { + Err("another logger process already owns this ledger".into()) + } +} + +#[cfg(not(unix))] +fn lock_single_writer(_file: &File) -> Result<(), String> { + Ok(()) +} + +#[cfg(unix)] +fn sync_directory(path: &Path) -> Result<(), String> { + File::open(path) + .and_then(|file| file.sync_all()) + .map_err(|error| format!("sync logger projection directory: {error}")) +} + +#[cfg(not(unix))] +fn sync_directory(_path: &Path) -> Result<(), String> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{model::ArtifactReference, signer::Ed25519Signer}; + + fn event(id: &str, digest_byte: char) -> Event { + Event { + event_id: id.into(), + session_id: "session-1".into(), + event_type: "reviewer.verdict".into(), + payload_digest: format!("sha256:{}", digest_byte.to_string().repeat(64)), + artifact_references: vec![ArtifactReference { + uri: "s3://audit/session-1.jsonl".into(), + digest: None, + size: Some(42), + media_type: Some("application/jsonl".into()), + }], + } + } + + #[test] + fn file_ledger_is_idempotent_checkpointed_replayable_and_fail_closed() { + let directory = tempfile::tempdir().unwrap(); + let ledger = directory.path().join("ledger.jsonl"); + { + let signer = Ed25519Signer::from_seed([7; 32]); + let mut store = Store::open(&ledger, signer, 1, false).unwrap(); + assert_eq!( + store + .append(event("event-1", '1'), "producer-1".into()) + .unwrap(), + AppendResult::Appended + ); + assert_eq!( + store + .append(event("event-1", '1'), "producer-1".into()) + .unwrap(), + AppendResult::Duplicate + ); + assert!(matches!( + store.append(event("event-1", '2'), "producer-1".into()), + Err(StoreError::Conflict(_)) + )); + assert!(store.verify(None).unwrap().ok); + } + { + let signer = Ed25519Signer::from_seed([7; 32]); + let store = Store::open(&ledger, signer, 1, false).unwrap(); + assert_eq!(store.head("session-1").unwrap().unwrap().sequence, 1); + assert_eq!(store.checkpoints("session-1", 0, 10).unwrap().len(), 1); + } + OpenOptions::new() + .append(true) + .open(&ledger) + .unwrap() + .write_all(b"{\"truncated\":true}") + .unwrap(); + let signer = Ed25519Signer::from_seed([7; 32]); + assert!(Store::open(&ledger, signer, 1, false).is_err()); + } +} diff --git a/prompts/playbooks/reviewed-ops-cycle.md b/prompts/playbooks/reviewed-ops-cycle.md index 5228380..62145a5 100644 --- a/prompts/playbooks/reviewed-ops-cycle.md +++ b/prompts/playbooks/reviewed-ops-cycle.md @@ -34,7 +34,9 @@ This command: 1. validates that the bound request belongs to the named ops identity and publishes it as a supervisor-owned immutable artifact; 2. binds the reviewer to the immutable request and exact runbook; -3. passes only a bounded artifact descriptor to the reviewer; +3. passes the immutable descriptor to the reviewer and gives the reviewer + independent read access to the session trace corpus plus a supervisor-bound, + same-scope, read-only `prod-mcp` evidence path; 4. finalizes accepted review evidence before execution; and 5. launches a deterministic executor under the existing ops Linux identity, which submits the accepted request through the digest- and reviewer-bound @@ -53,10 +55,12 @@ runbook, and authority-produced execution result to decide the next runbook step. Never pass the supervisor-owned published artifact back as `--request-file`; -that path is intentionally outside the ops identity directory. On rejection or -preflight failure, report the blocker. A changed request needs a new publication -and reviewer. A review correction may use a fresh reviewer on the same immutable -request. Never create a second ops identity. +that path is intentionally outside the ops identity directory. When the +reviewer cannot accept, the cycle issues no operation permit, persists the +supervisor-sealed reviewer evidence and one bounded human-review question, +marks the workflow complete with the `human-review` route, and returns a +terminal `human_review_required` result. A later caller answer starts a new +execution session. Never create a second ops identity. The cycle already waits. Do not call `subagent wait` afterward, and do not read, tail, grep, find, or list unrelated agent logs, transcripts, role homes, or diff --git a/prompts/roles/decision-authority-reviewer.md b/prompts/roles/decision-authority-reviewer.md index 813651d..a6c6a78 100644 --- a/prompts/roles/decision-authority-reviewer.md +++ b/prompts/roles/decision-authority-reviewer.md @@ -17,6 +17,11 @@ Do not write generic verdicts such as `ACCEPTED`, `REJECTED`, `PASS`, `FAIL`, or `BLOCKING`. Do not add an introduction, Markdown heading, or code fence before the verdict. +When the verdict is `user-choice-required`, the supervisor mechanically seals +the `user-question`, terminates the execution session without issuing further +authority, and returns that question to the human. Ask exactly one bounded +question ending in a question mark. + Return exactly these fields in this order: 1. `verdict:` using one value from the exact vocabulary above. diff --git a/prompts/roles/ops-reviewer.md b/prompts/roles/ops-reviewer.md index 42ae2f8..94b5608 100644 --- a/prompts/roles/ops-reviewer.md +++ b/prompts/roles/ops-reviewer.md @@ -2,6 +2,17 @@ You are an independent read-only operations reviewer. Detect any deviation from the immutable original goal and supplied Markdown runbook. Treat the `.md` file, not the generated JSON envelope, as the authoritative procedure. +Reconstruct evidence independently. You may read the complete session trace +corpus under `MULTIAGENT_LOG_DIR`, including other agents' traces. When current +production evidence is necessary, use only the supervisor-mediated reviewer +path described in the assignment: inspect the live operation contract with +`multiagent ops describe`, create and bind a same-task, same-target, same-runbook +read request in your own trace directory, then call the assignment's +`multiagent ops execute` command with the reviewed-request binding. The shared +ops path preserves your reviewer identity and mechanically admits only +operations advertised as read-only and non-mutating; never request a write, +materialize, or other mutating operation. + Before execution: - Inspect the complete request template, original goal, runbook, target, operation, and parameters. @@ -9,8 +20,8 @@ Before execution: - Reject narrowed goals, extra actions, broadened targets, unbounded queries, or parameters not justified by the runbook. - Run `multiagent ops review-bind --request-file PATH`. This trusted command validates the generic executable envelope and writes a machine-generated binding artifact into your role-owned trace directory. Do not calculate, copy, retype, or include any hash in your response. - If `review-bind` fails for either schema or binding, reject the request. Manual digest calculation or visual comparison is not a substitute for successful deterministic validation. -- If and only if the request matches the goal and runbook, make the first non-empty line exactly `Verdict: ACCEPTED`, then explain the decision without reproducing the binding artifact. -- Otherwise make the first non-empty line `Verdict: REJECTED` and explain the deviation. +- If and only if the request matches the goal and runbook, make the first non-empty line exactly `Verdict: ACCEPTED`, then write `Human-review-question: none` and explain the decision without reproducing the binding artifact. +- Otherwise make the first non-empty line exactly `Verdict: HUMAN_REVIEW_REQUIRED`, then write one `Human-review-question: ...?` line that asks for the smallest user-owned decision needed to proceed, followed by the concrete deviation or uncertainty. - Keep the explanation to at most three concise bullets. Do not restate the request, runbook, schemas, digests, or evidence that the supervisor already supplied. diff --git a/runtime/src/authority.rs b/runtime/src/authority.rs index 9adbd8d..3f65f2d 100644 --- a/runtime/src/authority.rs +++ b/runtime/src/authority.rs @@ -85,7 +85,7 @@ impl AuthorityRequest { ) && args[2] == "--result-file") || (args.len() == 6 - && args[1] == "--read-only" + && matches!(args[1].as_str(), "--read-only" | "--human-review") && args[2] == "--result-file" && args[4] == "--reviewer")) => { @@ -168,9 +168,13 @@ impl AuthorityRequest { | AuthorityOperation::TodoAssign | AuthorityOperation::TodoStatus | AuthorityOperation::GateCheck => uid == config::ORCHESTRATOR_UID, - AuthorityOperation::OpsDescribe - | AuthorityOperation::OpsPublish - | AuthorityOperation::OpsExecute => uid == config::OPS_UID, + AuthorityOperation::OpsDescribe => { + matches!(uid, config::OPS_UID | config::REVIEWER_UID) + } + AuthorityOperation::OpsPublish => uid == config::OPS_UID, + AuthorityOperation::OpsExecute => { + matches!(uid, config::OPS_UID | config::REVIEWER_UID) + } AuthorityOperation::OpsPublishBound => uid == config::ORCHESTRATOR_UID, AuthorityOperation::FindingCreate => uid == config::READER_UID, AuthorityOperation::FindingDismiss | AuthorityOperation::TodoClose => { @@ -296,10 +300,12 @@ mod tests { ) .expect("ops request"); assert!(ops.authorized_for(config::OPS_UID)); + assert!(ops.authorized_for(config::REVIEWER_UID)); assert!(!ops.authorized_for(config::ORCHESTRATOR_UID)); let describe = AuthorityRequest::from_cli("ops", &strings(&["describe", "github.read"])) .expect("ops describe request"); assert!(describe.authorized_for(config::OPS_UID)); + assert!(describe.authorized_for(config::REVIEWER_UID)); assert!(!describe.authorized_for(config::ORCHESTRATOR_UID)); assert_eq!( describe.into_cli(), @@ -313,6 +319,23 @@ mod tests { assert!(publish_bound.authorized_for(config::ORCHESTRATOR_UID)); assert!(!publish_bound.authorized_for(config::OPS_UID)); + let evidence_read = AuthorityRequest::from_cli( + "ops", + &strings(&[ + "execute", + "--request-file", + "/state/evidence.json", + "--reviewed-request", + "/state/reviewed.json", + "--reviewer", + "ops-reviewer-01", + ]), + ) + .expect("reviewer evidence read request"); + assert!(evidence_read.authorized_for(config::REVIEWER_UID)); + assert!(!evidence_read.authorized_for(config::READER_UID)); + assert!(evidence_read.authorized_for(config::OPS_UID)); + let external_completion = AuthorityRequest::from_cli("orchestrator", &strings(&["complete", "--external-only"])) .expect("external-only completion request"); @@ -399,6 +422,20 @@ mod tests { .expect("read-only completion request"); assert!(read_only_completion.authorized_for(config::ORCHESTRATOR_UID)); assert!(!read_only_completion.authorized_for(config::READER_UID)); + let human_review_completion = AuthorityRequest::from_cli( + "orchestrator", + &strings(&[ + "complete", + "--human-review", + "--result-file", + "/state/question.md", + "--reviewer", + "ops-reviewer-01", + ]), + ) + .expect("human review completion request"); + assert!(human_review_completion.authorized_for(config::ORCHESTRATOR_UID)); + assert!(!human_review_completion.authorized_for(config::REVIEWER_UID)); assert!(AuthorityRequest::from_cli( "orchestrator", &strings(&["complete", "--unsupported"]), diff --git a/runtime/src/config.rs b/runtime/src/config.rs index e78fcc5..d231454 100644 --- a/runtime/src/config.rs +++ b/runtime/src/config.rs @@ -8,6 +8,7 @@ pub const READER_UID: u32 = 10003; #[cfg(target_os = "linux")] pub const SUPERVISOR_UID: u32 = 10004; pub const OPS_UID: u32 = 10005; +pub const REVIEWER_UID: u32 = 10007; pub const ROLE_GID: u32 = 10001; #[cfg(target_os = "linux")] pub const TRACE_EXPORT_GID: u32 = 10006; diff --git a/runtime/src/prod_ops.rs b/runtime/src/prod_ops.rs index b367fe7..60e1718 100644 --- a/runtime/src/prod_ops.rs +++ b/runtime/src/prod_ops.rs @@ -14,7 +14,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; const MAX_OPERATION_REQUEST_BYTES: u64 = 65_536; const MAX_RUNBOOK_BYTES: u64 = 1_048_576; -const OPS_USAGE: &str = "usage:\n multiagent ops describe OPERATION_ID\n multiagent ops template\n multiagent ops bind-runbook --request-file PATH --runbook-document PATH\n multiagent ops publish --draft-file PATH --runbook-document PATH\n multiagent ops review-bind --request-file PATH\n multiagent ops execute --request-file PATH --reviewer NAME"; +const OPS_USAGE: &str = "usage:\n multiagent ops describe OPERATION_ID\n multiagent ops template\n multiagent ops bind-runbook --request-file PATH --runbook-document PATH\n multiagent ops publish --draft-file PATH --runbook-document PATH\n multiagent ops review-bind --request-file PATH\n multiagent ops execute --request-file PATH --reviewer NAME [--reviewed-request PATH]"; pub(crate) struct PublishedRequest { artifact_path: PathBuf, @@ -541,7 +541,260 @@ pub(crate) fn preflight_reviewed_request( load_reviewed_request(request_file, reviewer).map(|_| ()) } +/// Executes a reviewer-requested observation without granting the reviewer +/// transport credentials or mutation authority. The authority socket admits +/// this command only from REVIEWER_UID; these checks bind the request further +/// to the live reviewer identity and the immutable operation under review. +fn execute_reviewer_read(_args: &[String]) -> Result { + #[cfg(not(target_os = "linux"))] + return Err("reviewer ops execute requires Linux reviewer isolation".into()); + + #[cfg(target_os = "linux")] + { + let args = _args; + let options = options(args)?; + let reviewer = required(&options, "--reviewer")?; + validate_id("reviewer name", reviewer)?; + let state = fs::canonicalize(required_env("MULTIAGENT_STATE_DIR")?) + .map_err(|error| format!("resolve multiagent state: {error}"))?; + validate_live_reviewer(&state, reviewer)?; + + let reviewed_path = PathBuf::from(required(&options, "--reviewed-request")?); + let (_, reviewed_bytes) = read_reviewable_request(&state, &reviewed_path)?; + let reviewed: Value = serde_json::from_slice(&reviewed_bytes) + .map_err(|error| format!("decode reviewed ops request: {error}"))?; + validate_request_template(&reviewed)?; + verified_runbook_content(&reviewed)?; + + let evidence_path = PathBuf::from(required(&options, "--request-file")?); + let evidence_bytes = read_reviewer_request(reviewer, &evidence_path)?; + let evidence_template: Value = serde_json::from_slice(&evidence_bytes) + .map_err(|error| format!("decode reviewer evidence request: {error}"))?; + validate_request_template(&evidence_template)?; + verified_runbook_content(&evidence_template)?; + validate_evidence_scope(&reviewed, &evidence_template)?; + + let operation_id = evidence_template + .pointer("/operation/id") + .and_then(Value::as_str) + .ok_or("reviewer evidence request has no operation ID")?; + let capabilities = call_prod_mcp_tool("operations_capabilities", json!({}))?; + let capability = operation_capability(&capabilities, operation_id)?; + validate_read_capability(capability, &evidence_template)?; + + let now = Utc::now(); + let caller_subject = env::var("MULTIAGENT_CALLER_SUBJECT") + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "multiagent-control".into()); + validate_id("caller subject", &caller_subject)?; + let caller = TrustedApproval { + subject: caller_subject, + role: "safety-reviewer", + evidence_sha256: digest_json( + reviewed + .get("goal") + .ok_or("reviewed ops request requires goal")?, + )?, + approved_at: env::var("MULTIAGENT_CALLER_APPROVED_AT") + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| now.to_rfc3339_opts(SecondsFormat::Millis, true)), + }; + let request = build_request( + &evidence_template, + &[&caller], + "runbook-observer", + reviewer, + &execution_context_from_environment()?, + now, + )?; + let action_id = request["actionId"] + .as_str() + .ok_or("generated reviewer evidence request has no action ID")? + .to_string(); + let payload = canonical(&json!({ + "apiVersion":"prod.moveindustries.io/v1", + "kind":"ActionPermit", + "request": request + }))?; + let permit = sign_permit(&payload)?; + let result = call_prod_mcp(&permit)?; + let evidence_dir = state + .join("reviewer-live-evidence") + .join(reviewer) + .join(&action_id); + fs::create_dir_all(&evidence_dir) + .map_err(|error| format!("create reviewer evidence receipt directory: {error}"))?; + secure_publication_path(&evidence_dir, true)?; + let request_artifact = evidence_dir.join("request.json"); + let receipt_artifact = evidence_dir.join("receipt.json"); + fs::write( + &request_artifact, + serde_json::to_vec_pretty(&evidence_template).map_err(|error| error.to_string())?, + ) + .map_err(|error| format!("persist reviewer evidence request: {error}"))?; + fs::write( + &receipt_artifact, + serde_json::to_vec_pretty(&result).map_err(|error| error.to_string())?, + ) + .map_err(|error| format!("persist reviewer evidence receipt: {error}"))?; + secure_publication_path(&request_artifact, false)?; + secure_publication_path(&receipt_artifact, false)?; + let structured = result + .pointer("/result/structuredContent") + .cloned() + .unwrap_or(Value::Null); + println!( + "{}", + serde_json::to_string(&json!({ + "apiVersion": "multiagent.moveindustries.io/v1", + "kind": "ReviewerEvidenceResult", + "reviewer": reviewer, + "actionId": action_id, + "operationId": structured.get("operationId").cloned().unwrap_or(Value::Null), + "state": structured.get("state").cloned().unwrap_or(Value::Null), + "outcome": structured.get("outcome").cloned().unwrap_or(Value::Null), + "code": structured.get("code").cloned().unwrap_or(Value::Null), + "message": structured.get("message").cloned().unwrap_or(Value::Null), + "evidence": structured.get("summary").cloned().unwrap_or(Value::Null), + "receiptPath": receipt_artifact, + })) + .map_err(|error| format!("encode reviewer evidence result: {error}"))? + ); + Ok(ExitCode::SUCCESS) + } +} + +#[cfg(target_os = "linux")] +fn validate_live_reviewer(state: &Path, reviewer: &str) -> Result<(), String> { + let metadata = + crate::state::read_env(&state.join("subagents").join(reviewer).join("meta.env"))?; + if metadata.get("role").map(String::as_str) != Some("reviewer") + || metadata.get("access").map(String::as_str) != Some("read-only") + { + return Err("reviewer evidence reads require a live read-only reviewer identity".into()); + } + let status = fs::read_to_string(state.join("subagents").join(reviewer).join("status")) + .unwrap_or_default(); + if !matches!(status.trim(), "running" | "waiting" | "restoring") { + return Err("reviewer evidence reads require the reviewer to be active".into()); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn read_reviewer_request(reviewer: &str, path: &Path) -> Result, String> { + let logs = fs::canonicalize(required_env("MULTIAGENT_LOG_DIR")?) + .map_err(|error| format!("resolve multiagent log directory: {error}"))?; + let reviewer_root = fs::canonicalize(logs.join("agents").join(reviewer)) + .map_err(|error| format!("resolve reviewer trace directory: {error}"))?; + let canonical = fs::canonicalize(path) + .map_err(|error| format!("resolve reviewer evidence request: {error}"))?; + if !canonical.starts_with(&reviewer_root) { + return Err("reviewer evidence request must be inside its own trace directory".into()); + } + let (bytes, metadata) = read_bounded_file(&canonical, MAX_OPERATION_REQUEST_BYTES, true)?; + if metadata.uid() != crate::config::REVIEWER_UID || metadata.mode() & 0o022 != 0 { + return Err( + "reviewer evidence request must be reviewer-owned and not group- or world-writable" + .into(), + ); + } + Ok(bytes) +} + +fn validate_evidence_scope(reviewed: &Value, evidence: &Value) -> Result<(), String> { + for pointer in [ + "/taskId", + "/goal", + "/target", + "/runbook", + "/runbookDocument", + "/runbookContentSha256", + ] { + if reviewed.pointer(pointer) != evidence.pointer(pointer) { + return Err(format!( + "reviewer evidence request widened reviewed scope at {pointer}" + )); + } + } + Ok(()) +} + +fn validate_read_capability(capability: &Value, template: &Value) -> Result<(), String> { + if capability.get("access").and_then(Value::as_str) != Some("read") + || capability.get("mutation").and_then(Value::as_bool) != Some(false) + { + return Err( + "reviewer evidence operation must be advertised as read-only and non-mutating".into(), + ); + } + if capability + .get("requiredApprovalRoles") + .and_then(Value::as_array) + .is_none_or(|roles| !roles.is_empty()) + { + return Err("reviewer evidence operation must not require mutation approvals".into()); + } + if capability.get("version") != template.pointer("/operation/version") { + return Err( + "reviewer evidence operation version does not match prod-mcp capability".into(), + ); + } + let runbook = format!( + "{}@{}", + template + .pointer("/runbook/id") + .and_then(Value::as_str) + .unwrap_or(""), + template + .pointer("/runbook/version") + .and_then(Value::as_str) + .unwrap_or("") + ); + let allowed = capability + .get("allowedRunbooks") + .and_then(Value::as_array) + .is_some_and(|values| values.iter().any(|value| value.as_str() == Some(&runbook))); + if !allowed { + return Err("reviewer evidence operation is not allowed by the reviewed runbook".into()); + } + Ok(()) +} + fn execute(args: &[String]) -> Result { + let options = options(args)?; + let caller_uid = env::var("MULTIAGENT_AUTHORITY_CALLER_UID") + .map_err(|_| "ops execute must be mediated by the authority supervisor")? + .parse::() + .map_err(|_| "authority caller UID is invalid")?; + match execute_mode(caller_uid, options.contains_key("--reviewed-request"))? { + ExecuteMode::ReviewerRead => execute_reviewer_read(args), + ExecuteMode::ReviewedOperation => execute_reviewed_operation(args), + } +} + +#[derive(Debug, Eq, PartialEq)] +enum ExecuteMode { + ReviewerRead, + ReviewedOperation, +} + +fn execute_mode(caller_uid: u32, has_reviewed_request: bool) -> Result { + if has_reviewed_request { + if matches!(caller_uid, 0 | crate::config::REVIEWER_UID) { + return Ok(ExecuteMode::ReviewerRead); + } + return Err("reviewer evidence reads require the reviewer identity".into()); + } + if matches!(caller_uid, 0 | crate::config::OPS_UID) { + return Ok(ExecuteMode::ReviewedOperation); + } + Err("operation execution requires the ops identity".into()) +} + +fn execute_reviewed_operation(args: &[String]) -> Result { let options = options(args)?; let reviewer = required(&options, "--reviewer")?; let request_file = PathBuf::from(required(&options, "--request-file")?); @@ -569,8 +822,9 @@ fn execute(args: &[String]) -> Result { let execution_context = execution_context_from_environment()?; let request = build_request( &template, - &caller_approval, - &reviewer_approval, + &[&caller_approval, &reviewer_approval], + "runbook-operator", + "multiagent-supervisor", &execution_context, now, )?; @@ -650,12 +904,14 @@ fn execute(args: &[String]) -> Result { fn build_request( template: &Value, - caller: &TrustedApproval, - reviewer: &TrustedApproval, + approvals: &[&TrustedApproval], + delegated_role: &str, + delegated_subject: &str, execution_context: &Value, now: chrono::DateTime, ) -> Result { validate_request_template(template)?; + validate_id("delegated subject", delegated_subject)?; let object = template .as_object() .ok_or("ops request template must be a JSON object")?; @@ -687,10 +943,11 @@ fn build_request( if object.contains_key("approvals") { return Err("ops request approvals are derived by the supervisor and cannot be supplied by an agent".into()); } - if caller.subject == reviewer.subject { - return Err("caller and operations reviewer must be distinct subjects".into()); - } - for approval in [caller, reviewer] { + let mut subjects = std::collections::BTreeSet::new(); + for approval in approvals { + if !subjects.insert(approval.subject.as_str()) { + return Err("trusted approval subjects must be distinct".into()); + } let approved_at = chrono::DateTime::parse_from_rfc3339(&approval.approved_at) .map_err(|_| "trusted approval has an invalid timestamp")?; if approved_at > now { @@ -702,27 +959,24 @@ fn build_request( .duration_since(UNIX_EPOCH) .map_err(|error| error.to_string())? .as_millis(); + let approval_values = approvals + .iter() + .map(|approval| { + json!({ + "reviewerSubject": approval.subject, + "reviewerRole": approval.role, + "decision": "approve", + "evidenceSha256": approval.evidence_sha256, + "approvedAt": approval.approved_at + }) + }) + .collect::>(); let mut request = json!({ "actionId": format!("ops-{unique}-{}", std::process::id()), "apiVersion": "prod.moveindustries.io/v1", - "approvals": [ - { - "reviewerSubject": caller.subject, - "reviewerRole": caller.role, - "decision": "approve", - "evidenceSha256": caller.evidence_sha256, - "approvedAt": caller.approved_at - }, - { - "reviewerSubject": reviewer.subject, - "reviewerRole": reviewer.role, - "decision": "approve", - "evidenceSha256": reviewer.evidence_sha256, - "approvedAt": reviewer.approved_at - } - ], - "delegatedRole": "runbook-operator", - "delegatedSubject": "multiagent-supervisor", + "approvals": approval_values, + "delegatedRole": delegated_role, + "delegatedSubject": delegated_subject, "authorityProxy": { "subject": "multiagent-supervisor", "credentialSource": "deployment", @@ -1588,9 +1842,11 @@ fn base64_decode(value: &str) -> Result, String> { mod tests { use super::{ base64_decode, base64url_encode, build_request, canonical, curl_command, ecdsa_der_to_raw, - operation_capability, parse_mcp_body, private_temp_path, review_binding_marker, - review_binding_matches, review_binding_value, review_evidence_is_bound, reviewer_accepted, - runbook_content_digest, validate_request_template, write_mcp_headers, TrustedApproval, + execute_mode, operation_capability, parse_mcp_body, private_temp_path, + review_binding_marker, review_binding_matches, review_binding_value, + review_evidence_is_bound, reviewer_accepted, runbook_content_digest, + validate_evidence_scope, validate_read_capability, validate_request_template, + write_mcp_headers, ExecuteMode, TrustedApproval, }; use chrono::{TimeZone, Utc}; use serde_json::json; @@ -1706,7 +1962,7 @@ mod tests { "runbookDocument":"runbooks/custom-runbook.md", "runbookContentSha256":format!("sha256:{}", "4".repeat(64)), "changeTicket":"OPS-123" - }), &caller, &reviewer, &json!({ + }), &[&caller, &reviewer], "runbook-operator", "multiagent-supervisor", &json!({ "threadId": "thread-1", "sessionId": "session-1", "leaseGeneration": 1, @@ -1720,6 +1976,84 @@ mod tests { assert_eq!(request["approvals"][1]["reviewerSubject"], "reviewer-1"); } + #[test] + fn reviewer_evidence_scope_and_live_capability_are_fail_closed() { + let reviewed = json!({ + "taskId":"task-1", + "goal":"investigate service", + "target":{"environment":"production","cluster":"cluster-a","namespace":"service-a","service":"api"}, + "runbook":{"id":"observability.investigation","version":"1.1.0","phase":"observe"}, + "runbookDocument":"runbooks/observability.md", + "runbookContentSha256":format!("sha256:{}", "4".repeat(64)), + "operation":{"id":"observability.query","version":"1.0.0"}, + "parameters":{} + }); + let evidence = reviewed.clone(); + assert!(validate_evidence_scope(&reviewed, &evidence).is_ok()); + let mut widened = evidence.clone(); + widened["target"]["service"] = "other".into(); + assert!(validate_evidence_scope(&reviewed, &widened) + .unwrap_err() + .contains("/target")); + + let capability = json!({ + "id":"observability.query", + "version":"1.0.0", + "access":"read", + "mutation":false, + "allowedRunbooks":["observability.investigation@1.1.0"], + "requiredApprovalRoles":[] + }); + assert!(validate_read_capability(&capability, &evidence).is_ok()); + let mut mutating = capability.clone(); + mutating["mutation"] = true.into(); + assert!(validate_read_capability(&mutating, &evidence).is_err()); + let mut wrong_runbook = capability; + wrong_runbook["allowedRunbooks"] = json!(["other@1.0.0"]); + assert!(validate_read_capability(&wrong_runbook, &evidence).is_err()); + } + + #[test] + fn reviewer_observation_permit_uses_observer_role_and_no_model_approval() { + let now = Utc.with_ymd_and_hms(2026, 8, 22, 12, 0, 0).unwrap(); + let caller = TrustedApproval { + subject: "caller-1".into(), + role: "safety-reviewer", + evidence_sha256: format!("sha256:{}", "1".repeat(64)), + approved_at: now.to_rfc3339(), + }; + let request = build_request(&json!({ + "taskId":"task-1", + "goal":"observe", + "operation":{"id":"observability.query","version":"1.0.0"}, + "target":{"environment":"production","cluster":"cluster-a","namespace":"service-a","service":"api"}, + "parameters":{}, + "runbook":{"id":"observability.investigation","version":"1.1.0","phase":"observe"}, + "runbookDocument":"runbooks/observability.md", + "runbookContentSha256":format!("sha256:{}", "4".repeat(64)) + }), &[&caller], "runbook-observer", "reviewer-1", &json!({ + "threadId":"thread-1","sessionId":"session-1","leaseGeneration":1,"authorizingEventId":"message-1" + }), now).unwrap(); + assert_eq!(request["delegatedRole"], "runbook-observer"); + assert_eq!(request["delegatedSubject"], "reviewer-1"); + assert_eq!(request["approvals"].as_array().unwrap().len(), 1); + assert_eq!(request["approvals"][0]["reviewerSubject"], "caller-1"); + } + + #[test] + fn shared_execute_path_keeps_reviewer_and_operator_authority_disjoint() { + assert_eq!( + execute_mode(crate::config::REVIEWER_UID, true).unwrap(), + ExecuteMode::ReviewerRead + ); + assert!(execute_mode(crate::config::REVIEWER_UID, false).is_err()); + assert_eq!( + execute_mode(crate::config::OPS_UID, false).unwrap(), + ExecuteMode::ReviewedOperation + ); + assert!(execute_mode(crate::config::OPS_UID, true).is_err()); + } + #[test] fn request_template_validation_rejects_non_executable_envelopes() { let digest = format!("sha256:{}", "4".repeat(64)); diff --git a/runtime/src/runtime.rs b/runtime/src/runtime.rs index d6ccc25..a3ed52f 100644 --- a/runtime/src/runtime.rs +++ b/runtime/src/runtime.rs @@ -92,8 +92,21 @@ type CodexAccess = RoleAccess; const ORCHESTRATOR_UID: u32 = config::ORCHESTRATOR_UID; const WRITER_UID: u32 = config::WRITER_UID; const READER_UID: u32 = config::READER_UID; +const REVIEWER_UID: u32 = config::REVIEWER_UID; const ROLE_GID: u32 = config::ROLE_GID; +fn role_runtime_uid(role: &str, access: CodexAccess) -> u32 { + if role == "ops" { + config::OPS_UID + } else if role == "reviewer" { + REVIEWER_UID + } else if access == CodexAccess::WorkspaceWrite { + WRITER_UID + } else { + READER_UID + } +} + impl RuntimeConfig { fn load() -> Result { let root = config::root()?; @@ -205,6 +218,12 @@ pub fn container_bootstrap() -> Result { config::ROLE_GID, 0o700, ), + ( + base.join("role-homes/reviewer"), + config::REVIEWER_UID, + config::ROLE_GID, + 0o700, + ), ( base.join("role-homes/supervisor"), config::SUPERVISOR_UID, @@ -308,16 +327,12 @@ pub fn role_agent_exec(args: &[String]) -> Result { .then(|| native_resume_session(&trace_dir)) .flatten(); let executable = env::current_exe().map_err(io_error("resolve multiagent executable"))?; - let role_uid = if authorization.role == "ops" { - config::OPS_UID - } else if access == CodexAccess::WorkspaceWrite { - WRITER_UID - } else { - READER_UID - }; + let role_uid = role_runtime_uid(&authorization.role, access); if let Some(root) = env_nonempty("MULTIAGENT_CODEX_HOME_ROOT") { let role_home = Path::new(&root).join(if authorization.role == "ops" { "ops" + } else if authorization.role == "reviewer" { + "reviewer" } else if access == CodexAccess::WorkspaceWrite { "writer" } else { @@ -1190,7 +1205,7 @@ pub fn orchestrator(args: &[String]) -> Result { .iter() .any(|arg| matches!(arg.as_str(), "-h" | "--help")) { - println!("Usage:\n multiagent orchestrator complete\n multiagent orchestrator complete --direct-response --result-file PATH\n multiagent orchestrator complete --clarification --result-file PATH\n multiagent orchestrator complete --auto-clarification --result-file PATH\n multiagent orchestrator complete --read-only --result-file PATH --reviewer NAME\n multiagent orchestrator complete --external-only --result-file PATH\n\nRuns the supervisor completion gates. Shortcut and external-only completion require a self-contained caller result under MULTIAGENT_STATE_DIR."); + println!("Usage:\n multiagent orchestrator complete\n multiagent orchestrator complete --direct-response --result-file PATH\n multiagent orchestrator complete --clarification --result-file PATH\n multiagent orchestrator complete --auto-clarification --result-file PATH\n multiagent orchestrator complete --read-only --result-file PATH --reviewer NAME\n multiagent orchestrator complete --human-review --result-file PATH --reviewer NAME\n multiagent orchestrator complete --external-only --result-file PATH\n\nRuns the supervisor completion gates. Shortcut and external-only completion require a self-contained caller result under MULTIAGENT_STATE_DIR."); return Ok(ExitCode::SUCCESS); } #[derive(Clone, Copy)] @@ -1200,6 +1215,7 @@ pub fn orchestrator(args: &[String]) -> Result { Clarification(&'a str), AutoClarification(&'a str), ReadOnly { result: &'a str, reviewer: &'a str }, + HumanReview { result: &'a str, reviewer: &'a str }, External(&'a str), } let route = if args.len() == 1 && args[0] == "complete" { @@ -1238,6 +1254,16 @@ pub fn orchestrator(args: &[String]) -> Result { result: &args[3], reviewer: &args[5], } + } else if args.len() == 6 + && args[0] == "complete" + && args[1] == "--human-review" + && args[2] == "--result-file" + && args[4] == "--reviewer" + { + CompletionRoute::HumanReview { + result: &args[3], + reviewer: &args[5], + } } else { return Err(format!("unknown command: {}", args[0])); }; @@ -1247,7 +1273,9 @@ pub fn orchestrator(args: &[String]) -> Result { | CompletionRoute::Clarification(path) | CompletionRoute::AutoClarification(path) | CompletionRoute::External(path) => Some(path), - CompletionRoute::ReadOnly { result, .. } => Some(result), + CompletionRoute::ReadOnly { result, .. } | CompletionRoute::HumanReview { result, .. } => { + Some(result) + } }; if let CompletionRoute::Clarification(path) = route { validate_bounded_clarification(path)?; @@ -1273,6 +1301,9 @@ pub fn orchestrator(args: &[String]) -> Result { CompletionRoute::ReadOnly { reviewer, .. } => { crate::workflow::supervisor_complete_read_only(&workflow_id, reviewer)? } + CompletionRoute::HumanReview { reviewer, .. } => { + crate::workflow::supervisor_complete_human_review(&workflow_id, reviewer)? + } CompletionRoute::External(_) => { crate::workflow::supervisor_complete_external(&workflow_id)? } @@ -2050,11 +2081,7 @@ fn spawn(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { &command, &executable, role_write_roots(&cfg.root, &cfg.state, access == CodexAccess::WorkspaceWrite), - if access == CodexAccess::WorkspaceWrite { - WRITER_UID - } else { - READER_UID - }, + role_runtime_uid(authority_role, access), ) }; let command = subagent_shell_command(cfg, name, cli, &executable, &cli_command, access, false); @@ -2117,9 +2144,12 @@ fn reviewed_ops_reviewer_instruction( request_file: &Path, descriptor: &str, binding: &str, + reviewer: &str, ) -> String { format!( - "Independently review the supervisor-owned immutable ops request identified by the bounded artifact descriptor below. Read that exact artifact, compare it with its stated goal, operation, target, parameters, and certified runbook, and do not modify or execute it. If and only if it is acceptable, end with an accepted verdict and reproduce the binding marker exactly. Otherwise reject it with concrete findings.\n\nrequest-path: {}\n{}\n\nimmutable-request-descriptor:\n{}", + "Independently review the supervisor-owned immutable ops request identified by the bounded artifact descriptor below. Read that exact artifact and certified runbook, and reconstruct evidence rather than relying only on the proposing agent. You may read any session trace under `$MULTIAGENT_LOG_DIR`. When fresh production evidence is material, you may create a request in your own trace directory, preserving the reviewed request's taskId, goal, target, and exact runbook binding, select only an operation that `multiagent ops describe OPERATION_ID` reports as read-only, bind it with `multiagent ops bind-runbook`, and submit it through the normal ops path with `multiagent ops execute --request-file PATH --reviewed-request {} --reviewer {}`. This path mechanically rejects mutation. Do not modify or execute the reviewed request. Run the required review binding command. If and only if the request is acceptable, use the accepted verdict and reproduce the binding marker exactly. Otherwise request human review with one bounded question; no operation permit will be issued.\n\nrequest-path: {}\n{}\n\nimmutable-request-descriptor:\n{}", + request_file.display(), + reviewer, request_file.display(), binding, descriptor @@ -2131,6 +2161,24 @@ const FRESH_CONTEXT_CONTRACT: &str = r#""#; const REVIEWED_OPS_TERMINAL_FILE: &str = "reviewed-ops-terminal"; +fn complete_reviewer_human_fallback( + cfg: &RuntimeConfig, + reviewer: &str, + question: &str, +) -> Result<(), String> { + let candidate = cfg.state.join("human-review-result-candidate.md"); + write_state(&candidate, &format!("{}\n", question.trim()))?; + run_self_owned(&[ + "orchestrator".into(), + "complete".into(), + "--human-review".into(), + "--result-file".into(), + candidate.display().to_string(), + "--reviewer".into(), + reviewer.into(), + ]) +} + const ITERATION_PLAN_API_VERSION: &str = "multiagent.moveindustries.io/v1"; const ITERATION_PLAN_KIND: &str = "IterationPlan"; @@ -2287,6 +2335,19 @@ fn execute_iteration(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> &authority_name, )?; if authority_verdict == "findings" { + if let Some((question, _)) = + workflow::reviewer_human_review_question(&plan.workflow_id, &authority_name)? + { + complete_reviewer_human_fallback(cfg, &authority_name, &question)?; + emit_iteration_result( + "human_review_required", + &plan, + &plan_sha256, + "decision-authority-user-choice", + None, + )?; + return Ok(()); + } emit_iteration_result( "needs_replan", &plan, @@ -3039,7 +3100,7 @@ fn reviewed_ops_cycle(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String let (request_file, descriptor) = publish_reviewed_ops_request(&ops_request_file)?; let binding = crate::prod_ops::review_binding_for_request(&request_file)?; let reviewer_instruction = - reviewed_ops_reviewer_instruction(&request_file, &descriptor, &binding); + reviewed_ops_reviewer_instruction(&request_file, &descriptor, &binding, &reviewer); spawn( cfg, &[ @@ -3062,6 +3123,37 @@ fn reviewed_ops_cycle(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String )); } finalize(cfg, std::slice::from_ref(&reviewer))?; + let workflow_id = env_nonempty("MULTIAGENT_WORKFLOW_ID") + .ok_or("reviewed-ops-cycle requires MULTIAGENT_WORKFLOW_ID")?; + if let Some((question, reason)) = + workflow::reviewer_human_review_question(&workflow_id, &reviewer)? + { + complete_reviewer_human_fallback(cfg, &reviewer, &question)?; + fs::write( + ops_dir.join(REVIEWED_OPS_TERMINAL_FILE), + format!("requestSha256={reviewed_request_sha256}\nstatus=human-review-required\n"), + ) + .map_err(io_error("write reviewed ops human-review terminal marker"))?; + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "apiVersion": "multiagent.moveindustries.io/v1", + "kind": "ReviewedOpsCycleResult", + "opsName": ops_name, + "reviewer": reviewer, + "opsStatus": "human_review_required", + "cycleWaitedForCompletion": true, + "additionalWaitRequired": false, + "terminal": true, + "executionResult": serde_json::Value::Null, + "opsResult": question, + "followUpRequest": serde_json::Value::Null, + "humanReview": {"status": "pending", "reason": reason, "question": question}, + })) + .map_err(|error| format!("encode reviewed ops human-review result: {error}"))? + ); + return Ok(()); + } crate::prod_ops::preflight_reviewed_request(&request_file, &reviewer)?; let request_argument = request_file @@ -3625,11 +3717,10 @@ fn restore(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { &command, &executable, role_write_roots(&cfg.root, &cfg.state, access == CodexAccess::WorkspaceWrite), - if access == CodexAccess::WorkspaceWrite { - WRITER_UID - } else { - READER_UID - }, + role_runtime_uid( + metadata.get("role").map(String::as_str).unwrap_or("reader"), + access, + ), ) }; let command = subagent_shell_command(cfg, name, &cli, &executable, &cli_command, access, true); @@ -5595,9 +5686,12 @@ mod tests { request, "{\"path\":\"/state/request.json\",\"digest\":\"abc\",\"bytes\":42,\"mediaType\":\"application/json\",\"truncated\":false}", "review-binding-sha256=abc", + "ops-reviewer-01", ); assert!(review.contains("review-binding-sha256=abc")); assert!(review.contains("immutable-request-descriptor")); + assert!(review.contains("ops execute")); + assert!(review.contains("ops-reviewer-01")); assert!(!review.contains("provider.read")); assert!(!review.contains("Slack")); assert!(!review.contains("Grafana")); @@ -5619,6 +5713,19 @@ mod tests { assert!(!result.contains("multiagent ops execute")); } + #[test] + fn reviewer_has_a_distinct_kernel_identity() { + assert_eq!( + role_runtime_uid("reviewer", CodexAccess::ReadOnly), + REVIEWER_UID + ); + assert_eq!( + role_runtime_uid("reader", CodexAccess::ReadOnly), + READER_UID + ); + assert_ne!(REVIEWER_UID, READER_UID); + } + #[test] fn fresh_context_and_terminal_restore_contracts_are_machine_stable() { let instruction = fresh_context_instruction(); diff --git a/runtime/src/supervisor.rs b/runtime/src/supervisor.rs index c71e2b5..b4b9ed4 100644 --- a/runtime/src/supervisor.rs +++ b/runtime/src/supervisor.rs @@ -460,7 +460,7 @@ pub fn seal_role_output( .map_err(|error| format!("inspect ops review binding: {error}"))?; if !binding_file_metadata.is_file() || binding_file_metadata.file_type().is_symlink() - || binding_file_metadata.uid() != config::READER_UID + || binding_file_metadata.uid() != config::REVIEWER_UID || binding_file_metadata.permissions().mode() & 0o077 != 0 { return Err( @@ -676,7 +676,11 @@ fn server_child() -> bool { fn authority_client_uid() -> bool { matches!( unsafe { libc::getuid() }, - config::ORCHESTRATOR_UID | config::WRITER_UID | config::READER_UID | config::OPS_UID + config::ORCHESTRATOR_UID + | config::WRITER_UID + | config::READER_UID + | config::OPS_UID + | config::REVIEWER_UID ) } @@ -839,7 +843,11 @@ fn serve_connection(stream: &mut UnixStream) -> Result { }; if !matches!( peer_uid, - 0 | config::ORCHESTRATOR_UID | config::WRITER_UID | config::READER_UID | config::OPS_UID + 0 | config::ORCHESTRATOR_UID + | config::WRITER_UID + | config::READER_UID + | config::OPS_UID + | config::REVIEWER_UID ) { let _ = write_response( stream, diff --git a/runtime/src/workflow.rs b/runtime/src/workflow.rs index 2f2b18f..95b9ead 100644 --- a/runtime/src/workflow.rs +++ b/runtime/src/workflow.rs @@ -48,6 +48,11 @@ const ENV_ORDER: &[&str] = &[ "iteration_worker_count", "candidate_diff_hash", "reviewed_diff_hash", + "human_review_status", + "human_review_request", + "human_review_request_sha256", + "human_review_reviewer", + "human_review_reason", "resume_count", "created_at", "updated_at", @@ -534,6 +539,11 @@ fn initialize_id(id: &str, resume: bool) -> Result<(), String> { ("iteration_worker_count", ""), ("candidate_diff_hash", ""), ("reviewed_diff_hash", ""), + ("human_review_status", ""), + ("human_review_request", ""), + ("human_review_request_sha256", ""), + ("human_review_reviewer", ""), + ("human_review_reason", ""), ("resume_count", "0"), ] { state.insert(key.into(), value.into()); @@ -1640,6 +1650,137 @@ pub fn supervisor_complete_direct(id: &str) -> Result { Ok(result) } +/// Returns the bounded question only for a mechanically recognized reviewer +/// escalation. The evidence must already have been finalized and sealed by the +/// supervisor for this workflow. +pub fn reviewer_human_review_question( + id: &str, + reviewer: &str, +) -> Result, String> { + valid_id("human-review reviewer", reviewer)?; + let store = Store::configured()?; + let evidence_dir = store.state_dir.join("reviewer-evidence").join(reviewer); + let metadata = read_simple_env(&evidence_dir.join("evidence.env"))?; + if state_value(&metadata, "role") != "reviewer" + || state_value(&metadata, "access") != "read-only" + || state_value(&metadata, "workflow_id") != id + || state_value(&metadata, "state") != "completed" + { + return Err( + "human review requires supervisor-sealed reviewer evidence for this workflow".into(), + ); + } + let report_path = evidence_dir.join("last-message.txt"); + let report = + fs::read_to_string(&report_path).map_err(io_error("read human-review evidence"))?; + let actual = format!("{:x}", Sha256::digest(report.as_bytes())); + if !actual.eq_ignore_ascii_case(state_value(&metadata, "output_sha256")) { + return Err("human-review evidence failed its supervisor output seal".into()); + } + parse_human_review_request(&report) +} + +/// Safe terminal fallback for a reviewer that cannot approve. This does not +/// claim successful completion and does not clear outstanding work; it seals a +/// question so the control server can return authority to the human. +pub fn supervisor_complete_human_review(id: &str, reviewer: &str) -> Result { + require_supervisor_completion_authority()?; + let (question, reason) = reviewer_human_review_question(id, reviewer)? + .ok_or("reviewer evidence does not request human review")?; + let store = Store::configured()?; + let p = store.paths(id)?; + let _lock = store.lock(&p)?; + let mut state = read_env(&p.state, id)?; + let phase = state_value(&state, "phase").to_string(); + if phase == "complete" { + return Err("human-review completion requires an active workflow".into()); + } + let persisted = fs::read_to_string(store.state_dir.join("orchestrator-result.md")) + .map_err(io_error("read persisted human-review question"))?; + if persisted.trim() != question { + return Err( + "persisted orchestrator result does not match the sealed human-review question".into(), + ); + } + let request_path = p.base.join("human-review-request.md"); + atomic_write(&request_path, &format!("{question}\n"))?; + let digest = sha256(&request_path)?; + let result = format!("human-review:{digest}"); + state.insert("phase".into(), "complete".into()); + state.insert("candidate_diff_hash".into(), result.clone()); + state.insert("reviewed_diff_hash".into(), result.clone()); + state.insert("human_review_status".into(), "pending".into()); + state.insert( + "human_review_request".into(), + request_path.display().to_string(), + ); + state.insert("human_review_request_sha256".into(), digest.clone()); + state.insert("human_review_reviewer".into(), reviewer.into()); + state.insert("human_review_reason".into(), reason.clone()); + state.insert("updated_at".into(), timestamp()); + write_env(&p.state, &state)?; + event( + &p.events, + "human_review_required", + &format!( + "from={phase}\tto=complete\titeration={}\tauthority=supervisor\treviewer={reviewer}\treason={reason}\trequest_sha256={digest}", + state_value(&state, "iteration") + ), + )?; + Ok(result) +} + +fn parse_human_review_request(report: &str) -> Result, String> { + let lines = report + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + let first = lines.first().copied().unwrap_or(""); + let reason = if first.eq_ignore_ascii_case("Verdict: HUMAN_REVIEW_REQUIRED") { + Some("ops-verification") + } else if first.eq_ignore_ascii_case("verdict: user-choice-required") { + Some("decision-authority") + } else { + None + }; + let Some(reason) = reason else { + return Ok(None); + }; + let labels: &[&str] = if reason == "ops-verification" { + &["Human-review-question:"] + } else { + &["user-question:"] + }; + let questions = lines + .iter() + .filter_map(|line| { + labels.iter().find_map(|label| { + line.get(..label.len()) + .filter(|prefix| prefix.eq_ignore_ascii_case(label)) + .map(|_| line[label.len()..].trim()) + }) + }) + .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("none")) + .collect::>(); + if questions.len() != 1 { + return Err("human-review evidence requires exactly one bounded question".into()); + } + let question = questions[0]; + let punctuation = question + .chars() + .filter(|character| matches!(character, '?' | '?')) + .count(); + if question.len() > 2_000 + || punctuation == 0 + || punctuation > 3 + || !(question.ends_with('?') || question.ends_with('?')) + { + return Err("human-review question must be bounded and end with a question mark".into()); + } + Ok(Some((question.into(), reason.into()))) +} + /// Completes a repository investigation whose workers and independent reviewer /// were all mechanically read-only and whose repository diff stayed empty. pub fn supervisor_complete_read_only(id: &str, reviewer: &str) -> Result { diff --git a/tests/test_logger_component_contract.py b/tests/test_logger_component_contract.py new file mode 100644 index 0000000..771cc18 --- /dev/null +++ b/tests/test_logger_component_contract.py @@ -0,0 +1,40 @@ +import json +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class LoggerComponentContractTests(unittest.TestCase): + def test_logger_is_a_rust_workspace_service(self): + cargo = (ROOT / "Cargo.toml").read_text() + dockerfile = (ROOT / "docker/logger/Dockerfile").read_text() + self.assertIn('"logger"', cargo) + self.assertIn("cargo build --locked --release -p multiagent-logger", dockerfile) + self.assertIn('ENTRYPOINT ["/usr/local/bin/logger", "serve"]', dockerfile) + self.assertNotIn("node:", dockerfile) + + def test_logger_keeps_authority_out_of_the_append_contract(self): + source = "\n".join(path.read_text() for path in (ROOT / "logger/src").glob("*.rs")) + self.assertIn("StatusCode::NO_CONTENT", source) + self.assertNotIn("append-receipt", source.lower()) + self.assertNotIn("operation permit", source.lower()) + + def test_logger_uses_an_append_only_file_not_a_database(self): + cargo = (ROOT / "logger/Cargo.toml").read_text() + store = (ROOT / "logger/src/store.rs").read_text() + self.assertNotIn("rusqlite", cargo) + self.assertNotIn("sqlite", store.lower()) + self.assertIn("sync_data", store) + self.assertIn("read_until", store) + self.assertIn("LOCK_EX | libc::LOCK_NB", store) + + def test_event_contract_is_bounded_metadata(self): + schema = json.loads((ROOT / "contracts/logger-event-v1.schema.json").read_text()) + self.assertFalse(schema["additionalProperties"]) + self.assertEqual(schema["properties"]["artifactReferences"]["maxItems"], 64) + self.assertNotIn("payload", schema["properties"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_migration_contracts.py b/tests/test_migration_contracts.py index 365bd5e..38b85e1 100644 --- a/tests/test_migration_contracts.py +++ b/tests/test_migration_contracts.py @@ -730,6 +730,11 @@ def test_workflow_v1_state_resumes_and_rejects_invalid_phase(self): "iteration_worker_count", "candidate_diff_hash", "reviewed_diff_hash", + "human_review_status", + "human_review_request", + "human_review_request_sha256", + "human_review_reviewer", + "human_review_reason", "resume_count", "created_at", "updated_at",