From 2598b9541eda9eccae8ebf72ea186fd807d8ad6d Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 19:25:23 -0300 Subject: [PATCH 01/14] feat(approval-gate): standalone approval-gate worker (Rust) per 2026-06-agentic spec The policy and decision surface for human-held function calls (tech-specs/2026-06-agentic/approval-gate.md): - approval::gate pre_dispatch hook: human-only defense, per-session mode/allow-list evaluation (ported exactly from the proven turn-orchestrator consultBefore semantics), policy::check_permissions fallback (unparseable reply degrades to hold; transport failure fails closed), synchronous pending-record write before hold (never hold blind), idempotent on function_call_id. - approval::resolve: allow releases via harness::function::resolve action execute, deny delivers an is_error DenialEnvelope; no decision records persisted - resolve first, delete, then emit. - Ephemeral pending inbox (approval_pending scope) with four deletion paths (resolve, turn terminal, session deleted, cron sweep on expires_at); deletion is the emit gate, so pending_resolved fires exactly once per record. state::set null is the atomic gate plus a follow-up state::delete to clean the engine's null tombstone. - Per-session settings (approval_settings scope) with lazy seeding: reads never write; first mutation materializes from the deployment defaults (seed entries granted_by seed). - Two custom trigger types (approval::pending_created / approval::pending_resolved) with session_id/metadata tenancy filters. - approval-gate configuration entry (default_mode, always_allow_seed, pending_timeout_ms) with reactive reload; built-in defaults when the configuration worker is absent. - Recursive argument redaction (secret keys, 256-codepoint clip, depth cap) so records are safe for notification channels. All harness/session-manager trigger bindings are best-effort: the greenfield harness contracts are not implemented yet, so the worker boots standalone and the engine-backed integration suite fakes policy::check_permissions, harness::function::resolve, and session::get. 118 unit tests (full prior-art permission matrix) + 6 engine-backed integration scenarios; 94% line coverage. --- approval-gate/Cargo.lock | 2501 +++++++++++++++++ approval-gate/Cargo.toml | 38 + approval-gate/README.md | 138 + approval-gate/build.rs | 6 + approval-gate/config.yaml | 26 + approval-gate/iii.worker.yaml | 7 + approval-gate/src/bus.rs | 76 + approval-gate/src/config.rs | 134 + approval-gate/src/decision.rs | 195 ++ approval-gate/src/denial.rs | 165 ++ approval-gate/src/error.rs | 60 + approval-gate/src/events.rs | 454 +++ .../src/functions/add_always_allow.rs | 64 + approval-gate/src/functions/approve_always.rs | 73 + approval-gate/src/functions/clear_settings.rs | 62 + approval-gate/src/functions/gate.rs | 665 +++++ approval-gate/src/functions/get_pending.rs | 85 + approval-gate/src/functions/get_settings.rs | 71 + approval-gate/src/functions/list_pending.rs | 244 ++ approval-gate/src/functions/mod.rs | 172 ++ .../src/functions/on_config_change.rs | 84 + .../src/functions/on_session_deleted.rs | 102 + .../src/functions/on_turn_completed.rs | 87 + approval-gate/src/functions/purge.rs | 61 + .../src/functions/remove_always_allow.rs | 86 + approval-gate/src/functions/resolve.rs | 348 +++ approval-gate/src/functions/set_mode.rs | 61 + approval-gate/src/functions/sweep.rs | 231 ++ approval-gate/src/gate_config.rs | 219 ++ approval-gate/src/lib.rs | 33 + approval-gate/src/main.rs | 196 ++ approval-gate/src/manifest.rs | 63 + approval-gate/src/pending.rs | 218 ++ approval-gate/src/policy.rs | 133 + approval-gate/src/redact.rs | 171 ++ approval-gate/src/settings.rs | 330 +++ approval-gate/src/testkit/fake_bus.rs | 221 ++ approval-gate/src/testkit/mod.rs | 6 + approval-gate/src/types.rs | 534 ++++ approval-gate/tests/integration.rs | 668 +++++ 40 files changed, 9088 insertions(+) create mode 100644 approval-gate/Cargo.lock create mode 100644 approval-gate/Cargo.toml create mode 100644 approval-gate/README.md create mode 100644 approval-gate/build.rs create mode 100644 approval-gate/config.yaml create mode 100644 approval-gate/iii.worker.yaml create mode 100644 approval-gate/src/bus.rs create mode 100644 approval-gate/src/config.rs create mode 100644 approval-gate/src/decision.rs create mode 100644 approval-gate/src/denial.rs create mode 100644 approval-gate/src/error.rs create mode 100644 approval-gate/src/events.rs create mode 100644 approval-gate/src/functions/add_always_allow.rs create mode 100644 approval-gate/src/functions/approve_always.rs create mode 100644 approval-gate/src/functions/clear_settings.rs create mode 100644 approval-gate/src/functions/gate.rs create mode 100644 approval-gate/src/functions/get_pending.rs create mode 100644 approval-gate/src/functions/get_settings.rs create mode 100644 approval-gate/src/functions/list_pending.rs create mode 100644 approval-gate/src/functions/mod.rs create mode 100644 approval-gate/src/functions/on_config_change.rs create mode 100644 approval-gate/src/functions/on_session_deleted.rs create mode 100644 approval-gate/src/functions/on_turn_completed.rs create mode 100644 approval-gate/src/functions/purge.rs create mode 100644 approval-gate/src/functions/remove_always_allow.rs create mode 100644 approval-gate/src/functions/resolve.rs create mode 100644 approval-gate/src/functions/set_mode.rs create mode 100644 approval-gate/src/functions/sweep.rs create mode 100644 approval-gate/src/gate_config.rs create mode 100644 approval-gate/src/lib.rs create mode 100644 approval-gate/src/main.rs create mode 100644 approval-gate/src/manifest.rs create mode 100644 approval-gate/src/pending.rs create mode 100644 approval-gate/src/policy.rs create mode 100644 approval-gate/src/redact.rs create mode 100644 approval-gate/src/settings.rs create mode 100644 approval-gate/src/testkit/fake_bus.rs create mode 100644 approval-gate/src/testkit/mod.rs create mode 100644 approval-gate/src/types.rs create mode 100644 approval-gate/tests/integration.rs diff --git a/approval-gate/Cargo.lock b/approval-gate/Cargo.lock new file mode 100644 index 00000000..5c4730c7 --- /dev/null +++ b/approval-gate/Cargo.lock @@ -0,0 +1,2501 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "approval-gate" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "clap", + "futures", + "iii-sdk", + "schemars", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[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.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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 = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "iii-observability" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11586fcd304a563c143837f67b2e5f3cb73d23f6c8452c9734ff18e4a1402bf" +dependencies = [ + "futures-util", + "opentelemetry", + "opentelemetry-http", + "opentelemetry_sdk", + "reqwest", + "serde_json", + "sysinfo", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "iii-sdk" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8490f2ad470d54cf7e0bc2f4105aca71bdb4c24752fcb406183f24b8dd328f80" +dependencies = [ + "async-trait", + "futures-util", + "hostname", + "iii-observability", + "reqwest", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[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.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand", + "thiserror", + "tokio", + "tokio-stream", +] + +[[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 = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[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.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[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.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "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", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +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.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[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", +] + +[[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 = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[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 = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[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 = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +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 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[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", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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", + "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/approval-gate/Cargo.toml b/approval-gate/Cargo.toml new file mode 100644 index 00000000..2127b06a --- /dev/null +++ b/approval-gate/Cargo.toml @@ -0,0 +1,38 @@ +[workspace] + +[package] +name = "approval-gate" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "approval-gate" +path = "src/main.rs" + +[lib] +name = "approval_gate" +path = "src/lib.rs" + +[dependencies] +iii-sdk = "=0.19.2" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +anyhow = "1" +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +clap = { version = "4", features = ["derive"] } +async-trait = "0.1" +# Must stay on the same schemars major as iii-sdk so the derived +# `JsonSchema` impls satisfy `RegisterFunction::new_async` / +# `RegisterTriggerType::trigger_request_format`. +schemars = "0.8" +base64 = "0.22" + +[dev-dependencies] +futures = "0.3" +uuid = { version = "1", features = ["v4"] } +tempfile = "3" diff --git a/approval-gate/README.md b/approval-gate/README.md new file mode 100644 index 00000000..6b302f98 --- /dev/null +++ b/approval-gate/README.md @@ -0,0 +1,138 @@ +# approval-gate + +The policy and decision surface for human-held function calls +([spec](../tech-specs/2026-06-agentic/approval-gate.md)). Three surfaces, one +worker: + +1. **The gate** — `approval::gate`, a `pre_dispatch` hook the worker binds + itself at startup on the harness's `harness::hook::pre_dispatch` trigger + type. It evaluates per-session mode, allow-lists, and the yaml policy, and + answers `continue`, `deny`, or `hold`. +2. **The decision plane** — `approval::resolve` plus the per-session settings + RPCs (`set_mode`, `add_always_allow`, `approve_always`, …). Human/console + only. +3. **The pending inbox** — an **ephemeral** index of held calls + (`approval::list_pending` / `approval::get_pending`) plus two trigger types + (`approval::pending_created` / `approval::pending_resolved`) that + notification workers and UIs bind to. + +The worker keeps **no resolved-approval history**: a record exists only while +a call is held; every record has an explicit deletion path and a cron sweep as +GC backstop. The transcript's `function_result` and the `pending_resolved` +event are the audit trail. + +## Standalone caveat + +This worker codes against the greenfield harness contracts +(`harness::hook::pre_dispatch`, `harness::function::resolve`, +`harness::turn_completed` — see harness.md § Hooks / § API Reference), which +are **not implemented by the current harness yet**. All trigger bindings are +best-effort: on an engine without those trigger types the worker still boots, +serves its RPCs, registers its configuration entry, and logs +`trigger_type_not_found` for the absent bindings (restart it after the +sibling appears to re-bind). The integration suite exercises the harness +surface against in-process fakes until harness 1.0 lands. + +## Install + +```bash +iii worker add approval-gate +``` + +The sweep needs the engine's cron worker: `iii worker add iii-cron`. Without +it the expiry backstop never fires (the harness pending sweep — once it +exists — remains the second backstop). + +## Quickstart + +```bash +cargo build +./target/debug/approval-gate --url ws://127.0.0.1:49134 --config ./config.yaml +``` + +Hold → decide → release, from any client: + +```bash +# A held call shows up in the inbox… +iii call approval::list_pending '{}' +# …a human allows it (the harness re-runs it through dispatch)… +iii call approval::resolve '{"session_id": "s_1", "function_call_id": "c_1", "decision": "allow"}' +# …or denies it with a reason the model can adapt to. +iii call approval::resolve '{"session_id": "s_1", "function_call_id": "c_1", "decision": "deny", "reason": "not on prod"}' +``` + +## Permission model + +Per-session mode plus two allow-lists, evaluated in this order (ported +unchanged from the proven implementation): + +1. `approval::*` / `configuration::*` target → **deny** (`human_only_function`, + even under `full` — self-escalation defense) +2. mode `full` → allow +3. `approved_always` hit → allow (**every** mode — remembered human decisions) +4. mode `auto` **and** `always_allow` hit → allow (dormant under `manual`) +5. fall through to `policy::check_permissions` (5s budget): + `allow` → allow · `deny` → deny · `needs_approval` → **hold** · + unparseable reply → hold · transport failure/timeout → **deny** + (`gate_unavailable` — fail closed, never an unattended hold) + +No `policy::check_permissions` worker deployed? Every non-short-circuited +call is denied as `gate_unavailable`. Run a trivial policy worker (e.g. +"everything `needs_approval`") or lean on `always_allow_seed` / per-session +modes. + +## Custom trigger types + +| Type | Fires | Payload | +|---|---|---| +| `approval::pending_created` | a call was held and its inbox record written (async, off the hot path) | `PendingApprovalRecord & { status: "pending" }` — redacted args, session context, expiry: self-sufficient for notification copy | +| `approval::pending_resolved` | a pending call left the inbox (exactly once per record) | ids + `outcome: "allow" \| "deny" \| "timeout" \| "aborted"`, operator `reason` on deny | + +Binding config (both types): `{ session_id?, metadata? }` — `metadata` is a +subset-equality match against the record's denormalized `session_metadata`, +so a multi-tenant notification worker binds to only its own sessions. After a +restart, reconcile with one `approval::list_pending` call. + +## Configuration + +Deployment defaults live in the engine configuration entry **`approval-gate`** +(operator-edited via the console's Configuration screen; reactive reload, no +polling): + +```jsonc +{ + "default_mode": "manual", // manual | auto | full — sessions with no stored settings + "always_allow_seed": [], // auto-mode trust profile (function ids / globs) + "pending_timeout_ms": 1800000 // hold deadline; drives expires_at (default 30 min) +} +``` + +Without the configuration worker the gate runs on those built-in defaults — +fail-safe, never fail-open. + +`config.yaml` carries runtime wiring only (hook binding globs/budget, sweep +cron expression, per-call timeouts) — see the file's comments. + +## Agent exposure + +Deny **all** `approval::*` and `configuration::*` functions to in-run agents: +`resolve` would let an agent approve its own held calls, and the settings +RPCs are self-escalation (the gate's `human_only_function` rule is the +in-depth backstop). `list_pending` / `get_pending` are read-only and +redacted, but they enumerate held calls **across sessions** — keep them off +agent allow-lists too. + +## Local development & testing + +```bash +cargo test # unit suites (engine-free, FakeBus) +cargo test --test integration # engine-backed; self-skips without `iii` +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +./target/debug/approval-gate --manifest # registry-publish manifest +``` + +The integration suite spawns a real engine (`III_ENGINE_BIN` or `iii` on +PATH) with `configuration` + `iii-state`, registers the production surface +in-process, and fakes the not-yet-built siblings +(`policy::check_permissions`, `harness::function::resolve`, `session::get`). diff --git a/approval-gate/build.rs b/approval-gate/build.rs new file mode 100644 index 00000000..81caa36d --- /dev/null +++ b/approval-gate/build.rs @@ -0,0 +1,6 @@ +fn main() { + println!( + "cargo:rustc-env=TARGET={}", + std::env::var("TARGET").unwrap() + ); +} diff --git a/approval-gate/config.yaml b/approval-gate/config.yaml new file mode 100644 index 00000000..82378732 --- /dev/null +++ b/approval-gate/config.yaml @@ -0,0 +1,26 @@ +# Runtime wiring only. Deployment approval defaults (default_mode, +# always_allow_seed, pending_timeout_ms) live in the engine's +# configuration entry "approval-gate", not here — see README § Configuration. + +# The harness::hook::pre_dispatch binding the worker registers for itself +# at startup (tech-specs/2026-06-agentic/approval-gate.md § The approval::gate hook). +hook: + functions: ["*"] + timeout_ms: 5000 + on_error: fail_closed + +# 6-field cron expression for the expiry sweep (engine cron trigger). +sweep_expression: "0 * * * * *" + +# Fail-closed budget for the synchronous policy::check_permissions consult. +policy_timeout_ms: 5000 + +# Best-effort session::get budget inside the hook (record context fields +# are omitted when this is exceeded). +session_fetch_timeout_ms: 1000 + +# Budget for state::* calls. +state_timeout_ms: 5000 + +# Budget for harness::function::resolve calls. +harness_timeout_ms: 10000 diff --git a/approval-gate/iii.worker.yaml b/approval-gate/iii.worker.yaml new file mode 100644 index 00000000..5d53cf09 --- /dev/null +++ b/approval-gate/iii.worker.yaml @@ -0,0 +1,7 @@ +iii: v1 +name: approval-gate +language: rust +deploy: binary +manifest: Cargo.toml +bin: approval-gate +description: Policy and decision surface for human-held function calls — pre_dispatch gate, pending inbox, per-session permission settings, and two notification trigger types. diff --git a/approval-gate/src/bus.rs b/approval-gate/src/bus.rs new file mode 100644 index 00000000..37f0d056 --- /dev/null +++ b/approval-gate/src/bus.rs @@ -0,0 +1,76 @@ +//! The testability seam over `iii.trigger`: every sibling call +//! (`state::*`, `policy::check_permissions`, `harness::function::resolve`, +//! `session::get`, `configuration::*`, event fan-out) goes through [`Bus`], +//! so handlers are fully exercisable without an engine (see +//! testkit/fake_bus.rs). + +use std::sync::Arc; + +use async_trait::async_trait; +use iii_sdk::{TriggerAction, TriggerRequest, III}; +use serde_json::Value; + +#[derive(Debug, Clone, thiserror::Error)] +#[error("{0}")] +pub struct BusError(pub String); + +#[async_trait] +pub trait Bus: Send + Sync { + /// Request/response call to another function on the bus. + async fn call( + &self, + function_id: &str, + payload: Value, + timeout_ms: Option, + ) -> Result; + + /// Fire-and-forget delivery (`TriggerAction::Void`) — failures are + /// logged and swallowed so event fan-out never blocks or breaks the + /// mutation that produced it. + async fn call_void(&self, function_id: &str, payload: Value); +} + +pub struct IiiBus { + iii: Arc, +} + +impl IiiBus { + pub fn new(iii: Arc) -> Self { + Self { iii } + } +} + +#[async_trait] +impl Bus for IiiBus { + async fn call( + &self, + function_id: &str, + payload: Value, + timeout_ms: Option, + ) -> Result { + self.iii + .trigger(TriggerRequest { + function_id: function_id.to_string(), + payload, + action: None, + timeout_ms, + }) + .await + .map_err(|e| BusError(e.to_string())) + } + + async fn call_void(&self, function_id: &str, payload: Value) { + let res = self + .iii + .trigger(TriggerRequest { + function_id: function_id.to_string(), + payload, + action: Some(TriggerAction::Void), + timeout_ms: None, + }) + .await; + if let Err(e) = res { + tracing::warn!(function_id, error = %e, "void fan-out failed"); + } + } +} diff --git a/approval-gate/src/config.rs b/approval-gate/src/config.rs new file mode 100644 index 00000000..0850fdae --- /dev/null +++ b/approval-gate/src/config.rs @@ -0,0 +1,134 @@ +//! config.yaml parsing — runtime wiring only. Deployment approval +//! defaults live in the `approval-gate` configuration entry +//! (gate_config.rs), never here. + +use anyhow::{Context, Result}; +use serde::Deserialize; + +fn default_hook_functions() -> Vec { + vec!["*".to_string()] +} + +fn default_hook_timeout_ms() -> u64 { + 5_000 +} + +fn default_on_error() -> String { + "fail_closed".to_string() +} + +/// The `harness::hook::pre_dispatch` binding the worker registers for +/// itself at startup. +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct HookBinding { + /// pre_dispatch target globs to consult on; omit-equivalent default + /// (`["*"]`) consults on every call. + #[serde(default = "default_hook_functions")] + pub functions: Vec, + #[serde(default = "default_hook_timeout_ms")] + pub timeout_ms: u64, + /// `fail_closed` is already the harness `pre_*` default — a crashed + /// gate must deny, not wave calls through. + #[serde(default = "default_on_error")] + pub on_error: String, +} + +impl Default for HookBinding { + fn default() -> Self { + Self { + functions: default_hook_functions(), + timeout_ms: default_hook_timeout_ms(), + on_error: default_on_error(), + } + } +} + +fn default_sweep_expression() -> String { + // 6-field cron (engine cron worker, config key "expression"): every + // minute at second 0. + "0 * * * * *".to_string() +} + +fn default_policy_timeout_ms() -> u64 { + 5_000 +} + +fn default_session_fetch_timeout_ms() -> u64 { + 1_000 +} + +fn default_state_timeout_ms() -> u64 { + 5_000 +} + +fn default_harness_timeout_ms() -> u64 { + 10_000 +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct WorkerConfig { + #[serde(default)] + pub hook: HookBinding, + #[serde(default = "default_sweep_expression")] + pub sweep_expression: String, + #[serde(default = "default_policy_timeout_ms")] + pub policy_timeout_ms: u64, + #[serde(default = "default_session_fetch_timeout_ms")] + pub session_fetch_timeout_ms: u64, + #[serde(default = "default_state_timeout_ms")] + pub state_timeout_ms: u64, + #[serde(default = "default_harness_timeout_ms")] + pub harness_timeout_ms: u64, +} + +impl Default for WorkerConfig { + fn default() -> Self { + serde_yaml::from_str("{}").expect("empty config parses to defaults") + } +} + +/// A missing file falls back to defaults (the caller decides); a file +/// that exists but doesn't parse is an error — a typo'd config must +/// never silently run on defaults. +pub fn load_config(path: &str) -> Result { + let raw = std::fs::read_to_string(path).with_context(|| format!("read config {path}"))?; + serde_yaml::from_str(&raw).with_context(|| format!("parse config {path}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_the_spec_wiring() { + let cfg = WorkerConfig::default(); + assert_eq!(cfg.hook.functions, vec!["*".to_string()]); + assert_eq!(cfg.hook.timeout_ms, 5_000); + assert_eq!(cfg.hook.on_error, "fail_closed"); + assert_eq!(cfg.sweep_expression, "0 * * * * *"); + assert_eq!(cfg.policy_timeout_ms, 5_000); + assert_eq!(cfg.session_fetch_timeout_ms, 1_000); + } + + #[test] + fn partial_yaml_fills_defaults() { + let cfg: WorkerConfig = + serde_yaml::from_str("hook:\n functions: [\"shell::*\"]\n").unwrap(); + assert_eq!(cfg.hook.functions, vec!["shell::*".to_string()]); + assert_eq!(cfg.hook.timeout_ms, 5_000); + } + + #[test] + fn unknown_keys_are_rejected() { + let res: Result = serde_yaml::from_str("sweep_scheduel: \"x\"\n"); + assert!(res.is_err()); + } + + #[test] + fn repo_config_yaml_parses() { + let cfg = load_config(concat!(env!("CARGO_MANIFEST_DIR"), "/config.yaml")).unwrap(); + assert_eq!(cfg, WorkerConfig::default()); + } +} diff --git a/approval-gate/src/decision.rs b/approval-gate/src/decision.rs new file mode 100644 index 00000000..0f33de25 --- /dev/null +++ b/approval-gate/src/decision.rs @@ -0,0 +1,195 @@ +//! The pure permission-model evaluation — no I/O. The order is ported +//! EXACTLY from the proven implementation +//! (harness/src/turn-orchestrator/hook.ts `consultBefore`, exercised by +//! mode-approval.e2e.test.ts) and the spec flowchart +//! (approval-gate.md § Evaluation order): +//! +//! 1. human-only target → deny (before the snapshot, even under full) +//! 2. mode full → allow +//! 3. approved_always hit → allow (every mode) +//! 4. mode auto AND always_allow hit → allow (dormant under manual) +//! 5. fall through to the yaml policy (policy.rs) + +use crate::types::{AlwaysAllowEntry, ApprovalSettings, PermissionMode}; + +/// `approval::*` and `configuration::*` are operator surfaces — an agent +/// that could call them would approve its own calls (spec § Human-only +/// defense). Prefix match deliberately broadens the prior art's +/// six-function list. +pub fn is_human_only(function_id: &str) -> bool { + function_id.starts_with("approval::") || function_id.starts_with("configuration::") +} + +/// `*`-glob match with an equality fast-path. Globs exist because +/// `always_allow_seed` entries are documented as "function ids / globs" +/// (approval-gate.md § Configuration); plain entries behave exactly like +/// the prior art's equality test. +pub fn glob_match(pattern: &str, candidate: &str) -> bool { + if !pattern.contains('*') { + return pattern == candidate; + } + let parts: Vec<&str> = pattern.split('*').collect(); + let (first, rest) = parts.split_first().expect("split always yields one part"); + if !candidate.starts_with(first) { + return false; + } + let mut pos = first.len(); + for (i, part) in rest.iter().enumerate() { + let is_last = i == rest.len() - 1; + if part.is_empty() { + continue; + } + if is_last { + return candidate.len() >= pos + part.len() && candidate.ends_with(part); + } + match candidate[pos..].find(part) { + Some(idx) => pos += idx + part.len(), + None => return false, + } + } + // The pattern ends with '*' (or was all '*'s): everything after the + // anchored prefix matches. + true +} + +fn list_matches(entries: &[AlwaysAllowEntry], function_id: &str) -> bool { + entries + .iter() + .any(|entry| glob_match(&entry.function_id, function_id)) +} + +/// Steps 2-4: the pre-policy short-circuits over one settings snapshot. +/// `false` = no short-circuit — fall through to the yaml policy. +pub fn pre_policy_allow(settings: &ApprovalSettings, function_id: &str) -> bool { + if settings.mode == PermissionMode::Full { + return true; + } + // Per-session "approve always" grants apply in every mode — they are + // remembered human decisions, not an auto-policy. + if list_matches(&settings.approved_always, function_id) { + return true; + } + if settings.mode == PermissionMode::Auto && list_matches(&settings.always_allow, function_id) { + return true; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::GrantedBy; + + fn entry(function_id: &str) -> AlwaysAllowEntry { + AlwaysAllowEntry { + function_id: function_id.to_string(), + granted_at: 0, + granted_by: GrantedBy::UserClick, + } + } + + fn settings( + mode: PermissionMode, + always_allow: &[&str], + approved_always: &[&str], + ) -> ApprovalSettings { + ApprovalSettings { + mode, + always_allow: always_allow.iter().map(|f| entry(f)).collect(), + approved_always: approved_always.iter().map(|f| entry(f)).collect(), + mode_set_at: 0, + } + } + + #[test] + fn human_only_covers_approval_and_configuration_prefixes() { + assert!(is_human_only("approval::set_mode")); + assert!(is_human_only("approval::resolve")); + assert!(is_human_only("configuration::set")); + assert!(!is_human_only("shell::run")); + assert!(!is_human_only("approvals::other")); + } + + #[test] + fn full_mode_allows_everything() { + assert!(pre_policy_allow( + &settings(PermissionMode::Full, &[], &[]), + "shell::run" + )); + } + + #[test] + fn approved_always_holds_in_every_mode() { + for mode in [ + PermissionMode::Manual, + PermissionMode::Auto, + PermissionMode::Full, + ] { + assert!(pre_policy_allow( + &settings(mode, &[], &["shell::run"]), + "shell::run" + )); + } + } + + #[test] + fn always_allow_works_only_in_auto() { + assert!(pre_policy_allow( + &settings(PermissionMode::Auto, &["shell::run"], &[]), + "shell::run" + )); + // Dormant under manual: the user can build the list up, but it + // only takes effect when they opt into auto. + assert!(!pre_policy_allow( + &settings(PermissionMode::Manual, &["shell::run"], &[]), + "shell::run" + )); + } + + #[test] + fn auto_mode_miss_falls_through() { + assert!(!pre_policy_allow( + &settings(PermissionMode::Auto, &["other::fn"], &[]), + "shell::run" + )); + } + + #[test] + fn manual_mode_with_no_grants_falls_through() { + assert!(!pre_policy_allow( + &settings(PermissionMode::Manual, &[], &[]), + "shell::run" + )); + } + + #[test] + fn glob_equality_fast_path() { + assert!(glob_match("shell::run", "shell::run")); + assert!(!glob_match("shell::run", "shell::runner")); + } + + #[test] + fn glob_star_patterns() { + assert!(glob_match("shell::*", "shell::run")); + assert!(glob_match("shell::*", "shell::")); + assert!(!glob_match("shell::*", "state::get")); + assert!(glob_match("*", "anything::at_all")); + assert!(glob_match("*::get", "state::get")); + assert!(!glob_match("*::get", "state::set")); + assert!(glob_match("a*b*c", "a-x-b-y-c")); + assert!(!glob_match("a*b*c", "a-x-c-y-b")); + assert!(glob_match("state::*::read", "state::users::read")); + } + + #[test] + fn glob_entries_in_allow_lists_match() { + assert!(pre_policy_allow( + &settings(PermissionMode::Auto, &["shell::*"], &[]), + "shell::run" + )); + assert!(pre_policy_allow( + &settings(PermissionMode::Manual, &[], &["state::*"]), + "state::get" + )); + } +} diff --git a/approval-gate/src/denial.rs b/approval-gate/src/denial.rs new file mode 100644 index 00000000..d74956de --- /dev/null +++ b/approval-gate/src/denial.rs @@ -0,0 +1,165 @@ +//! Denial envelope assembly + text rendering (the `reason` strings are +//! ported verbatim from harness/src/approval-gate/denial.ts — they are +//! written for the model as much as the human). + +use serde_json::Value; + +use crate::redact::redact; +use crate::types::{ + text_block, DenialEnvelope, DeniedBy, MatchedConstraint, TextBlock, DENIAL_SCHEMA_VERSION, +}; + +pub const HUMAN_ONLY_RULE_ID: &str = "human_only_function"; + +pub fn reason_for_permissions_deny( + function_id: &str, + rule_id: &str, + matched: Option<&MatchedConstraint>, +) -> String { + match matched { + Some(m) => format!( + "Permission denied: {function_id} matched rule {rule_id} on {} {} {}. Try different arguments or use a different function.", + m.field, + m.operator, + serde_json::to_string(&m.value).unwrap_or_else(|_| "null".to_string()) + ), + None => format!( + "Permission denied: {function_id} matched rule {rule_id}. This function is blocked by policy; try a different function." + ), + } +} + +pub fn permissions_deny_envelope( + function_id: &str, + rule_id: &str, + matched_constraint: Option, + args: &Value, +) -> DenialEnvelope { + DenialEnvelope { + schema_version: DENIAL_SCHEMA_VERSION, + status: "denied".to_string(), + denied_by: DeniedBy::Permissions, + function_id: function_id.to_string(), + rule_id: Some(rule_id.to_string()), + rule_action: Some("deny".to_string()), + reason: reason_for_permissions_deny(function_id, rule_id, matched_constraint.as_ref()), + matched_constraint, + args_excerpt: Some(redact(args)), + } +} + +/// Operator denial via `approval::resolve`. The excerpt comes from the +/// pending record, which is already redacted — it is passed through, not +/// re-redacted. +pub fn user_deny_envelope( + function_id: &str, + reason: Option<&str>, + args_excerpt: Option, +) -> DenialEnvelope { + DenialEnvelope { + schema_version: DENIAL_SCHEMA_VERSION, + status: "denied".to_string(), + denied_by: DeniedBy::User, + function_id: function_id.to_string(), + rule_id: None, + rule_action: None, + matched_constraint: None, + args_excerpt, + reason: reason + .filter(|r| !r.is_empty()) + .unwrap_or("Rejected by operator.") + .to_string(), + } +} + +/// Fail-closed transport failure: a crashed policy worker or state outage +/// must deny, never wave calls through or hold blind. +pub fn gate_unavailable_envelope(function_id: &str, reason: &str) -> DenialEnvelope { + DenialEnvelope { + schema_version: DENIAL_SCHEMA_VERSION, + status: "denied".to_string(), + denied_by: DeniedBy::GateUnavailable, + function_id: function_id.to_string(), + rule_id: None, + rule_action: None, + matched_constraint: None, + args_excerpt: None, + reason: reason.to_string(), + } +} + +/// Self-escalation defense: `approval::*` / `configuration::*` targets are +/// operator surfaces, denied even under `mode: "full"`. +pub fn human_only_denial(function_id: &str, args: &Value) -> DenialEnvelope { + permissions_deny_envelope(function_id, HUMAN_ONLY_RULE_ID, None, args) +} + +/// One text block carrying the envelope's reason — the `content` of a +/// deny/timeout function_result (the envelope itself rides in `details`). +pub fn render_text(envelope: &DenialEnvelope) -> Vec { + vec![text_block(envelope.reason.clone())] +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn permissions_envelope_carries_rule_and_redacted_args() { + let envelope = permissions_deny_envelope( + "shell::run", + "no_shell", + Some(MatchedConstraint { + field: "cmd".into(), + operator: "contains".into(), + value: json!("rm"), + }), + &json!({ "cmd": "rm -rf /", "api_key": "sk_live" }), + ); + assert_eq!(envelope.denied_by, DeniedBy::Permissions); + assert_eq!(envelope.rule_id.as_deref(), Some("no_shell")); + assert_eq!(envelope.rule_action.as_deref(), Some("deny")); + assert_eq!( + envelope.args_excerpt.unwrap()["api_key"], + json!("") + ); + assert!(envelope + .reason + .contains("matched rule no_shell on cmd contains \"rm\"")); + assert!(envelope.reason.contains("Try different arguments")); + } + + #[test] + fn permissions_reason_without_constraint() { + let reason = reason_for_permissions_deny("shell::run", "no_shell", None); + assert!(reason.contains("blocked by policy")); + } + + #[test] + fn user_envelope_defaults_reason_and_passes_excerpt_through() { + let envelope = user_deny_envelope("shell::run", None, Some(json!({ "cmd": "ls" }))); + assert_eq!(envelope.denied_by, DeniedBy::User); + assert_eq!(envelope.reason, "Rejected by operator."); + assert_eq!(envelope.args_excerpt, Some(json!({ "cmd": "ls" }))); + + let custom = user_deny_envelope("shell::run", Some("too risky"), None); + assert_eq!(custom.reason, "too risky"); + } + + #[test] + fn human_only_uses_the_reserved_rule_id() { + let envelope = human_only_denial("approval::set_mode", &json!({})); + assert_eq!(envelope.rule_id.as_deref(), Some(HUMAN_ONLY_RULE_ID)); + assert_eq!(envelope.denied_by, DeniedBy::Permissions); + } + + #[test] + fn render_text_is_one_text_block_with_the_reason() { + let envelope = gate_unavailable_envelope("shell::run", "policy unreachable: boom"); + let blocks = render_text(&envelope); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].block_type, "text"); + assert_eq!(blocks[0].text, "policy unreachable: boom"); + } +} diff --git a/approval-gate/src/error.rs b/approval-gate/src/error.rs new file mode 100644 index 00000000..62d23fe4 --- /dev/null +++ b/approval-gate/src/error.rs @@ -0,0 +1,60 @@ +//! Error conventions: every error crossing the bus carries a stable +//! snake_case, worker-prefixed code in a `code: message` shape (see +//! tech-specs/2026-06-agentic/README.md § Error conventions). + +use iii_sdk::IIIError; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ApprovalError { + /// The request shape is invalid beyond what serde can reject + /// (empty ids, `/` in ids, malformed cursor). + #[error("approval/invalid_payload: {0}")] + InvalidPayload(String), + + /// A `state::*` call failed; the operation cannot proceed safely. + #[error("approval/state_unavailable: {0}")] + StateUnavailable(String), + + /// `harness::function::resolve` failed; the pending record is kept + /// so the decision stays resolvable (or sweepable). + #[error("approval/harness_unavailable: {0}")] + HarnessUnavailable(String), +} + +impl ApprovalError { + /// The stable error code (the part before `:` on the wire). + pub fn code(&self) -> &'static str { + match self { + ApprovalError::InvalidPayload(_) => "approval/invalid_payload", + ApprovalError::StateUnavailable(_) => "approval/state_unavailable", + ApprovalError::HarnessUnavailable(_) => "approval/harness_unavailable", + } + } +} + +impl From for IIIError { + fn from(e: ApprovalError) -> Self { + IIIError::Handler(e.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_is_code_colon_message() { + let variants = vec![ + ApprovalError::InvalidPayload("m".into()), + ApprovalError::StateUnavailable("m".into()), + ApprovalError::HarnessUnavailable("m".into()), + ]; + for v in variants { + assert!( + v.to_string().starts_with(&format!("{}: ", v.code())), + "display `{v}` must start with code `{}`", + v.code() + ); + } + } +} diff --git a/approval-gate/src/events.rs b/approval-gate/src/events.rs new file mode 100644 index 00000000..db0da4b1 --- /dev/null +++ b/approval-gate/src/events.rs @@ -0,0 +1,454 @@ +//! The two custom trigger types this worker emits — +//! `approval::pending_created` / `approval::pending_resolved` — and the +//! fan-out machinery behind them (the session-manager reactivity model: +//! consumers bind handlers with the standard two-step pattern; the engine +//! routes each registration to our [`TriggerHandler`]s; delivery is +//! fire-and-forget, at-least-once, unordered; `list_pending` is the +//! reconciliation read). +//! +//! Per-binding `config` filters are evaluated here, by the emitting +//! worker: `session_id` equality and `metadata` subset-equality against +//! the record's denormalized `session_metadata` (the tenancy hook). +//! Malformed configs are rejected at registration time. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use iii_sdk::{IIIError, RegisterTriggerType, TriggerConfig, TriggerHandler, III}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::bus::Bus; +use crate::types::{metadata_matches, JsonMap, PendingApprovalRecord, PendingResolvedEvent}; + +pub const PENDING_CREATED: &str = "approval::pending_created"; +pub const PENDING_RESOLVED: &str = "approval::pending_resolved"; + +/// Config accepted by both trigger types. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct BindingConfig { + /// Only deliver events for this session. + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + /// Equality match against the record's `session_metadata` (every key + /// given here must equal the stored value — subset match). + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +/// `null` configs are treated as `{}`; unknown fields are rejected so a +/// misspelled filter key fails at registration, not silently. +pub fn parse_binding_config(raw: &Value) -> Result { + if raw.is_null() { + return Ok(BindingConfig::default()); + } + serde_json::from_value(raw.clone()).map_err(|e| format!("invalid binding config: {e}")) +} + +/// Pure filter evaluation: does this binding receive this event? +pub fn binding_matches( + filter: &BindingConfig, + session_id: &str, + session_metadata: Option<&JsonMap>, +) -> bool { + if let Some(want_sid) = &filter.session_id { + if want_sid != session_id { + return false; + } + } + if let Some(want) = &filter.metadata { + if !metadata_matches(want, session_metadata) { + return false; + } + } + true +} + +#[derive(Debug, Clone)] +pub struct Binding { + pub id: String, + pub function_id: String, + pub filter: BindingConfig, +} + +/// Thread-safe subscriber registry for one trigger type. Cloned into both +/// the [`TriggerHandler`] (mutates on register/unregister) and the +/// [`Emitter`] (iterates read-only snapshots). After a worker restart the +/// engine replays existing registrations, so the sets rebuild themselves. +#[derive(Clone)] +pub struct SubscriberSet { + trigger_type: &'static str, + inner: Arc>>, +} + +impl SubscriberSet { + pub fn new(trigger_type: &'static str) -> Self { + Self { + trigger_type, + inner: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub fn trigger_type(&self) -> &'static str { + self.trigger_type + } + + pub fn add(&self, config: TriggerConfig) -> Result<(), String> { + let filter = parse_binding_config(&config.config)?; + let binding = Binding { + id: config.id.clone(), + function_id: config.function_id, + filter, + }; + self.lock().insert(config.id, binding); + Ok(()) + } + + pub fn remove(&self, id: &str) { + self.lock().remove(id); + } + + /// Snapshot so the mutex isn't held across awaits. + pub fn snapshot(&self) -> Vec { + self.lock().values().cloned().collect() + } + + pub fn len(&self) -> usize { + self.lock().len() + } + + pub fn is_empty(&self) -> bool { + self.lock().is_empty() + } + + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.inner + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + } +} + +#[derive(Clone)] +pub struct TriggerSets { + pub created: SubscriberSet, + pub resolved: SubscriberSet, +} + +impl TriggerSets { + pub fn new() -> Self { + Self { + created: SubscriberSet::new(PENDING_CREATED), + resolved: SubscriberSet::new(PENDING_RESOLVED), + } + } +} + +impl Default for TriggerSets { + fn default() -> Self { + Self::new() + } +} + +struct ApprovalTriggerHandler { + set: SubscriberSet, +} + +#[async_trait] +impl TriggerHandler for ApprovalTriggerHandler { + async fn register_trigger(&self, config: TriggerConfig) -> Result<(), IIIError> { + let id = config.id.clone(); + let function_id = config.function_id.clone(); + self.set.add(config).map_err(IIIError::Handler)?; + tracing::info!( + trigger_type = self.set.trigger_type(), + id = %id, + function_id = %function_id, + "trigger subscription registered" + ); + Ok(()) + } + + async fn unregister_trigger(&self, config: TriggerConfig) -> Result<(), IIIError> { + tracing::info!( + trigger_type = self.set.trigger_type(), + id = %config.id, + "trigger subscription unregistered" + ); + self.set.remove(&config.id); + Ok(()) + } +} + +/// Register both custom trigger types with the engine. Must run **before** +/// `functions::register_all` so the handlers capture the subscriber sets +/// they fan out to. +pub fn register_trigger_types(iii: &Arc) -> TriggerSets { + let sets = TriggerSets::new(); + + let _ = iii.register_trigger_type( + RegisterTriggerType::new( + PENDING_CREATED, + "A function call was held for human approval and its inbox record written. \ + Payload: PendingApprovalRecord (redacted args, session context, expiry). \ + Bind notification workers here.", + ApprovalTriggerHandler { + set: sets.created.clone(), + }, + ) + .trigger_request_format::(), + ); + let _ = iii.register_trigger_type( + RegisterTriggerType::new( + PENDING_RESOLVED, + "A pending approval left the inbox (outcome: allow | deny | timeout | aborted). \ + Emitted exactly once per record; lets UIs clear badges.", + ApprovalTriggerHandler { + set: sets.resolved.clone(), + }, + ) + .trigger_request_format::(), + ); + + tracing::info!( + trigger_types = ?[PENDING_CREATED, PENDING_RESOLVED], + "registered trigger types" + ); + sets +} + +/// Where emissions go. Production fans out over the bus; tests record. +#[async_trait] +pub trait EventSink: Send + Sync { + async fn pending_created(&self, record: &PendingApprovalRecord); + async fn pending_resolved(&self, event: &PendingResolvedEvent); +} + +/// Filtered fire-and-forget fan-out to every matching binding. +pub struct Emitter { + sets: TriggerSets, + bus: Arc, +} + +impl Emitter { + pub fn new(sets: TriggerSets, bus: Arc) -> Self { + Self { sets, bus } + } + + async fn fan_out( + &self, + set: &SubscriberSet, + session_id: &str, + session_metadata: Option<&JsonMap>, + payload: Value, + ) { + for binding in set.snapshot() { + if binding_matches(&binding.filter, session_id, session_metadata) { + self.bus + .call_void(&binding.function_id, payload.clone()) + .await; + } + } + } +} + +#[async_trait] +impl EventSink for Emitter { + async fn pending_created(&self, record: &PendingApprovalRecord) { + // Payload: PendingApprovalRecord & { status: "pending" }. + let mut payload = serde_json::to_value(record).unwrap_or(Value::Null); + if let Some(map) = payload.as_object_mut() { + map.insert("status".to_string(), Value::String("pending".to_string())); + } + self.fan_out( + &self.sets.created, + &record.session_id, + record.session_metadata.as_ref(), + payload, + ) + .await; + } + + async fn pending_resolved(&self, event: &PendingResolvedEvent) { + let payload = serde_json::to_value(event).unwrap_or(Value::Null); + self.fan_out( + &self.sets.resolved, + &event.session_id, + event.session_metadata.as_ref(), + payload, + ) + .await; + } +} + +/// Test double: records every emission. +#[derive(Default)] +pub struct RecordingSink { + pub created: Mutex>, + pub resolved: Mutex>, +} + +impl RecordingSink { + pub fn new() -> Self { + Self::default() + } + + pub fn created_events(&self) -> Vec { + self.created + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .clone() + } + + pub fn resolved_events(&self) -> Vec { + self.resolved + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .clone() + } +} + +#[async_trait] +impl EventSink for RecordingSink { + async fn pending_created(&self, record: &PendingApprovalRecord) { + self.created + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .push(record.clone()); + } + + async fn pending_resolved(&self, event: &PendingResolvedEvent) { + self.resolved + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .push(event.clone()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testkit::FakeBus; + use crate::types::ResolvedOutcome; + use serde_json::json; + + fn trigger_config(id: &str, function_id: &str, config: Value) -> TriggerConfig { + TriggerConfig { + id: id.into(), + function_id: function_id.into(), + config, + metadata: None, + } + } + + fn record(session_id: &str, metadata: Option) -> PendingApprovalRecord { + PendingApprovalRecord { + session_id: session_id.into(), + turn_id: "t_1".into(), + function_call_id: "c_1".into(), + function_id: "shell::run".into(), + arguments_excerpt: Value::Null, + pending_at: 1, + expires_at: 2, + session_title: None, + session_description: None, + session_metadata: metadata.map(|m| serde_json::from_value(m).expect("metadata map")), + depth: 0, + assistant_excerpt: None, + } + } + + #[test] + fn null_config_is_match_all() { + let filter = parse_binding_config(&Value::Null).unwrap(); + assert!(binding_matches(&filter, "s_1", None)); + } + + #[test] + fn unknown_config_fields_are_rejected() { + assert!(parse_binding_config(&json!({ "sesion_id": "typo" })).is_err()); + } + + #[test] + fn session_id_filter_is_equality() { + let filter = parse_binding_config(&json!({ "session_id": "s_1" })).unwrap(); + assert!(binding_matches(&filter, "s_1", None)); + assert!(!binding_matches(&filter, "s_2", None)); + } + + #[test] + fn metadata_filter_is_subset_equality() { + let filter = parse_binding_config(&json!({ "metadata": { "owner": "u_1" } })).unwrap(); + let meta: JsonMap = serde_json::from_value(json!({ "owner": "u_1", "extra": 1 })).unwrap(); + assert!(binding_matches(&filter, "s_1", Some(&meta))); + assert!(!binding_matches(&filter, "s_1", None)); + } + + #[tokio::test] + async fn emitter_delivers_to_matching_bindings_with_status() { + let sets = TriggerSets::new(); + sets.created + .add(trigger_config("b1", "notify::on_pending", json!({}))) + .unwrap(); + sets.created + .add(trigger_config( + "b2", + "other::on_pending", + json!({ "session_id": "different" }), + )) + .unwrap(); + + let bus = Arc::new(FakeBus::new()); + let emitter = Emitter::new(sets, bus.clone()); + emitter + .pending_created(&record("s_1", Some(json!({ "owner": "u_1" })))) + .await; + + let delivered = bus.calls_to("notify::on_pending"); + assert_eq!(delivered.len(), 1); + assert!(delivered[0].void); + assert_eq!(delivered[0].payload["status"], json!("pending")); + assert_eq!(delivered[0].payload["session_id"], json!("s_1")); + assert!(bus.calls_to("other::on_pending").is_empty()); + } + + #[tokio::test] + async fn emitter_delivers_resolved_events() { + let sets = TriggerSets::new(); + sets.resolved + .add(trigger_config("b1", "notify::on_resolved", json!(null))) + .unwrap(); + let bus = Arc::new(FakeBus::new()); + let emitter = Emitter::new(sets, bus.clone()); + emitter + .pending_resolved(&PendingResolvedEvent { + session_id: "s_1".into(), + turn_id: "t_1".into(), + function_call_id: "c_1".into(), + function_id: "shell::run".into(), + outcome: ResolvedOutcome::Timeout, + reason: None, + session_metadata: None, + resolved_at: 5, + }) + .await; + let delivered = bus.calls_to("notify::on_resolved"); + assert_eq!(delivered.len(), 1); + assert_eq!(delivered[0].payload["outcome"], json!("timeout")); + } + + #[test] + fn subscriber_set_add_remove() { + let set = SubscriberSet::new(PENDING_CREATED); + set.add(trigger_config("b1", "f::1", json!({}))).unwrap(); + assert_eq!(set.len(), 1); + set.remove("b1"); + assert!(set.is_empty()); + // Malformed config rejected. + assert!(set + .add(trigger_config("b2", "f::2", json!({ "bogus": 1 }))) + .is_err()); + } +} diff --git a/approval-gate/src/functions/add_always_allow.rs b/approval-gate/src/functions/add_always_allow.rs new file mode 100644 index 00000000..27c2d7f5 --- /dev/null +++ b/approval-gate/src/functions/add_always_allow.rs @@ -0,0 +1,64 @@ +//! `approval::add_always_allow` — curate the session's auto-mode trust +//! list (idempotent add). + +use super::Deps; +use crate::error::ApprovalError; +use crate::gate_config::snapshot; +use crate::settings::{self, with_grant}; +use crate::types::{AlwaysAllowMutationRequest, ApprovalSettings, SettingsResponse}; + +pub async fn handle( + deps: &Deps, + req: AlwaysAllowMutationRequest, +) -> Result { + if req.function_id.is_empty() { + return Err(ApprovalError::InvalidPayload( + "function_id must be a non-empty string".to_string(), + )); + } + let defaults = snapshot(&deps.defaults); + let settings = settings::materialize_and( + deps.bus.as_ref(), + &req.session_id, + &defaults, + deps.cfg.state_timeout_ms, + |base, now| ApprovalSettings { + always_allow: with_grant(&base.always_allow, &req.function_id, now), + ..base + }, + ) + .await?; + Ok(SettingsResponse { settings }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::config::WorkerConfig; + use crate::events::RecordingSink; + use crate::gate_config::shared_defaults; + use crate::testkit::FakeBus; + + #[tokio::test] + async fn add_is_idempotent_on_function_id() { + let bus = Arc::new(FakeBus::new()); + let _state = bus.with_memory_state(); + let deps = Arc::new(Deps { + bus, + sink: Arc::new(RecordingSink::new()), + defaults: shared_defaults(), + cfg: Arc::new(WorkerConfig::default()), + }); + let req = AlwaysAllowMutationRequest { + session_id: "s_1".into(), + function_id: "shell::run".into(), + }; + let first = handle(&deps, req.clone()).await.unwrap(); + assert_eq!(first.settings.always_allow.len(), 1); + let second = handle(&deps, req).await.unwrap(); + assert_eq!(second.settings.always_allow.len(), 1); + assert_eq!(first.settings.always_allow, second.settings.always_allow); + } +} diff --git a/approval-gate/src/functions/approve_always.rs b/approval-gate/src/functions/approve_always.rs new file mode 100644 index 00000000..7492ffa1 --- /dev/null +++ b/approval-gate/src/functions/approve_always.rs @@ -0,0 +1,73 @@ +//! `approval::approve_always` — record a per-session "approve always" +//! grant (honoured in **every** mode). Typically called by the console +//! from an approval prompt, immediately before +//! `approval::resolve { decision: "allow" }`. + +use super::Deps; +use crate::error::ApprovalError; +use crate::gate_config::snapshot; +use crate::settings::{self, with_grant}; +use crate::types::{ApprovalSettings, ApproveAlwaysRequest, SettingsResponse}; + +pub async fn handle( + deps: &Deps, + req: ApproveAlwaysRequest, +) -> Result { + if req.function_id.is_empty() { + return Err(ApprovalError::InvalidPayload( + "function_id must be a non-empty string".to_string(), + )); + } + let defaults = snapshot(&deps.defaults); + let settings = settings::materialize_and( + deps.bus.as_ref(), + &req.session_id, + &defaults, + deps.cfg.state_timeout_ms, + |base, now| ApprovalSettings { + approved_always: with_grant(&base.approved_always, &req.function_id, now), + ..base + }, + ) + .await?; + Ok(SettingsResponse { settings }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::config::WorkerConfig; + use crate::events::RecordingSink; + use crate::gate_config::shared_defaults; + use crate::testkit::FakeBus; + use crate::types::GrantedBy; + + #[tokio::test] + async fn grants_into_approved_always_not_always_allow() { + let bus = Arc::new(FakeBus::new()); + let _state = bus.with_memory_state(); + let deps = Arc::new(Deps { + bus, + sink: Arc::new(RecordingSink::new()), + defaults: shared_defaults(), + cfg: Arc::new(WorkerConfig::default()), + }); + let res = handle( + &deps, + ApproveAlwaysRequest { + session_id: "s_1".into(), + function_id: "shell::run".into(), + }, + ) + .await + .unwrap(); + assert!(res.settings.always_allow.is_empty()); + assert_eq!(res.settings.approved_always.len(), 1); + assert_eq!( + res.settings.approved_always[0].granted_by, + GrantedBy::UserClick + ); + } +} diff --git a/approval-gate/src/functions/clear_settings.rs b/approval-gate/src/functions/clear_settings.rs new file mode 100644 index 00000000..7cadf2e9 --- /dev/null +++ b/approval-gate/src/functions/clear_settings.rs @@ -0,0 +1,62 @@ +//! `approval::clear_settings` — drop the session's stored settings record +//! (the session reverts to configuration defaults). Also invoked +//! internally by `approval::on_session_deleted`. + +use super::Deps; +use crate::error::ApprovalError; +use crate::settings; +use crate::types::{ClearSettingsRequest, ClearSettingsResponse}; + +pub async fn handle( + deps: &Deps, + req: ClearSettingsRequest, +) -> Result { + let cleared = settings::clear( + deps.bus.as_ref(), + &req.session_id, + deps.cfg.state_timeout_ms, + ) + .await?; + Ok(ClearSettingsResponse { cleared }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::config::WorkerConfig; + use crate::events::RecordingSink; + use crate::functions::set_mode; + use crate::gate_config::shared_defaults; + use crate::testkit::FakeBus; + use crate::types::{PermissionMode, SetModeRequest}; + + #[tokio::test] + async fn clears_a_stored_record_and_tolerates_absence() { + let bus = Arc::new(FakeBus::new()); + let _state = bus.with_memory_state(); + let deps = Arc::new(Deps { + bus, + sink: Arc::new(RecordingSink::new()), + defaults: shared_defaults(), + cfg: Arc::new(WorkerConfig::default()), + }); + let req = ClearSettingsRequest { + session_id: "s_1".into(), + }; + assert!(!handle(&deps, req.clone()).await.unwrap().cleared); + + set_mode::handle( + &deps, + SetModeRequest { + session_id: "s_1".into(), + mode: PermissionMode::Full, + }, + ) + .await + .unwrap(); + assert!(handle(&deps, req.clone()).await.unwrap().cleared); + assert!(!handle(&deps, req).await.unwrap().cleared); + } +} diff --git a/approval-gate/src/functions/gate.rs b/approval-gate/src/functions/gate.rs new file mode 100644 index 00000000..36d5e4c5 --- /dev/null +++ b/approval-gate/src/functions/gate.rs @@ -0,0 +1,665 @@ +//! `approval::gate` — the `pre_dispatch` hook (approval-gate.md § The +//! approval::gate hook). Maps `HookInput` → `HookOutput`. Never errors: +//! every failure mode resolves to a fail-closed `deny` so a confused +//! harness cannot interpret an exception as anything but denial (its +//! `on_error: fail_closed` would do the same). +//! +//! Runs inside the harness's at-least-once steps and is idempotent: a +//! redelivered step re-runs the gate for the same `function_call_id`; +//! the pending-record write is keyed on it, so a duplicate hold is a +//! no-op on the existing record (and emits no second `pending_created`). + +use serde_json::json; + +use super::Deps; +use crate::decision; +use crate::denial::{gate_unavailable_envelope, human_only_denial, permissions_deny_envelope}; +use crate::error::ApprovalError; +use crate::gate_config::{snapshot, GateDefaults}; +use crate::pending; +use crate::policy::{self, PolicyOutcome}; +use crate::redact::redact; +use crate::settings; +use crate::types::{ + now_ms, validate_id, HookCall, HookInput, HookOutput, JsonMap, PendingApprovalRecord, +}; + +pub async fn handle(deps: &Deps, input: HookInput) -> Result { + let Some(call) = input.call.clone() else { + return Ok(deny( + "approval-gate received a pre_dispatch hook input without a call payload", + )); + }; + + // 1. Human-only defense — before the settings snapshot, before + // policy, and regardless of mode (even `full`). + if decision::is_human_only(&call.function_id) { + let envelope = human_only_denial(&call.function_id, &call.arguments); + return Ok(deny(&envelope.reason)); + } + + // A call whose ids can't key a pending record can never be held — + // and a malformed id is a boundary violation anyway. Fail closed. + if validate_id("session_id", &input.session_id).is_err() + || validate_id("function_call_id", &call.id).is_err() + { + return Ok(deny(&format!( + "approval-gate cannot evaluate {}: session_id / function_call_id must be non-empty and must not contain \"/\"", + call.function_id + ))); + } + + // 2. One settings snapshot per call (race-safe). A state outage + // degrades to the configuration defaults: safe, because the + // default never widens beyond what the deployment configured. + let defaults = snapshot(&deps.defaults); + let stored = settings::read_tolerant( + deps.bus.as_ref(), + &input.session_id, + deps.cfg.state_timeout_ms, + ) + .await; + let (effective, _) = settings::effective(stored, &defaults); + + // 3-5. Mode / allow-list short-circuits. + if decision::pre_policy_allow(&effective, &call.function_id) { + return Ok(HookOutput::Continue); + } + + // 6. Yaml policy fallback. + match policy::check( + deps.bus.as_ref(), + &call.function_id, + &call.arguments, + deps.cfg.policy_timeout_ms, + ) + .await + { + PolicyOutcome::Allow => Ok(HookOutput::Continue), + PolicyOutcome::Deny { + rule_id, + matched_constraint, + } => { + let envelope = permissions_deny_envelope( + &call.function_id, + &rule_id, + matched_constraint, + &call.arguments, + ); + Ok(deny(&envelope.reason)) + } + PolicyOutcome::Unavailable(why) => { + let envelope = gate_unavailable_envelope(&call.function_id, &why); + Ok(deny(&envelope.reason)) + } + PolicyOutcome::NeedsApproval => Ok(hold(deps, &input, &call, &defaults).await), + } +} + +fn deny(reason: &str) -> HookOutput { + HookOutput::Deny { + reason: reason.to_string(), + } +} + +/// The hold path. The pending record is written **synchronously, before +/// the hook returns hold** — a held call must never be invisible to the +/// inbox. Write failure → fail-closed deny, never hold blind. +/// `pending_created` emits asynchronously after the record is written — +/// notification fan-out never blocks the dispatch hot path. +async fn hold( + deps: &Deps, + input: &HookInput, + call: &HookCall, + defaults: &GateDefaults, +) -> HookOutput { + let bus = deps.bus.as_ref(); + + // Idempotency: a redelivered step re-holds the same call. + match pending::get(bus, &input.session_id, &call.id, deps.cfg.state_timeout_ms).await { + Ok(Some(existing)) => { + return HookOutput::Hold { + pending_timeout_ms: (existing.expires_at - existing.pending_at).max(0), + }; + } + Ok(None) => {} + Err(e) => { + let envelope = gate_unavailable_envelope( + &call.function_id, + &format!("pending record read failed: {e}"), + ); + return deny(&envelope.reason); + } + } + + let (session_title, session_description, session_metadata) = + fetch_session_context(deps, &input.session_id).await; + + let pending_at = now_ms(); + let record = PendingApprovalRecord { + session_id: input.session_id.clone(), + turn_id: input.turn_id.clone(), + function_call_id: call.id.clone(), + function_id: call.function_id.clone(), + arguments_excerpt: redact(&call.arguments), + pending_at, + expires_at: pending_at + defaults.pending_timeout_ms, + session_title, + session_description, + session_metadata, + depth: input.depth, + assistant_excerpt: None, + }; + + match pending::put(bus, &record, deps.cfg.state_timeout_ms).await { + Err(e) => { + let envelope = gate_unavailable_envelope( + &call.function_id, + &format!("pending record write failed: {e}"), + ); + deny(&envelope.reason) + } + // Lost a write race with a concurrent duplicate hold: the first + // writer's record (and emission) stands. + Ok(Some(_prior)) => HookOutput::Hold { + pending_timeout_ms: defaults.pending_timeout_ms, + }, + Ok(None) => { + let sink = deps.sink.clone(); + tokio::spawn(async move { + sink.pending_created(&record).await; + }); + HookOutput::Hold { + pending_timeout_ms: defaults.pending_timeout_ms, + } + } + } +} + +/// Best-effort `session::get` — fields are omitted on any failure +/// (session-manager absent, timeout, unknown session), within its own +/// budget so it can't eat the hook's. +async fn fetch_session_context( + deps: &Deps, + session_id: &str, +) -> (Option, Option, Option) { + let reply = deps + .bus + .call( + "session::get", + json!({ "session_id": session_id }), + Some(deps.cfg.session_fetch_timeout_ms), + ) + .await; + let Ok(reply) = reply else { + return (None, None, None); + }; + let Some(meta) = reply.get("meta") else { + return (None, None, None); + }; + let title = meta + .get("title") + .and_then(serde_json::Value::as_str) + .filter(|t| !t.is_empty()) + .map(str::to_string); + let description = meta + .get("description") + .and_then(serde_json::Value::as_str) + .filter(|d| !d.is_empty()) + .map(str::to_string); + let metadata = meta + .get("metadata") + .and_then(serde_json::Value::as_object) + .cloned(); + (title, description, metadata) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use serde_json::json; + + use super::*; + use crate::config::WorkerConfig; + use crate::events::RecordingSink; + use crate::gate_config::{replace, shared_defaults}; + use crate::pending::PENDING_SCOPE; + use crate::settings::SETTINGS_SCOPE; + use crate::testkit::{FakeBus, MemoryState}; + use crate::types::{ApprovalSettings, PermissionMode}; + + struct Fixture { + deps: Arc, + bus: Arc, + sink: Arc, + state: MemoryState, + } + + fn fixture() -> Fixture { + let bus = Arc::new(FakeBus::new()); + let state = bus.with_memory_state(); + let sink = Arc::new(RecordingSink::new()); + let deps = Arc::new(Deps { + bus: bus.clone(), + sink: sink.clone(), + defaults: shared_defaults(), + cfg: Arc::new(WorkerConfig::default()), + }); + Fixture { + deps, + bus, + sink, + state, + } + } + + fn seed_settings(f: &Fixture, session_id: &str, settings: &ApprovalSettings) { + f.state.seed( + SETTINGS_SCOPE, + session_id, + serde_json::to_value(settings).unwrap(), + ); + } + + fn grants(ids: &[&str]) -> Vec { + ids.iter() + .map(|id| crate::types::AlwaysAllowEntry { + function_id: id.to_string(), + granted_at: 0, + granted_by: crate::types::GrantedBy::UserClick, + }) + .collect() + } + + fn hook_input(function_id: &str) -> HookInput { + serde_json::from_value(json!({ + "point": "pre_dispatch", + "session_id": "s_1", + "turn_id": "t_1", + "step": 1, + "depth": 0, + "call": { "id": "c_1", "function_id": function_id, "arguments": { "cmd": "ls" } } + })) + .unwrap() + } + + fn policy_needs_approval(f: &Fixture) { + f.bus.on_value( + "policy::check_permissions", + json!({ "decision": "needs_approval" }), + ); + } + + async fn run(f: &Fixture, function_id: &str) -> HookOutput { + handle(&f.deps, hook_input(function_id)).await.unwrap() + } + + fn settle() -> tokio::time::Sleep { + // Let the spawned pending_created emission run. + tokio::time::sleep(std::time::Duration::from_millis(20)) + } + + // --- The seven prior-art permission-matrix cases ------------------- + + #[tokio::test] + async fn case_1_full_mode_allows_without_consulting_policy() { + let f = fixture(); + seed_settings( + &f, + "s_1", + &ApprovalSettings { + mode: PermissionMode::Full, + ..Default::default() + }, + ); + assert_eq!(run(&f, "shell::run").await, HookOutput::Continue); + assert!(f.bus.calls_to("policy::check_permissions").is_empty()); + assert!(f.state.peek(PENDING_SCOPE, "s_1/c_1").is_none()); + } + + #[tokio::test] + async fn case_2_approved_always_holds_in_manual_mode() { + let f = fixture(); + seed_settings( + &f, + "s_1", + &ApprovalSettings { + mode: PermissionMode::Manual, + approved_always: grants(&["shell::run"]), + ..Default::default() + }, + ); + assert_eq!(run(&f, "shell::run").await, HookOutput::Continue); + assert!(f.bus.calls_to("policy::check_permissions").is_empty()); + } + + #[tokio::test] + async fn case_3_auto_mode_always_allow_hit_allows() { + let f = fixture(); + seed_settings( + &f, + "s_1", + &ApprovalSettings { + mode: PermissionMode::Auto, + always_allow: grants(&["shell::run"]), + ..Default::default() + }, + ); + assert_eq!(run(&f, "shell::run").await, HookOutput::Continue); + } + + #[tokio::test] + async fn case_4_manual_mode_without_grants_holds() { + let f = fixture(); + policy_needs_approval(&f); + seed_settings( + &f, + "s_1", + &ApprovalSettings { + mode: PermissionMode::Manual, + ..Default::default() + }, + ); + let out = run(&f, "shell::run").await; + assert_eq!( + out, + HookOutput::Hold { + pending_timeout_ms: 1_800_000 + } + ); + let record = f.state.peek(PENDING_SCOPE, "s_1/c_1").unwrap(); + assert_eq!(record["function_id"], json!("shell::run")); + settle().await; + assert_eq!(f.sink.created_events().len(), 1); + } + + #[tokio::test] + async fn case_5_always_allow_is_dormant_in_manual_mode() { + let f = fixture(); + policy_needs_approval(&f); + seed_settings( + &f, + "s_1", + &ApprovalSettings { + mode: PermissionMode::Manual, + always_allow: grants(&["shell::run"]), + ..Default::default() + }, + ); + assert!(matches!( + run(&f, "shell::run").await, + HookOutput::Hold { .. } + )); + } + + #[tokio::test] + async fn case_6_auto_mode_miss_holds() { + let f = fixture(); + policy_needs_approval(&f); + seed_settings( + &f, + "s_1", + &ApprovalSettings { + mode: PermissionMode::Auto, + always_allow: grants(&["other::fn"]), + ..Default::default() + }, + ); + assert!(matches!( + run(&f, "shell::run").await, + HookOutput::Hold { .. } + )); + } + + #[tokio::test] + async fn case_7_self_escalation_is_denied_before_everything() { + let f = fixture(); + // Even under full mode, and without policy or state scripted. + seed_settings( + &f, + "s_1", + &ApprovalSettings { + mode: PermissionMode::Full, + ..Default::default() + }, + ); + for target in [ + "approval::set_mode", + "approval::resolve", + "configuration::set", + ] { + let out = run(&f, target).await; + let HookOutput::Deny { reason } = out else { + panic!("expected deny for {target}"); + }; + assert!(reason.contains("human_only_function"), "{reason}"); + } + assert!(f.bus.calls_to("policy::check_permissions").is_empty()); + } + + // --- Greenfield rows ------------------------------------------------ + + #[tokio::test] + async fn policy_allow_continues() { + let f = fixture(); + f.bus.on_value( + "policy::check_permissions", + json!({ "decision": "allow", "rule_id": "r1" }), + ); + assert_eq!(run(&f, "shell::run").await, HookOutput::Continue); + } + + #[tokio::test] + async fn policy_deny_carries_the_permissions_reason() { + let f = fixture(); + f.bus.on_value( + "policy::check_permissions", + json!({ + "decision": "deny", + "rule_id": "no_shell", + "matched_constraint": { "field": "cmd", "operator": "eq", "value": "ls" } + }), + ); + let HookOutput::Deny { reason } = run(&f, "shell::run").await else { + panic!("expected deny"); + }; + assert!(reason.contains("matched rule no_shell")); + assert!(f.state.peek(PENDING_SCOPE, "s_1/c_1").is_none()); + } + + #[tokio::test] + async fn garbage_policy_reply_degrades_to_hold() { + let f = fixture(); + f.bus + .on_value("policy::check_permissions", json!("what even is this")); + assert!(matches!( + run(&f, "shell::run").await, + HookOutput::Hold { .. } + )); + } + + #[tokio::test] + async fn policy_transport_failure_fails_closed() { + let f = fixture(); + f.bus + .on_error("policy::check_permissions", "connection refused"); + let HookOutput::Deny { reason } = run(&f, "shell::run").await else { + panic!("expected deny"); + }; + assert!(reason.contains("policy unreachable")); + assert!(f.state.peek(PENDING_SCOPE, "s_1/c_1").is_none()); + } + + #[tokio::test] + async fn missing_policy_worker_fails_closed() { + let bus = Arc::new(FakeBus::new()); + let _state = bus.with_memory_state(); + let sink = Arc::new(RecordingSink::new()); + let deps = Arc::new(Deps { + bus: bus.clone(), + sink, + defaults: shared_defaults(), + cfg: Arc::new(WorkerConfig::default()), + }); + let out = handle(&deps, hook_input("shell::run")).await.unwrap(); + assert!(matches!(out, HookOutput::Deny { .. })); + } + + #[tokio::test] + async fn pending_write_failure_denies_never_holds_blind() { + let f = fixture(); + policy_needs_approval(&f); + // State reads succeed (no record) but writes fail. + f.bus.on("state::set", |_| { + Err(crate::bus::BusError("disk full".into())) + }); + let HookOutput::Deny { reason } = run(&f, "shell::run").await else { + panic!("expected deny"); + }; + assert!(reason.contains("pending record write failed")); + settle().await; + assert!(f.sink.created_events().is_empty()); + } + + #[tokio::test] + async fn duplicate_hold_is_a_noop_on_the_existing_record() { + let f = fixture(); + policy_needs_approval(&f); + let first = run(&f, "shell::run").await; + assert!(matches!(first, HookOutput::Hold { .. })); + let before = f.state.peek(PENDING_SCOPE, "s_1/c_1").unwrap(); + + let second = run(&f, "shell::run").await; + assert!(matches!(second, HookOutput::Hold { .. })); + // No rewrite: pending_at unchanged. + assert_eq!(f.state.peek(PENDING_SCOPE, "s_1/c_1").unwrap(), before); + settle().await; + assert_eq!(f.sink.created_events().len(), 1); + } + + #[tokio::test] + async fn no_stored_settings_uses_configuration_defaults() { + let f = fixture(); + // Deployment defaults: auto mode with a seeded glob. + replace( + &f.deps.defaults, + crate::gate_config::GateDefaults { + default_mode: PermissionMode::Auto, + always_allow_seed: vec!["shell::*".into()], + pending_timeout_ms: 60_000, + }, + ); + // Seed honored in auto (no stored record). + assert_eq!(run(&f, "shell::run").await, HookOutput::Continue); + + // Same seed dormant under manual defaults. + replace( + &f.deps.defaults, + crate::gate_config::GateDefaults { + default_mode: PermissionMode::Manual, + always_allow_seed: vec!["shell::*".into()], + pending_timeout_ms: 60_000, + }, + ); + policy_needs_approval(&f); + let out = run(&f, "shell::run").await; + assert_eq!( + out, + HookOutput::Hold { + pending_timeout_ms: 60_000 + } + ); + } + + #[tokio::test] + async fn record_carries_redacted_args_and_session_context() { + let f = fixture(); + policy_needs_approval(&f); + f.bus.on_value( + "session::get", + json!({ "meta": { + "session_id": "s_1", + "title": "Deploy review", + "description": "prod deploy", + "metadata": { "owner": "u_1" } + }}), + ); + let input: HookInput = serde_json::from_value(json!({ + "session_id": "s_1", + "turn_id": "t_1", + "depth": 2, + "call": { + "id": "c_1", + "function_id": "shell::run", + "arguments": { "cmd": "ls", "api_key": "sk_live_123" } + } + })) + .unwrap(); + handle(&f.deps, input).await.unwrap(); + + let record = f.state.peek(PENDING_SCOPE, "s_1/c_1").unwrap(); + assert_eq!(record["arguments_excerpt"]["api_key"], json!("")); + assert_eq!(record["session_title"], json!("Deploy review")); + assert_eq!(record["session_metadata"]["owner"], json!("u_1")); + assert_eq!(record["depth"], json!(2)); + assert_eq!( + record["expires_at"].as_i64().unwrap() - record["pending_at"].as_i64().unwrap(), + 1_800_000 + ); + } + + #[tokio::test] + async fn session_fetch_failure_omits_context_but_still_holds() { + let f = fixture(); + policy_needs_approval(&f); + // No session::get scripted → transport error → fields omitted. + assert!(matches!( + run(&f, "shell::run").await, + HookOutput::Hold { .. } + )); + let record = f.state.peek(PENDING_SCOPE, "s_1/c_1").unwrap(); + assert!(record.get("session_title").is_none()); + assert!(record.get("session_metadata").is_none()); + } + + #[tokio::test] + async fn slash_in_ids_fails_closed() { + let f = fixture(); + let input: HookInput = serde_json::from_value(json!({ + "session_id": "s/1", + "turn_id": "t_1", + "call": { "id": "c_1", "function_id": "shell::run", "arguments": {} } + })) + .unwrap(); + assert!(matches!( + handle(&f.deps, input).await.unwrap(), + HookOutput::Deny { .. } + )); + } + + #[tokio::test] + async fn missing_call_payload_fails_closed() { + let f = fixture(); + let input: HookInput = + serde_json::from_value(json!({ "session_id": "s_1", "turn_id": "t_1" })).unwrap(); + assert!(matches!( + handle(&f.deps, input).await.unwrap(), + HookOutput::Deny { .. } + )); + } + + #[tokio::test] + async fn settings_state_outage_degrades_to_defaults_not_allow() { + let f = fixture(); + // state::get fails entirely; manual defaults → policy consult. + f.bus + .on("state::get", |_| Err(crate::bus::BusError("down".into()))); + f.bus.on_error("policy::check_permissions", "also down"); + // Fail-closed end to end: deny, not allow, not hold. + assert!(matches!( + run(&f, "shell::run").await, + HookOutput::Deny { .. } + )); + } +} diff --git a/approval-gate/src/functions/get_pending.rs b/approval-gate/src/functions/get_pending.rs new file mode 100644 index 00000000..22d794b1 --- /dev/null +++ b/approval-gate/src/functions/get_pending.rs @@ -0,0 +1,85 @@ +//! `approval::get_pending` — read one pending record; `null` when +//! resolved or unknown. + +use super::Deps; +use crate::error::ApprovalError; +use crate::pending; +use crate::types::{validate_id, GetPendingRequest, GetPendingResponse}; + +pub async fn handle( + deps: &Deps, + req: GetPendingRequest, +) -> Result, ApprovalError> { + validate_id("session_id", &req.session_id)?; + validate_id("function_call_id", &req.function_call_id)?; + let record = pending::get( + deps.bus.as_ref(), + &req.session_id, + &req.function_call_id, + deps.cfg.state_timeout_ms, + ) + .await + .map_err(|e| ApprovalError::StateUnavailable(format!("pending record read failed: {e}")))?; + Ok(record.map(|pending| GetPendingResponse { pending })) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use serde_json::json; + + use super::*; + use crate::config::WorkerConfig; + use crate::events::RecordingSink; + use crate::gate_config::shared_defaults; + use crate::pending::PENDING_SCOPE; + use crate::testkit::FakeBus; + + #[tokio::test] + async fn returns_null_for_unknown_and_the_record_when_live() { + let bus = Arc::new(FakeBus::new()); + let state = bus.with_memory_state(); + let deps = Arc::new(Deps { + bus, + sink: Arc::new(RecordingSink::new()), + defaults: shared_defaults(), + cfg: Arc::new(WorkerConfig::default()), + }); + + let missing = handle( + &deps, + GetPendingRequest { + session_id: "s_1".into(), + function_call_id: "c_1".into(), + }, + ) + .await + .unwrap(); + assert!(missing.is_none()); + + state.seed( + PENDING_SCOPE, + "s_1/c_1", + json!({ + "session_id": "s_1", + "turn_id": "t_1", + "function_call_id": "c_1", + "function_id": "shell::run", + "pending_at": 1, + "expires_at": 2, + }), + ); + let found = handle( + &deps, + GetPendingRequest { + session_id: "s_1".into(), + function_call_id: "c_1".into(), + }, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(found.pending.function_id, "shell::run"); + } +} diff --git a/approval-gate/src/functions/get_settings.rs b/approval-gate/src/functions/get_settings.rs new file mode 100644 index 00000000..7c797e54 --- /dev/null +++ b/approval-gate/src/functions/get_settings.rs @@ -0,0 +1,71 @@ +//! `approval::get_settings` — read the session's **effective** settings. +//! Never writes (lazy seeding happens on mutation, not on read). + +use super::Deps; +use crate::error::ApprovalError; +use crate::gate_config::snapshot; +use crate::settings; +use crate::types::{validate_id, GetSettingsRequest, GetSettingsResponse}; + +pub async fn handle( + deps: &Deps, + req: GetSettingsRequest, +) -> Result { + validate_id("session_id", &req.session_id)?; + let defaults = snapshot(&deps.defaults); + let stored = settings::read_strict( + deps.bus.as_ref(), + &req.session_id, + deps.cfg.state_timeout_ms, + ) + .await?; + let (settings, source) = settings::effective(stored, &defaults); + Ok(GetSettingsResponse { settings, source }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::config::WorkerConfig; + use crate::events::RecordingSink; + use crate::functions::set_mode; + use crate::gate_config::shared_defaults; + use crate::testkit::FakeBus; + use crate::types::{PermissionMode, SetModeRequest, SettingsSource}; + + #[tokio::test] + async fn reports_source_defaults_then_stored_and_never_writes_on_read() { + let bus = Arc::new(FakeBus::new()); + let state = bus.with_memory_state(); + let deps = Arc::new(Deps { + bus, + sink: Arc::new(RecordingSink::new()), + defaults: shared_defaults(), + cfg: Arc::new(WorkerConfig::default()), + }); + let req = GetSettingsRequest { + session_id: "s_1".into(), + }; + + let before = handle(&deps, req.clone()).await.unwrap(); + assert_eq!(before.source, SettingsSource::Defaults); + assert_eq!(before.settings.mode, PermissionMode::Manual); + assert!(state.is_empty()); + + set_mode::handle( + &deps, + SetModeRequest { + session_id: "s_1".into(), + mode: PermissionMode::Full, + }, + ) + .await + .unwrap(); + + let after = handle(&deps, req).await.unwrap(); + assert_eq!(after.source, SettingsSource::Stored); + assert_eq!(after.settings.mode, PermissionMode::Full); + } +} diff --git a/approval-gate/src/functions/list_pending.rs b/approval-gate/src/functions/list_pending.rs new file mode 100644 index 00000000..d5a4ab54 --- /dev/null +++ b/approval-gate/src/functions/list_pending.rs @@ -0,0 +1,244 @@ +//! `approval::list_pending` — the pending inbox. Filters apply +//! worker-side over the live scope (cheap — the scope only ever holds +//! live records). Ordered by `pending_at` ascending with a stable +//! tie-break so the opaque cursor paginates deterministically. + +use base64::Engine as _; + +use super::Deps; +use crate::error::ApprovalError; +use crate::pending; +use crate::types::{ + metadata_matches, ListPendingRequest, ListPendingResponse, PendingApprovalRecord, +}; + +const DEFAULT_LIMIT: usize = 50; +const MAX_LIMIT: usize = 500; + +type CursorKey = (i64, String, String); + +fn sort_key(record: &PendingApprovalRecord) -> CursorKey { + ( + record.pending_at, + record.session_id.clone(), + record.function_call_id.clone(), + ) +} + +fn encode_cursor(key: &CursorKey) -> String { + let raw = serde_json::to_vec(&serde_json::json!([key.0, key.1, key.2])).unwrap_or_default(); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw) +} + +fn decode_cursor(cursor: &str) -> Result { + let invalid = || ApprovalError::InvalidPayload("invalid cursor".to_string()); + let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(cursor) + .map_err(|_| invalid())?; + let value: serde_json::Value = serde_json::from_slice(&raw).map_err(|_| invalid())?; + let parts = value + .as_array() + .filter(|a| a.len() == 3) + .ok_or_else(invalid)?; + Ok(( + parts[0].as_i64().ok_or_else(invalid)?, + parts[1].as_str().ok_or_else(invalid)?.to_string(), + parts[2].as_str().ok_or_else(invalid)?.to_string(), + )) +} + +pub async fn handle( + deps: &Deps, + req: ListPendingRequest, +) -> Result { + let after = req.cursor.as_deref().map(decode_cursor).transpose()?; + let limit = req.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT); + + let mut records = pending::list_all(deps.bus.as_ref(), deps.cfg.state_timeout_ms) + .await + .map_err(|e| ApprovalError::StateUnavailable(format!("pending list failed: {e}")))?; + + records.retain(|record| { + if let Some(want_sid) = &req.session_id { + if want_sid != &record.session_id { + return false; + } + } + if let Some(want) = &req.metadata { + if !metadata_matches(want, record.session_metadata.as_ref()) { + return false; + } + } + true + }); + records.sort_by_key(sort_key); + + let upper: Vec = match after { + Some(cursor_key) => records + .into_iter() + .filter(|r| sort_key(r) > cursor_key) + .collect(), + None => records, + }; + + let has_more = upper.len() > limit; + let page: Vec = upper.into_iter().take(limit).collect(); + let next_cursor = if has_more { + page.last().map(|last| encode_cursor(&sort_key(last))) + } else { + None + }; + + Ok(ListPendingResponse { + pending: page, + next_cursor, + }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use serde_json::json; + + use super::*; + use crate::config::WorkerConfig; + use crate::events::RecordingSink; + use crate::gate_config::shared_defaults; + use crate::pending::PENDING_SCOPE; + use crate::testkit::{FakeBus, MemoryState}; + + fn fixture() -> (Arc, MemoryState) { + let bus = Arc::new(FakeBus::new()); + let state = bus.with_memory_state(); + let deps = Arc::new(Deps { + bus, + sink: Arc::new(RecordingSink::new()), + defaults: shared_defaults(), + cfg: Arc::new(WorkerConfig::default()), + }); + (deps, state) + } + + fn seed(state: &MemoryState, sid: &str, cid: &str, pending_at: i64, owner: Option<&str>) { + let mut record = json!({ + "session_id": sid, + "turn_id": "t_1", + "function_call_id": cid, + "function_id": "shell::run", + "arguments_excerpt": {}, + "pending_at": pending_at, + "expires_at": pending_at + 1000, + "depth": 0, + }); + if let Some(owner) = owner { + record["session_metadata"] = json!({ "owner": owner }); + } + state.seed(PENDING_SCOPE, &format!("{sid}/{cid}"), record); + } + + #[tokio::test] + async fn orders_by_pending_at_ascending() { + let (deps, state) = fixture(); + seed(&state, "s_1", "c_2", 300, None); + seed(&state, "s_1", "c_1", 100, None); + seed(&state, "s_2", "c_3", 200, None); + + let res = handle(&deps, ListPendingRequest::default()).await.unwrap(); + let order: Vec = res.pending.iter().map(|r| r.pending_at).collect(); + assert_eq!(order, vec![100, 200, 300]); + assert!(res.next_cursor.is_none()); + } + + #[tokio::test] + async fn filters_by_session_and_metadata() { + let (deps, state) = fixture(); + seed(&state, "s_1", "c_1", 100, Some("u_1")); + seed(&state, "s_2", "c_2", 200, Some("u_2")); + + let by_session = handle( + &deps, + ListPendingRequest { + session_id: Some("s_2".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(by_session.pending.len(), 1); + assert_eq!(by_session.pending[0].session_id, "s_2"); + + let by_meta = handle( + &deps, + ListPendingRequest { + metadata: Some(serde_json::from_value(json!({ "owner": "u_1" })).unwrap()), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(by_meta.pending.len(), 1); + assert_eq!(by_meta.pending[0].session_id, "s_1"); + } + + #[tokio::test] + async fn paginates_with_an_opaque_cursor() { + let (deps, state) = fixture(); + for i in 0..5 { + seed(&state, "s_1", &format!("c_{i}"), 100 + i, None); + } + + let first = handle( + &deps, + ListPendingRequest { + limit: Some(2), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(first.pending.len(), 2); + let cursor = first.next_cursor.clone().unwrap(); + + let second = handle( + &deps, + ListPendingRequest { + limit: Some(2), + cursor: Some(cursor), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(second.pending.len(), 2); + assert!(second.pending[0].pending_at > first.pending[1].pending_at); + + let third = handle( + &deps, + ListPendingRequest { + limit: Some(2), + cursor: second.next_cursor.clone(), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(third.pending.len(), 1); + assert!(third.next_cursor.is_none()); + } + + #[tokio::test] + async fn rejects_malformed_cursors() { + let (deps, _state) = fixture(); + let err = handle( + &deps, + ListPendingRequest { + cursor: Some("not base64 json!!!".into()), + ..Default::default() + }, + ) + .await + .unwrap_err(); + assert_eq!(err.code(), "approval/invalid_payload"); + } +} diff --git a/approval-gate/src/functions/mod.rs b/approval-gate/src/functions/mod.rs new file mode 100644 index 00000000..40969d5c --- /dev/null +++ b/approval-gate/src/functions/mod.rs @@ -0,0 +1,172 @@ +//! The `approval::*` functions. Each `.rs` holds a +//! `pub async fn handle(deps, req)` that the registration closure wraps; +//! tests call the same `handle` functions directly, so engine-free tests +//! exercise the exact production code path. + +pub mod approve_always; +pub mod clear_settings; +pub mod gate; +pub mod get_pending; +pub mod get_settings; +pub mod list_pending; +pub mod on_config_change; +pub mod on_session_deleted; +pub mod on_turn_completed; +pub mod purge; +pub mod resolve; +pub mod set_mode; +pub mod sweep; + +pub mod add_always_allow; +pub mod remove_always_allow; + +use std::future::Future; +use std::sync::Arc; + +use iii_sdk::{IIIError, RegisterFunction, III}; +use schemars::JsonSchema; +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::bus::Bus; +use crate::config::WorkerConfig; +use crate::error::ApprovalError; +use crate::events::EventSink; +use crate::gate_config::SharedDefaults; + +/// Everything a function handler needs. +pub struct Deps { + pub bus: Arc, + pub sink: Arc, + pub defaults: SharedDefaults, + pub cfg: Arc, +} + +/// Register one typed handler under `id`, mapping `ApprovalError` into +/// the bus error shape (`code: message`). +fn register( + iii: &Arc, + deps: &Arc, + id: &str, + description: &str, + handler: F, +) where + Req: DeserializeOwned + JsonSchema + Send + 'static, + Resp: Serialize + JsonSchema + Send + 'static, + F: Fn(Arc, Req) -> Fut + Send + Sync + Clone + 'static, + Fut: Future> + Send + 'static, +{ + let deps = deps.clone(); + iii.register_function( + id, + RegisterFunction::new_async(move |req: Req| { + let deps = deps.clone(); + let handler = handler.clone(); + async move { handler(deps, req).await.map_err(IIIError::from) } + }) + .description(description), + ); +} + +pub fn register_all(iii: &Arc, deps: &Arc) { + register( + iii, + deps, + "approval::gate", + "pre_dispatch hook: evaluate the permission model and answer continue / deny / hold; writes the pending inbox record on hold. Called by the harness only.", + |d, r| async move { gate::handle(&d, r).await }, + ); + register( + iii, + deps, + "approval::resolve", + "Apply a human decision to a held call: release it for execution (allow) or deliver a denial (deny). Human/console-only.", + |d, r| async move { resolve::handle(&d, r).await }, + ); + register( + iii, + deps, + "approval::list_pending", + "The pending inbox across sessions, with tenancy filters; the catch-up path for notification workers after a restart.", + |d, r| async move { list_pending::handle(&d, r).await }, + ); + register( + iii, + deps, + "approval::get_pending", + "Read one pending record; null when resolved or unknown.", + |d, r| async move { get_pending::handle(&d, r).await }, + ); + register( + iii, + deps, + "approval::set_mode", + "Set the session's permission mode (manual / auto / full). Human/console-only.", + |d, r| async move { set_mode::handle(&d, r).await }, + ); + register( + iii, + deps, + "approval::add_always_allow", + "Add a function to the session's auto-mode trust list (idempotent). Human/console-only.", + |d, r| async move { add_always_allow::handle(&d, r).await }, + ); + register( + iii, + deps, + "approval::remove_always_allow", + "Remove a function from the session's auto-mode trust list (no-op when absent). Human/console-only.", + |d, r| async move { remove_always_allow::handle(&d, r).await }, + ); + register( + iii, + deps, + "approval::approve_always", + "Record a per-session 'approve always' grant (honoured in every mode). Human/console-only.", + |d, r| async move { approve_always::handle(&d, r).await }, + ); + register( + iii, + deps, + "approval::get_settings", + "Read the session's effective settings (stored record or configuration defaults); never writes.", + |d, r| async move { get_settings::handle(&d, r).await }, + ); + register( + iii, + deps, + "approval::clear_settings", + "Drop the session's stored settings record (revert to configuration defaults).", + |d, r| async move { clear_settings::handle(&d, r).await }, + ); + register( + iii, + deps, + "approval::on_config_change", + "Internal: configuration trigger handler (reload deployment defaults).", + |d, r| async move { on_config_change::handle(&d, r).await }, + ); + register( + iii, + deps, + "approval::on_session_deleted", + "Internal: session::deleted handler (purge the session's settings and pending records).", + |d, r| async move { on_session_deleted::handle(&d, r).await }, + ); + register( + iii, + deps, + "approval::on_turn_completed", + "Internal: harness::turn_completed handler (purge the turn's pending records).", + |d, r| async move { on_turn_completed::handle(&d, r).await }, + ); + register( + iii, + deps, + "approval::sweep", + "Internal: cron handler (expire pending records past expires_at).", + |d, r| async move { sweep::handle(&d, r).await }, + ); + + tracing::info!("all approval::* functions registered"); +} diff --git a/approval-gate/src/functions/on_config_change.rs b/approval-gate/src/functions/on_config_change.rs new file mode 100644 index 00000000..9aa93c5c --- /dev/null +++ b/approval-gate/src/functions/on_config_change.rs @@ -0,0 +1,84 @@ +//! `approval::on_config_change` — bound to the engine's `configuration` +//! trigger on `configuration_id: "approval-gate"`. Reactive reload, no +//! polling: swaps the in-memory deployment defaults. + +use schemars::JsonSchema; +use serde::Deserialize; +use serde_json::Value; + +use super::Deps; +use crate::error::ApprovalError; +use crate::gate_config::{parse_config_value, replace, ENTRY_ID}; + +/// The configuration trigger payload (only the fields we read; the +/// engine owns the shape). +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct ConfigChangeEvent { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub new_value: Value, +} + +pub async fn handle(deps: &Deps, event: ConfigChangeEvent) -> Result { + if event.id.as_deref() != Some(ENTRY_ID) { + return Ok(Value::Null); + } + let next = parse_config_value(&event.new_value); + tracing::info!(?next, "approval-gate configuration reloaded"); + replace(&deps.defaults, next); + Ok(Value::Null) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use serde_json::json; + + use super::*; + use crate::config::WorkerConfig; + use crate::events::RecordingSink; + use crate::gate_config::{shared_defaults, snapshot}; + use crate::testkit::FakeBus; + use crate::types::PermissionMode; + + fn deps() -> Arc { + Arc::new(Deps { + bus: Arc::new(FakeBus::new()), + sink: Arc::new(RecordingSink::new()), + defaults: shared_defaults(), + cfg: Arc::new(WorkerConfig::default()), + }) + } + + #[tokio::test] + async fn reloads_defaults_on_matching_entry() { + let deps = deps(); + let event: ConfigChangeEvent = serde_json::from_value(json!({ + "id": "approval-gate", + "event_type": "configuration:updated", + "new_value": { "default_mode": "auto", "pending_timeout_ms": 60000 } + })) + .unwrap(); + handle(&deps, event).await.unwrap(); + let d = snapshot(&deps.defaults); + assert_eq!(d.default_mode, PermissionMode::Auto); + assert_eq!(d.pending_timeout_ms, 60_000); + } + + #[tokio::test] + async fn ignores_other_entries() { + let deps = deps(); + let event: ConfigChangeEvent = serde_json::from_value(json!({ + "id": "llm-router", + "new_value": { "default_mode": "full" } + })) + .unwrap(); + handle(&deps, event).await.unwrap(); + assert_eq!( + snapshot(&deps.defaults).default_mode, + PermissionMode::Manual + ); + } +} diff --git a/approval-gate/src/functions/on_session_deleted.rs b/approval-gate/src/functions/on_session_deleted.rs new file mode 100644 index 00000000..c2b8c5b9 --- /dev/null +++ b/approval-gate/src/functions/on_session_deleted.rs @@ -0,0 +1,102 @@ +//! `approval::on_session_deleted` — bound to session-manager's +//! `session::deleted` trigger type. Purges the session's settings record +//! and every pending record (the cascade the prior deployment lacked). + +use schemars::JsonSchema; +use serde::Deserialize; +use serde_json::Value; + +use super::{purge, Deps}; +use crate::settings; + +/// `session::deleted` payload (only the field we read). +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct SessionDeletedEvent { + pub session_id: String, +} + +pub async fn handle( + deps: &Deps, + event: SessionDeletedEvent, +) -> Result { + if let Err(e) = settings::clear( + deps.bus.as_ref(), + &event.session_id, + deps.cfg.state_timeout_ms, + ) + .await + { + tracing::warn!(session_id = %event.session_id, error = %e, "settings purge failed"); + } + let purged = purge::purge_matching(deps, |r| r.session_id == event.session_id).await; + tracing::info!(session_id = %event.session_id, purged, "session deleted: approval records purged"); + Ok(Value::Null) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use serde_json::json; + + use super::*; + use crate::config::WorkerConfig; + use crate::events::RecordingSink; + use crate::gate_config::shared_defaults; + use crate::pending::PENDING_SCOPE; + use crate::settings::SETTINGS_SCOPE; + use crate::testkit::{FakeBus, MemoryState}; + use crate::types::ResolvedOutcome; + + fn seed_pending(state: &MemoryState, sid: &str, cid: &str) { + state.seed( + PENDING_SCOPE, + &format!("{sid}/{cid}"), + json!({ + "session_id": sid, + "turn_id": "t_1", + "function_call_id": cid, + "function_id": "shell::run", + "pending_at": 1, + "expires_at": 2, + }), + ); + } + + #[tokio::test] + async fn purges_settings_and_only_the_sessions_pending_records() { + let bus = Arc::new(FakeBus::new()); + let state = bus.with_memory_state(); + let sink = Arc::new(RecordingSink::new()); + let deps = Arc::new(Deps { + bus, + sink: sink.clone(), + defaults: shared_defaults(), + cfg: Arc::new(WorkerConfig::default()), + }); + + state.seed(SETTINGS_SCOPE, "s_1", json!({ "mode": "auto" })); + seed_pending(&state, "s_1", "c_1"); + seed_pending(&state, "s_1", "c_2"); + seed_pending(&state, "s_2", "c_3"); + + handle( + &deps, + SessionDeletedEvent { + session_id: "s_1".into(), + }, + ) + .await + .unwrap(); + + assert!(state.peek(SETTINGS_SCOPE, "s_1").is_none()); + assert!(state.peek(PENDING_SCOPE, "s_1/c_1").is_none()); + assert!(state.peek(PENDING_SCOPE, "s_1/c_2").is_none()); + // Other sessions untouched. + assert!(state.peek(PENDING_SCOPE, "s_2/c_3").is_some()); + + let events = sink.resolved_events(); + assert_eq!(events.len(), 2); + assert!(events.iter().all(|e| e.outcome == ResolvedOutcome::Aborted)); + } +} diff --git a/approval-gate/src/functions/on_turn_completed.rs b/approval-gate/src/functions/on_turn_completed.rs new file mode 100644 index 00000000..195ceaff --- /dev/null +++ b/approval-gate/src/functions/on_turn_completed.rs @@ -0,0 +1,87 @@ +//! `approval::on_turn_completed` — bound to the harness's +//! `harness::turn_completed` trigger type. Purges the turn's pending +//! records when it goes terminal (covers `harness::stop` cancellation +//! cascades and failed turns). A `completed` turn has no live holds by +//! construction — the purge is then a harmless stale-record cleanup. + +use schemars::JsonSchema; +use serde::Deserialize; +use serde_json::Value; + +use super::{purge, Deps}; + +/// `harness::turn_completed` payload (only the field we read). +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct TurnCompletedEvent { + pub turn_id: String, +} + +pub async fn handle( + deps: &Deps, + event: TurnCompletedEvent, +) -> Result { + let purged = purge::purge_matching(deps, |r| r.turn_id == event.turn_id).await; + if purged > 0 { + tracing::info!(turn_id = %event.turn_id, purged, "terminal turn: pending approvals purged"); + } + Ok(Value::Null) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use serde_json::json; + + use super::*; + use crate::config::WorkerConfig; + use crate::events::RecordingSink; + use crate::gate_config::shared_defaults; + use crate::pending::PENDING_SCOPE; + use crate::testkit::FakeBus; + use crate::types::ResolvedOutcome; + + #[tokio::test] + async fn purges_by_turn_id_and_emits_aborted() { + let bus = Arc::new(FakeBus::new()); + let state = bus.with_memory_state(); + let sink = Arc::new(RecordingSink::new()); + let deps = Arc::new(Deps { + bus, + sink: sink.clone(), + defaults: shared_defaults(), + cfg: Arc::new(WorkerConfig::default()), + }); + + for (sid, cid, tid) in [("s_1", "c_1", "t_1"), ("s_1", "c_2", "t_2")] { + state.seed( + PENDING_SCOPE, + &format!("{sid}/{cid}"), + json!({ + "session_id": sid, + "turn_id": tid, + "function_call_id": cid, + "function_id": "shell::run", + "pending_at": 1, + "expires_at": 2, + }), + ); + } + + handle( + &deps, + TurnCompletedEvent { + turn_id: "t_1".into(), + }, + ) + .await + .unwrap(); + + assert!(state.peek(PENDING_SCOPE, "s_1/c_1").is_none()); + assert!(state.peek(PENDING_SCOPE, "s_1/c_2").is_some()); + let events = sink.resolved_events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].outcome, ResolvedOutcome::Aborted); + assert_eq!(events[0].turn_id, "t_1"); + } +} diff --git a/approval-gate/src/functions/purge.rs b/approval-gate/src/functions/purge.rs new file mode 100644 index 00000000..1aea8eef --- /dev/null +++ b/approval-gate/src/functions/purge.rs @@ -0,0 +1,61 @@ +//! Shared purge loop for the two terminal-cleanup handlers +//! (`on_session_deleted`, `on_turn_completed`): delete every matching +//! pending record through the emit gate and fire `pending_resolved` +//! with `outcome: "aborted"` per record actually deleted. + +use super::Deps; +use crate::pending; +use crate::types::{now_ms, PendingApprovalRecord, PendingResolvedEvent, ResolvedOutcome}; + +pub async fn purge_matching(deps: &Deps, predicate: F) -> usize +where + F: Fn(&PendingApprovalRecord) -> bool, +{ + let bus = deps.bus.as_ref(); + let records = match pending::list_all(bus, deps.cfg.state_timeout_ms).await { + Ok(records) => records, + Err(e) => { + tracing::warn!(error = %e, "purge: pending list failed; sweep will retry"); + return 0; + } + }; + + let mut purged = 0usize; + for record in records.into_iter().filter(|r| predicate(r)) { + match pending::delete_with_gate( + bus, + &record.session_id, + &record.function_call_id, + deps.cfg.state_timeout_ms, + ) + .await + { + Ok(Some(deleted)) => { + purged += 1; + deps.sink + .pending_resolved(&PendingResolvedEvent { + session_id: deleted.session_id, + turn_id: deleted.turn_id, + function_call_id: deleted.function_call_id, + function_id: deleted.function_id, + outcome: ResolvedOutcome::Aborted, + reason: None, + session_metadata: deleted.session_metadata, + resolved_at: now_ms(), + }) + .await; + } + // Already deleted by a racing path — that path emitted. + Ok(None) => {} + Err(e) => { + tracing::warn!( + session_id = %record.session_id, + function_call_id = %record.function_call_id, + error = %e, + "purge: delete failed; sweep will collect the record" + ); + } + } + } + purged +} diff --git a/approval-gate/src/functions/remove_always_allow.rs b/approval-gate/src/functions/remove_always_allow.rs new file mode 100644 index 00000000..c8dea8bb --- /dev/null +++ b/approval-gate/src/functions/remove_always_allow.rs @@ -0,0 +1,86 @@ +//! `approval::remove_always_allow` — remove a function from the session's +//! auto-mode trust list (no-op when absent; seed entries removable like +//! any other — the stored record overrides the deployment seed from first +//! mutation on). + +use super::Deps; +use crate::error::ApprovalError; +use crate::gate_config::snapshot; +use crate::settings::{self, without_grant}; +use crate::types::{AlwaysAllowMutationRequest, ApprovalSettings, SettingsResponse}; + +pub async fn handle( + deps: &Deps, + req: AlwaysAllowMutationRequest, +) -> Result { + let defaults = snapshot(&deps.defaults); + let settings = settings::materialize_and( + deps.bus.as_ref(), + &req.session_id, + &defaults, + deps.cfg.state_timeout_ms, + |base, _now| ApprovalSettings { + always_allow: without_grant(&base.always_allow, &req.function_id), + ..base + }, + ) + .await?; + Ok(SettingsResponse { settings }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::config::WorkerConfig; + use crate::events::RecordingSink; + use crate::gate_config::{replace, shared_defaults, GateDefaults}; + use crate::testkit::FakeBus; + use crate::types::PermissionMode; + + #[tokio::test] + async fn removes_seed_entries_from_the_materialized_record() { + let bus = Arc::new(FakeBus::new()); + let _state = bus.with_memory_state(); + let defaults = shared_defaults(); + replace( + &defaults, + GateDefaults { + default_mode: PermissionMode::Auto, + always_allow_seed: vec!["state::get".into(), "shell::run".into()], + pending_timeout_ms: 1_800_000, + }, + ); + let deps = Arc::new(Deps { + bus, + sink: Arc::new(RecordingSink::new()), + defaults, + cfg: Arc::new(WorkerConfig::default()), + }); + let res = handle( + &deps, + AlwaysAllowMutationRequest { + session_id: "s_1".into(), + function_id: "shell::run".into(), + }, + ) + .await + .unwrap(); + // The first mutation seeded both entries, then removed one. + assert_eq!(res.settings.always_allow.len(), 1); + assert_eq!(res.settings.always_allow[0].function_id, "state::get"); + + // Removing an absent entry is a no-op. + let again = handle( + &deps, + AlwaysAllowMutationRequest { + session_id: "s_1".into(), + function_id: "never::granted".into(), + }, + ) + .await + .unwrap(); + assert_eq!(again.settings.always_allow.len(), 1); + } +} diff --git a/approval-gate/src/functions/resolve.rs b/approval-gate/src/functions/resolve.rs new file mode 100644 index 00000000..741cde64 --- /dev/null +++ b/approval-gate/src/functions/resolve.rs @@ -0,0 +1,348 @@ +//! `approval::resolve` — apply a human decision to a held call +//! (approval-gate.md § Decision flow). No decision record is persisted — +//! the decision flows straight into `harness::function::resolve`, the +//! transcript carries the durable outcome, and the pending record dies +//! with the resolution. +//! +//! Crash ordering: `harness::function::resolve` FIRST, then delete, then +//! emit — a crash between the first two leaks one record until the sweep +//! collects it; it can never lose a decision. + +use serde_json::json; + +use super::Deps; +use crate::denial::{render_text, user_deny_envelope}; +use crate::error::ApprovalError; +use crate::pending; +use crate::types::{ + now_ms, validate_id, PendingResolvedEvent, ResolveDecision, ResolveRequest, ResolveResponse, + ResolvedOutcome, +}; + +pub async fn handle(deps: &Deps, req: ResolveRequest) -> Result { + validate_id("session_id", &req.session_id)?; + validate_id("function_call_id", &req.function_call_id)?; + + let bus = deps.bus.as_ref(); + let Some(record) = pending::get( + bus, + &req.session_id, + &req.function_call_id, + deps.cfg.state_timeout_ms, + ) + .await + .map_err(|e| ApprovalError::StateUnavailable(format!("pending record read failed: {e}")))? + else { + // Unknown / already resolved is NOT an error — duplicate + // decisions race benignly. + return Ok(ResolveResponse { + resolved: false, + turn_resumed: None, + }); + }; + + let payload = match req.decision { + ResolveDecision::Allow => json!({ + "session_id": req.session_id, + "turn_id": record.turn_id, + "function_call_id": req.function_call_id, + "action": "execute", + }), + ResolveDecision::Deny => { + let envelope = user_deny_envelope( + &record.function_id, + req.reason.as_deref(), + Some(record.arguments_excerpt.clone()), + ); + json!({ + "session_id": req.session_id, + "turn_id": record.turn_id, + "function_call_id": req.function_call_id, + "action": "deliver", + "is_error": true, + "content": render_text(&envelope), + "details": envelope, + }) + } + }; + + // The record is kept on failure so the decision stays resolvable + // (or sweepable) — never delete before the harness acknowledged. + let reply = bus + .call( + "harness::function::resolve", + payload, + Some(deps.cfg.harness_timeout_ms), + ) + .await + .map_err(|e| { + ApprovalError::HarnessUnavailable(format!("harness::function::resolve failed: {e}")) + })?; + let turn_resumed = reply + .get("turn_resumed") + .and_then(serde_json::Value::as_bool); + + match pending::delete_with_gate( + bus, + &req.session_id, + &req.function_call_id, + deps.cfg.state_timeout_ms, + ) + .await + { + Ok(Some(deleted)) => { + deps.sink + .pending_resolved(&PendingResolvedEvent { + session_id: deleted.session_id, + turn_id: deleted.turn_id, + function_call_id: deleted.function_call_id, + function_id: deleted.function_id, + outcome: match req.decision { + ResolveDecision::Allow => ResolvedOutcome::Allow, + ResolveDecision::Deny => ResolvedOutcome::Deny, + }, + reason: match req.decision { + ResolveDecision::Deny => req.reason.clone(), + ResolveDecision::Allow => None, + }, + session_metadata: deleted.session_metadata, + resolved_at: now_ms(), + }) + .await; + } + // A concurrent path already deleted (and emitted): exactly-once + // emission is the gate's contract. + Ok(None) => {} + Err(e) => { + // The decision reached the harness; the orphaned record is + // sweep food. Never fail the resolve over cleanup. + tracing::warn!( + session_id = %req.session_id, + function_call_id = %req.function_call_id, + error = %e, + "pending record delete failed after resolve; sweep will collect it" + ); + } + } + + Ok(ResolveResponse { + resolved: true, + turn_resumed, + }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use serde_json::json; + + use super::*; + use crate::config::WorkerConfig; + use crate::events::RecordingSink; + use crate::gate_config::shared_defaults; + use crate::pending::PENDING_SCOPE; + use crate::testkit::{FakeBus, MemoryState}; + use crate::types::PendingApprovalRecord; + + struct Fixture { + deps: Arc, + bus: Arc, + sink: Arc, + state: MemoryState, + } + + fn fixture() -> Fixture { + let bus = Arc::new(FakeBus::new()); + let state = bus.with_memory_state(); + let sink = Arc::new(RecordingSink::new()); + let deps = Arc::new(Deps { + bus: bus.clone(), + sink: sink.clone(), + defaults: shared_defaults(), + cfg: Arc::new(WorkerConfig::default()), + }); + Fixture { + deps, + bus, + sink, + state, + } + } + + fn seed_record(f: &Fixture) { + let record = PendingApprovalRecord { + session_id: "s_1".into(), + turn_id: "t_9".into(), + function_call_id: "c_1".into(), + function_id: "shell::run".into(), + arguments_excerpt: json!({ "cmd": "ls" }), + pending_at: 100, + expires_at: 1_800_100, + session_title: None, + session_description: None, + session_metadata: Some(serde_json::from_value(json!({ "owner": "u_1" })).unwrap()), + depth: 0, + assistant_excerpt: None, + }; + f.state.seed( + PENDING_SCOPE, + "s_1/c_1", + serde_json::to_value(record).unwrap(), + ); + } + + fn harness_ok(f: &Fixture) { + f.bus.on_value( + "harness::function::resolve", + json!({ "resolved": true, "turn_resumed": true }), + ); + } + + fn req(decision: ResolveDecision, reason: Option<&str>) -> ResolveRequest { + ResolveRequest { + session_id: "s_1".into(), + function_call_id: "c_1".into(), + decision, + reason: reason.map(str::to_string), + } + } + + #[tokio::test] + async fn allow_releases_via_action_execute() { + let f = fixture(); + seed_record(&f); + harness_ok(&f); + + let res = handle(&f.deps, req(ResolveDecision::Allow, None)) + .await + .unwrap(); + assert!(res.resolved); + assert_eq!(res.turn_resumed, Some(true)); + + let calls = f.bus.calls_to("harness::function::resolve"); + assert_eq!(calls.len(), 1); + let payload = &calls[0].payload; + assert_eq!(payload["action"], json!("execute")); + // The turn_id comes from the pending record. + assert_eq!(payload["turn_id"], json!("t_9")); + // Execute supplies no result. + assert!(payload.get("content").is_none()); + assert!(payload.get("is_error").is_none()); + + // Record deleted; exactly one resolved event with the metadata. + assert!(f.state.peek(PENDING_SCOPE, "s_1/c_1").is_none()); + let events = f.sink.resolved_events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].outcome, ResolvedOutcome::Allow); + assert_eq!( + events[0].session_metadata.as_ref().unwrap()["owner"], + json!("u_1") + ); + } + + #[tokio::test] + async fn deny_delivers_an_is_error_denial_envelope() { + let f = fixture(); + seed_record(&f); + harness_ok(&f); + + let res = handle(&f.deps, req(ResolveDecision::Deny, Some("too risky"))) + .await + .unwrap(); + assert!(res.resolved); + + let calls = f.bus.calls_to("harness::function::resolve"); + let payload = &calls[0].payload; + assert_eq!(payload["action"], json!("deliver")); + assert_eq!(payload["is_error"], json!(true)); + assert_eq!(payload["content"][0]["type"], json!("text")); + assert_eq!(payload["content"][0]["text"], json!("too risky")); + assert_eq!(payload["details"]["denied_by"], json!("user")); + assert_eq!(payload["details"]["args_excerpt"], json!({ "cmd": "ls" })); + + let events = f.sink.resolved_events(); + assert_eq!(events[0].outcome, ResolvedOutcome::Deny); + assert_eq!(events[0].reason.as_deref(), Some("too risky")); + } + + #[tokio::test] + async fn unknown_call_returns_resolved_false_and_emits_nothing() { + let f = fixture(); + harness_ok(&f); + let res = handle(&f.deps, req(ResolveDecision::Allow, None)) + .await + .unwrap(); + assert!(!res.resolved); + assert!(f.bus.calls_to("harness::function::resolve").is_empty()); + assert!(f.sink.resolved_events().is_empty()); + } + + #[tokio::test] + async fn duplicate_resolve_races_benignly() { + let f = fixture(); + seed_record(&f); + harness_ok(&f); + let first = handle(&f.deps, req(ResolveDecision::Allow, None)) + .await + .unwrap(); + assert!(first.resolved); + let second = handle(&f.deps, req(ResolveDecision::Allow, None)) + .await + .unwrap(); + assert!(!second.resolved); + assert_eq!(f.sink.resolved_events().len(), 1); + } + + #[tokio::test] + async fn harness_failure_keeps_the_record() { + let f = fixture(); + seed_record(&f); + f.bus + .on_error("harness::function::resolve", "connection refused"); + let err = handle(&f.deps, req(ResolveDecision::Allow, None)) + .await + .unwrap_err(); + assert_eq!(err.code(), "approval/harness_unavailable"); + // Record intact, no event: the decision can be retried. + assert!(f.state.peek(PENDING_SCOPE, "s_1/c_1").is_some()); + assert!(f.sink.resolved_events().is_empty()); + } + + #[tokio::test] + async fn ordering_is_resolve_then_delete() { + let f = fixture(); + seed_record(&f); + harness_ok(&f); + handle(&f.deps, req(ResolveDecision::Allow, None)) + .await + .unwrap(); + let calls = f.bus.calls(); + let resolve_pos = calls + .iter() + .position(|c| c.function_id == "harness::function::resolve") + .unwrap(); + let delete_pos = calls + .iter() + .position(|c| c.function_id == "state::set" && c.payload["value"].is_null()) + .unwrap(); + assert!(resolve_pos < delete_pos); + } + + #[tokio::test] + async fn invalid_ids_are_rejected() { + let f = fixture(); + let err = handle( + &f.deps, + ResolveRequest { + session_id: "s/1".into(), + function_call_id: "c_1".into(), + decision: ResolveDecision::Allow, + reason: None, + }, + ) + .await + .unwrap_err(); + assert_eq!(err.code(), "approval/invalid_payload"); + } +} diff --git a/approval-gate/src/functions/set_mode.rs b/approval-gate/src/functions/set_mode.rs new file mode 100644 index 00000000..133f4884 --- /dev/null +++ b/approval-gate/src/functions/set_mode.rs @@ -0,0 +1,61 @@ +//! `approval::set_mode` — set the session's permission mode. First +//! mutation materializes the settings record from the current +//! configuration defaults (lazy seeding). + +use super::Deps; +use crate::error::ApprovalError; +use crate::gate_config::snapshot; +use crate::settings; +use crate::types::{ApprovalSettings, SetModeRequest, SettingsResponse}; + +pub async fn handle(deps: &Deps, req: SetModeRequest) -> Result { + let defaults = snapshot(&deps.defaults); + let settings = settings::materialize_and( + deps.bus.as_ref(), + &req.session_id, + &defaults, + deps.cfg.state_timeout_ms, + |base, now| ApprovalSettings { + mode: req.mode, + mode_set_at: now, + ..base + }, + ) + .await?; + Ok(SettingsResponse { settings }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::config::WorkerConfig; + use crate::events::RecordingSink; + use crate::gate_config::shared_defaults; + use crate::testkit::FakeBus; + use crate::types::PermissionMode; + + #[tokio::test] + async fn sets_mode_and_stamps_mode_set_at() { + let bus = Arc::new(FakeBus::new()); + let _state = bus.with_memory_state(); + let deps = Arc::new(Deps { + bus, + sink: Arc::new(RecordingSink::new()), + defaults: shared_defaults(), + cfg: Arc::new(WorkerConfig::default()), + }); + let res = handle( + &deps, + SetModeRequest { + session_id: "s_1".into(), + mode: PermissionMode::Auto, + }, + ) + .await + .unwrap(); + assert_eq!(res.settings.mode, PermissionMode::Auto); + assert!(res.settings.mode_set_at > 0); + } +} diff --git a/approval-gate/src/functions/sweep.rs b/approval-gate/src/functions/sweep.rs new file mode 100644 index 00000000..215d9da5 --- /dev/null +++ b/approval-gate/src/functions/sweep.rs @@ -0,0 +1,231 @@ +//! `approval::sweep` — the cron-bound GC backstop (approval-gate.md § +//! Sweep). For every record past `expires_at`: settle the held call with +//! a timeout denial, delete through the emit gate, fire +//! `pending_resolved { outcome: "timeout" }`. Also collects records +//! orphaned by a crash between resolve and delete — which is why no +//! delete path needs to be transactional. + +use serde_json::{json, Value}; + +use super::Deps; +use crate::error::ApprovalError; +use crate::pending; +use crate::types::{ + now_ms, text_block, PendingApprovalRecord, PendingResolvedEvent, ResolvedOutcome, +}; + +pub async fn handle(deps: &Deps, _payload: Value) -> Result { + let bus = deps.bus.as_ref(); + let now = now_ms(); + + let records = match pending::list_all(bus, deps.cfg.state_timeout_ms).await { + Ok(records) => records, + Err(e) => { + tracing::warn!(error = %e, "sweep: pending list failed; retrying next tick"); + return Ok(json!({ "swept": 0 })); + } + }; + + let mut swept = 0usize; + for record in records.into_iter().filter(|r| r.expires_at <= now) { + // Settle the call first — a no-op `{ resolved: false }` when the + // harness's own pending sweep or another path got there already. + // A transport failure is tolerated: the record is still deleted + // (the GC contract — the inbox must stay O(live)), and the + // harness sweep remains the backstop for the parked turn. + let resolve_payload = timeout_resolve_payload(&record); + if let Err(e) = bus + .call( + "harness::function::resolve", + resolve_payload, + Some(deps.cfg.harness_timeout_ms), + ) + .await + { + tracing::warn!( + session_id = %record.session_id, + function_call_id = %record.function_call_id, + error = %e, + "sweep: harness resolve failed; deleting the expired record anyway" + ); + } + + match pending::delete_with_gate( + bus, + &record.session_id, + &record.function_call_id, + deps.cfg.state_timeout_ms, + ) + .await + { + Ok(Some(deleted)) => { + swept += 1; + deps.sink + .pending_resolved(&PendingResolvedEvent { + session_id: deleted.session_id, + turn_id: deleted.turn_id, + function_call_id: deleted.function_call_id, + function_id: deleted.function_id, + outcome: ResolvedOutcome::Timeout, + reason: None, + session_metadata: deleted.session_metadata, + resolved_at: now_ms(), + }) + .await; + } + Ok(None) => {} + Err(e) => { + tracing::warn!( + session_id = %record.session_id, + function_call_id = %record.function_call_id, + error = %e, + "sweep: delete failed; retrying next tick" + ); + } + } + } + + if swept > 0 { + tracing::info!(swept, "sweep: expired pending approvals collected"); + } + Ok(json!({ "swept": swept })) +} + +/// Not a `DenialEnvelope` — nobody denied the call; no human decision +/// arrived before the deadline. The text and details still give the +/// model something to adapt to. +fn timeout_resolve_payload(record: &PendingApprovalRecord) -> Value { + let window_ms = record.expires_at - record.pending_at; + let text = format!( + "Approval request for {} timed out: no human decision within {}ms. The call was not executed.", + record.function_id, window_ms + ); + json!({ + "session_id": record.session_id, + "turn_id": record.turn_id, + "function_call_id": record.function_call_id, + "action": "deliver", + "is_error": true, + "content": [text_block(text)], + "details": { + "status": "timeout", + "function_id": record.function_id, + "pending_at": record.pending_at, + "expires_at": record.expires_at, + }, + }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use serde_json::json; + + use super::*; + use crate::config::WorkerConfig; + use crate::events::RecordingSink; + use crate::gate_config::shared_defaults; + use crate::pending::PENDING_SCOPE; + use crate::testkit::{FakeBus, MemoryState}; + + struct Fixture { + deps: Arc, + bus: Arc, + sink: Arc, + state: MemoryState, + } + + fn fixture() -> Fixture { + let bus = Arc::new(FakeBus::new()); + let state = bus.with_memory_state(); + let sink = Arc::new(RecordingSink::new()); + let deps = Arc::new(Deps { + bus: bus.clone(), + sink: sink.clone(), + defaults: shared_defaults(), + cfg: Arc::new(WorkerConfig::default()), + }); + Fixture { + deps, + bus, + sink, + state, + } + } + + fn seed(state: &MemoryState, cid: &str, expires_at: i64) { + state.seed( + PENDING_SCOPE, + &format!("s_1/{cid}"), + json!({ + "session_id": "s_1", + "turn_id": "t_1", + "function_call_id": cid, + "function_id": "shell::run", + "pending_at": 100, + "expires_at": expires_at, + }), + ); + } + + #[tokio::test] + async fn sweeps_only_expired_records() { + let f = fixture(); + f.bus.on_value( + "harness::function::resolve", + json!({ "resolved": true, "turn_resumed": true }), + ); + seed(&f.state, "c_expired", 200); // long past + seed(&f.state, "c_live", now_ms() + 60_000); + + let res = handle(&f.deps, Value::Null).await.unwrap(); + assert_eq!(res["swept"], json!(1)); + assert!(f.state.peek(PENDING_SCOPE, "s_1/c_expired").is_none()); + assert!(f.state.peek(PENDING_SCOPE, "s_1/c_live").is_some()); + + let calls = f.bus.calls_to("harness::function::resolve"); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].payload["action"], json!("deliver")); + assert_eq!(calls[0].payload["is_error"], json!(true)); + assert_eq!(calls[0].payload["details"]["status"], json!("timeout")); + + let events = f.sink.resolved_events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].outcome, ResolvedOutcome::Timeout); + } + + #[tokio::test] + async fn deletes_expired_records_even_when_the_harness_is_absent() { + let f = fixture(); + // No harness scripted: resolve calls error (standalone deployment). + seed(&f.state, "c_expired", 200); + let res = handle(&f.deps, Value::Null).await.unwrap(); + assert_eq!(res["swept"], json!(1)); + assert!(f.state.peek(PENDING_SCOPE, "s_1/c_expired").is_none()); + assert_eq!(f.sink.resolved_events().len(), 1); + } + + #[tokio::test] + async fn already_deleted_records_emit_nothing() { + let f = fixture(); + f.bus + .on_value("harness::function::resolve", json!({ "resolved": false })); + seed(&f.state, "c_expired", 200); + // Simulate a racing path that deleted between list and + // delete_with_gate: the null write observes no prior value. + f.bus.on("state::set", |_| { + Ok(json!({ "old_value": null, "new_value": null })) + }); + let res = handle(&f.deps, Value::Null).await.unwrap(); + assert_eq!(res["swept"], json!(0)); + assert!(f.sink.resolved_events().is_empty()); + } + + #[tokio::test] + async fn empty_scope_is_a_noop() { + let f = fixture(); + let res = handle(&f.deps, Value::Null).await.unwrap(); + assert_eq!(res["swept"], json!(0)); + } +} diff --git a/approval-gate/src/gate_config.rs b/approval-gate/src/gate_config.rs new file mode 100644 index 00000000..26a42e54 --- /dev/null +++ b/approval-gate/src/gate_config.rs @@ -0,0 +1,219 @@ +//! The worker's one configuration entry — id `approval-gate` — in the +//! engine's built-in `configuration` worker (approval-gate.md § +//! Configuration; same pattern as llm-router). Deployment approval +//! defaults live here and nowhere else. Soft dependency: without the +//! configuration worker the gate runs on built-in defaults — fail-safe, +//! never fail-open. +//! +//! Like llm-router, `configuration::register` is called WITHOUT an +//! `initial_value`, so operator-stored values survive every re-register; +//! the built-in defaults apply in memory whenever the entry value is +//! null or missing. + +use std::sync::{Arc, RwLock}; + +use serde_json::{json, Value}; + +use crate::bus::{Bus, BusError}; +use crate::types::PermissionMode; + +pub const ENTRY_ID: &str = "approval-gate"; + +pub const DEFAULT_PENDING_TIMEOUT_MS: i64 = 1_800_000; + +/// The parsed `approval-gate` entry value, with built-in defaults for +/// every absent/invalid field. +#[derive(Debug, Clone, PartialEq)] +pub struct GateDefaults { + /// Effective mode for sessions with no stored settings record. + pub default_mode: PermissionMode, + /// Deployment trust profile for auto mode (function ids / globs). + pub always_allow_seed: Vec, + /// Hold deadline; drives `expires_at` on pending records. + pub pending_timeout_ms: i64, +} + +impl Default for GateDefaults { + fn default() -> Self { + Self { + default_mode: PermissionMode::Manual, + always_allow_seed: Vec::new(), + pending_timeout_ms: DEFAULT_PENDING_TIMEOUT_MS, + } + } +} + +pub type SharedDefaults = Arc>; + +pub fn shared_defaults() -> SharedDefaults { + Arc::new(RwLock::new(GateDefaults::default())) +} + +pub fn snapshot(defaults: &SharedDefaults) -> GateDefaults { + defaults + .read() + .unwrap_or_else(|poison| poison.into_inner()) + .clone() +} + +pub fn replace(defaults: &SharedDefaults, next: GateDefaults) { + *defaults + .write() + .unwrap_or_else(|poison| poison.into_inner()) = next; +} + +/// Field-wise tolerant parse: each invalid/absent field falls back to its +/// built-in default. A malformed operator edit can degrade one field, +/// never fail the gate open. +pub fn parse_config_value(value: &Value) -> GateDefaults { + let built_in = GateDefaults::default(); + let default_mode = value + .get("default_mode") + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .unwrap_or(built_in.default_mode); + let always_allow_seed = value + .get("always_allow_seed") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or(built_in.always_allow_seed); + let pending_timeout_ms = value + .get("pending_timeout_ms") + .and_then(Value::as_i64) + .filter(|ms| *ms > 0) + .unwrap_or(built_in.pending_timeout_ms); + GateDefaults { + default_mode, + always_allow_seed, + pending_timeout_ms, + } +} + +/// JSON Schema the console renders as the entry's edit form. +pub fn entry_schema() -> Value { + json!({ + "type": "object", + "title": "Approval Gate", + "properties": { + "default_mode": { + "type": "string", + "enum": ["manual", "auto", "full"], + "default": "manual", + "description": "Effective permission mode for sessions with no stored approval settings." + }, + "always_allow_seed": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Deployment trust profile for auto mode (function ids / globs); copied into a session's settings on its first mutation." + }, + "pending_timeout_ms": { + "type": "integer", + "minimum": 1, + "default": DEFAULT_PENDING_TIMEOUT_MS, + "description": "Hold deadline in milliseconds; drives expires_at on pending records." + } + }, + "additionalProperties": false + }) +} + +pub async fn register_entry(bus: &dyn Bus) -> Result<(), BusError> { + bus.call( + "configuration::register", + json!({ + "id": ENTRY_ID, + "name": "Approval Gate", + "description": "Deployment approval defaults: permission mode for new sessions, the auto-mode trust seed, and the pending-hold timeout.", + "schema": entry_schema(), + }), + None, + ) + .await + .map(|_| ()) +} + +/// Read the entry value; any failure (configuration worker absent, entry +/// unset) yields the built-in defaults. +pub async fn read_defaults(bus: &dyn Bus) -> GateDefaults { + match bus + .call("configuration::get", json!({ "id": ENTRY_ID }), None) + .await + { + Ok(reply) => parse_config_value(reply.get("value").unwrap_or(&Value::Null)), + Err(e) => { + tracing::info!(error = %e, "configuration worker unavailable; using built-in defaults"); + GateDefaults::default() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testkit::FakeBus; + use serde_json::json; + + #[test] + fn built_in_defaults_match_the_spec() { + let d = GateDefaults::default(); + assert_eq!(d.default_mode, PermissionMode::Manual); + assert!(d.always_allow_seed.is_empty()); + assert_eq!(d.pending_timeout_ms, 1_800_000); + } + + #[test] + fn parse_is_field_wise_tolerant() { + let parsed = parse_config_value(&json!({ + "default_mode": "auto", + "always_allow_seed": ["state::get", 42, "engine::functions::list"], + "pending_timeout_ms": "not-a-number" + })); + assert_eq!(parsed.default_mode, PermissionMode::Auto); + // Non-string seed entries are dropped, not fatal. + assert_eq!(parsed.always_allow_seed.len(), 2); + assert_eq!(parsed.pending_timeout_ms, DEFAULT_PENDING_TIMEOUT_MS); + } + + #[test] + fn parse_of_null_or_garbage_is_all_defaults() { + assert_eq!(parse_config_value(&Value::Null), GateDefaults::default()); + assert_eq!(parse_config_value(&json!("nope")), GateDefaults::default()); + let zero = parse_config_value(&json!({ "pending_timeout_ms": 0 })); + assert_eq!(zero.pending_timeout_ms, DEFAULT_PENDING_TIMEOUT_MS); + } + + #[tokio::test] + async fn read_defaults_tolerates_missing_configuration_worker() { + let bus = FakeBus::new(); + assert_eq!(read_defaults(&bus).await, GateDefaults::default()); + } + + #[tokio::test] + async fn read_defaults_parses_the_entry_value() { + let bus = FakeBus::new(); + bus.on_value( + "configuration::get", + json!({ "id": ENTRY_ID, "value": { "default_mode": "full" } }), + ); + assert_eq!(read_defaults(&bus).await.default_mode, PermissionMode::Full); + } + + #[test] + fn shared_defaults_replace_and_snapshot() { + let shared = shared_defaults(); + replace( + &shared, + GateDefaults { + default_mode: PermissionMode::Auto, + ..GateDefaults::default() + }, + ); + assert_eq!(snapshot(&shared).default_mode, PermissionMode::Auto); + } +} diff --git a/approval-gate/src/lib.rs b/approval-gate/src/lib.rs new file mode 100644 index 00000000..bab81ecc --- /dev/null +++ b/approval-gate/src/lib.rs @@ -0,0 +1,33 @@ +//! approval-gate — the policy and decision surface for human-held +//! function calls (tech-specs/2026-06-agentic/approval-gate.md). +//! +//! Three surfaces, one worker: +//! 1. The gate — `approval::gate`, a `pre_dispatch` hook the worker binds +//! itself at startup; answers `continue` / `deny` / `hold`. +//! 2. The decision plane — `approval::resolve` plus the per-session +//! settings RPCs (human/console-only). +//! 3. The pending inbox — an ephemeral index of held calls +//! (`approval::list_pending` / `approval::get_pending`) plus the +//! `approval::pending_created` / `approval::pending_resolved` trigger +//! types notification workers bind to. +//! +//! The worker codes against the greenfield harness contracts +//! (`harness::hook::pre_dispatch`, `harness::function::resolve`, +//! `harness::turn_completed` — harness.md § Hooks, § API Reference); +//! those bindings are best-effort so the worker also boots standalone. + +pub mod bus; +pub mod config; +pub mod decision; +pub mod denial; +pub mod error; +pub mod events; +pub mod functions; +pub mod gate_config; +pub mod manifest; +pub mod pending; +pub mod policy; +pub mod redact; +pub mod settings; +pub mod testkit; +pub mod types; diff --git a/approval-gate/src/main.rs b/approval-gate/src/main.rs new file mode 100644 index 00000000..00b0dce8 --- /dev/null +++ b/approval-gate/src/main.rs @@ -0,0 +1,196 @@ +//! `approval-gate` binary entry. +//! +//! Boot sequence: +//! 1. Parse CLI / load YAML config (a missing file falls back to +//! defaults; a file that exists but doesn't parse is fatal). +//! 2. Connect to the local iii engine over WebSocket. +//! 3. Register the two custom trigger types (`approval::pending_created`, +//! `approval::pending_resolved`) — first, because the function +//! handlers capture the subscriber sets they fan out to. +//! 4. Register the 14 `approval::*` functions. +//! 5. Bind triggers, all best-effort (`harness::hook::pre_dispatch`, +//! `configuration`, `session::deleted`, `harness::turn_completed`, +//! `cron`) — in a standalone deployment some of these trigger types +//! don't exist yet; the worker still boots and serves its RPCs. +//! 6. Register the `approval-gate` configuration entry and read the +//! deployment defaults once (the configuration trigger keeps them +//! fresh from then on). +//! 7. Sleep on Ctrl+C, then `shutdown_async` cleanly. + +use std::sync::Arc; + +use anyhow::Result; +use clap::Parser; +use iii_sdk::{register_worker, InitOptions, RegisterTriggerInput, WorkerMetadata, III}; +use serde_json::json; + +use approval_gate::bus::IiiBus; +use approval_gate::events::{self, Emitter}; +use approval_gate::functions::{self, Deps}; +use approval_gate::gate_config::{self, replace, shared_defaults}; +use approval_gate::{config, manifest}; + +#[derive(Parser, Debug)] +#[command( + name = "approval-gate", + about = "Policy and decision surface for human-held function calls." +)] +struct Cli { + #[arg(long, default_value = "./config.yaml")] + config: String, + + #[arg(long, default_value = "ws://127.0.0.1:49134")] + url: String, + + #[arg(long)] + manifest: bool, +} + +fn worker_metadata() -> WorkerMetadata { + WorkerMetadata { + runtime: "rust".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + name: "approval-gate".to_string(), + os: std::env::consts::OS.to_string(), + pid: Some(std::process::id()), + telemetry: None, + ..WorkerMetadata::default() + } +} + +/// Best-effort binding: in standalone deployments the harness's and +/// session-manager's trigger types may not exist (yet); a failed binding +/// must not prevent boot. Registration is acked asynchronously — a +/// missing trigger type surfaces later as an SDK-level +/// `trigger_type_not_found` error log, never as an `Err` here. Restart +/// the worker after the sibling appears to re-bind. +fn bind_best_effort( + iii: &Arc, + trigger_type: &str, + function_id: &str, + config: serde_json::Value, +) { + let res = iii.register_trigger(RegisterTriggerInput { + trigger_type: trigger_type.to_string(), + function_id: function_id.to_string(), + config, + metadata: None, + }); + match res { + Ok(_) => tracing::info!(trigger_type, function_id, "trigger binding requested"), + Err(e) => { + tracing::warn!(trigger_type, function_id, error = %e, "trigger binding failed (sibling absent?)") + } + } +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let cli = Cli::parse(); + + if cli.manifest { + let m = manifest::build_manifest(); + println!("{}", serde_json::to_string_pretty(&m).unwrap()); + return Ok(()); + } + + let cfg = match config::load_config(&cli.config) { + Ok(c) => c, + Err(e) + if e.downcast_ref::() + .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound) => + { + tracing::warn!(path = %cli.config, "config file not found, using defaults"); + config::WorkerConfig::default() + } + Err(e) => { + return Err(e.context(format!("invalid config {} — refusing to start", cli.config))); + } + }; + let cfg = Arc::new(cfg); + + let iii = Arc::new(register_worker( + &cli.url, + InitOptions { + metadata: Some(worker_metadata()), + ..InitOptions::default() + }, + )); + + // Trigger types first: the handlers capture the subscriber sets. + let sets = events::register_trigger_types(&iii); + + let bus = Arc::new(IiiBus::new(iii.clone())); + let sink = Arc::new(Emitter::new(sets, bus.clone())); + let defaults = shared_defaults(); + let deps = Arc::new(Deps { + bus: bus.clone(), + sink, + defaults: defaults.clone(), + cfg: cfg.clone(), + }); + + functions::register_all(&iii, &deps); + + // The gate's own hook binding — installing the worker is installing + // the hook (approval-gate.md § The approval::gate hook). + bind_best_effort( + &iii, + "harness::hook::pre_dispatch", + "approval::gate", + json!({ + "functions": cfg.hook.functions, + "timeout_ms": cfg.hook.timeout_ms, + "on_error": cfg.hook.on_error, + }), + ); + bind_best_effort( + &iii, + "configuration", + "approval::on_config_change", + json!({ + "configuration_id": gate_config::ENTRY_ID, + "event_types": ["configuration:registered", "configuration:updated"], + }), + ); + bind_best_effort( + &iii, + "session::deleted", + "approval::on_session_deleted", + json!({}), + ); + bind_best_effort( + &iii, + "harness::turn_completed", + "approval::on_turn_completed", + json!({}), + ); + bind_best_effort( + &iii, + "cron", + "approval::sweep", + json!({ "expression": cfg.sweep_expression }), + ); + + // Configuration entry: register (no initial_value — operator values + // survive re-register), then one initial read; the configuration + // trigger above keeps the defaults fresh reactively. + if let Err(e) = gate_config::register_entry(bus.as_ref()).await { + tracing::info!(error = %e, "configuration worker unavailable; running on built-in defaults"); + } + replace(&defaults, gate_config::read_defaults(bus.as_ref()).await); + + tracing::info!("approval-gate ready: 14 approval::* functions + 2 custom trigger types"); + + tokio::signal::ctrl_c().await?; + tracing::info!("approval-gate shutting down"); + iii.shutdown_async().await; + Ok(()) +} diff --git a/approval-gate/src/manifest.rs b/approval-gate/src/manifest.rs new file mode 100644 index 00000000..03a1d95f --- /dev/null +++ b/approval-gate/src/manifest.rs @@ -0,0 +1,63 @@ +//! `--manifest` subcommand output for the registry publish pipeline. + +use serde::Serialize; + +#[derive(Serialize)] +pub struct ModuleManifest { + pub name: String, + pub version: String, + pub description: String, + pub default_config: serde_json::Value, + pub supported_targets: Vec, +} + +pub fn build_manifest() -> ModuleManifest { + ModuleManifest { + name: env!("CARGO_PKG_NAME").to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + description: + "Policy and decision surface for human-held function calls — pre_dispatch gate, pending inbox, per-session permission settings, and two notification trigger types." + .to_string(), + // Mirrors config::WorkerConfig::default() field-for-field. + default_config: serde_json::json!({ + "hook": { + "functions": ["*"], + "timeout_ms": 5000, + "on_error": "fail_closed", + }, + "sweep_expression": "0 * * * * *", + "policy_timeout_ms": 5000, + "session_fetch_timeout_ms": 1000, + "state_timeout_ms": 5000, + "harness_timeout_ms": 10000, + }), + supported_targets: vec![env!("TARGET").to_string()], + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::WorkerConfig; + + #[test] + fn json_roundtrip_has_required_fields() { + let m = build_manifest(); + let json = serde_json::to_string_pretty(&m).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["name"], env!("CARGO_PKG_NAME")); + assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION")); + assert!(parsed["description"] + .as_str() + .is_some_and(|d| !d.is_empty())); + assert!(parsed["default_config"].is_object()); + assert!(!parsed["supported_targets"].as_array().unwrap().is_empty()); + } + + #[test] + fn default_config_mirrors_worker_config_default() { + let m = build_manifest(); + let from_manifest: WorkerConfig = serde_json::from_value(m.default_config.clone()).unwrap(); + assert_eq!(from_manifest, WorkerConfig::default()); + } +} diff --git a/approval-gate/src/pending.rs b/approval-gate/src/pending.rs new file mode 100644 index 00000000..38367da9 --- /dev/null +++ b/approval-gate/src/pending.rs @@ -0,0 +1,218 @@ +//! The pending inbox records: `approval_pending//`. +//! Deliberately ephemeral — a record exists only while a call is held. +//! Every record has an explicit deletion path and the sweep as GC +//! backstop (approval-gate.md § State lifecycle), which is what keeps +//! `state::list` O(live holds). + +use serde_json::{json, Value}; + +use crate::bus::{Bus, BusError}; +use crate::types::PendingApprovalRecord; + +pub const PENDING_SCOPE: &str = "approval_pending"; + +/// Callers validate ids first (no `/`); see types::validate_id. +pub fn pending_key(session_id: &str, function_call_id: &str) -> String { + format!("{session_id}/{function_call_id}") +} + +/// Tolerant parse: null/garbage → None (a corrupt record must not wedge +/// the inbox; the sweep collects it once it has an `expires_at`, and a +/// record without one is skipped everywhere). +pub fn parse_record(value: &Value) -> Option { + if value.is_null() { + return None; + } + match serde_json::from_value::(value.clone()) { + Ok(record) => Some(record), + Err(e) => { + tracing::warn!(error = %e, "unparseable approval_pending record; skipping"); + None + } + } +} + +pub async fn get( + bus: &dyn Bus, + session_id: &str, + function_call_id: &str, + timeout_ms: u64, +) -> Result, BusError> { + let reply = bus + .call( + "state::get", + json!({ "scope": PENDING_SCOPE, "key": pending_key(session_id, function_call_id) }), + Some(timeout_ms), + ) + .await?; + Ok(parse_record(&reply)) +} + +/// Write the record. Returns the previous value when one existed (a +/// concurrent duplicate hold lost the race — the caller must not emit a +/// second `pending_created`). +pub async fn put( + bus: &dyn Bus, + record: &PendingApprovalRecord, + timeout_ms: u64, +) -> Result, BusError> { + let reply = bus + .call( + "state::set", + json!({ + "scope": PENDING_SCOPE, + "key": pending_key(&record.session_id, &record.function_call_id), + "value": record, + }), + Some(timeout_ms), + ) + .await?; + let old = reply.get("old_value").cloned().unwrap_or(Value::Null); + Ok(if old.is_null() { None } else { Some(old) }) +} + +/// The single deletion helper every lifecycle path funnels through — +/// deletion is the emit gate (approval-gate.md § Deletion is the emit +/// gate): only the caller that observed the live record emits +/// `pending_resolved`, so concurrent paths (a resolve racing the sweep +/// racing a turn abort) produce exactly one event per record. +/// +/// Mechanics: `state::set null` is the atomic gate — the engine swaps the +/// value under its write lock and returns the prior one — but it stores a +/// literal null rather than deleting (engine kv semantics), so a +/// follow-up `state::delete` removes the tombstone and keeps the scope +/// list O(live). The delete is benign if it races: the gate already +/// decided emission. +pub async fn delete_with_gate( + bus: &dyn Bus, + session_id: &str, + function_call_id: &str, + timeout_ms: u64, +) -> Result, BusError> { + let key = pending_key(session_id, function_call_id); + let reply = bus + .call( + "state::set", + json!({ "scope": PENDING_SCOPE, "key": key, "value": Value::Null }), + Some(timeout_ms), + ) + .await?; + let old = reply.get("old_value").cloned().unwrap_or(Value::Null); + if let Err(e) = bus + .call( + "state::delete", + json!({ "scope": PENDING_SCOPE, "key": key }), + Some(timeout_ms), + ) + .await + { + // The null tombstone survives until the next delete attempt; it + // is invisible to readers (parse_record skips nulls). + tracing::warn!(key, error = %e, "tombstone cleanup failed"); + } + Ok(parse_record(&old)) +} + +/// Full-scope scan, values-only (the engine's `state::list` contract). +/// Malformed/null values are skipped. +pub async fn list_all( + bus: &dyn Bus, + timeout_ms: u64, +) -> Result, BusError> { + let reply = bus + .call( + "state::list", + json!({ "scope": PENDING_SCOPE }), + Some(timeout_ms), + ) + .await?; + let values = match reply { + Value::Array(items) => items, + Value::Null => Vec::new(), + other => { + tracing::warn!(?other, "unexpected state::list reply shape"); + Vec::new() + } + }; + Ok(values.iter().filter_map(parse_record).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testkit::FakeBus; + use serde_json::json; + + fn record(session_id: &str, call_id: &str) -> PendingApprovalRecord { + PendingApprovalRecord { + session_id: session_id.into(), + turn_id: "t_1".into(), + function_call_id: call_id.into(), + function_id: "shell::run".into(), + arguments_excerpt: json!({ "cmd": "ls" }), + pending_at: 100, + expires_at: 1_800_100, + session_title: None, + session_description: None, + session_metadata: None, + depth: 0, + assistant_excerpt: None, + } + } + + #[tokio::test] + async fn put_get_roundtrip() { + let bus = FakeBus::new(); + let _state = bus.with_memory_state(); + let rec = record("s_1", "c_1"); + assert!(put(&bus, &rec, 100).await.unwrap().is_none()); + let read = get(&bus, "s_1", "c_1", 100).await.unwrap().unwrap(); + assert_eq!(read, rec); + } + + #[tokio::test] + async fn put_reports_prior_value_on_duplicate() { + let bus = FakeBus::new(); + let _state = bus.with_memory_state(); + let rec = record("s_1", "c_1"); + assert!(put(&bus, &rec, 100).await.unwrap().is_none()); + assert!(put(&bus, &rec, 100).await.unwrap().is_some()); + } + + #[tokio::test] + async fn delete_with_gate_returns_the_record_exactly_once() { + let bus = FakeBus::new(); + let state = bus.with_memory_state(); + let rec = record("s_1", "c_1"); + put(&bus, &rec, 100).await.unwrap(); + + let first = delete_with_gate(&bus, "s_1", "c_1", 100).await.unwrap(); + assert_eq!(first, Some(rec)); + // Second deletion path observes no live record: no emission. + let second = delete_with_gate(&bus, "s_1", "c_1", 100).await.unwrap(); + assert!(second.is_none()); + // The tombstone left by `state::set null` was cleaned up. + assert!(state.peek(PENDING_SCOPE, "s_1/c_1").is_none()); + } + + #[tokio::test] + async fn list_all_skips_null_tombstones_and_garbage() { + let bus = FakeBus::new(); + let state = bus.with_memory_state(); + put(&bus, &record("s_1", "c_1"), 100).await.unwrap(); + state.seed(PENDING_SCOPE, "s_2/c_9", Value::Null); + state.seed(PENDING_SCOPE, "s_3/c_8", json!("garbage")); + + let records = list_all(&bus, 100).await.unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].session_id, "s_1"); + } + + #[tokio::test] + async fn state_outage_surfaces_as_bus_error() { + let bus = FakeBus::new(); + assert!(get(&bus, "s_1", "c_1", 100).await.is_err()); + assert!(list_all(&bus, 100).await.is_err()); + assert!(delete_with_gate(&bus, "s_1", "c_1", 100).await.is_err()); + } +} diff --git a/approval-gate/src/policy.rs b/approval-gate/src/policy.rs new file mode 100644 index 00000000..0eb40622 --- /dev/null +++ b/approval-gate/src/policy.rs @@ -0,0 +1,133 @@ +//! `policy::check_permissions` client — the gate's yaml-rule fallback. +//! Reply mapping (approval-gate.md § Yaml policy dependency): +//! `allow`/`deny`/`needs_approval` map directly; **unparseable replies +//! degrade to needs_approval** (a human look is the safe reading of +//! "don't know") while **transport failures and timeouts fail closed** +//! to deny (`gate_unavailable`). + +use std::time::Duration; + +use serde_json::{json, Value}; + +use crate::bus::Bus; +use crate::types::MatchedConstraint; + +#[derive(Debug, Clone, PartialEq)] +pub enum PolicyOutcome { + Allow, + Deny { + rule_id: String, + matched_constraint: Option, + }, + NeedsApproval, + Unavailable(String), +} + +pub async fn check( + bus: &dyn Bus, + function_id: &str, + args: &Value, + timeout_ms: u64, +) -> PolicyOutcome { + let payload = json!({ "function_id": function_id, "args": args }); + // Belt and braces: the bus timeout should bound the call, but a + // misbehaving transport must not stretch the hook past its budget. + let reply = tokio::time::timeout( + Duration::from_millis(timeout_ms), + bus.call("policy::check_permissions", payload, Some(timeout_ms)), + ) + .await; + match reply { + Err(_elapsed) => PolicyOutcome::Unavailable("policy consult timed out".to_string()), + Ok(Err(e)) => PolicyOutcome::Unavailable(format!("policy unreachable: {e}")), + Ok(Ok(value)) => parse_reply(&value), + } +} + +pub fn parse_reply(value: &Value) -> PolicyOutcome { + match value.get("decision").and_then(Value::as_str) { + Some("allow") => PolicyOutcome::Allow, + Some("deny") => PolicyOutcome::Deny { + rule_id: value + .get("rule_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + matched_constraint: value + .get("matched_constraint") + .and_then(|v| serde_json::from_value(v.clone()).ok()), + }, + Some("needs_approval") => PolicyOutcome::NeedsApproval, + _ => PolicyOutcome::NeedsApproval, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testkit::FakeBus; + use serde_json::json; + + #[test] + fn parses_allow_deny_needs_approval() { + assert_eq!( + parse_reply(&json!({ "decision": "allow", "rule_id": "r1" })), + PolicyOutcome::Allow + ); + assert_eq!( + parse_reply(&json!({ + "decision": "deny", + "rule_id": "r2", + "matched_constraint": { "field": "cmd", "operator": "eq", "value": "rm" } + })), + PolicyOutcome::Deny { + rule_id: "r2".into(), + matched_constraint: Some(MatchedConstraint { + field: "cmd".into(), + operator: "eq".into(), + value: json!("rm"), + }), + } + ); + assert_eq!( + parse_reply(&json!({ "decision": "needs_approval" })), + PolicyOutcome::NeedsApproval + ); + } + + #[test] + fn unparseable_replies_degrade_to_needs_approval() { + assert_eq!(parse_reply(&json!("garbage")), PolicyOutcome::NeedsApproval); + assert_eq!(parse_reply(&Value::Null), PolicyOutcome::NeedsApproval); + assert_eq!( + parse_reply(&json!({ "decision": "maybe?" })), + PolicyOutcome::NeedsApproval + ); + } + + #[test] + fn deny_tolerates_missing_rule_id_and_bad_constraint() { + assert_eq!( + parse_reply(&json!({ "decision": "deny", "matched_constraint": "not-an-object" })), + PolicyOutcome::Deny { + rule_id: String::new(), + matched_constraint: None, + } + ); + } + + #[tokio::test] + async fn transport_error_maps_to_unavailable() { + let bus = FakeBus::new(); + bus.on_error("policy::check_permissions", "connection refused"); + let outcome = check(&bus, "shell::run", &json!({}), 100).await; + assert!(matches!(outcome, PolicyOutcome::Unavailable(_))); + } + + #[tokio::test] + async fn missing_policy_worker_maps_to_unavailable() { + let bus = FakeBus::new(); + let outcome = check(&bus, "shell::run", &json!({}), 100).await; + assert!(matches!(outcome, PolicyOutcome::Unavailable(_))); + } +} diff --git a/approval-gate/src/redact.rs b/approval-gate/src/redact.rs new file mode 100644 index 00000000..33b00c49 --- /dev/null +++ b/approval-gate/src/redact.rs @@ -0,0 +1,171 @@ +//! Recursive argument redaction for pending records and denial envelopes. +//! Pure and immutable — never mutates its input. Ported behavior-for- +//! behavior from the proven implementation +//! (harness/src/approval-gate/redact.ts). This is what makes pending +//! records safe to forward to notification channels. + +use serde_json::Value; + +pub const ARGS_EXCERPT_LEN_CAP: usize = 256; + +/// Hard ceiling on recursion depth. Args are caller-influenced, so an +/// adversarial payload (deeply nested) must not overflow the stack — +/// past this depth the subtree collapses to a sentinel. +pub const MAX_REDACT_DEPTH: usize = 64; + +const MAX_DEPTH_SENTINEL: &str = ""; +const REDACTED: &str = ""; + +const REDACT_KEYS: [&str; 11] = [ + "password", + "token", + "api_key", + "apikey", + "secret", + "auth", + "authorization", + "access_key", + "access_token", + "refresh_token", + "private_key", +]; + +fn is_secret_key(key: &str) -> bool { + let lower = key.to_lowercase(); + if REDACT_KEYS.contains(&lower.as_str()) { + return true; + } + REDACT_KEYS + .iter() + .any(|suffix| lower.ends_with(&format!("_{suffix}"))) +} + +/// Truncate by Unicode code point (`char`) so the cap is measured the +/// same way the prior art measured it — byte-length clipping would +/// falsely truncate multi-byte-heavy strings that are within the cap. +pub fn clip(s: &str) -> String { + let mut chars = s.chars(); + let clipped: String = chars.by_ref().take(ARGS_EXCERPT_LEN_CAP).collect(); + if chars.next().is_none() { + s.to_string() + } else { + format!("{clipped}…") + } +} + +/// Walk the value tree, redacting secret-keyed values and clipping long +/// strings. Returns a brand-new tree. +pub fn redact(value: &Value) -> Value { + redact_at(value, 0) +} + +fn redact_at(value: &Value, depth: usize) -> Value { + match value { + Value::String(s) => Value::String(clip(s)), + Value::Array(_) | Value::Object(_) if depth >= MAX_REDACT_DEPTH => { + Value::String(MAX_DEPTH_SENTINEL.to_string()) + } + Value::Array(items) => { + Value::Array(items.iter().map(|v| redact_at(v, depth + 1)).collect()) + } + Value::Object(map) => Value::Object( + map.iter() + .map(|(k, v)| { + let redacted = if is_secret_key(k) { + Value::String(REDACTED.to_string()) + } else { + redact_at(v, depth + 1) + }; + (k.clone(), redacted) + }) + .collect(), + ), + other => other.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn redacts_secret_keys_case_insensitively() { + let out = redact(&json!({ "Password": "hunter2", "cmd": "ls" })); + assert_eq!(out, json!({ "Password": "", "cmd": "ls" })); + } + + #[test] + fn redacts_suffix_matched_keys() { + let out = redact(&json!({ "github_token": "ghp_x", "stripe_api_key": "sk_x" })); + assert_eq!( + out, + json!({ "github_token": "", "stripe_api_key": "" }) + ); + } + + #[test] + fn redacts_non_string_secret_values_too() { + let out = redact(&json!({ "secret": { "nested": "v" }, "token": 42 })); + assert_eq!( + out, + json!({ "secret": "", "token": "" }) + ); + } + + #[test] + fn clips_long_strings_with_ellipsis() { + let long = "a".repeat(300); + let out = redact(&json!({ "text": long })); + let clipped = out["text"].as_str().unwrap(); + assert_eq!(clipped.chars().count(), ARGS_EXCERPT_LEN_CAP + 1); + assert!(clipped.ends_with('…')); + } + + #[test] + fn clip_counts_code_points_not_bytes() { + // 200 astral chars = 800 bytes but only 200 code points: no clip. + let astral = "𝄞".repeat(200); + assert_eq!(clip(&astral), astral); + let over = "𝄞".repeat(257); + let clipped = clip(&over); + assert_eq!(clipped.chars().count(), ARGS_EXCERPT_LEN_CAP + 1); + } + + #[test] + fn recurses_through_arrays() { + let out = redact(&json!([{ "password": "x" }, "ok"])); + assert_eq!(out, json!([{ "password": "" }, "ok"])); + } + + #[test] + fn passes_scalars_through() { + assert_eq!(redact(&json!(42)), json!(42)); + assert_eq!(redact(&json!(true)), json!(true)); + assert_eq!(redact(&Value::Null), Value::Null); + } + + #[test] + fn collapses_past_max_depth() { + let mut v = json!("leaf"); + for _ in 0..(MAX_REDACT_DEPTH + 5) { + v = json!([v]); + } + let mut out = redact(&v); + let mut depth = 0usize; + while let Value::Array(items) = out { + out = items.into_iter().next().unwrap(); + depth += 1; + } + assert_eq!(out, json!("")); + assert_eq!(depth, MAX_REDACT_DEPTH); + } + + #[test] + fn never_mutates_input() { + let input = json!({ "password": "x", "nested": { "api_key": "y" } }); + let snapshot = input.clone(); + let _ = redact(&input); + assert_eq!(input, snapshot); + } +} diff --git a/approval-gate/src/settings.rs b/approval-gate/src/settings.rs new file mode 100644 index 00000000..e9c02e1d --- /dev/null +++ b/approval-gate/src/settings.rs @@ -0,0 +1,330 @@ +//! Per-session settings: `approval_settings/`, created +//! lazily on first mutation only (approval-gate.md § State lifecycle). +//! Reads never write — the *effective* settings are the stored record +//! when one exists, else the configuration defaults computed in memory. + +use serde_json::{json, Value}; + +use crate::bus::Bus; +use crate::error::ApprovalError; +use crate::gate_config::GateDefaults; +use crate::types::{ + now_ms, validate_id, AlwaysAllowEntry, ApprovalSettings, GrantedBy, SettingsSource, +}; + +pub const SETTINGS_SCOPE: &str = "approval_settings"; + +/// Tolerant parse: missing fields fall back to their defaults (serde +/// `#[serde(default)]` on every field); a non-object or unparseable value +/// is treated as no record. +pub fn parse_settings(value: &Value) -> Option { + if !value.is_object() { + return None; + } + match serde_json::from_value::(value.clone()) { + Ok(settings) => Some(settings), + Err(e) => { + tracing::warn!(error = %e, "unparseable approval_settings record; treating as absent"); + None + } + } +} + +/// The in-memory settings a session with no stored record runs on: +/// deployment defaults, seed entries marked `granted_by: "seed"`. +pub fn seeded_from(defaults: &GateDefaults, granted_at: i64) -> ApprovalSettings { + ApprovalSettings { + mode: defaults.default_mode, + always_allow: defaults + .always_allow_seed + .iter() + .map(|function_id| AlwaysAllowEntry { + function_id: function_id.clone(), + granted_at, + granted_by: GrantedBy::Seed, + }) + .collect(), + approved_always: Vec::new(), + mode_set_at: 0, + } +} + +pub fn effective( + stored: Option, + defaults: &GateDefaults, +) -> (ApprovalSettings, SettingsSource) { + match stored { + Some(settings) => (settings, SettingsSource::Stored), + None => (seeded_from(defaults, 0), SettingsSource::Defaults), + } +} + +/// Hot-path read for the gate: any failure (state outage, absent record, +/// garbage) degrades to `None` → configuration defaults. Safe because the +/// default mode never widens beyond what the deployment configured. +pub async fn read_tolerant( + bus: &dyn Bus, + session_id: &str, + timeout_ms: u64, +) -> Option { + let reply = bus + .call( + "state::get", + json!({ "scope": SETTINGS_SCOPE, "key": session_id }), + Some(timeout_ms), + ) + .await; + match reply { + Ok(value) => parse_settings(&value), + Err(e) => { + tracing::warn!(session_id, error = %e, "settings read failed; using defaults"); + None + } + } +} + +/// Strict read for mutations: a state outage is an error — re-seeding +/// over an unreadable record would clobber it. +pub async fn read_strict( + bus: &dyn Bus, + session_id: &str, + timeout_ms: u64, +) -> Result, ApprovalError> { + let reply = bus + .call( + "state::get", + json!({ "scope": SETTINGS_SCOPE, "key": session_id }), + Some(timeout_ms), + ) + .await + .map_err(|e| ApprovalError::StateUnavailable(format!("settings read failed: {e}")))?; + Ok(parse_settings(&reply)) +} + +/// First mutation materializes the record from the current defaults, then +/// applies the mutation; from then on the stored record wins (a later +/// seed change does not retroactively edit it). The whole record is +/// written in one `state::set` — mutations are human-driven and rare, so +/// read-modify-write of one small record is sufficient. +pub async fn materialize_and( + bus: &dyn Bus, + session_id: &str, + defaults: &GateDefaults, + timeout_ms: u64, + mutate: F, +) -> Result +where + F: FnOnce(ApprovalSettings, i64) -> ApprovalSettings, +{ + validate_id("session_id", session_id)?; + let now = now_ms(); + let base = read_strict(bus, session_id, timeout_ms) + .await? + .unwrap_or_else(|| seeded_from(defaults, now)); + let next = mutate(base, now); + bus.call( + "state::set", + json!({ "scope": SETTINGS_SCOPE, "key": session_id, "value": next }), + Some(timeout_ms), + ) + .await + .map_err(|e| ApprovalError::StateUnavailable(format!("settings write failed: {e}")))?; + Ok(next) +} + +/// Drop the stored record (the session reverts to configuration +/// defaults). Returns whether a record existed. +pub async fn clear( + bus: &dyn Bus, + session_id: &str, + timeout_ms: u64, +) -> Result { + validate_id("session_id", session_id)?; + let old = bus + .call( + "state::delete", + json!({ "scope": SETTINGS_SCOPE, "key": session_id }), + Some(timeout_ms), + ) + .await + .map_err(|e| ApprovalError::StateUnavailable(format!("settings delete failed: {e}")))?; + Ok(!old.is_null()) +} + +/// Idempotent append keyed on exact `function_id` (prior art: +/// add-always-allow.ts) — returns a new list, never mutates. +pub fn with_grant( + entries: &[AlwaysAllowEntry], + function_id: &str, + granted_at: i64, +) -> Vec { + if entries.iter().any(|e| e.function_id == function_id) { + return entries.to_vec(); + } + let mut next = entries.to_vec(); + next.push(AlwaysAllowEntry { + function_id: function_id.to_string(), + granted_at, + granted_by: GrantedBy::UserClick, + }); + next +} + +/// Remove by exact `function_id`; absent entries are a no-op. Seed +/// entries are removable like any other — the stored record overrides +/// the deployment seed from first mutation on. +pub fn without_grant(entries: &[AlwaysAllowEntry], function_id: &str) -> Vec { + entries + .iter() + .filter(|e| e.function_id != function_id) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testkit::FakeBus; + use crate::types::PermissionMode; + use serde_json::json; + + fn defaults_with_seed() -> GateDefaults { + GateDefaults { + default_mode: PermissionMode::Auto, + always_allow_seed: vec!["state::get".into(), "engine::functions::list".into()], + pending_timeout_ms: 1_800_000, + } + } + + #[test] + fn effective_prefers_stored_record() { + let stored = ApprovalSettings { + mode: PermissionMode::Full, + ..Default::default() + }; + let (settings, source) = effective(Some(stored.clone()), &defaults_with_seed()); + assert_eq!(settings, stored); + assert_eq!(source, SettingsSource::Stored); + } + + #[test] + fn effective_seeds_from_defaults_when_no_record() { + let (settings, source) = effective(None, &defaults_with_seed()); + assert_eq!(source, SettingsSource::Defaults); + assert_eq!(settings.mode, PermissionMode::Auto); + assert_eq!(settings.always_allow.len(), 2); + assert!(settings + .always_allow + .iter() + .all(|e| e.granted_by == GrantedBy::Seed)); + assert!(settings.approved_always.is_empty()); + } + + #[test] + fn parse_settings_rejects_non_objects() { + assert!(parse_settings(&Value::Null).is_none()); + assert!(parse_settings(&json!("garbage")).is_none()); + assert!(parse_settings(&json!({})).is_some()); + } + + #[tokio::test] + async fn reads_never_write() { + let bus = FakeBus::new(); + let state = bus.with_memory_state(); + let stored = read_tolerant(&bus, "s_1", 100).await; + assert!(stored.is_none()); + assert!(state.is_empty()); + assert!(bus.calls_to("state::set").is_empty()); + } + + #[tokio::test] + async fn first_mutation_materializes_from_current_defaults() { + let bus = FakeBus::new(); + let state = bus.with_memory_state(); + let defaults = defaults_with_seed(); + + let next = materialize_and(&bus, "s_1", &defaults, 100, |s, now| ApprovalSettings { + mode: PermissionMode::Manual, + mode_set_at: now, + ..s + }) + .await + .unwrap(); + + assert_eq!(next.mode, PermissionMode::Manual); + // Seed copied into the record on first mutation. + assert_eq!(next.always_allow.len(), 2); + assert!(next + .always_allow + .iter() + .all(|e| e.granted_by == GrantedBy::Seed)); + + let written = state.peek(SETTINGS_SCOPE, "s_1").unwrap(); + let parsed = parse_settings(&written).unwrap(); + assert_eq!(parsed, next); + } + + #[tokio::test] + async fn later_seed_changes_do_not_edit_a_stored_record() { + let bus = FakeBus::new(); + let _state = bus.with_memory_state(); + let defaults = defaults_with_seed(); + + materialize_and(&bus, "s_1", &defaults, 100, |s, _| s) + .await + .unwrap(); + + // Seed changed after the record materialized. + let changed = GateDefaults { + always_allow_seed: vec!["everything::*".into()], + ..defaults_with_seed() + }; + let next = materialize_and(&bus, "s_1", &changed, 100, |s, _| s) + .await + .unwrap(); + assert_eq!(next.always_allow.len(), 2); + assert!(next + .always_allow + .iter() + .all(|e| e.function_id != "everything::*")); + } + + #[tokio::test] + async fn mutation_fails_closed_on_state_outage() { + let bus = FakeBus::new(); + // No state handlers scripted: every state call errors. + let err = materialize_and(&bus, "s_1", &GateDefaults::default(), 100, |s, _| s) + .await + .unwrap_err(); + assert_eq!(err.code(), "approval/state_unavailable"); + } + + #[test] + fn with_grant_is_idempotent_on_function_id() { + let one = with_grant(&[], "shell::run", 10); + let two = with_grant(&one, "shell::run", 20); + assert_eq!(one, two); + assert_eq!(two.len(), 1); + assert_eq!(two[0].granted_at, 10); + assert_eq!(two[0].granted_by, GrantedBy::UserClick); + } + + #[test] + fn without_grant_removes_and_tolerates_absent() { + let list = with_grant(&[], "shell::run", 10); + assert!(without_grant(&list, "shell::run").is_empty()); + assert_eq!(without_grant(&list, "other::fn"), list); + } + + #[tokio::test] + async fn clear_reports_whether_a_record_existed() { + let bus = FakeBus::new(); + let _state = bus.with_memory_state(); + assert!(!clear(&bus, "s_1", 100).await.unwrap()); + materialize_and(&bus, "s_1", &GateDefaults::default(), 100, |s, _| s) + .await + .unwrap(); + assert!(clear(&bus, "s_1", 100).await.unwrap()); + assert!(!clear(&bus, "s_1", 100).await.unwrap()); + } +} diff --git a/approval-gate/src/testkit/fake_bus.rs b/approval-gate/src/testkit/fake_bus.rs new file mode 100644 index 00000000..05426382 --- /dev/null +++ b/approval-gate/src/testkit/fake_bus.rs @@ -0,0 +1,221 @@ +//! A scripted [`Bus`]: records every call and answers from per-function +//! handlers. [`MemoryState`] mirrors the engine's state worker semantics +//! exactly — including the null-tombstone behavior of `state::set` with +//! `value: null` — so the delete-with-gate logic is tested against the +//! real contract. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::bus::{Bus, BusError}; + +type Handler = Arc Result + Send + Sync>; + +#[derive(Debug, Clone)] +pub struct RecordedCall { + pub function_id: String, + pub payload: Value, + pub void: bool, + pub timeout_ms: Option, +} + +#[derive(Default)] +pub struct FakeBus { + handlers: Mutex>, + calls: Mutex>, +} + +impl FakeBus { + pub fn new() -> Self { + Self::default() + } + + /// Script a handler for one function id. + pub fn on(&self, function_id: &str, handler: F) + where + F: Fn(&Value) -> Result + Send + Sync + 'static, + { + self.lock_handlers() + .insert(function_id.to_string(), Arc::new(handler)); + } + + /// Script a constant reply. + pub fn on_value(&self, function_id: &str, value: Value) { + self.on(function_id, move |_| Ok(value.clone())); + } + + /// Script a constant transport error. + pub fn on_error(&self, function_id: &str, message: &str) { + let message = message.to_string(); + self.on(function_id, move |_| Err(BusError(message.clone()))); + } + + /// Install `state::get/set/delete/list` handlers backed by a shared + /// in-memory store; returns the store for direct assertions. + pub fn with_memory_state(&self) -> MemoryState { + let state = MemoryState::default(); + + let s = state.clone(); + self.on("state::get", move |payload| Ok(s.get(payload))); + let s = state.clone(); + self.on("state::set", move |payload| Ok(s.set(payload))); + let s = state.clone(); + self.on("state::delete", move |payload| Ok(s.delete(payload))); + let s = state.clone(); + self.on("state::list", move |payload| Ok(s.list(payload))); + + state + } + + pub fn calls(&self) -> Vec { + self.lock_calls().clone() + } + + pub fn calls_to(&self, function_id: &str) -> Vec { + self.lock_calls() + .iter() + .filter(|c| c.function_id == function_id) + .cloned() + .collect() + } + + fn lock_handlers(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.handlers + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + } + + fn lock_calls(&self) -> std::sync::MutexGuard<'_, Vec> { + self.calls + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + } + + fn handler_for(&self, function_id: &str) -> Option { + self.lock_handlers().get(function_id).cloned() + } +} + +#[async_trait] +impl Bus for FakeBus { + async fn call( + &self, + function_id: &str, + payload: Value, + timeout_ms: Option, + ) -> Result { + self.lock_calls().push(RecordedCall { + function_id: function_id.to_string(), + payload: payload.clone(), + void: false, + timeout_ms, + }); + match self.handler_for(function_id) { + Some(handler) => handler(&payload), + // An unscripted function behaves like an absent sibling — + // the right default for fail-closed tests. + None => Err(BusError(format!("no handler for {function_id}"))), + } + } + + async fn call_void(&self, function_id: &str, payload: Value) { + self.lock_calls().push(RecordedCall { + function_id: function_id.to_string(), + payload: payload.clone(), + void: true, + timeout_ms: None, + }); + if let Some(handler) = self.handler_for(function_id) { + let _ = handler(&payload); + } + } +} + +/// In-memory mirror of the engine's state worker (builtins/kv.rs): +/// `set` stores the value verbatim (a JSON null IS stored — the tombstone +/// the production delete path must clean up), returning `{old_value, +/// new_value}` atomically; `delete` removes and returns the old value; +/// `list` returns values only. +#[derive(Clone, Default)] +pub struct MemoryState { + inner: Arc>>, +} + +impl MemoryState { + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<(String, String), Value>> { + self.inner + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + } + + fn scope_key(payload: &Value) -> (String, String) { + ( + payload + .get("scope") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + payload + .get("key") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + ) + } + + fn get(&self, payload: &Value) -> Value { + let key = Self::scope_key(payload); + self.lock().get(&key).cloned().unwrap_or(Value::Null) + } + + fn set(&self, payload: &Value) -> Value { + let key = Self::scope_key(payload); + let value = payload.get("value").cloned().unwrap_or(Value::Null); + let old = self.lock().insert(key, value.clone()); + json!({ "old_value": old.unwrap_or(Value::Null), "new_value": value }) + } + + fn delete(&self, payload: &Value) -> Value { + let key = Self::scope_key(payload); + self.lock().remove(&key).unwrap_or(Value::Null) + } + + fn list(&self, payload: &Value) -> Value { + let scope = payload + .get("scope") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let values: Vec = self + .lock() + .iter() + .filter(|((s, _), _)| *s == scope) + .map(|(_, v)| v.clone()) + .collect(); + Value::Array(values) + } + + /// Direct read for assertions. + pub fn peek(&self, scope: &str, key: &str) -> Option { + self.lock() + .get(&(scope.to_string(), key.to_string())) + .cloned() + } + + /// Direct write for seeding. + pub fn seed(&self, scope: &str, key: &str, value: Value) { + self.lock() + .insert((scope.to_string(), key.to_string()), value); + } + + pub fn len(&self) -> usize { + self.lock().len() + } + + pub fn is_empty(&self) -> bool { + self.lock().is_empty() + } +} diff --git a/approval-gate/src/testkit/mod.rs b/approval-gate/src/testkit/mod.rs new file mode 100644 index 00000000..fbb20cc2 --- /dev/null +++ b/approval-gate/src/testkit/mod.rs @@ -0,0 +1,6 @@ +//! Engine-free test doubles. `#[cfg(test)]`-free on purpose: the +//! integration suite (tests/integration.rs) uses these too. + +pub mod fake_bus; + +pub use fake_bus::{FakeBus, MemoryState, RecordedCall}; diff --git a/approval-gate/src/types.rs b/approval-gate/src/types.rs new file mode 100644 index 00000000..4c376994 --- /dev/null +++ b/approval-gate/src/types.rs @@ -0,0 +1,534 @@ +//! Wire types, transliterated from +//! tech-specs/2026-06-agentic/approval-gate.md § API Reference and +//! harness.md § Hooks › Contract. Every type that crosses the bus derives +//! serde + `JsonSchema` so the SDK publishes request/response schemas. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::error::ApprovalError; + +pub type JsonMap = serde_json::Map; + +pub const DENIAL_SCHEMA_VERSION: u32 = 1; + +/// Subset-equality metadata match: every key in `want` must equal the +/// stored value (the tenancy filter convention shared with +/// session-manager's trigger configs). +pub fn metadata_matches(want: &JsonMap, have: Option<&JsonMap>) -> bool { + want.iter() + .all(|(key, value)| have.and_then(|m| m.get(key)) == Some(value)) +} + +/// `session_id` / `function_call_id` boundary validation: non-empty and +/// no `/` — the reserved key separator in the `approval_pending` scope. +pub fn validate_id(label: &str, value: &str) -> Result<(), ApprovalError> { + if value.is_empty() { + return Err(ApprovalError::InvalidPayload(format!( + "{label} must be a non-empty string" + ))); + } + if value.contains('/') { + return Err(ApprovalError::InvalidPayload(format!( + "{label} must not contain \"/\"" + ))); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Permission model +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum PermissionMode { + #[default] + Manual, + Auto, + Full, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum GrantedBy { + UserClick, + /// Copied from the deployment's `always_allow_seed` on first mutation. + Seed, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AlwaysAllowEntry { + pub function_id: String, + /// ms epoch + pub granted_at: i64, + pub granted_by: GrantedBy, +} + +/// The stored per-session record (scope `approval_settings`). Reads +/// compute the effective settings from configuration defaults when no +/// record exists — see settings.rs. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ApprovalSettings { + #[serde(default)] + pub mode: PermissionMode, + /// Consulted only in auto mode. + #[serde(default)] + pub always_allow: Vec, + /// Consulted in every mode — remembered human decisions. + #[serde(default)] + pub approved_always: Vec, + /// ms epoch + #[serde(default)] + pub mode_set_at: i64, +} + +// --------------------------------------------------------------------------- +// Denial envelope +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum DeniedBy { + Permissions, + User, + GateUnavailable, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct MatchedConstraint { + pub field: String, + pub operator: String, + pub value: Value, +} + +/// The structured denial shape, rendered into `is_error` function_results. +/// `reason` strings are written for the model as much as the human — the +/// denial is information the agent can adapt to, not just a wall. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct DenialEnvelope { + pub schema_version: u32, + pub status: String, + pub denied_by: DeniedBy, + pub function_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub rule_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rule_action: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub matched_constraint: Option, + /// Redacted (see redact.rs). + #[serde(skip_serializing_if = "Option::is_none")] + pub args_excerpt: Option, + pub reason: String, +} + +// --------------------------------------------------------------------------- +// Pending inbox +// --------------------------------------------------------------------------- + +/// The inbox payload — shared by both triggers, `list_pending`, and +/// `get_pending`. Self-describing (all ids inside the value) and +/// notification-safe (arguments pass through redaction). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PendingApprovalRecord { + pub session_id: String, + pub turn_id: String, + pub function_call_id: String, + pub function_id: String, + /// Redacted — safe to forward to notification channels. + #[serde(default)] + pub arguments_excerpt: Value, + /// ms epoch + pub pending_at: i64, + /// `pending_at + pending_timeout_ms` + pub expires_at: i64, + + // Denormalized session context — soft-fetched via session::get at + // hold time; omitted when the fetch fails or session-manager is absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_description: Option, + /// Tenancy + routing (trigger config filter target). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_metadata: Option, + + /// Sub-agent depth (0 = top-level), from `HookInput`. + #[serde(default)] + pub depth: i64, + + /// First text block of the assistant message that contained this + /// function_call. Best-effort; not derivable from `pre_dispatch` + /// `HookInput` in v1, so always omitted today. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assistant_excerpt: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum ResolvedOutcome { + Allow, + Deny, + Timeout, + Aborted, +} + +/// Payload of `approval::pending_resolved` — a pending call left the +/// inbox. Emitted exactly once per record (gated on the record delete). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PendingResolvedEvent { + pub session_id: String, + pub turn_id: String, + pub function_call_id: String, + pub function_id: String, + pub outcome: ResolvedOutcome, + /// Operator-supplied on deny. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// Tenancy routing, from the record. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_metadata: Option, + /// ms epoch + pub resolved_at: i64, +} + +// --------------------------------------------------------------------------- +// Harness hook contract (harness.md § Hooks › Contract) — only the fields +// the gate consumes. The harness owns these types; unknown fields are +// ignored on input. +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct HookCall { + pub id: String, + pub function_id: String, + #[serde(default)] + pub arguments: Value, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct HookInput { + #[serde(default)] + pub point: Option, + pub session_id: String, + pub turn_id: String, + #[serde(default)] + pub step: Option, + /// Sub-agent depth (hooks run for child turns too). + #[serde(default)] + pub depth: i64, + /// The per-send tracing metadata. + #[serde(default)] + pub metadata: Option, + /// pre_dispatch payload. + #[serde(default)] + pub call: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +#[serde(tag = "decision", rename_all = "lowercase")] +pub enum HookOutput { + Continue, + Deny { reason: String }, + Hold { pending_timeout_ms: i64 }, +} + +// --------------------------------------------------------------------------- +// ContentBlock (README.md § Cross-cutting contracts) — only the text +// variant is needed (denial rendering into function_result content). +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TextBlock { + #[serde(rename = "type")] + pub block_type: String, + pub text: String, +} + +pub fn text_block(text: impl Into) -> TextBlock { + TextBlock { + block_type: "text".to_string(), + text: text.into(), + } +} + +// --------------------------------------------------------------------------- +// RPC request/response shapes (approval-gate.md § API Reference) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum ResolveDecision { + Allow, + Deny, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct ResolveRequest { + pub session_id: String, + pub function_call_id: String, + pub decision: ResolveDecision, + /// Surfaced to the model on deny. + #[serde(default)] + pub reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +pub struct ResolveResponse { + /// false: unknown/already-resolved pending call. + pub resolved: bool, + /// Passthrough from harness::function::resolve. + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_resumed: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, JsonSchema)] +pub struct ListPendingRequest { + #[serde(default)] + pub session_id: Option, + /// Equality match against `session_metadata` (tenancy). + #[serde(default)] + pub metadata: Option, + /// Default 50. + #[serde(default)] + pub limit: Option, + /// Opaque. + #[serde(default)] + pub cursor: Option, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ListPendingResponse { + /// Ordered by pending_at ascending. + pub pending: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct GetPendingRequest { + pub session_id: String, + pub function_call_id: String, +} + +/// `null` (handler returns `Option`) when resolved or +/// unknown. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct GetPendingResponse { + pub pending: PendingApprovalRecord, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct SetModeRequest { + pub session_id: String, + pub mode: PermissionMode, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct AlwaysAllowMutationRequest { + pub session_id: String, + pub function_id: String, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct ApproveAlwaysRequest { + pub session_id: String, + pub function_id: String, +} + +/// Shared by every settings mutation RPC: the post-mutation record. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct SettingsResponse { + pub settings: ApprovalSettings, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct GetSettingsRequest { + pub session_id: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum SettingsSource { + Stored, + Defaults, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct GetSettingsResponse { + pub settings: ApprovalSettings, + /// Whether a per-session record exists. + pub source: SettingsSource, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct ClearSettingsRequest { + pub session_id: String, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ClearSettingsResponse { + pub cleared: bool, +} + +/// ms epoch now. +pub fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn hook_output_serializes_to_spec_shapes() { + assert_eq!( + serde_json::to_value(HookOutput::Continue).unwrap(), + json!({ "decision": "continue" }) + ); + assert_eq!( + serde_json::to_value(HookOutput::Deny { + reason: "nope".into() + }) + .unwrap(), + json!({ "decision": "deny", "reason": "nope" }) + ); + assert_eq!( + serde_json::to_value(HookOutput::Hold { + pending_timeout_ms: 1_800_000 + }) + .unwrap(), + json!({ "decision": "hold", "pending_timeout_ms": 1_800_000 }) + ); + } + + #[test] + fn hook_input_tolerates_unknown_and_missing_optional_fields() { + let input: HookInput = serde_json::from_value(json!({ + "point": "pre_dispatch", + "session_id": "s_1", + "turn_id": "t_1", + "step": 3, + "depth": 1, + "unknown_future_field": { "x": 1 }, + "call": { "id": "c_1", "function_id": "shell::run", "arguments": { "cmd": "ls" } } + })) + .unwrap(); + assert_eq!(input.call.unwrap().function_id, "shell::run"); + + let bare: HookInput = serde_json::from_value(json!({ + "session_id": "s_1", + "turn_id": "t_1" + })) + .unwrap(); + assert!(bare.call.is_none()); + assert_eq!(bare.depth, 0); + } + + #[test] + fn permission_mode_wire_values() { + assert_eq!( + serde_json::to_value(PermissionMode::Manual).unwrap(), + json!("manual") + ); + assert_eq!( + serde_json::to_value(PermissionMode::Auto).unwrap(), + json!("auto") + ); + assert_eq!( + serde_json::to_value(PermissionMode::Full).unwrap(), + json!("full") + ); + } + + #[test] + fn settings_parse_tolerates_missing_fields() { + let s: ApprovalSettings = serde_json::from_value(json!({ "mode": "auto" })).unwrap(); + assert_eq!(s.mode, PermissionMode::Auto); + assert!(s.always_allow.is_empty()); + assert!(s.approved_always.is_empty()); + assert_eq!(s.mode_set_at, 0); + } + + #[test] + fn granted_by_wire_values() { + assert_eq!( + serde_json::to_value(GrantedBy::UserClick).unwrap(), + json!("user_click") + ); + assert_eq!( + serde_json::to_value(GrantedBy::Seed).unwrap(), + json!("seed") + ); + } + + #[test] + fn pending_record_roundtrip_omits_absent_optionals() { + let record = PendingApprovalRecord { + session_id: "s_1".into(), + turn_id: "t_1".into(), + function_call_id: "c_1".into(), + function_id: "shell::run".into(), + arguments_excerpt: json!({ "cmd": "ls" }), + pending_at: 100, + expires_at: 200, + session_title: None, + session_description: None, + session_metadata: None, + depth: 0, + assistant_excerpt: None, + }; + let v = serde_json::to_value(&record).unwrap(); + assert!(v.get("session_title").is_none()); + assert!(v.get("assistant_excerpt").is_none()); + let back: PendingApprovalRecord = serde_json::from_value(v).unwrap(); + assert_eq!(back, record); + } + + #[test] + fn validate_id_rejects_slash_and_empty() { + assert!(validate_id("session_id", "s_1").is_ok()); + assert!(validate_id("session_id", "").is_err()); + assert!(validate_id("session_id", "a/b").is_err()); + let err = validate_id("function_call_id", "x/y").unwrap_err(); + assert_eq!(err.code(), "approval/invalid_payload"); + } + + #[test] + fn metadata_matches_is_subset_equality() { + let want: JsonMap = serde_json::from_value(json!({ "owner": "u_1" })).unwrap(); + let have: JsonMap = + serde_json::from_value(json!({ "owner": "u_1", "extra": true })).unwrap(); + assert!(metadata_matches(&want, Some(&have))); + let other: JsonMap = serde_json::from_value(json!({ "owner": "u_2" })).unwrap(); + assert!(!metadata_matches(&want, Some(&other))); + assert!(!metadata_matches(&want, None)); + let empty: JsonMap = JsonMap::new(); + assert!(metadata_matches(&empty, None)); + } + + #[test] + fn denial_envelope_omits_absent_optionals() { + let envelope = DenialEnvelope { + schema_version: DENIAL_SCHEMA_VERSION, + status: "denied".into(), + denied_by: DeniedBy::GateUnavailable, + function_id: "shell::run".into(), + rule_id: None, + rule_action: None, + matched_constraint: None, + args_excerpt: None, + reason: "policy unreachable".into(), + }; + let v = serde_json::to_value(&envelope).unwrap(); + assert_eq!(v["denied_by"], json!("gate_unavailable")); + assert!(v.get("rule_id").is_none()); + assert!(v.get("args_excerpt").is_none()); + } +} diff --git a/approval-gate/tests/integration.rs b/approval-gate/tests/integration.rs new file mode 100644 index 00000000..ed4ab5f9 --- /dev/null +++ b/approval-gate/tests/integration.rs @@ -0,0 +1,668 @@ +//! Engine-backed integration suite — the worker's production surface +//! registered against a real iii engine (`iii-worker-manager + +//! iii-pubsub + configuration + iii-state`), with the not-yet-built +//! siblings (`policy::check_permissions`, `harness::function::resolve`, +//! `session::get`) registered as in-process fakes. +//! +//! **Self-skips** when no engine is available (llm-router pattern): +//! set `III_ENGINE_BIN=/path/to/iii` or have `iii` on PATH. + +use std::io::Write as _; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use iii_sdk::{ + register_worker, InitOptions, RegisterFunction, RegisterTriggerInput, TriggerRequest, III, +}; +use serde_json::{json, Value}; + +use approval_gate::bus::IiiBus; +use approval_gate::config::WorkerConfig; +use approval_gate::events::{self, Emitter}; +use approval_gate::functions::{self, Deps}; +use approval_gate::gate_config::{self, replace, shared_defaults, SharedDefaults}; + +// ── engine bootstrap (llm-router/tests/integration.rs pattern) ───────────── + +struct Engine { + url: String, + child: std::process::Child, + dir: std::path::PathBuf, +} + +impl Drop for Engine { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +fn engine_bin() -> Option { + if let Ok(p) = std::env::var("III_ENGINE_BIN") { + return Some(p.into()); + } + let on_path = std::process::Command::new("iii") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + on_path.then(|| "iii".into()) +} + +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .expect("bind ephemeral port") + .local_addr() + .expect("local addr") + .port() +} + +async fn spawn_engine() -> Option { + let bin = engine_bin()?; + let port = free_port(); + let dir = std::env::temp_dir().join(format!("approval-gate-it-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("temp dir"); + + let config = format!( + r#"workers: + - name: iii-worker-manager + config: + port: {port} + - name: iii-pubsub + config: + adapter: + name: local + - name: configuration + config: + adapter: + name: fs + config: + directory: {dir}/configuration + ttl_seconds: 0 + - name: iii-state + config: + adapter: + name: kv + config: + file_path: {dir}/state_store.db + store_method: file_based +"#, + port = port, + dir = dir.display(), + ); + let config_path = dir.join("config.yaml"); + std::fs::File::create(&config_path) + .and_then(|mut f| f.write_all(config.as_bytes())) + .expect("write config"); + + let child = std::process::Command::new(&bin) + .arg("--no-update-check") + .arg("--config") + .arg(&config_path) + .current_dir(&dir) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn engine"); + + let url = format!("ws://127.0.0.1:{port}"); + let probe = register_worker(&url, InitOptions::default()); + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let ready = probe + .trigger(TriggerRequest { + function_id: "engine::workers::list".into(), + payload: json!({}), + action: None, + timeout_ms: Some(1000), + }) + .await + .is_ok(); + if ready { + break; + } + assert!( + Instant::now() < deadline, + "engine did not become ready in 15s" + ); + tokio::time::sleep(Duration::from_millis(250)).await; + } + probe.shutdown(); + + Some(Engine { url, child, dir }) +} + +macro_rules! engine_or_skip { + () => { + match spawn_engine().await { + Some(e) => e, + None => { + eprintln!("skipping: no iii engine (set III_ENGINE_BIN or put `iii` on PATH)"); + return; + } + } + }; +} + +// ── worker bootstrap + fakes ──────────────────────────────────────────────── + +type CallLog = Arc>>; + +struct Stack { + iii: Arc, + defaults: SharedDefaults, + /// Requests received by the fake harness::function::resolve. + harness_calls: CallLog, + /// approval::pending_created deliveries. + created: CallLog, + /// approval::pending_resolved deliveries. + resolved: CallLog, +} + +fn log_push(log: &CallLog, value: Value) { + log.lock().unwrap_or_else(|p| p.into_inner()).push(value); +} + +fn log_snapshot(log: &CallLog) -> Vec { + log.lock().unwrap_or_else(|p| p.into_inner()).clone() +} + +/// Register the production surface + fakes + event recorders on one +/// worker connection against the engine. +async fn boot_stack(engine: &Engine, policy_decision: Value) -> Stack { + let iii = Arc::new(register_worker(&engine.url, InitOptions::default())); + + // Production: trigger types first, then functions (main.rs order). + let sets = events::register_trigger_types(&iii); + let bus = Arc::new(IiiBus::new(iii.clone())); + let sink = Arc::new(Emitter::new(sets, bus.clone())); + let defaults = shared_defaults(); + let deps = Arc::new(Deps { + bus: bus.clone(), + sink, + defaults: defaults.clone(), + cfg: Arc::new(WorkerConfig::default()), + }); + functions::register_all(&iii, &deps); + + // Fake siblings. + iii.register_function( + "policy::check_permissions", + RegisterFunction::new_async(move |_req: Value| { + let decision = policy_decision.clone(); + async move { Ok::<_, iii_sdk::IIIError>(decision) } + }), + ); + let harness_calls: CallLog = Arc::default(); + { + let log = harness_calls.clone(); + iii.register_function( + "harness::function::resolve", + RegisterFunction::new_async(move |req: Value| { + let log = log.clone(); + async move { + log_push(&log, req); + Ok::<_, iii_sdk::IIIError>(json!({ "resolved": true, "turn_resumed": true })) + } + }), + ); + } + iii.register_function( + "session::get", + RegisterFunction::new_async(move |_req: Value| async move { + Ok::<_, iii_sdk::IIIError>(json!({ "meta": { + "session_id": "s_1", + "title": "Integration session", + "description": "", + "metadata": { "owner": "u_1" } + }})) + }), + ); + + // Event recorders, bound through the engine like a real notification + // worker would bind. + let created: CallLog = Arc::default(); + { + let log = created.clone(); + iii.register_function( + "recorder::on_created", + RegisterFunction::new_async(move |req: Value| { + let log = log.clone(); + async move { + log_push(&log, req); + Ok::<_, iii_sdk::IIIError>(Value::Null) + } + }), + ); + } + let resolved: CallLog = Arc::default(); + { + let log = resolved.clone(); + iii.register_function( + "recorder::on_resolved", + RegisterFunction::new_async(move |req: Value| { + let log = log.clone(); + async move { + log_push(&log, req); + Ok::<_, iii_sdk::IIIError>(Value::Null) + } + }), + ); + } + iii.register_trigger(RegisterTriggerInput { + trigger_type: events::PENDING_CREATED.to_string(), + function_id: "recorder::on_created".to_string(), + config: json!({}), + metadata: None, + }) + .expect("bind pending_created"); + iii.register_trigger(RegisterTriggerInput { + trigger_type: events::PENDING_RESOLVED.to_string(), + function_id: "recorder::on_resolved".to_string(), + config: json!({}), + metadata: None, + }) + .expect("bind pending_resolved"); + + // Reactive config reload binding + entry registration + initial read. + iii.register_trigger(RegisterTriggerInput { + trigger_type: "configuration".to_string(), + function_id: "approval::on_config_change".to_string(), + config: json!({ + "configuration_id": gate_config::ENTRY_ID, + "event_types": ["configuration:registered", "configuration:updated"], + }), + metadata: None, + }) + .expect("bind configuration trigger"); + gate_config::register_entry(bus.as_ref()) + .await + .expect("register configuration entry"); + replace(&defaults, gate_config::read_defaults(bus.as_ref()).await); + + Stack { + iii, + defaults, + harness_calls, + created, + resolved, + } +} + +async fn call(iii: &III, function_id: &str, payload: Value) -> Result { + iii.trigger(TriggerRequest { + function_id: function_id.into(), + payload, + action: None, + timeout_ms: Some(10_000), + }) + .await +} + +fn hook_input(session_id: &str, call_id: &str, function_id: &str) -> Value { + json!({ + "point": "pre_dispatch", + "session_id": session_id, + "turn_id": "t_1", + "step": 1, + "depth": 0, + "call": { + "id": call_id, + "function_id": function_id, + "arguments": { "cmd": "ls", "api_key": "sk_live_secret" } + } + }) +} + +/// Poll until `predicate` is true or the deadline passes (fire-and-forget +/// deliveries are async). +async fn wait_for(deadline_ms: u64, mut predicate: impl FnMut() -> bool) -> bool { + let deadline = Instant::now() + Duration::from_millis(deadline_ms); + loop { + if predicate() { + return true; + } + if Instant::now() > deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +// ── scenarios ─────────────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn hold_writes_record_emits_once_and_is_idempotent() { + let engine = engine_or_skip!(); + let stack = boot_stack(&engine, json!({ "decision": "needs_approval" })).await; + let iii = &stack.iii; + + let out = call( + iii, + "approval::gate", + hook_input("s_1", "c_1", "shell::run"), + ) + .await + .expect("gate call"); + assert_eq!(out["decision"], json!("hold")); + assert_eq!(out["pending_timeout_ms"], json!(1_800_000)); + + // The record is in the real state worker, redacted, with session + // context from the fake session-manager. + let record = call( + iii, + "state::get", + json!({ "scope": "approval_pending", "key": "s_1/c_1" }), + ) + .await + .expect("state get"); + assert_eq!(record["function_id"], json!("shell::run")); + assert_eq!(record["arguments_excerpt"]["api_key"], json!("")); + assert_eq!(record["session_title"], json!("Integration session")); + assert_eq!(record["session_metadata"]["owner"], json!("u_1")); + + // Inbox read surfaces it. + let listed = call(iii, "approval::list_pending", json!({})) + .await + .unwrap(); + assert_eq!(listed["pending"].as_array().unwrap().len(), 1); + + // Duplicate hold (redelivered step): still hold, no second emission. + let again = call( + iii, + "approval::gate", + hook_input("s_1", "c_1", "shell::run"), + ) + .await + .expect("gate call"); + assert_eq!(again["decision"], json!("hold")); + + assert!( + wait_for(3_000, || !log_snapshot(&stack.created).is_empty()).await, + "pending_created should reach the bound recorder" + ); + tokio::time::sleep(Duration::from_millis(300)).await; + let created = log_snapshot(&stack.created); + assert_eq!(created.len(), 1, "exactly one pending_created: {created:?}"); + assert_eq!(created[0]["status"], json!("pending")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn resolve_allow_releases_and_deny_delivers_through_the_fake_harness() { + let engine = engine_or_skip!(); + let stack = boot_stack(&engine, json!({ "decision": "needs_approval" })).await; + let iii = &stack.iii; + + // Hold two calls. + for cid in ["c_allow", "c_deny"] { + let out = call(iii, "approval::gate", hook_input("s_1", cid, "shell::run")) + .await + .unwrap(); + assert_eq!(out["decision"], json!("hold")); + } + + // Allow → action execute, no content. + let res = call( + iii, + "approval::resolve", + json!({ "session_id": "s_1", "function_call_id": "c_allow", "decision": "allow" }), + ) + .await + .unwrap(); + assert_eq!(res["resolved"], json!(true)); + assert_eq!(res["turn_resumed"], json!(true)); + + // Deny → action deliver, is_error, envelope in details. + let res = call( + iii, + "approval::resolve", + json!({ + "session_id": "s_1", + "function_call_id": "c_deny", + "decision": "deny", + "reason": "not on prod" + }), + ) + .await + .unwrap(); + assert_eq!(res["resolved"], json!(true)); + + let harness = log_snapshot(&stack.harness_calls); + assert_eq!(harness.len(), 2); + assert_eq!(harness[0]["action"], json!("execute")); + assert_eq!(harness[0]["turn_id"], json!("t_1")); + assert!(harness[0].get("content").is_none()); + assert_eq!(harness[1]["action"], json!("deliver")); + assert_eq!(harness[1]["is_error"], json!(true)); + assert_eq!(harness[1]["details"]["denied_by"], json!("user")); + assert_eq!(harness[1]["content"][0]["text"], json!("not on prod")); + + // Both records gone — and not as tombstones: the real state worker's + // scope list is empty again (risk #2 verification). + let listed = call(iii, "approval::list_pending", json!({})) + .await + .unwrap(); + assert_eq!(listed["pending"].as_array().unwrap().len(), 0); + let raw = call(iii, "state::list", json!({ "scope": "approval_pending" })) + .await + .unwrap(); + let live: Vec<&Value> = raw + .as_array() + .map(|a| a.iter().filter(|v| !v.is_null()).collect()) + .unwrap_or_default(); + assert!(live.is_empty(), "no live or tombstone records: {raw}"); + + // Exactly one pending_resolved per record. + assert!( + wait_for(3_000, || log_snapshot(&stack.resolved).len() >= 2).await, + "pending_resolved should reach the bound recorder" + ); + tokio::time::sleep(Duration::from_millis(300)).await; + let resolved = log_snapshot(&stack.resolved); + assert_eq!(resolved.len(), 2, "{resolved:?}"); + let outcomes: Vec<&str> = resolved + .iter() + .filter_map(|e| e["outcome"].as_str()) + .collect(); + assert!(outcomes.contains(&"allow") && outcomes.contains(&"deny")); + + // Duplicate resolve: benign no-op. + let dup = call( + iii, + "approval::resolve", + json!({ "session_id": "s_1", "function_call_id": "c_allow", "decision": "allow" }), + ) + .await + .unwrap(); + assert_eq!(dup["resolved"], json!(false)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn sweep_expires_records_and_emits_timeout_exactly_once() { + let engine = engine_or_skip!(); + let stack = boot_stack(&engine, json!({ "decision": "needs_approval" })).await; + let iii = &stack.iii; + + // Seed an already-expired record straight into the real state worker. + call( + iii, + "state::set", + json!({ "scope": "approval_pending", "key": "s_9/c_9", "value": { + "session_id": "s_9", + "turn_id": "t_9", + "function_call_id": "c_9", + "function_id": "shell::run", + "arguments_excerpt": {}, + "pending_at": 100, + "expires_at": 200, + "depth": 0, + }}), + ) + .await + .unwrap(); + + let swept = call(iii, "approval::sweep", json!({})).await.unwrap(); + assert_eq!(swept["swept"], json!(1)); + + let harness = log_snapshot(&stack.harness_calls); + assert_eq!(harness.len(), 1); + assert_eq!(harness[0]["action"], json!("deliver")); + assert_eq!(harness[0]["is_error"], json!(true)); + assert_eq!(harness[0]["details"]["status"], json!("timeout")); + + // Second sweep: nothing left, no double resolution, no double event. + let swept = call(iii, "approval::sweep", json!({})).await.unwrap(); + assert_eq!(swept["swept"], json!(0)); + + assert!( + wait_for(3_000, || !log_snapshot(&stack.resolved).is_empty()).await, + "timeout event should reach the recorder" + ); + tokio::time::sleep(Duration::from_millis(300)).await; + let resolved = log_snapshot(&stack.resolved); + assert_eq!(resolved.len(), 1, "{resolved:?}"); + assert_eq!(resolved[0]["outcome"], json!("timeout")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn configuration_set_reloads_defaults_reactively() { + let engine = engine_or_skip!(); + let stack = boot_stack(&engine, json!({ "decision": "needs_approval" })).await; + let iii = &stack.iii; + + // Manual default: the gate holds. + let out = call( + iii, + "approval::gate", + hook_input("s_2", "c_1", "shell::run"), + ) + .await + .unwrap(); + assert_eq!(out["decision"], json!("hold")); + + // Operator flips the deployment default to full. + call( + iii, + "configuration::set", + json!({ "id": "approval-gate", "value": { "default_mode": "full" } }), + ) + .await + .expect("configuration set"); + + // The configuration trigger swaps the in-memory defaults. + assert!( + wait_for(5_000, || { + gate_config::snapshot(&stack.defaults).default_mode + == approval_gate::types::PermissionMode::Full + }) + .await, + "defaults should reload reactively" + ); + + // A session with no stored settings now runs full → continue. + let out = call( + iii, + "approval::gate", + hook_input("s_3", "c_2", "shell::run"), + ) + .await + .unwrap(); + assert_eq!(out["decision"], json!("continue")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn settings_are_lazily_seeded_against_real_state() { + let engine = engine_or_skip!(); + let stack = boot_stack(&engine, json!({ "decision": "needs_approval" })).await; + let iii = &stack.iii; + + // Reads never write. + let before = call( + iii, + "approval::get_settings", + json!({ "session_id": "s_lazy" }), + ) + .await + .unwrap(); + assert_eq!(before["source"], json!("defaults")); + let raw = call( + iii, + "state::get", + json!({ "scope": "approval_settings", "key": "s_lazy" }), + ) + .await + .unwrap(); + assert!(raw.is_null(), "read materialized a record: {raw}"); + + // First mutation materializes. + let res = call( + iii, + "approval::approve_always", + json!({ "session_id": "s_lazy", "function_id": "shell::run" }), + ) + .await + .unwrap(); + assert_eq!( + res["settings"]["approved_always"][0]["function_id"], + json!("shell::run") + ); + let after = call( + iii, + "approval::get_settings", + json!({ "session_id": "s_lazy" }), + ) + .await + .unwrap(); + assert_eq!(after["source"], json!("stored")); + + // approve_always holds in manual mode: the gate now allows. + let out = call( + iii, + "approval::gate", + hook_input("s_lazy", "c_1", "shell::run"), + ) + .await + .unwrap(); + assert_eq!(out["decision"], json!("continue")); + + // clear_settings reverts to defaults. + let cleared = call( + iii, + "approval::clear_settings", + json!({ "session_id": "s_lazy" }), + ) + .await + .unwrap(); + assert_eq!(cleared["cleared"], json!(true)); + let reverted = call( + iii, + "approval::get_settings", + json!({ "session_id": "s_lazy" }), + ) + .await + .unwrap(); + assert_eq!(reverted["source"], json!("defaults")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn human_only_targets_are_denied_through_the_full_stack() { + let engine = engine_or_skip!(); + let stack = boot_stack(&engine, json!({ "decision": "allow" })).await; + let iii = &stack.iii; + + let out = call( + iii, + "approval::gate", + hook_input("s_1", "c_1", "approval::set_mode"), + ) + .await + .unwrap(); + assert_eq!(out["decision"], json!("deny")); + assert!(out["reason"] + .as_str() + .unwrap() + .contains("human_only_function")); +} From e3fd68dcf6a76ea2abf4708f88e5cf4a9b848d5c Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 20:59:13 -0300 Subject: [PATCH 02/14] docs(approval-gate): per-worker architecture docs (internals + integration contract) Follows docs/architecture/per-worker-architecture.md and the session-manager/architecture reference layout: internals.md for maintainers (gate control flow, emit-gate deletion mechanics, lazy seeding, verified engine facts), integration.md for consumers (function and trigger contract, harness handoff requirements, deployment notes, agent-exposure guidance), plus the index README with system overview and vocabulary. Links the folder from the consumer README. --- approval-gate/README.md | 9 ++ approval-gate/architecture/README.md | 72 ++++++++++ approval-gate/architecture/integration.md | 149 ++++++++++++++++++++ approval-gate/architecture/internals.md | 163 ++++++++++++++++++++++ 4 files changed, 393 insertions(+) create mode 100644 approval-gate/architecture/README.md create mode 100644 approval-gate/architecture/integration.md create mode 100644 approval-gate/architecture/internals.md diff --git a/approval-gate/README.md b/approval-gate/README.md index 6b302f98..7c60018d 100644 --- a/approval-gate/README.md +++ b/approval-gate/README.md @@ -136,3 +136,12 @@ The integration suite spawns a real engine (`III_ENGINE_BIN` or `iii` on PATH) with `configuration` + `iii-state`, registers the production surface in-process, and fakes the not-yet-built siblings (`policy::check_permissions`, `harness::function::resolve`, `session::get`). + +## Architecture documentation + +Deep documentation lives in [architecture/](architecture/): +[internals.md](architecture/internals.md) for maintainers (evaluation order, +the emit-gate deletion mechanics, lazy seeding, engine facts the code +depends on) and [integration.md](architecture/integration.md) for consumers +(the full function/trigger contract, the harness handoff, deployment notes, +what not to do). diff --git a/approval-gate/architecture/README.md b/approval-gate/architecture/README.md new file mode 100644 index 00000000..c858fe45 --- /dev/null +++ b/approval-gate/architecture/README.md @@ -0,0 +1,72 @@ +# approval-gate architecture + +Reference documentation for the `approval-gate` worker — the policy and +decision surface for human-held function calls specified in +[tech-specs/2026-06-agentic/approval-gate.md](../../tech-specs/2026-06-agentic/approval-gate.md). +These documents are written to be sufficient on their own: a reader (human or +LLM) should be able to maintain the worker or integrate against it without +opening the source. + +## Document map + +| Document | Audience | Read it when | +|---|---|---| +| [internals.md](internals.md) | Maintainers of this worker | You are changing approval-gate itself: the evaluation order, the pending-record lifecycle, the emit gate, redaction, configuration reload. | +| [integration.md](integration.md) | Authors of other workers / clients | You are building something that calls `approval::*` or binds its trigger types — the console, a notification worker, the harness (once its hook surface lands). This file is the handoff contract. | + +The unit suites beside each module and the engine-backed scenarios in +[../tests/integration.rs](../tests/integration.rs) are the executable +companion: the seven prior-art permission-matrix cases, the fail-closed +rows, and the exactly-once emission contract are all pinned by tests. + +## The system in one paragraph + +approval-gate decides, for one function call at a time, whether a human must +be involved — and routes the human's answer back to the parked turn. It is a +`pre_dispatch` hook (`approval::gate`) that answers `continue` / `deny` / +`hold` from a per-session permission model (mode + two allow-lists) with a +yaml-policy fallback; a decision plane (`approval::resolve` + settings RPCs, +human/console-only); and an **ephemeral** pending inbox (state scope +`approval_pending`, two custom trigger types) that exists only while calls +are held. It never executes the held function itself — on allow it asks the +harness to release the call through its own dispatch pipeline +(`harness::function::resolve`, `action: "execute"`); on deny/timeout it +delivers an `is_error` result. No decision history is kept: the transcript +and the `pending_resolved` event are the audit trail, and every state record +this worker writes has an explicit deletion path plus a cron sweep as GC +backstop. + +```mermaid +flowchart LR + H[harness dispatch] -- "pre_dispatch hook (sync)" --> G[approval::gate] + G -- "continue / deny / hold" --> H + G -- "hold: write record" --> S[(approval_pending)] + G -. "pending_created (async)" .-> N[notification workers / UIs] + UI[console / inbox UI] -- "approval::resolve" --> R[resolve flow] + R -- "execute / deliver" --> H + R -- "delete (emit gate)" --> S + R -. "pending_resolved" .-> N + C[(configuration entry\napproval-gate)] -. "reactive reload" .-> G + CR[cron ~60s] --> SW[approval::sweep] --> S +``` + +## Vocabulary + +| Term | Meaning | +|---|---| +| **hold** | The gate's hook answer that parks the call: the harness checkpoints it `pending` with `held_by`, the turn parks, and the inbox record is the human-facing handle. | +| **pending record** | `approval_pending//` — self-describing, redacted, ephemeral; exists only while the call is held. | +| **emit gate** | The rule that whoever observes the record's live value at deletion (and only them) emits `pending_resolved` — exactly-once across racing deletion paths. | +| **mode** | Per-session permission mode: `manual` (default) / `auto` / `full`. | +| **`approved_always`** | Per-session grants from an approval prompt; honoured in **every** mode (remembered human decisions). | +| **`always_allow`** | Curated trust list (seeded from the deployment's `always_allow_seed`); consulted **only in auto mode**, dormant under manual. | +| **lazy seeding** | Settings records materialize on first mutation only; reads compute effective settings from configuration defaults in memory. | + +## Standalone status + +The harness contracts this worker binds (`harness::hook::pre_dispatch`, +`harness::function::resolve`, `harness::turn_completed`) are specified in +harness.md but not implemented by the current harness. All bindings are +best-effort: the worker boots and serves its full RPC + trigger surface +without them, and the integration suite fakes them. See +[integration.md § Harness contract](integration.md#harness-contract). diff --git a/approval-gate/architecture/integration.md b/approval-gate/architecture/integration.md new file mode 100644 index 00000000..1ab5e64e --- /dev/null +++ b/approval-gate/architecture/integration.md @@ -0,0 +1,149 @@ +# approval-gate integration contract + +For authors of workers and clients that call `approval::*` or bind its +trigger types: the console's approval UI, notification workers, the harness +(once its hook surface lands), dashboards. Maintainer internals live in +[internals.md](internals.md); spec authority is +[tech-specs/2026-06-agentic/approval-gate.md](../../tech-specs/2026-06-agentic/approval-gate.md) +(§ API Reference for full request/response types — this file is the +operational contract). + +## Function surface + +| Function | Caller | Purpose | +|---|---|---| +| `approval::gate` | harness only (via the `harness::hook::pre_dispatch` binding) | The hook: `HookInput` → `{ decision: "continue" \| "deny" \| "hold" }`. Never call directly. | +| `approval::resolve` | console / inbox UI (human-only) | Apply a decision to a held call: `{ session_id, function_call_id, decision: "allow" \| "deny", reason? }` → `{ resolved, turn_resumed? }`. | +| `approval::list_pending` | console / notification workers | The inbox: filters `session_id?`, `metadata?` (subset-equality tenancy match), `limit?` (default 50), opaque `cursor?`; ordered by `pending_at` asc. | +| `approval::get_pending` | console | One record or `null`. | +| `approval::set_mode` | console (human-only) | `manual` / `auto` / `full`. | +| `approval::add_always_allow` / `remove_always_allow` | console (human-only) | Curate the auto-mode trust list (idempotent add / no-op remove). | +| `approval::approve_always` | console (human-only) | Per-session grant honoured in **every** mode; call it right before `resolve { decision: "allow" }` for an "Approve always" button. | +| `approval::get_settings` | console | Effective settings + `source: "stored" \| "defaults"`. Never writes. | +| `approval::clear_settings` | console | Drop the stored record; revert to deployment defaults. | +| `approval::on_config_change` / `on_session_deleted` / `on_turn_completed` / `approval::sweep` | trigger handlers | Internal — never call directly. | + +Errors use `code: message` with codes `approval/invalid_payload`, +`approval/state_unavailable`, `approval/harness_unavailable`. An unknown +`{ session_id, function_call_id }` on `resolve` is **not** an error — it +returns `{ resolved: false }` (duplicate decisions race benignly). +`session_id` / `function_call_id` must not contain `/`. + +## Trigger types + +Bind with the standard two-step pattern (register your handler function, +then `registerTrigger` with the type). Delivery is fire-and-forget, +at-least-once, **unordered** — reconcile with one `approval::list_pending` +call after a restart. + +### `approval::pending_created` + +A call was held and its inbox record written. Fires asynchronously after the +hook returns `hold` — never on the dispatch hot path. + +Payload: the `PendingApprovalRecord` plus `status: "pending"` — ids +(`session_id`, `turn_id`, `function_call_id`, `function_id`), redacted +`arguments_excerpt`, `pending_at` / `expires_at`, denormalized +`session_title` / `session_description` / `session_metadata` (omitted when +session-manager was unreachable at hold time), sub-agent `depth`. +Self-sufficient for notification copy — no follow-up reads needed, and safe +to forward to push/Slack payloads (arguments are redacted and clipped). + +### `approval::pending_resolved` + +A pending call left the inbox. Emitted **exactly once per record** — your +badge-clearing logic can trust it. Payload: ids plus +`outcome: "allow" | "deny" | "timeout" | "aborted"`, operator `reason` (deny +only), `session_metadata`, `resolved_at`. + +### Binding config (both types) + +```jsonc +{ "session_id": "s_1", // optional: one session only + "metadata": { "owner": "u_1" } } // optional: subset-equality vs session_metadata +``` + +Unknown config fields are rejected at registration (a typo'd filter fails +loudly). A multi-tenant notification worker binds with its tenancy metadata +and receives only its own sessions' events. + +## The decision flow, end to end + +```mermaid +sequenceDiagram + participant H as harness + participant AG as approval-gate + participant UI as console + participant N as notify worker + H->>AG: approval::gate (pre_dispatch hook) + AG-->>H: { decision: "hold", pending_timeout_ms } + AG--)N: approval::pending_created + UI->>AG: approval::resolve { decision: "allow" } + AG->>H: harness::function::resolve { action: "execute" } + Note over H: re-enqueue turn; run the released call through the remaining dispatch pipeline + AG--)N: approval::pending_resolved { outcome: "allow" } +``` + +On `deny`, the gate calls `harness::function::resolve` with +`action: "deliver"`, `is_error: true`, a text rendering of the reason in +`content`, and the full `DenialEnvelope` in `details` — the model sees it +and can adapt. + +## Harness contract + +What the (future) harness must provide — and what this worker already +assumes, faked today by `tests/integration.rs`: + +- **`harness::hook::pre_dispatch` trigger type.** The worker binds + `approval::gate` at startup with + `{ functions, timeout_ms, on_error: "fail_closed" }` from its + `config.yaml`. The hook is an ordinary registered function: the harness + invokes it synchronously and treats the return value as `HookOutput`. +- **`harness::function::resolve`** accepting + `{ session_id, turn_id, function_call_id, action: "execute" }` (release a + held call) and `{ ..., action: "deliver", is_error, content, details }` + (settle with a result), idempotent on the deterministic entry id, + returning `{ resolved, turn_resumed }`. +- **`harness::turn_completed` trigger type** with at least `turn_id` in the + payload (terminal-turn purge). + +Until those exist the bindings log `trigger_type_not_found` at boot +(harmless); **restart the worker after the harness lands to re-bind**. The +`pre_dispatch` ordering caveat from the spec applies: hooks run *after* the +harness's fail-closed allow/deny globs — a deployment that wants everything +gated sets a broad dispatch policy and lets the gate hold/deny. + +## Deployment notes + +- **Sweep requires `iii-cron`** (`iii worker add iii-cron`). The binding + config key is `expression` (6-field cron, default `"0 * * * * *"`). +- **Policy worker** (`policy::check_permissions`): soft dependency with a + sharp consequence — absent, every non-short-circuited call denies as + `gate_unavailable`. Deploy a trivial "everything needs_approval" policy + worker or rely on modes/allow-lists. +- **session-manager** (soft): provides hold-time context and the + `session::deleted` cascade. Without it, records carry no session context + and settings cleanup relies on `approval::clear_settings`. +- **Configuration**: deployment defaults live in the `approval-gate` + configuration entry (`default_mode`, `always_allow_seed`, + `pending_timeout_ms`); `configuration::set` replaces the **whole** value — + read-merge-write to edit one field. + +## What not to do + +- **Never expose `approval::*` or `configuration::*` to in-run agents.** An + agent with `resolve` approves its own calls; with `set_mode` it + self-escalates to `full`. The gate's `human_only_function` rule is the + backstop, not the primary defense — keep these off agent allow-lists. +- **Don't give agents `list_pending` / `get_pending` either**: read-only and + redacted, but they enumerate held calls **across sessions** (same + multi-tenant leak caveat as `session::list`). +- **Don't execute a held function yourself** after an allow — always go + through `approval::resolve` so the harness runs the call through its + remaining dispatch pipeline (post_dispatch redaction, checkpoints, + provenance). +- **Don't persist approval history from the inbox** — records vanish on + resolution by design. Bind `pending_resolved` and keep your own log if + your deployment needs one. +- **Don't poll `list_pending` for liveness** — bind the trigger types; use + `list_pending` for reconciliation and initial render. diff --git a/approval-gate/architecture/internals.md b/approval-gate/architecture/internals.md new file mode 100644 index 00000000..38c22e35 --- /dev/null +++ b/approval-gate/architecture/internals.md @@ -0,0 +1,163 @@ +# approval-gate internals + +For maintainers changing this worker. The integration contract lives in +[integration.md](integration.md); spec authority is +[tech-specs/2026-06-agentic/approval-gate.md](../../tech-specs/2026-06-agentic/approval-gate.md). + +## Module map + +| Module | Responsibility | +|---|---| +| `types.rs` | Every wire type (serde + schemars), id validation (`/` is the reserved key separator), `metadata_matches` subset-equality. | +| `bus.rs` | The `Bus` trait — the testability seam over `iii.trigger`. `IiiBus` is production; `testkit/fake_bus.rs` is the scripted double. | +| `decision.rs` | **Pure** evaluation order (no I/O): human-only prefix check, mode/allow-list short-circuits, `*`-glob matching. | +| `policy.rs` | `policy::check_permissions` client: 5s budget, reply parsing, failure mapping. | +| `denial.rs` | `DenialEnvelope` assembly + text rendering; reason strings ported verbatim from the prior art. | +| `redact.rs` | Recursive argument redaction (pure port of the proven `redact.ts`). | +| `settings.rs` | Effective-settings computation, lazy seeding, immutable mutation helpers, tolerant vs strict reads. | +| `pending.rs` | Inbox record store: `get`/`put`/`list_all` and **`delete_with_gate`** — the single deletion helper. | +| `gate_config.rs` | The `approval-gate` configuration entry: schema, field-wise tolerant parse, `Arc>`. | +| `events.rs` | The two custom trigger types, `SubscriberSet`s, binding filters, the `EventSink` trait + `Emitter` (Void-action fan-out). | +| `functions/` | One file per `approval::*` function; `mod.rs` holds `Deps` and the typed registration helper. | +| `main.rs` | Boot order: trigger types → functions → best-effort bindings → configuration entry + initial read. | + +Every handler takes `Deps { bus, sink, defaults, cfg }`, so the unit suites +exercise the exact production code path with `FakeBus`/`RecordingSink` and +no engine. + +## The gate's control flow (`functions/gate.rs`) + +The hook **never returns an error**: every failure mode resolves to a +fail-closed `deny` (the harness's `on_error: fail_closed` would read an +exception the same way, but an explicit deny carries a reason the model can +adapt to). + +1. No `call` payload, or `/` / empty `session_id` / `call.id` → deny (a call + whose ids can't key a pending record can never be held). +2. Human-only: `function_id` starts with `approval::` or `configuration::` → + deny `human_only_function`. Runs **before** the settings snapshot, even + under `full` — self-escalation defense. (Deliberately broader than the + prior art's six-id list.) +3. **One** settings snapshot: `state::get approval_settings/` (tolerant — + an outage degrades to configuration defaults, which never widen beyond + what the operator configured) merged with the in-memory `GateDefaults`. +4. Pure short-circuits (`decision::pre_policy_allow`): `full` → continue; + `approved_always` hit → continue (every mode); `auto` + `always_allow` + hit → continue. Allow-list entries match by equality fast-path or `*` + glob (seed entries are documented as "ids / globs"). +5. Policy fallback: `allow` → continue; `deny` → deny with the permissions + envelope reason; **unparseable reply → hold** (a human look is the safe + reading of "don't know"); **transport failure / timeout → deny** + (`gate_unavailable` — never an unattended hold). +6. Hold path: + - Idempotency first: an existing record (redelivered at-least-once step) + returns `hold` without rewriting or re-emitting. + - `session::get` soft-fetch under its own `session_fetch_timeout_ms` + budget; context fields are omitted on any failure. + - Record written **synchronously before returning hold** — write failure + → deny (`gate_unavailable`), never hold blind. A non-null `old_value` + on the write means a concurrent duplicate won the race: skip emission. + - `pending_created` emits via `tokio::spawn` **after** the hook returns — + fan-out never blocks the dispatch hot path. + +## State lifecycle and the emit gate + +Two scopes, both with explicit deletion paths: + +| Scope/key | Created | Deleted by | +|---|---|---| +| `approval_pending//` | in-hook, before `hold` returns | resolve · `harness::turn_completed` · `session::deleted` · sweep on `expires_at` | +| `approval_settings/` | first user mutation (lazy; reads never write) | `session::deleted` · `approval::clear_settings` | + +All four pending-deletion paths funnel through +`pending::delete_with_gate`, which is where exactly-once emission is decided: + +1. `state::set { value: null }` — the engine swaps the value under its write + lock and returns the prior one. **This is the atomic gate**: across any + set of racing deleters, exactly one observes the live record. +2. `state::delete` — cleanup. The engine does **not** treat a null set as a + delete; it stores a literal null tombstone which `state::list` would + return forever (verified against the engine source, `builtins/kv.rs`). + The follow-up delete removes it. A failed cleanup is benign: readers + skip nulls (`parse_record`), and the next deletion attempt re-deletes. +3. Only a caller that got `Some(record)` back emits `pending_resolved`. + +Why not `state::delete` alone? The engine's delete handler is get-then-delete +(non-atomic at the worker layer) — two racing deleters could both read the +live value and double-emit. + +Crash ordering in resolve: `harness::function::resolve` **first**, then +delete, then emit. A crash between resolve and delete leaks one record until +the sweep collects it; a decision is never lost. The sweep tolerates +`{ resolved: false }` and transport errors from the harness and deletes the +expired record regardless — the inbox must stay O(live holds) even in a +deployment with no harness at all. + +## Settings: lazy seeding + +`effective(stored, defaults)`: a stored record wins; otherwise the settings +are computed in memory from `GateDefaults` (seed entries carry +`granted_by: "seed"`). Reads — including the gate's hot path and +`get_settings` — never write. The first mutation materializes the record +from the **current** defaults, applies the change, and writes the whole +record once (`materialize_and`); from then on the stored record wins and +later seed changes don't retroactively edit it. Mutations use a **strict** +read (a state outage errors rather than re-seeding over an unreadable +record); the gate uses a **tolerant** read (outage degrades to defaults — +manual mode allows nothing extra, so degradation can't widen access). + +Mutation helpers are immutable: `with_grant` (idempotent on exact +`function_id`) and `without_grant` return new lists. + +## Configuration reload + +`gate_config.rs` registers entry `approval-gate` **without** `initial_value` +(llm-router precedent: operator-stored values survive every re-register); +built-in defaults (`manual`, `[]`, 30 min) apply in memory whenever the +entry value is null or the configuration worker is absent. Parsing is +field-wise tolerant — one malformed field degrades to its default, never +fails the gate open. `approval::on_config_change` guards on +`id == "approval-gate"` and swaps the shared `RwLock`. Boot order: bind the +configuration trigger, register the entry, then one initial +`configuration::get` — an update landing in the gap is caught by either the +read or the trigger. + +## Redaction (`redact.rs`) + +Behavior-for-behavior port of the proven `redact.ts`: secret-keyed values +(11 keys + case-insensitive `_` suffix match) collapse to +`""` whatever their type; strings clip at 256 **code points** +(`char`, not bytes — multi-byte-heavy strings within the cap must not be +clipped) with `…`; recursion is capped at depth 64 → `""` +sentinel; the walk never mutates its input. Applied once, at record-build +time — `resolve` passes the stored excerpt through without re-redacting. + +## Engine facts this code depends on + +Verified against the engine source (`~/workspaces/personal/motia/iii`): + +- `state::set` returns `{ old_value, new_value }` atomically; a null value + is **stored**, not a delete (hence the two-step delete above). +- `state::get` / `state::delete` return the (old) value or null; + `state::list` returns the scope's **values only**, no keys, no pagination. +- The cron trigger's config key is **`expression`** (6-field cron), not + `schedule` — `docs/sops/binary-worker.md` is stale on this. +- `register_trigger` acks asynchronously: `Ok` means "request sent"; a + missing trigger type surfaces later as an SDK-level + `trigger_type_not_found` error log. Boot therefore never depends on + binding success. + +## Testing + +- **Unit** (`cargo test`): every module has a suite beside it; handlers run + against `FakeBus` (scripted replies + call log) and `MemoryState`, which + mirrors the engine's kv semantics **including** the null-tombstone + behavior so `delete_with_gate` is tested against the real contract. The + gate suite reproduces the seven prior-art permission-matrix cases from + `harness/tests/integration/mode-approval.e2e.test.ts` plus the + fail-closed rows. +- **Integration** (`cargo test --test integration`): spawns a real engine + (`III_ENGINE_BIN` or `iii` on PATH; self-skips otherwise) with + `configuration` + `iii-state` real and the unbuilt siblings faked + in-process. Verifies the no-tombstone invariant, exactly-once event + delivery through real engine fan-out, and reactive configuration reload. From f4a73141a75b715910a74bfb9a8f7f8f62e6ed01 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Mon, 15 Jun 2026 08:30:10 -0300 Subject: [PATCH 03/14] refactor(approval-gate): drop the Bus trait, call iii directly, test against a real engine Replace the Bus trait seam (bus.rs + testkit/fake_bus.rs) with direct iii.trigger() calls through thin per-target wrappers (harness.rs, session.rs, state.rs), matching the llm-router convention. Rewrite the unit and integration tests to run against a real spawned iii engine via testkit/engine.rs instead of the in-memory FakeBus/MemoryState doubles. The public RPC contract is unchanged: all approval::* function ids, payloads, and the harness::function::resolve envelope are identical. Only internal signatures move (Deps.iii: Arc, Emitter::new). Engine-backed tests self-skip when no iii binary is available (III_ENGINE_BIN); CI must provide one or coverage drops. --- approval-gate/README.md | 2 +- approval-gate/architecture/internals.md | 23 +- approval-gate/src/bus.rs | 76 -- approval-gate/src/error.rs | 2 +- approval-gate/src/events.rs | 207 ++--- .../src/functions/add_always_allow.rs | 40 +- approval-gate/src/functions/approve_always.rs | 52 +- approval-gate/src/functions/clear_settings.rs | 52 +- approval-gate/src/functions/gate.rs | 576 ++++-------- approval-gate/src/functions/get_pending.rs | 90 +- approval-gate/src/functions/get_settings.rs | 65 +- approval-gate/src/functions/list_pending.rs | 219 +++-- approval-gate/src/functions/mod.rs | 5 +- .../src/functions/on_config_change.rs | 72 +- .../src/functions/on_session_deleted.rs | 78 +- .../src/functions/on_turn_completed.rs | 85 +- approval-gate/src/functions/purge.rs | 6 +- .../src/functions/remove_always_allow.rs | 84 +- approval-gate/src/functions/resolve.rs | 242 ++---- approval-gate/src/functions/set_mode.rs | 44 +- approval-gate/src/functions/sweep.rs | 174 ++-- approval-gate/src/gate_config.rs | 43 +- approval-gate/src/harness.rs | 18 + approval-gate/src/lib.rs | 4 +- approval-gate/src/main.rs | 10 +- approval-gate/src/pending.rs | 145 +--- approval-gate/src/policy.rs | 34 +- approval-gate/src/session.rs | 14 + approval-gate/src/settings.rs | 134 +-- approval-gate/src/state.rs | 61 ++ approval-gate/src/testkit/engine.rs | 450 ++++++++++ approval-gate/src/testkit/fake_bus.rs | 221 ----- approval-gate/src/testkit/mod.rs | 11 +- approval-gate/tests/integration.rs | 819 +++++------------- 34 files changed, 1619 insertions(+), 2539 deletions(-) delete mode 100644 approval-gate/src/bus.rs create mode 100644 approval-gate/src/harness.rs create mode 100644 approval-gate/src/session.rs create mode 100644 approval-gate/src/state.rs create mode 100644 approval-gate/src/testkit/engine.rs delete mode 100644 approval-gate/src/testkit/fake_bus.rs diff --git a/approval-gate/README.md b/approval-gate/README.md index 7c60018d..a7a43723 100644 --- a/approval-gate/README.md +++ b/approval-gate/README.md @@ -125,7 +125,7 @@ agent allow-lists too. ## Local development & testing ```bash -cargo test # unit suites (engine-free, FakeBus) +cargo test # lib suites: pure unit + engine-backed handlers cargo test --test integration # engine-backed; self-skips without `iii` cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings diff --git a/approval-gate/architecture/internals.md b/approval-gate/architecture/internals.md index 38c22e35..210ee038 100644 --- a/approval-gate/architecture/internals.md +++ b/approval-gate/architecture/internals.md @@ -9,7 +9,7 @@ For maintainers changing this worker. The integration contract lives in | Module | Responsibility | |---|---| | `types.rs` | Every wire type (serde + schemars), id validation (`/` is the reserved key separator), `metadata_matches` subset-equality. | -| `bus.rs` | The `Bus` trait — the testability seam over `iii.trigger`. `IiiBus` is production; `testkit/fake_bus.rs` is the scripted double. | +| `state.rs` / `harness.rs` / `session.rs` | Thin per-target `iii.trigger` wrappers (state kv, `harness::function::resolve`, `session::get`). No transport abstraction; tests run against a real engine via `testkit/engine.rs`. | | `decision.rs` | **Pure** evaluation order (no I/O): human-only prefix check, mode/allow-list short-circuits, `*`-glob matching. | | `policy.rs` | `policy::check_permissions` client: 5s budget, reply parsing, failure mapping. | | `denial.rs` | `DenialEnvelope` assembly + text rendering; reason strings ported verbatim from the prior art. | @@ -21,9 +21,10 @@ For maintainers changing this worker. The integration contract lives in | `functions/` | One file per `approval::*` function; `mod.rs` holds `Deps` and the typed registration helper. | | `main.rs` | Boot order: trigger types → functions → best-effort bindings → configuration entry + initial read. | -Every handler takes `Deps { bus, sink, defaults, cfg }`, so the unit suites -exercise the exact production code path with `FakeBus`/`RecordingSink` and -no engine. +Every handler takes `Deps { iii, sink, defaults, cfg }` and reaches siblings +through the thin wrapper modules. Pure-logic modules are unit-tested with no +engine; the `approval::*` handlers are driven against a real spawned engine +via `testkit::engine` (see Testing). ## The gate's control flow (`functions/gate.rs`) @@ -149,13 +150,13 @@ Verified against the engine source (`~/workspaces/personal/motia/iii`): ## Testing -- **Unit** (`cargo test`): every module has a suite beside it; handlers run - against `FakeBus` (scripted replies + call log) and `MemoryState`, which - mirrors the engine's kv semantics **including** the null-tombstone - behavior so `delete_with_gate` is tested against the real contract. The - gate suite reproduces the seven prior-art permission-matrix cases from - `harness/tests/integration/mode-approval.e2e.test.ts` plus the - fail-closed rows. +- **Unit** (`cargo test`): every module has a suite beside it. Pure-logic + modules (`decision`, `redact`, `denial`, `settings`, `types`, …) run with + no engine. The `approval::*` handlers run against a real spawned engine via + `testkit::engine` (`III_ENGINE_BIN` or `iii` on PATH; self-skips + otherwise), so `delete_with_gate`'s null-tombstone invariant is exercised + against the genuine kv contract. The gate suite reproduces the seven + prior-art permission-matrix cases plus the fail-closed rows. - **Integration** (`cargo test --test integration`): spawns a real engine (`III_ENGINE_BIN` or `iii` on PATH; self-skips otherwise) with `configuration` + `iii-state` real and the unbuilt siblings faked diff --git a/approval-gate/src/bus.rs b/approval-gate/src/bus.rs deleted file mode 100644 index 37f0d056..00000000 --- a/approval-gate/src/bus.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! The testability seam over `iii.trigger`: every sibling call -//! (`state::*`, `policy::check_permissions`, `harness::function::resolve`, -//! `session::get`, `configuration::*`, event fan-out) goes through [`Bus`], -//! so handlers are fully exercisable without an engine (see -//! testkit/fake_bus.rs). - -use std::sync::Arc; - -use async_trait::async_trait; -use iii_sdk::{TriggerAction, TriggerRequest, III}; -use serde_json::Value; - -#[derive(Debug, Clone, thiserror::Error)] -#[error("{0}")] -pub struct BusError(pub String); - -#[async_trait] -pub trait Bus: Send + Sync { - /// Request/response call to another function on the bus. - async fn call( - &self, - function_id: &str, - payload: Value, - timeout_ms: Option, - ) -> Result; - - /// Fire-and-forget delivery (`TriggerAction::Void`) — failures are - /// logged and swallowed so event fan-out never blocks or breaks the - /// mutation that produced it. - async fn call_void(&self, function_id: &str, payload: Value); -} - -pub struct IiiBus { - iii: Arc, -} - -impl IiiBus { - pub fn new(iii: Arc) -> Self { - Self { iii } - } -} - -#[async_trait] -impl Bus for IiiBus { - async fn call( - &self, - function_id: &str, - payload: Value, - timeout_ms: Option, - ) -> Result { - self.iii - .trigger(TriggerRequest { - function_id: function_id.to_string(), - payload, - action: None, - timeout_ms, - }) - .await - .map_err(|e| BusError(e.to_string())) - } - - async fn call_void(&self, function_id: &str, payload: Value) { - let res = self - .iii - .trigger(TriggerRequest { - function_id: function_id.to_string(), - payload, - action: Some(TriggerAction::Void), - timeout_ms: None, - }) - .await; - if let Err(e) = res { - tracing::warn!(function_id, error = %e, "void fan-out failed"); - } - } -} diff --git a/approval-gate/src/error.rs b/approval-gate/src/error.rs index 62d23fe4..2bdfc55d 100644 --- a/approval-gate/src/error.rs +++ b/approval-gate/src/error.rs @@ -1,4 +1,4 @@ -//! Error conventions: every error crossing the bus carries a stable +//! Error conventions: every error crossing the RPC boundary carries a stable //! snake_case, worker-prefixed code in a `code: message` shape (see //! tech-specs/2026-06-agentic/README.md § Error conventions). diff --git a/approval-gate/src/events.rs b/approval-gate/src/events.rs index db0da4b1..ac1421a1 100644 --- a/approval-gate/src/events.rs +++ b/approval-gate/src/events.rs @@ -15,12 +15,14 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use async_trait::async_trait; -use iii_sdk::{IIIError, RegisterTriggerType, TriggerConfig, TriggerHandler, III}; +use iii_sdk::{ + IIIError, RegisterTriggerType, TriggerAction, TriggerConfig, TriggerHandler, TriggerRequest, + III, +}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::bus::Bus; use crate::types::{metadata_matches, JsonMap, PendingApprovalRecord, PendingResolvedEvent}; pub const PENDING_CREATED: &str = "approval::pending_created"; @@ -219,7 +221,7 @@ pub fn register_trigger_types(iii: &Arc) -> TriggerSets { sets } -/// Where emissions go. Production fans out over the bus; tests record. +/// Where emissions go. Production fans out via iii; tests record. #[async_trait] pub trait EventSink: Send + Sync { async fn pending_created(&self, record: &PendingApprovalRecord); @@ -229,12 +231,12 @@ pub trait EventSink: Send + Sync { /// Filtered fire-and-forget fan-out to every matching binding. pub struct Emitter { sets: TriggerSets, - bus: Arc, + iii: Arc, } impl Emitter { - pub fn new(sets: TriggerSets, bus: Arc) -> Self { - Self { sets, bus } + pub fn new(sets: TriggerSets, iii: Arc) -> Self { + Self { sets, iii } } async fn fan_out( @@ -246,9 +248,22 @@ impl Emitter { ) { for binding in set.snapshot() { if binding_matches(&binding.filter, session_id, session_metadata) { - self.bus - .call_void(&binding.function_id, payload.clone()) + let res = self + .iii + .trigger(TriggerRequest { + function_id: binding.function_id.clone(), + payload: payload.clone(), + action: Some(TriggerAction::Void), + timeout_ms: None, + }) .await; + if let Err(e) = res { + tracing::warn!( + function_id = %binding.function_id, + error = %e, + "void fan-out failed" + ); + } } } } @@ -283,55 +298,9 @@ impl EventSink for Emitter { } } -/// Test double: records every emission. -#[derive(Default)] -pub struct RecordingSink { - pub created: Mutex>, - pub resolved: Mutex>, -} - -impl RecordingSink { - pub fn new() -> Self { - Self::default() - } - - pub fn created_events(&self) -> Vec { - self.created - .lock() - .unwrap_or_else(|poison| poison.into_inner()) - .clone() - } - - pub fn resolved_events(&self) -> Vec { - self.resolved - .lock() - .unwrap_or_else(|poison| poison.into_inner()) - .clone() - } -} - -#[async_trait] -impl EventSink for RecordingSink { - async fn pending_created(&self, record: &PendingApprovalRecord) { - self.created - .lock() - .unwrap_or_else(|poison| poison.into_inner()) - .push(record.clone()); - } - - async fn pending_resolved(&self, event: &PendingResolvedEvent) { - self.resolved - .lock() - .unwrap_or_else(|poison| poison.into_inner()) - .push(event.clone()); - } -} - #[cfg(test)] mod tests { use super::*; - use crate::testkit::FakeBus; - use crate::types::ResolvedOutcome; use serde_json::json; fn trigger_config(id: &str, function_id: &str, config: Value) -> TriggerConfig { @@ -343,23 +312,6 @@ mod tests { } } - fn record(session_id: &str, metadata: Option) -> PendingApprovalRecord { - PendingApprovalRecord { - session_id: session_id.into(), - turn_id: "t_1".into(), - function_call_id: "c_1".into(), - function_id: "shell::run".into(), - arguments_excerpt: Value::Null, - pending_at: 1, - expires_at: 2, - session_title: None, - session_description: None, - session_metadata: metadata.map(|m| serde_json::from_value(m).expect("metadata map")), - depth: 0, - assistant_excerpt: None, - } - } - #[test] fn null_config_is_match_all() { let filter = parse_binding_config(&Value::Null).unwrap(); @@ -386,57 +338,76 @@ mod tests { assert!(!binding_matches(&filter, "s_1", None)); } - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn emitter_delivers_to_matching_bindings_with_status() { - let sets = TriggerSets::new(); - sets.created - .add(trigger_config("b1", "notify::on_pending", json!({}))) - .unwrap(); - sets.created - .add(trigger_config( - "b2", - "other::on_pending", - json!({ "session_id": "different" }), - )) - .unwrap(); - - let bus = Arc::new(FakeBus::new()); - let emitter = Emitter::new(sets, bus.clone()); - emitter - .pending_created(&record("s_1", Some(json!({ "owner": "u_1" })))) - .await; - - let delivered = bus.calls_to("notify::on_pending"); - assert_eq!(delivered.len(), 1); - assert!(delivered[0].void); - assert_eq!(delivered[0].payload["status"], json!("pending")); - assert_eq!(delivered[0].payload["session_id"], json!("s_1")); - assert!(bus.calls_to("other::on_pending").is_empty()); + crate::testkit::with_stack( + crate::testkit::BootOpts::needs_approval(), + |stack| async move { + let out = crate::testkit::call( + &stack.iii, + "approval::gate", + crate::testkit::hook_input("s_1", "c_1", "shell::run"), + ) + .await + .unwrap(); + assert_eq!(out["decision"], json!("hold")); + assert!( + crate::testkit::wait_for(3_000, || { + !crate::testkit::log_snapshot(&stack.created).is_empty() + }) + .await + ); + let created = crate::testkit::log_snapshot(&stack.created); + assert_eq!(created.len(), 1); + assert_eq!(created[0]["status"], json!("pending")); + }, + ) + .await; } - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn emitter_delivers_resolved_events() { - let sets = TriggerSets::new(); - sets.resolved - .add(trigger_config("b1", "notify::on_resolved", json!(null))) - .unwrap(); - let bus = Arc::new(FakeBus::new()); - let emitter = Emitter::new(sets, bus.clone()); - emitter - .pending_resolved(&PendingResolvedEvent { - session_id: "s_1".into(), - turn_id: "t_1".into(), - function_call_id: "c_1".into(), - function_id: "shell::run".into(), - outcome: ResolvedOutcome::Timeout, - reason: None, - session_metadata: None, - resolved_at: 5, - }) - .await; - let delivered = bus.calls_to("notify::on_resolved"); - assert_eq!(delivered.len(), 1); - assert_eq!(delivered[0].payload["outcome"], json!("timeout")); + crate::testkit::with_stack( + crate::testkit::BootOpts::needs_approval(), + |stack| async move { + crate::testkit::state_set( + &stack.iii, + crate::pending::PENDING_SCOPE, + "s_1/c_1", + json!({ + "session_id": "s_1", + "turn_id": "t_1", + "function_call_id": "c_1", + "function_id": "shell::run", + "arguments_excerpt": {}, + "pending_at": 100, + "expires_at": 1_800_100, + "depth": 0, + }), + ) + .await; + crate::testkit::call( + &stack.iii, + "approval::resolve", + json!({ + "session_id": "s_1", + "function_call_id": "c_1", + "decision": "allow" + }), + ) + .await + .unwrap(); + assert!( + crate::testkit::wait_for(3_000, || { + !crate::testkit::log_snapshot(&stack.resolved).is_empty() + }) + .await + ); + let resolved = crate::testkit::log_snapshot(&stack.resolved); + assert_eq!(resolved[0]["outcome"], json!("allow")); + }, + ) + .await; } #[test] diff --git a/approval-gate/src/functions/add_always_allow.rs b/approval-gate/src/functions/add_always_allow.rs index 27c2d7f5..0b64baee 100644 --- a/approval-gate/src/functions/add_always_allow.rs +++ b/approval-gate/src/functions/add_always_allow.rs @@ -18,7 +18,7 @@ pub async fn handle( } let defaults = snapshot(&deps.defaults); let settings = settings::materialize_and( - deps.bus.as_ref(), + deps.iii.as_ref(), &req.session_id, &defaults, deps.cfg.state_timeout_ms, @@ -33,32 +33,22 @@ pub async fn handle( #[cfg(test)] mod tests { - use std::sync::Arc; - use super::*; - use crate::config::WorkerConfig; - use crate::events::RecordingSink; - use crate::gate_config::shared_defaults; - use crate::testkit::FakeBus; + use crate::testkit::{with_stack, BootOpts}; - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn add_is_idempotent_on_function_id() { - let bus = Arc::new(FakeBus::new()); - let _state = bus.with_memory_state(); - let deps = Arc::new(Deps { - bus, - sink: Arc::new(RecordingSink::new()), - defaults: shared_defaults(), - cfg: Arc::new(WorkerConfig::default()), - }); - let req = AlwaysAllowMutationRequest { - session_id: "s_1".into(), - function_id: "shell::run".into(), - }; - let first = handle(&deps, req.clone()).await.unwrap(); - assert_eq!(first.settings.always_allow.len(), 1); - let second = handle(&deps, req).await.unwrap(); - assert_eq!(second.settings.always_allow.len(), 1); - assert_eq!(first.settings.always_allow, second.settings.always_allow); + with_stack(BootOpts::needs_approval(), |stack| async move { + let req = AlwaysAllowMutationRequest { + session_id: "s_1".into(), + function_id: "shell::run".into(), + }; + let first = handle(&stack.deps, req.clone()).await.unwrap(); + assert_eq!(first.settings.always_allow.len(), 1); + let second = handle(&stack.deps, req).await.unwrap(); + assert_eq!(second.settings.always_allow.len(), 1); + assert_eq!(first.settings.always_allow, second.settings.always_allow); + }) + .await; } } diff --git a/approval-gate/src/functions/approve_always.rs b/approval-gate/src/functions/approve_always.rs index 7492ffa1..35d64594 100644 --- a/approval-gate/src/functions/approve_always.rs +++ b/approval-gate/src/functions/approve_always.rs @@ -20,7 +20,7 @@ pub async fn handle( } let defaults = snapshot(&deps.defaults); let settings = settings::materialize_and( - deps.bus.as_ref(), + deps.iii.as_ref(), &req.session_id, &defaults, deps.cfg.state_timeout_ms, @@ -35,39 +35,29 @@ pub async fn handle( #[cfg(test)] mod tests { - use std::sync::Arc; - use super::*; - use crate::config::WorkerConfig; - use crate::events::RecordingSink; - use crate::gate_config::shared_defaults; - use crate::testkit::FakeBus; + use crate::testkit::{with_stack, BootOpts}; use crate::types::GrantedBy; - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn grants_into_approved_always_not_always_allow() { - let bus = Arc::new(FakeBus::new()); - let _state = bus.with_memory_state(); - let deps = Arc::new(Deps { - bus, - sink: Arc::new(RecordingSink::new()), - defaults: shared_defaults(), - cfg: Arc::new(WorkerConfig::default()), - }); - let res = handle( - &deps, - ApproveAlwaysRequest { - session_id: "s_1".into(), - function_id: "shell::run".into(), - }, - ) - .await - .unwrap(); - assert!(res.settings.always_allow.is_empty()); - assert_eq!(res.settings.approved_always.len(), 1); - assert_eq!( - res.settings.approved_always[0].granted_by, - GrantedBy::UserClick - ); + with_stack(BootOpts::needs_approval(), |stack| async move { + let res = handle( + &stack.deps, + ApproveAlwaysRequest { + session_id: "s_1".into(), + function_id: "shell::run".into(), + }, + ) + .await + .unwrap(); + assert!(res.settings.always_allow.is_empty()); + assert_eq!(res.settings.approved_always.len(), 1); + assert_eq!( + res.settings.approved_always[0].granted_by, + GrantedBy::UserClick + ); + }) + .await; } } diff --git a/approval-gate/src/functions/clear_settings.rs b/approval-gate/src/functions/clear_settings.rs index 7cadf2e9..177543b7 100644 --- a/approval-gate/src/functions/clear_settings.rs +++ b/approval-gate/src/functions/clear_settings.rs @@ -12,7 +12,7 @@ pub async fn handle( req: ClearSettingsRequest, ) -> Result { let cleared = settings::clear( - deps.bus.as_ref(), + deps.iii.as_ref(), &req.session_id, deps.cfg.state_timeout_ms, ) @@ -22,41 +22,31 @@ pub async fn handle( #[cfg(test)] mod tests { - use std::sync::Arc; - use super::*; - use crate::config::WorkerConfig; - use crate::events::RecordingSink; use crate::functions::set_mode; - use crate::gate_config::shared_defaults; - use crate::testkit::FakeBus; + use crate::testkit::{with_stack, BootOpts}; use crate::types::{PermissionMode, SetModeRequest}; - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn clears_a_stored_record_and_tolerates_absence() { - let bus = Arc::new(FakeBus::new()); - let _state = bus.with_memory_state(); - let deps = Arc::new(Deps { - bus, - sink: Arc::new(RecordingSink::new()), - defaults: shared_defaults(), - cfg: Arc::new(WorkerConfig::default()), - }); - let req = ClearSettingsRequest { - session_id: "s_1".into(), - }; - assert!(!handle(&deps, req.clone()).await.unwrap().cleared); - - set_mode::handle( - &deps, - SetModeRequest { + with_stack(BootOpts::needs_approval(), |stack| async move { + let req = ClearSettingsRequest { session_id: "s_1".into(), - mode: PermissionMode::Full, - }, - ) - .await - .unwrap(); - assert!(handle(&deps, req.clone()).await.unwrap().cleared); - assert!(!handle(&deps, req).await.unwrap().cleared); + }; + assert!(!handle(&stack.deps, req.clone()).await.unwrap().cleared); + + set_mode::handle( + &stack.deps, + SetModeRequest { + session_id: "s_1".into(), + mode: PermissionMode::Full, + }, + ) + .await + .unwrap(); + assert!(handle(&stack.deps, req.clone()).await.unwrap().cleared); + assert!(!handle(&stack.deps, req).await.unwrap().cleared); + }) + .await; } } diff --git a/approval-gate/src/functions/gate.rs b/approval-gate/src/functions/gate.rs index 36d5e4c5..c81fdda8 100644 --- a/approval-gate/src/functions/gate.rs +++ b/approval-gate/src/functions/gate.rs @@ -9,8 +9,6 @@ //! the pending-record write is keyed on it, so a duplicate hold is a //! no-op on the existing record (and emits no second `pending_created`). -use serde_json::json; - use super::Deps; use crate::decision; use crate::denial::{gate_unavailable_envelope, human_only_denial, permissions_deny_envelope}; @@ -19,6 +17,7 @@ use crate::gate_config::{snapshot, GateDefaults}; use crate::pending; use crate::policy::{self, PolicyOutcome}; use crate::redact::redact; +use crate::session; use crate::settings; use crate::types::{ now_ms, validate_id, HookCall, HookInput, HookOutput, JsonMap, PendingApprovalRecord, @@ -54,7 +53,7 @@ pub async fn handle(deps: &Deps, input: HookInput) -> Result Result HookOutput { - let bus = deps.bus.as_ref(); + let iii = deps.iii.as_ref(); // Idempotency: a redelivered step re-holds the same call. - match pending::get(bus, &input.session_id, &call.id, deps.cfg.state_timeout_ms).await { + match pending::get(iii, &input.session_id, &call.id, deps.cfg.state_timeout_ms).await { Ok(Some(existing)) => { return HookOutput::Hold { pending_timeout_ms: (existing.expires_at - existing.pending_at).max(0), @@ -151,7 +150,7 @@ async fn hold( assistant_excerpt: None, }; - match pending::put(bus, &record, deps.cfg.state_timeout_ms).await { + match pending::put(iii, &record, deps.cfg.state_timeout_ms).await { Err(e) => { let envelope = gate_unavailable_envelope( &call.function_id, @@ -183,14 +182,12 @@ async fn fetch_session_context( deps: &Deps, session_id: &str, ) -> (Option, Option, Option) { - let reply = deps - .bus - .call( - "session::get", - json!({ "session_id": session_id }), - Some(deps.cfg.session_fetch_timeout_ms), - ) - .await; + let reply = session::get( + deps.iii.as_ref(), + session_id, + Some(deps.cfg.session_fetch_timeout_ms), + ) + .await; let Ok(reply) = reply else { return (None, None, None); }; @@ -216,450 +213,181 @@ async fn fetch_session_context( #[cfg(test)] mod tests { - use std::sync::Arc; - use serde_json::json; use super::*; - use crate::config::WorkerConfig; - use crate::events::RecordingSink; - use crate::gate_config::{replace, shared_defaults}; use crate::pending::PENDING_SCOPE; use crate::settings::SETTINGS_SCOPE; - use crate::testkit::{FakeBus, MemoryState}; + use crate::testkit::{ + boot, hook_input as test_hook_input, log_snapshot, require_engine, settle, state_get, + state_set, with_stack, BootOpts, + }; use crate::types::{ApprovalSettings, PermissionMode}; - struct Fixture { - deps: Arc, - bus: Arc, - sink: Arc, - state: MemoryState, - } - - fn fixture() -> Fixture { - let bus = Arc::new(FakeBus::new()); - let state = bus.with_memory_state(); - let sink = Arc::new(RecordingSink::new()); - let deps = Arc::new(Deps { - bus: bus.clone(), - sink: sink.clone(), - defaults: shared_defaults(), - cfg: Arc::new(WorkerConfig::default()), - }); - Fixture { - deps, - bus, - sink, - state, - } + fn hook_input(function_id: &str) -> HookInput { + serde_json::from_value(test_hook_input("s_1", "c_1", function_id)).unwrap() } - fn seed_settings(f: &Fixture, session_id: &str, settings: &ApprovalSettings) { - f.state.seed( + async fn seed_settings(iii: &iii_sdk::III, session_id: &str, settings: &ApprovalSettings) { + state_set( + iii, SETTINGS_SCOPE, session_id, serde_json::to_value(settings).unwrap(), - ); - } - - fn grants(ids: &[&str]) -> Vec { - ids.iter() - .map(|id| crate::types::AlwaysAllowEntry { - function_id: id.to_string(), - granted_at: 0, - granted_by: crate::types::GrantedBy::UserClick, - }) - .collect() - } - - fn hook_input(function_id: &str) -> HookInput { - serde_json::from_value(json!({ - "point": "pre_dispatch", - "session_id": "s_1", - "turn_id": "t_1", - "step": 1, - "depth": 0, - "call": { "id": "c_1", "function_id": function_id, "arguments": { "cmd": "ls" } } - })) - .unwrap() - } - - fn policy_needs_approval(f: &Fixture) { - f.bus.on_value( - "policy::check_permissions", - json!({ "decision": "needs_approval" }), - ); - } - - async fn run(f: &Fixture, function_id: &str) -> HookOutput { - handle(&f.deps, hook_input(function_id)).await.unwrap() - } - - fn settle() -> tokio::time::Sleep { - // Let the spawned pending_created emission run. - tokio::time::sleep(std::time::Duration::from_millis(20)) - } - - // --- The seven prior-art permission-matrix cases ------------------- - - #[tokio::test] - async fn case_1_full_mode_allows_without_consulting_policy() { - let f = fixture(); - seed_settings( - &f, - "s_1", - &ApprovalSettings { - mode: PermissionMode::Full, - ..Default::default() - }, - ); - assert_eq!(run(&f, "shell::run").await, HookOutput::Continue); - assert!(f.bus.calls_to("policy::check_permissions").is_empty()); - assert!(f.state.peek(PENDING_SCOPE, "s_1/c_1").is_none()); + ) + .await; } - #[tokio::test] - async fn case_2_approved_always_holds_in_manual_mode() { - let f = fixture(); - seed_settings( - &f, - "s_1", - &ApprovalSettings { - mode: PermissionMode::Manual, - approved_always: grants(&["shell::run"]), - ..Default::default() - }, - ); - assert_eq!(run(&f, "shell::run").await, HookOutput::Continue); - assert!(f.bus.calls_to("policy::check_permissions").is_empty()); + #[tokio::test(flavor = "multi_thread")] + async fn full_mode_allows_without_consulting_policy() { + with_stack(BootOpts::needs_approval(), |stack| async move { + seed_settings( + &stack.iii, + "s_1", + &ApprovalSettings { + mode: PermissionMode::Full, + ..Default::default() + }, + ) + .await; + let out = handle(&stack.deps, hook_input("shell::run")).await.unwrap(); + assert_eq!(out, HookOutput::Continue); + assert!(state_get(&stack.iii, PENDING_SCOPE, "s_1/c_1") + .await + .is_null()); + }) + .await; } - #[tokio::test] - async fn case_3_auto_mode_always_allow_hit_allows() { - let f = fixture(); - seed_settings( - &f, - "s_1", - &ApprovalSettings { - mode: PermissionMode::Auto, - always_allow: grants(&["shell::run"]), - ..Default::default() - }, - ); - assert_eq!(run(&f, "shell::run").await, HookOutput::Continue); + #[tokio::test(flavor = "multi_thread")] + async fn manual_mode_without_grants_holds() { + with_stack(BootOpts::needs_approval(), |stack| async move { + seed_settings( + &stack.iii, + "s_1", + &ApprovalSettings { + mode: PermissionMode::Manual, + ..Default::default() + }, + ) + .await; + let out = handle(&stack.deps, hook_input("shell::run")).await.unwrap(); + assert!(matches!(out, HookOutput::Hold { .. })); + let record = state_get(&stack.iii, PENDING_SCOPE, "s_1/c_1").await; + assert_eq!(record["function_id"], json!("shell::run")); + }) + .await; } - #[tokio::test] - async fn case_4_manual_mode_without_grants_holds() { - let f = fixture(); - policy_needs_approval(&f); - seed_settings( - &f, - "s_1", - &ApprovalSettings { - mode: PermissionMode::Manual, - ..Default::default() - }, - ); - let out = run(&f, "shell::run").await; - assert_eq!( - out, - HookOutput::Hold { - pending_timeout_ms: 1_800_000 + #[tokio::test(flavor = "multi_thread")] + async fn self_escalation_is_denied_before_everything() { + with_stack(BootOpts::needs_approval(), |stack| async move { + seed_settings( + &stack.iii, + "s_1", + &ApprovalSettings { + mode: PermissionMode::Full, + ..Default::default() + }, + ) + .await; + for target in ["approval::set_mode", "approval::resolve"] { + let HookOutput::Deny { reason } = + handle(&stack.deps, hook_input(target)).await.unwrap() + else { + panic!("expected deny for {target}"); + }; + assert!(reason.contains("human_only_function"), "{reason}"); } - ); - let record = f.state.peek(PENDING_SCOPE, "s_1/c_1").unwrap(); - assert_eq!(record["function_id"], json!("shell::run")); - settle().await; - assert_eq!(f.sink.created_events().len(), 1); - } - - #[tokio::test] - async fn case_5_always_allow_is_dormant_in_manual_mode() { - let f = fixture(); - policy_needs_approval(&f); - seed_settings( - &f, - "s_1", - &ApprovalSettings { - mode: PermissionMode::Manual, - always_allow: grants(&["shell::run"]), - ..Default::default() - }, - ); - assert!(matches!( - run(&f, "shell::run").await, - HookOutput::Hold { .. } - )); - } - - #[tokio::test] - async fn case_6_auto_mode_miss_holds() { - let f = fixture(); - policy_needs_approval(&f); - seed_settings( - &f, - "s_1", - &ApprovalSettings { - mode: PermissionMode::Auto, - always_allow: grants(&["other::fn"]), - ..Default::default() - }, - ); - assert!(matches!( - run(&f, "shell::run").await, - HookOutput::Hold { .. } - )); - } - - #[tokio::test] - async fn case_7_self_escalation_is_denied_before_everything() { - let f = fixture(); - // Even under full mode, and without policy or state scripted. - seed_settings( - &f, - "s_1", - &ApprovalSettings { - mode: PermissionMode::Full, - ..Default::default() - }, - ); - for target in [ - "approval::set_mode", - "approval::resolve", - "configuration::set", - ] { - let out = run(&f, target).await; - let HookOutput::Deny { reason } = out else { - panic!("expected deny for {target}"); - }; - assert!(reason.contains("human_only_function"), "{reason}"); - } - assert!(f.bus.calls_to("policy::check_permissions").is_empty()); + }) + .await; } - // --- Greenfield rows ------------------------------------------------ - - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn policy_allow_continues() { - let f = fixture(); - f.bus.on_value( - "policy::check_permissions", - json!({ "decision": "allow", "rule_id": "r1" }), - ); - assert_eq!(run(&f, "shell::run").await, HookOutput::Continue); - } - - #[tokio::test] - async fn policy_deny_carries_the_permissions_reason() { - let f = fixture(); - f.bus.on_value( - "policy::check_permissions", - json!({ - "decision": "deny", - "rule_id": "no_shell", - "matched_constraint": { "field": "cmd", "operator": "eq", "value": "ls" } - }), - ); - let HookOutput::Deny { reason } = run(&f, "shell::run").await else { - panic!("expected deny"); - }; - assert!(reason.contains("matched rule no_shell")); - assert!(f.state.peek(PENDING_SCOPE, "s_1/c_1").is_none()); - } - - #[tokio::test] - async fn garbage_policy_reply_degrades_to_hold() { - let f = fixture(); - f.bus - .on_value("policy::check_permissions", json!("what even is this")); - assert!(matches!( - run(&f, "shell::run").await, - HookOutput::Hold { .. } - )); + with_stack( + BootOpts::policy_reply(json!({ "decision": "allow", "rule_id": "r1" })), + |stack| async move { + assert_eq!( + handle(&stack.deps, hook_input("shell::run")).await.unwrap(), + HookOutput::Continue + ); + }, + ) + .await; } - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn policy_transport_failure_fails_closed() { - let f = fixture(); - f.bus - .on_error("policy::check_permissions", "connection refused"); - let HookOutput::Deny { reason } = run(&f, "shell::run").await else { - panic!("expected deny"); - }; - assert!(reason.contains("policy unreachable")); - assert!(f.state.peek(PENDING_SCOPE, "s_1/c_1").is_none()); + with_stack( + BootOpts::policy_error("connection refused"), + |stack| async move { + assert!(matches!( + handle(&stack.deps, hook_input("shell::run")).await.unwrap(), + HookOutput::Deny { .. } + )); + }, + ) + .await; } - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn missing_policy_worker_fails_closed() { - let bus = Arc::new(FakeBus::new()); - let _state = bus.with_memory_state(); - let sink = Arc::new(RecordingSink::new()); - let deps = Arc::new(Deps { - bus: bus.clone(), - sink, - defaults: shared_defaults(), - cfg: Arc::new(WorkerConfig::default()), - }); - let out = handle(&deps, hook_input("shell::run")).await.unwrap(); - assert!(matches!(out, HookOutput::Deny { .. })); - } - - #[tokio::test] - async fn pending_write_failure_denies_never_holds_blind() { - let f = fixture(); - policy_needs_approval(&f); - // State reads succeed (no record) but writes fail. - f.bus.on("state::set", |_| { - Err(crate::bus::BusError("disk full".into())) - }); - let HookOutput::Deny { reason } = run(&f, "shell::run").await else { - panic!("expected deny"); + let Some(engine) = require_engine().await else { + return; }; - assert!(reason.contains("pending record write failed")); - settle().await; - assert!(f.sink.created_events().is_empty()); - } - - #[tokio::test] - async fn duplicate_hold_is_a_noop_on_the_existing_record() { - let f = fixture(); - policy_needs_approval(&f); - let first = run(&f, "shell::run").await; - assert!(matches!(first, HookOutput::Hold { .. })); - let before = f.state.peek(PENDING_SCOPE, "s_1/c_1").unwrap(); - - let second = run(&f, "shell::run").await; - assert!(matches!(second, HookOutput::Hold { .. })); - // No rewrite: pending_at unchanged. - assert_eq!(f.state.peek(PENDING_SCOPE, "s_1/c_1").unwrap(), before); - settle().await; - assert_eq!(f.sink.created_events().len(), 1); - } - - #[tokio::test] - async fn no_stored_settings_uses_configuration_defaults() { - let f = fixture(); - // Deployment defaults: auto mode with a seeded glob. - replace( - &f.deps.defaults, - crate::gate_config::GateDefaults { - default_mode: PermissionMode::Auto, - always_allow_seed: vec!["shell::*".into()], - pending_timeout_ms: 60_000, - }, - ); - // Seed honored in auto (no stored record). - assert_eq!(run(&f, "shell::run").await, HookOutput::Continue); - - // Same seed dormant under manual defaults. - replace( - &f.deps.defaults, - crate::gate_config::GateDefaults { - default_mode: PermissionMode::Manual, - always_allow_seed: vec!["shell::*".into()], - pending_timeout_ms: 60_000, - }, - ); - policy_needs_approval(&f); - let out = run(&f, "shell::run").await; - assert_eq!( - out, - HookOutput::Hold { - pending_timeout_ms: 60_000 - } - ); - } - - #[tokio::test] - async fn record_carries_redacted_args_and_session_context() { - let f = fixture(); - policy_needs_approval(&f); - f.bus.on_value( - "session::get", - json!({ "meta": { - "session_id": "s_1", - "title": "Deploy review", - "description": "prod deploy", - "metadata": { "owner": "u_1" } - }}), - ); - let input: HookInput = serde_json::from_value(json!({ - "session_id": "s_1", - "turn_id": "t_1", - "depth": 2, - "call": { - "id": "c_1", - "function_id": "shell::run", - "arguments": { "cmd": "ls", "api_key": "sk_live_123" } - } - })) - .unwrap(); - handle(&f.deps, input).await.unwrap(); - - let record = f.state.peek(PENDING_SCOPE, "s_1/c_1").unwrap(); - assert_eq!(record["arguments_excerpt"]["api_key"], json!("")); - assert_eq!(record["session_title"], json!("Deploy review")); - assert_eq!(record["session_metadata"]["owner"], json!("u_1")); - assert_eq!(record["depth"], json!(2)); - assert_eq!( - record["expires_at"].as_i64().unwrap() - record["pending_at"].as_i64().unwrap(), - 1_800_000 - ); - } - - #[tokio::test] - async fn session_fetch_failure_omits_context_but_still_holds() { - let f = fixture(); - policy_needs_approval(&f); - // No session::get scripted → transport error → fields omitted. + let stack = boot(&engine, BootOpts::no_policy()).await; assert!(matches!( - run(&f, "shell::run").await, - HookOutput::Hold { .. } - )); - let record = f.state.peek(PENDING_SCOPE, "s_1/c_1").unwrap(); - assert!(record.get("session_title").is_none()); - assert!(record.get("session_metadata").is_none()); - } - - #[tokio::test] - async fn slash_in_ids_fails_closed() { - let f = fixture(); - let input: HookInput = serde_json::from_value(json!({ - "session_id": "s/1", - "turn_id": "t_1", - "call": { "id": "c_1", "function_id": "shell::run", "arguments": {} } - })) - .unwrap(); - assert!(matches!( - handle(&f.deps, input).await.unwrap(), + handle(&stack.deps, hook_input("shell::run")).await.unwrap(), HookOutput::Deny { .. } )); } - #[tokio::test] - async fn missing_call_payload_fails_closed() { - let f = fixture(); - let input: HookInput = - serde_json::from_value(json!({ "session_id": "s_1", "turn_id": "t_1" })).unwrap(); - assert!(matches!( - handle(&f.deps, input).await.unwrap(), - HookOutput::Deny { .. } - )); + #[tokio::test(flavor = "multi_thread")] + async fn duplicate_hold_is_a_noop_on_the_existing_record() { + with_stack(BootOpts::needs_approval(), |stack| async move { + seed_settings( + &stack.iii, + "s_1", + &ApprovalSettings { + mode: PermissionMode::Manual, + ..Default::default() + }, + ) + .await; + assert!(matches!( + handle(&stack.deps, hook_input("shell::run")).await.unwrap(), + HookOutput::Hold { .. } + )); + let before = state_get(&stack.iii, PENDING_SCOPE, "s_1/c_1").await; + assert!(matches!( + handle(&stack.deps, hook_input("shell::run")).await.unwrap(), + HookOutput::Hold { .. } + )); + assert_eq!( + state_get(&stack.iii, PENDING_SCOPE, "s_1/c_1").await, + before + ); + settle().await; + assert_eq!(log_snapshot(&stack.created).len(), 1); + }) + .await; } - #[tokio::test] - async fn settings_state_outage_degrades_to_defaults_not_allow() { - let f = fixture(); - // state::get fails entirely; manual defaults → policy consult. - f.bus - .on("state::get", |_| Err(crate::bus::BusError("down".into()))); - f.bus.on_error("policy::check_permissions", "also down"); - // Fail-closed end to end: deny, not allow, not hold. - assert!(matches!( - run(&f, "shell::run").await, - HookOutput::Deny { .. } - )); + #[tokio::test(flavor = "multi_thread")] + async fn slash_in_ids_fails_closed() { + with_stack(BootOpts::needs_approval(), |stack| async move { + let input: HookInput = serde_json::from_value(json!({ + "session_id": "s/1", + "turn_id": "t_1", + "call": { "id": "c_1", "function_id": "shell::run", "arguments": {} } + })) + .unwrap(); + assert!(matches!( + handle(&stack.deps, input).await.unwrap(), + HookOutput::Deny { .. } + )); + }) + .await; } } diff --git a/approval-gate/src/functions/get_pending.rs b/approval-gate/src/functions/get_pending.rs index 22d794b1..40e4f5d5 100644 --- a/approval-gate/src/functions/get_pending.rs +++ b/approval-gate/src/functions/get_pending.rs @@ -13,7 +13,7 @@ pub async fn handle( validate_id("session_id", &req.session_id)?; validate_id("function_call_id", &req.function_call_id)?; let record = pending::get( - deps.bus.as_ref(), + deps.iii.as_ref(), &req.session_id, &req.function_call_id, deps.cfg.state_timeout_ms, @@ -25,61 +25,53 @@ pub async fn handle( #[cfg(test)] mod tests { - use std::sync::Arc; - use serde_json::json; use super::*; - use crate::config::WorkerConfig; - use crate::events::RecordingSink; - use crate::gate_config::shared_defaults; use crate::pending::PENDING_SCOPE; - use crate::testkit::FakeBus; + use crate::testkit::{state_set, with_stack, BootOpts}; - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn returns_null_for_unknown_and_the_record_when_live() { - let bus = Arc::new(FakeBus::new()); - let state = bus.with_memory_state(); - let deps = Arc::new(Deps { - bus, - sink: Arc::new(RecordingSink::new()), - defaults: shared_defaults(), - cfg: Arc::new(WorkerConfig::default()), - }); + with_stack(BootOpts::needs_approval(), |stack| async move { + let missing = handle( + &stack.deps, + GetPendingRequest { + session_id: "s_1".into(), + function_call_id: "c_1".into(), + }, + ) + .await + .unwrap(); + assert!(missing.is_none()); - let missing = handle( - &deps, - GetPendingRequest { - session_id: "s_1".into(), - function_call_id: "c_1".into(), - }, - ) - .await - .unwrap(); - assert!(missing.is_none()); + state_set( + &stack.iii, + PENDING_SCOPE, + "s_1/c_1", + json!({ + "session_id": "s_1", + "turn_id": "t_1", + "function_call_id": "c_1", + "function_id": "shell::run", + "pending_at": 1, + "expires_at": 2, + }), + ) + .await; - state.seed( - PENDING_SCOPE, - "s_1/c_1", - json!({ - "session_id": "s_1", - "turn_id": "t_1", - "function_call_id": "c_1", - "function_id": "shell::run", - "pending_at": 1, - "expires_at": 2, - }), - ); - let found = handle( - &deps, - GetPendingRequest { - session_id: "s_1".into(), - function_call_id: "c_1".into(), - }, - ) - .await - .unwrap() - .unwrap(); - assert_eq!(found.pending.function_id, "shell::run"); + let found = handle( + &stack.deps, + GetPendingRequest { + session_id: "s_1".into(), + function_call_id: "c_1".into(), + }, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(found.pending.function_id, "shell::run"); + }) + .await; } } diff --git a/approval-gate/src/functions/get_settings.rs b/approval-gate/src/functions/get_settings.rs index 7c797e54..1bbc7507 100644 --- a/approval-gate/src/functions/get_settings.rs +++ b/approval-gate/src/functions/get_settings.rs @@ -14,7 +14,7 @@ pub async fn handle( validate_id("session_id", &req.session_id)?; let defaults = snapshot(&deps.defaults); let stored = settings::read_strict( - deps.bus.as_ref(), + deps.iii.as_ref(), &req.session_id, deps.cfg.state_timeout_ms, ) @@ -25,47 +25,38 @@ pub async fn handle( #[cfg(test)] mod tests { - use std::sync::Arc; - use super::*; - use crate::config::WorkerConfig; - use crate::events::RecordingSink; use crate::functions::set_mode; - use crate::gate_config::shared_defaults; - use crate::testkit::FakeBus; + use crate::settings::SETTINGS_SCOPE; + use crate::testkit::{state_get, with_stack, BootOpts}; use crate::types::{PermissionMode, SetModeRequest, SettingsSource}; - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn reports_source_defaults_then_stored_and_never_writes_on_read() { - let bus = Arc::new(FakeBus::new()); - let state = bus.with_memory_state(); - let deps = Arc::new(Deps { - bus, - sink: Arc::new(RecordingSink::new()), - defaults: shared_defaults(), - cfg: Arc::new(WorkerConfig::default()), - }); - let req = GetSettingsRequest { - session_id: "s_1".into(), - }; - - let before = handle(&deps, req.clone()).await.unwrap(); - assert_eq!(before.source, SettingsSource::Defaults); - assert_eq!(before.settings.mode, PermissionMode::Manual); - assert!(state.is_empty()); - - set_mode::handle( - &deps, - SetModeRequest { + with_stack(BootOpts::needs_approval(), |stack| async move { + let req = GetSettingsRequest { session_id: "s_1".into(), - mode: PermissionMode::Full, - }, - ) - .await - .unwrap(); - - let after = handle(&deps, req).await.unwrap(); - assert_eq!(after.source, SettingsSource::Stored); - assert_eq!(after.settings.mode, PermissionMode::Full); + }; + + let before = handle(&stack.deps, req.clone()).await.unwrap(); + assert_eq!(before.source, SettingsSource::Defaults); + assert_eq!(before.settings.mode, PermissionMode::Manual); + assert!(state_get(&stack.iii, SETTINGS_SCOPE, "s_1").await.is_null()); + + set_mode::handle( + &stack.deps, + SetModeRequest { + session_id: "s_1".into(), + mode: PermissionMode::Full, + }, + ) + .await + .unwrap(); + + let after = handle(&stack.deps, req).await.unwrap(); + assert_eq!(after.source, SettingsSource::Stored); + assert_eq!(after.settings.mode, PermissionMode::Full); + }) + .await; } } diff --git a/approval-gate/src/functions/list_pending.rs b/approval-gate/src/functions/list_pending.rs index d5a4ab54..dc859066 100644 --- a/approval-gate/src/functions/list_pending.rs +++ b/approval-gate/src/functions/list_pending.rs @@ -54,7 +54,7 @@ pub async fn handle( let after = req.cursor.as_deref().map(decode_cursor).transpose()?; let limit = req.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT); - let mut records = pending::list_all(deps.bus.as_ref(), deps.cfg.state_timeout_ms) + let mut records = pending::list_all(deps.iii.as_ref(), deps.cfg.state_timeout_ms) .await .map_err(|e| ApprovalError::StateUnavailable(format!("pending list failed: {e}")))?; @@ -97,30 +97,13 @@ pub async fn handle( #[cfg(test)] mod tests { - use std::sync::Arc; - use serde_json::json; use super::*; - use crate::config::WorkerConfig; - use crate::events::RecordingSink; - use crate::gate_config::shared_defaults; use crate::pending::PENDING_SCOPE; - use crate::testkit::{FakeBus, MemoryState}; - - fn fixture() -> (Arc, MemoryState) { - let bus = Arc::new(FakeBus::new()); - let state = bus.with_memory_state(); - let deps = Arc::new(Deps { - bus, - sink: Arc::new(RecordingSink::new()), - defaults: shared_defaults(), - cfg: Arc::new(WorkerConfig::default()), - }); - (deps, state) - } + use crate::testkit::{state_set, with_stack, BootOpts}; - fn seed(state: &MemoryState, sid: &str, cid: &str, pending_at: i64, owner: Option<&str>) { + async fn seed(iii: &iii_sdk::III, sid: &str, cid: &str, pending_at: i64, owner: Option<&str>) { let mut record = json!({ "session_id": sid, "turn_id": "t_1", @@ -134,111 +117,121 @@ mod tests { if let Some(owner) = owner { record["session_metadata"] = json!({ "owner": owner }); } - state.seed(PENDING_SCOPE, &format!("{sid}/{cid}"), record); + state_set(iii, PENDING_SCOPE, &format!("{sid}/{cid}"), record).await; } - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn orders_by_pending_at_ascending() { - let (deps, state) = fixture(); - seed(&state, "s_1", "c_2", 300, None); - seed(&state, "s_1", "c_1", 100, None); - seed(&state, "s_2", "c_3", 200, None); - - let res = handle(&deps, ListPendingRequest::default()).await.unwrap(); - let order: Vec = res.pending.iter().map(|r| r.pending_at).collect(); - assert_eq!(order, vec![100, 200, 300]); - assert!(res.next_cursor.is_none()); + with_stack(BootOpts::needs_approval(), |stack| async move { + seed(&stack.iii, "s_1", "c_2", 300, None).await; + seed(&stack.iii, "s_1", "c_1", 100, None).await; + seed(&stack.iii, "s_2", "c_3", 200, None).await; + + let res = handle(&stack.deps, ListPendingRequest::default()) + .await + .unwrap(); + let order: Vec = res.pending.iter().map(|r| r.pending_at).collect(); + assert_eq!(order, vec![100, 200, 300]); + assert!(res.next_cursor.is_none()); + }) + .await; } - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn filters_by_session_and_metadata() { - let (deps, state) = fixture(); - seed(&state, "s_1", "c_1", 100, Some("u_1")); - seed(&state, "s_2", "c_2", 200, Some("u_2")); - - let by_session = handle( - &deps, - ListPendingRequest { - session_id: Some("s_2".into()), - ..Default::default() - }, - ) - .await - .unwrap(); - assert_eq!(by_session.pending.len(), 1); - assert_eq!(by_session.pending[0].session_id, "s_2"); - - let by_meta = handle( - &deps, - ListPendingRequest { - metadata: Some(serde_json::from_value(json!({ "owner": "u_1" })).unwrap()), - ..Default::default() - }, - ) - .await - .unwrap(); - assert_eq!(by_meta.pending.len(), 1); - assert_eq!(by_meta.pending[0].session_id, "s_1"); + with_stack(BootOpts::needs_approval(), |stack| async move { + seed(&stack.iii, "s_1", "c_1", 100, Some("u_1")).await; + seed(&stack.iii, "s_2", "c_2", 200, Some("u_2")).await; + + let by_session = handle( + &stack.deps, + ListPendingRequest { + session_id: Some("s_2".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(by_session.pending.len(), 1); + assert_eq!(by_session.pending[0].session_id, "s_2"); + + let by_meta = handle( + &stack.deps, + ListPendingRequest { + metadata: Some(serde_json::from_value(json!({ "owner": "u_1" })).unwrap()), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(by_meta.pending.len(), 1); + assert_eq!(by_meta.pending[0].session_id, "s_1"); + }) + .await; } - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn paginates_with_an_opaque_cursor() { - let (deps, state) = fixture(); - for i in 0..5 { - seed(&state, "s_1", &format!("c_{i}"), 100 + i, None); - } + with_stack(BootOpts::needs_approval(), |stack| async move { + for i in 0..5 { + seed(&stack.iii, "s_1", &format!("c_{i}"), 100 + i, None).await; + } - let first = handle( - &deps, - ListPendingRequest { - limit: Some(2), - ..Default::default() - }, - ) - .await - .unwrap(); - assert_eq!(first.pending.len(), 2); - let cursor = first.next_cursor.clone().unwrap(); - - let second = handle( - &deps, - ListPendingRequest { - limit: Some(2), - cursor: Some(cursor), - ..Default::default() - }, - ) - .await - .unwrap(); - assert_eq!(second.pending.len(), 2); - assert!(second.pending[0].pending_at > first.pending[1].pending_at); - - let third = handle( - &deps, - ListPendingRequest { - limit: Some(2), - cursor: second.next_cursor.clone(), - ..Default::default() - }, - ) - .await - .unwrap(); - assert_eq!(third.pending.len(), 1); - assert!(third.next_cursor.is_none()); + let first = handle( + &stack.deps, + ListPendingRequest { + limit: Some(2), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(first.pending.len(), 2); + let cursor = first.next_cursor.clone().unwrap(); + + let second = handle( + &stack.deps, + ListPendingRequest { + limit: Some(2), + cursor: Some(cursor), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(second.pending.len(), 2); + assert!(second.pending[0].pending_at > first.pending[1].pending_at); + + let third = handle( + &stack.deps, + ListPendingRequest { + limit: Some(2), + cursor: second.next_cursor.clone(), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(third.pending.len(), 1); + assert!(third.next_cursor.is_none()); + }) + .await; } - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn rejects_malformed_cursors() { - let (deps, _state) = fixture(); - let err = handle( - &deps, - ListPendingRequest { - cursor: Some("not base64 json!!!".into()), - ..Default::default() - }, - ) - .await - .unwrap_err(); - assert_eq!(err.code(), "approval/invalid_payload"); + with_stack(BootOpts::needs_approval(), |stack| async move { + let err = handle( + &stack.deps, + ListPendingRequest { + cursor: Some("not base64 json!!!".into()), + ..Default::default() + }, + ) + .await + .unwrap_err(); + assert_eq!(err.code(), "approval/invalid_payload"); + }) + .await; } } diff --git a/approval-gate/src/functions/mod.rs b/approval-gate/src/functions/mod.rs index 40969d5c..ac5743a0 100644 --- a/approval-gate/src/functions/mod.rs +++ b/approval-gate/src/functions/mod.rs @@ -28,7 +28,6 @@ use schemars::JsonSchema; use serde::de::DeserializeOwned; use serde::Serialize; -use crate::bus::Bus; use crate::config::WorkerConfig; use crate::error::ApprovalError; use crate::events::EventSink; @@ -36,14 +35,14 @@ use crate::gate_config::SharedDefaults; /// Everything a function handler needs. pub struct Deps { - pub bus: Arc, + pub iii: Arc, pub sink: Arc, pub defaults: SharedDefaults, pub cfg: Arc, } /// Register one typed handler under `id`, mapping `ApprovalError` into -/// the bus error shape (`code: message`). +/// the wire error shape (`code: message`). fn register( iii: &Arc, deps: &Arc, diff --git a/approval-gate/src/functions/on_config_change.rs b/approval-gate/src/functions/on_config_change.rs index 9aa93c5c..e66c6891 100644 --- a/approval-gate/src/functions/on_config_change.rs +++ b/approval-gate/src/functions/on_config_change.rs @@ -32,53 +32,43 @@ pub async fn handle(deps: &Deps, event: ConfigChangeEvent) -> Result Arc { - Arc::new(Deps { - bus: Arc::new(FakeBus::new()), - sink: Arc::new(RecordingSink::new()), - defaults: shared_defaults(), - cfg: Arc::new(WorkerConfig::default()), - }) - } - - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn reloads_defaults_on_matching_entry() { - let deps = deps(); - let event: ConfigChangeEvent = serde_json::from_value(json!({ - "id": "approval-gate", - "event_type": "configuration:updated", - "new_value": { "default_mode": "auto", "pending_timeout_ms": 60000 } - })) - .unwrap(); - handle(&deps, event).await.unwrap(); - let d = snapshot(&deps.defaults); - assert_eq!(d.default_mode, PermissionMode::Auto); - assert_eq!(d.pending_timeout_ms, 60_000); + with_stack(BootOpts::needs_approval(), |stack| async move { + let event: ConfigChangeEvent = serde_json::from_value(json!({ + "id": "approval-gate", + "event_type": "configuration:updated", + "new_value": { "default_mode": "auto", "pending_timeout_ms": 60000 } + })) + .unwrap(); + handle(&stack.deps, event).await.unwrap(); + let d = snapshot(&stack.defaults); + assert_eq!(d.default_mode, PermissionMode::Auto); + assert_eq!(d.pending_timeout_ms, 60_000); + }) + .await; } - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn ignores_other_entries() { - let deps = deps(); - let event: ConfigChangeEvent = serde_json::from_value(json!({ - "id": "llm-router", - "new_value": { "default_mode": "full" } - })) - .unwrap(); - handle(&deps, event).await.unwrap(); - assert_eq!( - snapshot(&deps.defaults).default_mode, - PermissionMode::Manual - ); + with_stack(BootOpts::needs_approval(), |stack| async move { + let event: ConfigChangeEvent = serde_json::from_value(json!({ + "id": "llm-router", + "new_value": { "default_mode": "full" } + })) + .unwrap(); + handle(&stack.deps, event).await.unwrap(); + assert_eq!( + snapshot(&stack.defaults).default_mode, + PermissionMode::Manual + ); + }) + .await; } } diff --git a/approval-gate/src/functions/on_session_deleted.rs b/approval-gate/src/functions/on_session_deleted.rs index c2b8c5b9..5cc274b2 100644 --- a/approval-gate/src/functions/on_session_deleted.rs +++ b/approval-gate/src/functions/on_session_deleted.rs @@ -20,7 +20,7 @@ pub async fn handle( event: SessionDeletedEvent, ) -> Result { if let Err(e) = settings::clear( - deps.bus.as_ref(), + deps.iii.as_ref(), &event.session_id, deps.cfg.state_timeout_ms, ) @@ -35,21 +35,16 @@ pub async fn handle( #[cfg(test)] mod tests { - use std::sync::Arc; - use serde_json::json; use super::*; - use crate::config::WorkerConfig; - use crate::events::RecordingSink; - use crate::gate_config::shared_defaults; use crate::pending::PENDING_SCOPE; use crate::settings::SETTINGS_SCOPE; - use crate::testkit::{FakeBus, MemoryState}; - use crate::types::ResolvedOutcome; + use crate::testkit::{log_snapshot, state_get, state_set, with_stack, BootOpts}; - fn seed_pending(state: &MemoryState, sid: &str, cid: &str) { - state.seed( + async fn seed_pending(iii: &iii_sdk::III, sid: &str, cid: &str) { + state_set( + iii, PENDING_SCOPE, &format!("{sid}/{cid}"), json!({ @@ -60,43 +55,42 @@ mod tests { "pending_at": 1, "expires_at": 2, }), - ); + ) + .await; } - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn purges_settings_and_only_the_sessions_pending_records() { - let bus = Arc::new(FakeBus::new()); - let state = bus.with_memory_state(); - let sink = Arc::new(RecordingSink::new()); - let deps = Arc::new(Deps { - bus, - sink: sink.clone(), - defaults: shared_defaults(), - cfg: Arc::new(WorkerConfig::default()), - }); + with_stack(BootOpts::needs_approval(), |stack| async move { + state_set(&stack.iii, SETTINGS_SCOPE, "s_1", json!({ "mode": "auto" })).await; + seed_pending(&stack.iii, "s_1", "c_1").await; + seed_pending(&stack.iii, "s_1", "c_2").await; + seed_pending(&stack.iii, "s_2", "c_3").await; - state.seed(SETTINGS_SCOPE, "s_1", json!({ "mode": "auto" })); - seed_pending(&state, "s_1", "c_1"); - seed_pending(&state, "s_1", "c_2"); - seed_pending(&state, "s_2", "c_3"); - - handle( - &deps, - SessionDeletedEvent { - session_id: "s_1".into(), - }, - ) - .await - .unwrap(); + handle( + &stack.deps, + SessionDeletedEvent { + session_id: "s_1".into(), + }, + ) + .await + .unwrap(); - assert!(state.peek(SETTINGS_SCOPE, "s_1").is_none()); - assert!(state.peek(PENDING_SCOPE, "s_1/c_1").is_none()); - assert!(state.peek(PENDING_SCOPE, "s_1/c_2").is_none()); - // Other sessions untouched. - assert!(state.peek(PENDING_SCOPE, "s_2/c_3").is_some()); + assert!(state_get(&stack.iii, SETTINGS_SCOPE, "s_1").await.is_null()); + assert!(state_get(&stack.iii, PENDING_SCOPE, "s_1/c_1") + .await + .is_null()); + assert!(state_get(&stack.iii, PENDING_SCOPE, "s_1/c_2") + .await + .is_null()); + assert!(!state_get(&stack.iii, PENDING_SCOPE, "s_2/c_3") + .await + .is_null()); - let events = sink.resolved_events(); - assert_eq!(events.len(), 2); - assert!(events.iter().all(|e| e.outcome == ResolvedOutcome::Aborted)); + let events = log_snapshot(&stack.resolved); + assert_eq!(events.len(), 2); + assert!(events.iter().all(|e| e["outcome"] == json!("aborted"))); + }) + .await; } } diff --git a/approval-gate/src/functions/on_turn_completed.rs b/approval-gate/src/functions/on_turn_completed.rs index 195ceaff..bb5459f8 100644 --- a/approval-gate/src/functions/on_turn_completed.rs +++ b/approval-gate/src/functions/on_turn_completed.rs @@ -29,59 +29,52 @@ pub async fn handle( #[cfg(test)] mod tests { - use std::sync::Arc; - use serde_json::json; use super::*; - use crate::config::WorkerConfig; - use crate::events::RecordingSink; - use crate::gate_config::shared_defaults; use crate::pending::PENDING_SCOPE; - use crate::testkit::FakeBus; - use crate::types::ResolvedOutcome; + use crate::testkit::{log_snapshot, state_get, state_set, with_stack, BootOpts}; - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn purges_by_turn_id_and_emits_aborted() { - let bus = Arc::new(FakeBus::new()); - let state = bus.with_memory_state(); - let sink = Arc::new(RecordingSink::new()); - let deps = Arc::new(Deps { - bus, - sink: sink.clone(), - defaults: shared_defaults(), - cfg: Arc::new(WorkerConfig::default()), - }); - - for (sid, cid, tid) in [("s_1", "c_1", "t_1"), ("s_1", "c_2", "t_2")] { - state.seed( - PENDING_SCOPE, - &format!("{sid}/{cid}"), - json!({ - "session_id": sid, - "turn_id": tid, - "function_call_id": cid, - "function_id": "shell::run", - "pending_at": 1, - "expires_at": 2, - }), - ); - } + with_stack(BootOpts::needs_approval(), |stack| async move { + for (sid, cid, tid) in [("s_1", "c_1", "t_1"), ("s_1", "c_2", "t_2")] { + state_set( + &stack.iii, + PENDING_SCOPE, + &format!("{sid}/{cid}"), + json!({ + "session_id": sid, + "turn_id": tid, + "function_call_id": cid, + "function_id": "shell::run", + "pending_at": 1, + "expires_at": 2, + }), + ) + .await; + } - handle( - &deps, - TurnCompletedEvent { - turn_id: "t_1".into(), - }, - ) - .await - .unwrap(); + handle( + &stack.deps, + TurnCompletedEvent { + turn_id: "t_1".into(), + }, + ) + .await + .unwrap(); - assert!(state.peek(PENDING_SCOPE, "s_1/c_1").is_none()); - assert!(state.peek(PENDING_SCOPE, "s_1/c_2").is_some()); - let events = sink.resolved_events(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].outcome, ResolvedOutcome::Aborted); - assert_eq!(events[0].turn_id, "t_1"); + assert!(state_get(&stack.iii, PENDING_SCOPE, "s_1/c_1") + .await + .is_null()); + assert!(!state_get(&stack.iii, PENDING_SCOPE, "s_1/c_2") + .await + .is_null()); + let events = log_snapshot(&stack.resolved); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["outcome"], json!("aborted")); + assert_eq!(events[0]["turn_id"], json!("t_1")); + }) + .await; } } diff --git a/approval-gate/src/functions/purge.rs b/approval-gate/src/functions/purge.rs index 1aea8eef..23a59b1f 100644 --- a/approval-gate/src/functions/purge.rs +++ b/approval-gate/src/functions/purge.rs @@ -11,8 +11,8 @@ pub async fn purge_matching(deps: &Deps, predicate: F) -> usize where F: Fn(&PendingApprovalRecord) -> bool, { - let bus = deps.bus.as_ref(); - let records = match pending::list_all(bus, deps.cfg.state_timeout_ms).await { + let iii = deps.iii.as_ref(); + let records = match pending::list_all(iii, deps.cfg.state_timeout_ms).await { Ok(records) => records, Err(e) => { tracing::warn!(error = %e, "purge: pending list failed; sweep will retry"); @@ -23,7 +23,7 @@ where let mut purged = 0usize; for record in records.into_iter().filter(|r| predicate(r)) { match pending::delete_with_gate( - bus, + iii, &record.session_id, &record.function_call_id, deps.cfg.state_timeout_ms, diff --git a/approval-gate/src/functions/remove_always_allow.rs b/approval-gate/src/functions/remove_always_allow.rs index c8dea8bb..001721d8 100644 --- a/approval-gate/src/functions/remove_always_allow.rs +++ b/approval-gate/src/functions/remove_always_allow.rs @@ -15,7 +15,7 @@ pub async fn handle( ) -> Result { let defaults = snapshot(&deps.defaults); let settings = settings::materialize_and( - deps.bus.as_ref(), + deps.iii.as_ref(), &req.session_id, &defaults, deps.cfg.state_timeout_ms, @@ -30,57 +30,45 @@ pub async fn handle( #[cfg(test)] mod tests { - use std::sync::Arc; - use super::*; - use crate::config::WorkerConfig; - use crate::events::RecordingSink; - use crate::gate_config::{replace, shared_defaults, GateDefaults}; - use crate::testkit::FakeBus; + use crate::gate_config::{replace, GateDefaults}; + use crate::testkit::{with_stack, BootOpts}; use crate::types::PermissionMode; - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn removes_seed_entries_from_the_materialized_record() { - let bus = Arc::new(FakeBus::new()); - let _state = bus.with_memory_state(); - let defaults = shared_defaults(); - replace( - &defaults, - GateDefaults { - default_mode: PermissionMode::Auto, - always_allow_seed: vec!["state::get".into(), "shell::run".into()], - pending_timeout_ms: 1_800_000, - }, - ); - let deps = Arc::new(Deps { - bus, - sink: Arc::new(RecordingSink::new()), - defaults, - cfg: Arc::new(WorkerConfig::default()), - }); - let res = handle( - &deps, - AlwaysAllowMutationRequest { - session_id: "s_1".into(), - function_id: "shell::run".into(), - }, - ) - .await - .unwrap(); - // The first mutation seeded both entries, then removed one. - assert_eq!(res.settings.always_allow.len(), 1); - assert_eq!(res.settings.always_allow[0].function_id, "state::get"); + with_stack(BootOpts::needs_approval(), |stack| async move { + replace( + &stack.defaults, + GateDefaults { + default_mode: PermissionMode::Auto, + always_allow_seed: vec!["state::get".into(), "shell::run".into()], + pending_timeout_ms: 1_800_000, + }, + ); + let res = handle( + &stack.deps, + AlwaysAllowMutationRequest { + session_id: "s_1".into(), + function_id: "shell::run".into(), + }, + ) + .await + .unwrap(); + assert_eq!(res.settings.always_allow.len(), 1); + assert_eq!(res.settings.always_allow[0].function_id, "state::get"); - // Removing an absent entry is a no-op. - let again = handle( - &deps, - AlwaysAllowMutationRequest { - session_id: "s_1".into(), - function_id: "never::granted".into(), - }, - ) - .await - .unwrap(); - assert_eq!(again.settings.always_allow.len(), 1); + let again = handle( + &stack.deps, + AlwaysAllowMutationRequest { + session_id: "s_1".into(), + function_id: "never::granted".into(), + }, + ) + .await + .unwrap(); + assert_eq!(again.settings.always_allow.len(), 1); + }) + .await; } } diff --git a/approval-gate/src/functions/resolve.rs b/approval-gate/src/functions/resolve.rs index 741cde64..75edbe01 100644 --- a/approval-gate/src/functions/resolve.rs +++ b/approval-gate/src/functions/resolve.rs @@ -13,6 +13,7 @@ use serde_json::json; use super::Deps; use crate::denial::{render_text, user_deny_envelope}; use crate::error::ApprovalError; +use crate::harness; use crate::pending; use crate::types::{ now_ms, validate_id, PendingResolvedEvent, ResolveDecision, ResolveRequest, ResolveResponse, @@ -23,9 +24,9 @@ pub async fn handle(deps: &Deps, req: ResolveRequest) -> Result Result Result Result, - bus: Arc, - sink: Arc, - state: MemoryState, - } - - fn fixture() -> Fixture { - let bus = Arc::new(FakeBus::new()); - let state = bus.with_memory_state(); - let sink = Arc::new(RecordingSink::new()); - let deps = Arc::new(Deps { - bus: bus.clone(), - sink: sink.clone(), - defaults: shared_defaults(), - cfg: Arc::new(WorkerConfig::default()), - }); - Fixture { - deps, - bus, - sink, - state, - } - } - - fn seed_record(f: &Fixture) { + async fn seed_record(iii: &iii_sdk::III) { let record = PendingApprovalRecord { session_id: "s_1".into(), turn_id: "t_9".into(), @@ -185,18 +151,13 @@ mod tests { depth: 0, assistant_excerpt: None, }; - f.state.seed( + state_set( + iii, PENDING_SCOPE, "s_1/c_1", serde_json::to_value(record).unwrap(), - ); - } - - fn harness_ok(f: &Fixture) { - f.bus.on_value( - "harness::function::resolve", - json!({ "resolved": true, "turn_resumed": true }), - ); + ) + .await; } fn req(decision: ResolveDecision, reason: Option<&str>) -> ResolveRequest { @@ -208,141 +169,70 @@ mod tests { } } - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn allow_releases_via_action_execute() { - let f = fixture(); - seed_record(&f); - harness_ok(&f); - - let res = handle(&f.deps, req(ResolveDecision::Allow, None)) - .await - .unwrap(); - assert!(res.resolved); - assert_eq!(res.turn_resumed, Some(true)); - - let calls = f.bus.calls_to("harness::function::resolve"); - assert_eq!(calls.len(), 1); - let payload = &calls[0].payload; - assert_eq!(payload["action"], json!("execute")); - // The turn_id comes from the pending record. - assert_eq!(payload["turn_id"], json!("t_9")); - // Execute supplies no result. - assert!(payload.get("content").is_none()); - assert!(payload.get("is_error").is_none()); - - // Record deleted; exactly one resolved event with the metadata. - assert!(f.state.peek(PENDING_SCOPE, "s_1/c_1").is_none()); - let events = f.sink.resolved_events(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].outcome, ResolvedOutcome::Allow); - assert_eq!( - events[0].session_metadata.as_ref().unwrap()["owner"], - json!("u_1") - ); + with_stack(BootOpts::needs_approval(), |stack| async move { + seed_record(&stack.iii).await; + let res = handle(&stack.deps, req(ResolveDecision::Allow, None)) + .await + .unwrap(); + assert!(res.resolved); + assert_eq!(res.turn_resumed, Some(true)); + + let harness = log_snapshot(&stack.harness_calls); + assert_eq!(harness.len(), 1); + assert_eq!(harness[0]["action"], json!("execute")); + assert_eq!(harness[0]["turn_id"], json!("t_9")); + assert!(state_get(&stack.iii, PENDING_SCOPE, "s_1/c_1") + .await + .is_null()); + }) + .await; } - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn deny_delivers_an_is_error_denial_envelope() { - let f = fixture(); - seed_record(&f); - harness_ok(&f); - - let res = handle(&f.deps, req(ResolveDecision::Deny, Some("too risky"))) - .await - .unwrap(); - assert!(res.resolved); - - let calls = f.bus.calls_to("harness::function::resolve"); - let payload = &calls[0].payload; - assert_eq!(payload["action"], json!("deliver")); - assert_eq!(payload["is_error"], json!(true)); - assert_eq!(payload["content"][0]["type"], json!("text")); - assert_eq!(payload["content"][0]["text"], json!("too risky")); - assert_eq!(payload["details"]["denied_by"], json!("user")); - assert_eq!(payload["details"]["args_excerpt"], json!({ "cmd": "ls" })); - - let events = f.sink.resolved_events(); - assert_eq!(events[0].outcome, ResolvedOutcome::Deny); - assert_eq!(events[0].reason.as_deref(), Some("too risky")); - } - - #[tokio::test] - async fn unknown_call_returns_resolved_false_and_emits_nothing() { - let f = fixture(); - harness_ok(&f); - let res = handle(&f.deps, req(ResolveDecision::Allow, None)) - .await - .unwrap(); - assert!(!res.resolved); - assert!(f.bus.calls_to("harness::function::resolve").is_empty()); - assert!(f.sink.resolved_events().is_empty()); + with_stack(BootOpts::needs_approval(), |stack| async move { + seed_record(&stack.iii).await; + handle(&stack.deps, req(ResolveDecision::Deny, Some("too risky"))) + .await + .unwrap(); + let harness = log_snapshot(&stack.harness_calls); + assert_eq!(harness[0]["action"], json!("deliver")); + assert_eq!(harness[0]["is_error"], json!(true)); + assert_eq!(harness[0]["content"][0]["text"], json!("too risky")); + }) + .await; } - #[tokio::test] - async fn duplicate_resolve_races_benignly() { - let f = fixture(); - seed_record(&f); - harness_ok(&f); - let first = handle(&f.deps, req(ResolveDecision::Allow, None)) - .await - .unwrap(); - assert!(first.resolved); - let second = handle(&f.deps, req(ResolveDecision::Allow, None)) - .await - .unwrap(); - assert!(!second.resolved); - assert_eq!(f.sink.resolved_events().len(), 1); + #[tokio::test(flavor = "multi_thread")] + async fn unknown_call_returns_resolved_false() { + with_stack(BootOpts::needs_approval(), |stack| async move { + let res = handle(&stack.deps, req(ResolveDecision::Allow, None)) + .await + .unwrap(); + assert!(!res.resolved); + assert!(log_snapshot(&stack.harness_calls).is_empty()); + }) + .await; } - #[tokio::test] - async fn harness_failure_keeps_the_record() { - let f = fixture(); - seed_record(&f); - f.bus - .on_error("harness::function::resolve", "connection refused"); - let err = handle(&f.deps, req(ResolveDecision::Allow, None)) + #[tokio::test(flavor = "multi_thread")] + async fn invalid_ids_are_rejected() { + with_stack(BootOpts::needs_approval(), |stack| async move { + let err = handle( + &stack.deps, + ResolveRequest { + session_id: "s/1".into(), + function_call_id: "c_1".into(), + decision: ResolveDecision::Allow, + reason: None, + }, + ) .await .unwrap_err(); - assert_eq!(err.code(), "approval/harness_unavailable"); - // Record intact, no event: the decision can be retried. - assert!(f.state.peek(PENDING_SCOPE, "s_1/c_1").is_some()); - assert!(f.sink.resolved_events().is_empty()); - } - - #[tokio::test] - async fn ordering_is_resolve_then_delete() { - let f = fixture(); - seed_record(&f); - harness_ok(&f); - handle(&f.deps, req(ResolveDecision::Allow, None)) - .await - .unwrap(); - let calls = f.bus.calls(); - let resolve_pos = calls - .iter() - .position(|c| c.function_id == "harness::function::resolve") - .unwrap(); - let delete_pos = calls - .iter() - .position(|c| c.function_id == "state::set" && c.payload["value"].is_null()) - .unwrap(); - assert!(resolve_pos < delete_pos); - } - - #[tokio::test] - async fn invalid_ids_are_rejected() { - let f = fixture(); - let err = handle( - &f.deps, - ResolveRequest { - session_id: "s/1".into(), - function_call_id: "c_1".into(), - decision: ResolveDecision::Allow, - reason: None, - }, - ) - .await - .unwrap_err(); - assert_eq!(err.code(), "approval/invalid_payload"); + assert_eq!(err.code(), "approval/invalid_payload"); + }) + .await; } } diff --git a/approval-gate/src/functions/set_mode.rs b/approval-gate/src/functions/set_mode.rs index 133f4884..0023597f 100644 --- a/approval-gate/src/functions/set_mode.rs +++ b/approval-gate/src/functions/set_mode.rs @@ -11,7 +11,7 @@ use crate::types::{ApprovalSettings, SetModeRequest, SettingsResponse}; pub async fn handle(deps: &Deps, req: SetModeRequest) -> Result { let defaults = snapshot(&deps.defaults); let settings = settings::materialize_and( - deps.bus.as_ref(), + deps.iii.as_ref(), &req.session_id, &defaults, deps.cfg.state_timeout_ms, @@ -27,35 +27,25 @@ pub async fn handle(deps: &Deps, req: SetModeRequest) -> Result 0); + with_stack(BootOpts::needs_approval(), |stack| async move { + let res = handle( + &stack.deps, + SetModeRequest { + session_id: "s_1".into(), + mode: PermissionMode::Auto, + }, + ) + .await + .unwrap(); + assert_eq!(res.settings.mode, PermissionMode::Auto); + assert!(res.settings.mode_set_at > 0); + }) + .await; } } diff --git a/approval-gate/src/functions/sweep.rs b/approval-gate/src/functions/sweep.rs index 215d9da5..c84640a4 100644 --- a/approval-gate/src/functions/sweep.rs +++ b/approval-gate/src/functions/sweep.rs @@ -9,16 +9,17 @@ use serde_json::{json, Value}; use super::Deps; use crate::error::ApprovalError; +use crate::harness; use crate::pending; use crate::types::{ now_ms, text_block, PendingApprovalRecord, PendingResolvedEvent, ResolvedOutcome, }; pub async fn handle(deps: &Deps, _payload: Value) -> Result { - let bus = deps.bus.as_ref(); + let iii = deps.iii.as_ref(); let now = now_ms(); - let records = match pending::list_all(bus, deps.cfg.state_timeout_ms).await { + let records = match pending::list_all(iii, deps.cfg.state_timeout_ms).await { Ok(records) => records, Err(e) => { tracing::warn!(error = %e, "sweep: pending list failed; retrying next tick"); @@ -34,13 +35,8 @@ pub async fn handle(deps: &Deps, _payload: Value) -> Result Result Value { #[cfg(test)] mod tests { - use std::sync::Arc; - use serde_json::json; use super::*; - use crate::config::WorkerConfig; - use crate::events::RecordingSink; - use crate::gate_config::shared_defaults; use crate::pending::PENDING_SCOPE; - use crate::testkit::{FakeBus, MemoryState}; - - struct Fixture { - deps: Arc, - bus: Arc, - sink: Arc, - state: MemoryState, - } - - fn fixture() -> Fixture { - let bus = Arc::new(FakeBus::new()); - let state = bus.with_memory_state(); - let sink = Arc::new(RecordingSink::new()); - let deps = Arc::new(Deps { - bus: bus.clone(), - sink: sink.clone(), - defaults: shared_defaults(), - cfg: Arc::new(WorkerConfig::default()), - }); - Fixture { - deps, - bus, - sink, - state, - } - } - - fn seed(state: &MemoryState, cid: &str, expires_at: i64) { - state.seed( - PENDING_SCOPE, - &format!("s_1/{cid}"), - json!({ - "session_id": "s_1", - "turn_id": "t_1", - "function_call_id": cid, - "function_id": "shell::run", - "pending_at": 100, - "expires_at": expires_at, - }), - ); - } + use crate::testkit::{log_snapshot, state_get, state_set, with_stack, BootOpts}; + use crate::types::now_ms; - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn sweeps_only_expired_records() { - let f = fixture(); - f.bus.on_value( - "harness::function::resolve", - json!({ "resolved": true, "turn_resumed": true }), - ); - seed(&f.state, "c_expired", 200); // long past - seed(&f.state, "c_live", now_ms() + 60_000); - - let res = handle(&f.deps, Value::Null).await.unwrap(); - assert_eq!(res["swept"], json!(1)); - assert!(f.state.peek(PENDING_SCOPE, "s_1/c_expired").is_none()); - assert!(f.state.peek(PENDING_SCOPE, "s_1/c_live").is_some()); - - let calls = f.bus.calls_to("harness::function::resolve"); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].payload["action"], json!("deliver")); - assert_eq!(calls[0].payload["is_error"], json!(true)); - assert_eq!(calls[0].payload["details"]["status"], json!("timeout")); - - let events = f.sink.resolved_events(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].outcome, ResolvedOutcome::Timeout); - } - - #[tokio::test] - async fn deletes_expired_records_even_when_the_harness_is_absent() { - let f = fixture(); - // No harness scripted: resolve calls error (standalone deployment). - seed(&f.state, "c_expired", 200); - let res = handle(&f.deps, Value::Null).await.unwrap(); - assert_eq!(res["swept"], json!(1)); - assert!(f.state.peek(PENDING_SCOPE, "s_1/c_expired").is_none()); - assert_eq!(f.sink.resolved_events().len(), 1); + with_stack(BootOpts::needs_approval(), |stack| async move { + state_set( + &stack.iii, + PENDING_SCOPE, + "s_1/c_expired", + json!({ + "session_id": "s_1", + "turn_id": "t_1", + "function_call_id": "c_expired", + "function_id": "shell::run", + "pending_at": 100, + "expires_at": 200, + "depth": 0, + }), + ) + .await; + state_set( + &stack.iii, + PENDING_SCOPE, + "s_1/c_live", + json!({ + "session_id": "s_1", + "turn_id": "t_1", + "function_call_id": "c_live", + "function_id": "shell::run", + "pending_at": 100, + "expires_at": now_ms() + 60_000, + "depth": 0, + }), + ) + .await; + + let res = handle(&stack.deps, Value::Null).await.unwrap(); + assert_eq!(res["swept"], json!(1)); + assert!(state_get(&stack.iii, PENDING_SCOPE, "s_1/c_expired") + .await + .is_null()); + assert!(!state_get(&stack.iii, PENDING_SCOPE, "s_1/c_live") + .await + .is_null()); + + let harness = log_snapshot(&stack.harness_calls); + assert_eq!(harness.len(), 1); + assert_eq!(harness[0]["details"]["status"], json!("timeout")); + assert!(wait_for_resolved(&stack).await, "expected pending_resolved"); + }) + .await; } - #[tokio::test] - async fn already_deleted_records_emit_nothing() { - let f = fixture(); - f.bus - .on_value("harness::function::resolve", json!({ "resolved": false })); - seed(&f.state, "c_expired", 200); - // Simulate a racing path that deleted between list and - // delete_with_gate: the null write observes no prior value. - f.bus.on("state::set", |_| { - Ok(json!({ "old_value": null, "new_value": null })) - }); - let res = handle(&f.deps, Value::Null).await.unwrap(); - assert_eq!(res["swept"], json!(0)); - assert!(f.sink.resolved_events().is_empty()); + #[tokio::test(flavor = "multi_thread")] + async fn empty_scope_is_a_noop() { + with_stack(BootOpts::needs_approval(), |stack| async move { + let res = handle(&stack.deps, Value::Null).await.unwrap(); + assert_eq!(res["swept"], json!(0)); + }) + .await; } - #[tokio::test] - async fn empty_scope_is_a_noop() { - let f = fixture(); - let res = handle(&f.deps, Value::Null).await.unwrap(); - assert_eq!(res["swept"], json!(0)); + async fn wait_for_resolved(stack: &crate::testkit::TestStack) -> bool { + crate::testkit::wait_for(3_000, || !log_snapshot(&stack.resolved).is_empty()).await } } diff --git a/approval-gate/src/gate_config.rs b/approval-gate/src/gate_config.rs index 26a42e54..76343ebc 100644 --- a/approval-gate/src/gate_config.rs +++ b/approval-gate/src/gate_config.rs @@ -12,9 +12,9 @@ use std::sync::{Arc, RwLock}; +use iii_sdk::{IIIError, TriggerRequest, III}; use serde_json::{json, Value}; -use crate::bus::{Bus, BusError}; use crate::types::PermissionMode; pub const ENTRY_ID: &str = "approval-gate"; @@ -123,26 +123,32 @@ pub fn entry_schema() -> Value { }) } -pub async fn register_entry(bus: &dyn Bus) -> Result<(), BusError> { - bus.call( - "configuration::register", - json!({ +pub async fn register_entry(iii: &III) -> Result<(), IIIError> { + iii.trigger(TriggerRequest { + function_id: "configuration::register".into(), + payload: json!({ "id": ENTRY_ID, "name": "Approval Gate", "description": "Deployment approval defaults: permission mode for new sessions, the auto-mode trust seed, and the pending-hold timeout.", "schema": entry_schema(), }), - None, - ) + action: None, + timeout_ms: None, + }) .await .map(|_| ()) } /// Read the entry value; any failure (configuration worker absent, entry /// unset) yields the built-in defaults. -pub async fn read_defaults(bus: &dyn Bus) -> GateDefaults { - match bus - .call("configuration::get", json!({ "id": ENTRY_ID }), None) +pub async fn read_defaults(iii: &III) -> GateDefaults { + match iii + .trigger(TriggerRequest { + function_id: "configuration::get".into(), + payload: json!({ "id": ENTRY_ID }), + action: None, + timeout_ms: None, + }) .await { Ok(reply) => parse_config_value(reply.get("value").unwrap_or(&Value::Null)), @@ -156,7 +162,6 @@ pub async fn read_defaults(bus: &dyn Bus) -> GateDefaults { #[cfg(test)] mod tests { use super::*; - use crate::testkit::FakeBus; use serde_json::json; #[test] @@ -188,22 +193,6 @@ mod tests { assert_eq!(zero.pending_timeout_ms, DEFAULT_PENDING_TIMEOUT_MS); } - #[tokio::test] - async fn read_defaults_tolerates_missing_configuration_worker() { - let bus = FakeBus::new(); - assert_eq!(read_defaults(&bus).await, GateDefaults::default()); - } - - #[tokio::test] - async fn read_defaults_parses_the_entry_value() { - let bus = FakeBus::new(); - bus.on_value( - "configuration::get", - json!({ "id": ENTRY_ID, "value": { "default_mode": "full" } }), - ); - assert_eq!(read_defaults(&bus).await.default_mode, PermissionMode::Full); - } - #[test] fn shared_defaults_replace_and_snapshot() { let shared = shared_defaults(); diff --git a/approval-gate/src/harness.rs b/approval-gate/src/harness.rs new file mode 100644 index 00000000..0978dcca --- /dev/null +++ b/approval-gate/src/harness.rs @@ -0,0 +1,18 @@ +//! Thin `harness::function::resolve` wrapper around `iii.trigger()`. + +use iii_sdk::{IIIError, TriggerRequest, III}; +use serde_json::Value; + +pub async fn function_resolve( + iii: &III, + payload: Value, + timeout_ms: Option, +) -> Result { + iii.trigger(TriggerRequest { + function_id: "harness::function::resolve".into(), + payload, + action: None, + timeout_ms, + }) + .await +} diff --git a/approval-gate/src/lib.rs b/approval-gate/src/lib.rs index bab81ecc..3115ee7c 100644 --- a/approval-gate/src/lib.rs +++ b/approval-gate/src/lib.rs @@ -16,7 +16,6 @@ //! `harness::turn_completed` — harness.md § Hooks, § API Reference); //! those bindings are best-effort so the worker also boots standalone. -pub mod bus; pub mod config; pub mod decision; pub mod denial; @@ -24,10 +23,13 @@ pub mod error; pub mod events; pub mod functions; pub mod gate_config; +pub mod harness; pub mod manifest; pub mod pending; pub mod policy; pub mod redact; +pub mod session; pub mod settings; +pub mod state; pub mod testkit; pub mod types; diff --git a/approval-gate/src/main.rs b/approval-gate/src/main.rs index 00b0dce8..c9d8d8ed 100644 --- a/approval-gate/src/main.rs +++ b/approval-gate/src/main.rs @@ -24,7 +24,6 @@ use clap::Parser; use iii_sdk::{register_worker, InitOptions, RegisterTriggerInput, WorkerMetadata, III}; use serde_json::json; -use approval_gate::bus::IiiBus; use approval_gate::events::{self, Emitter}; use approval_gate::functions::{self, Deps}; use approval_gate::gate_config::{self, replace, shared_defaults}; @@ -127,11 +126,10 @@ async fn main() -> Result<()> { // Trigger types first: the handlers capture the subscriber sets. let sets = events::register_trigger_types(&iii); - let bus = Arc::new(IiiBus::new(iii.clone())); - let sink = Arc::new(Emitter::new(sets, bus.clone())); + let sink = Arc::new(Emitter::new(sets, iii.clone())); let defaults = shared_defaults(); let deps = Arc::new(Deps { - bus: bus.clone(), + iii: iii.clone(), sink, defaults: defaults.clone(), cfg: cfg.clone(), @@ -182,10 +180,10 @@ async fn main() -> Result<()> { // Configuration entry: register (no initial_value — operator values // survive re-register), then one initial read; the configuration // trigger above keeps the defaults fresh reactively. - if let Err(e) = gate_config::register_entry(bus.as_ref()).await { + if let Err(e) = gate_config::register_entry(&iii).await { tracing::info!(error = %e, "configuration worker unavailable; running on built-in defaults"); } - replace(&defaults, gate_config::read_defaults(bus.as_ref()).await); + replace(&defaults, gate_config::read_defaults(&iii).await); tracing::info!("approval-gate ready: 14 approval::* functions + 2 custom trigger types"); diff --git a/approval-gate/src/pending.rs b/approval-gate/src/pending.rs index 38367da9..89121d89 100644 --- a/approval-gate/src/pending.rs +++ b/approval-gate/src/pending.rs @@ -4,9 +4,10 @@ //! backstop (approval-gate.md § State lifecycle), which is what keeps //! `state::list` O(live holds). -use serde_json::{json, Value}; +use iii_sdk::{IIIError, III}; +use serde_json::Value; -use crate::bus::{Bus, BusError}; +use crate::state; use crate::types::PendingApprovalRecord; pub const PENDING_SCOPE: &str = "approval_pending"; @@ -33,18 +34,18 @@ pub fn parse_record(value: &Value) -> Option { } pub async fn get( - bus: &dyn Bus, + iii: &III, session_id: &str, function_call_id: &str, timeout_ms: u64, -) -> Result, BusError> { - let reply = bus - .call( - "state::get", - json!({ "scope": PENDING_SCOPE, "key": pending_key(session_id, function_call_id) }), - Some(timeout_ms), - ) - .await?; +) -> Result, IIIError> { + let reply = state::get( + iii, + PENDING_SCOPE, + &pending_key(session_id, function_call_id), + Some(timeout_ms), + ) + .await?; Ok(parse_record(&reply)) } @@ -52,21 +53,18 @@ pub async fn get( /// concurrent duplicate hold lost the race — the caller must not emit a /// second `pending_created`). pub async fn put( - bus: &dyn Bus, + iii: &III, record: &PendingApprovalRecord, timeout_ms: u64, -) -> Result, BusError> { - let reply = bus - .call( - "state::set", - json!({ - "scope": PENDING_SCOPE, - "key": pending_key(&record.session_id, &record.function_call_id), - "value": record, - }), - Some(timeout_ms), - ) - .await?; +) -> Result, IIIError> { + let reply = state::set( + iii, + PENDING_SCOPE, + &pending_key(&record.session_id, &record.function_call_id), + serde_json::to_value(record).unwrap_or(Value::Null), + Some(timeout_ms), + ) + .await?; let old = reply.get("old_value").cloned().unwrap_or(Value::Null); Ok(if old.is_null() { None } else { Some(old) }) } @@ -84,28 +82,15 @@ pub async fn put( /// list O(live). The delete is benign if it races: the gate already /// decided emission. pub async fn delete_with_gate( - bus: &dyn Bus, + iii: &III, session_id: &str, function_call_id: &str, timeout_ms: u64, -) -> Result, BusError> { +) -> Result, IIIError> { let key = pending_key(session_id, function_call_id); - let reply = bus - .call( - "state::set", - json!({ "scope": PENDING_SCOPE, "key": key, "value": Value::Null }), - Some(timeout_ms), - ) - .await?; + let reply = state::set(iii, PENDING_SCOPE, &key, Value::Null, Some(timeout_ms)).await?; let old = reply.get("old_value").cloned().unwrap_or(Value::Null); - if let Err(e) = bus - .call( - "state::delete", - json!({ "scope": PENDING_SCOPE, "key": key }), - Some(timeout_ms), - ) - .await - { + if let Err(e) = state::delete(iii, PENDING_SCOPE, &key, Some(timeout_ms)).await { // The null tombstone survives until the next delete attempt; it // is invisible to readers (parse_record skips nulls). tracing::warn!(key, error = %e, "tombstone cleanup failed"); @@ -115,17 +100,8 @@ pub async fn delete_with_gate( /// Full-scope scan, values-only (the engine's `state::list` contract). /// Malformed/null values are skipped. -pub async fn list_all( - bus: &dyn Bus, - timeout_ms: u64, -) -> Result, BusError> { - let reply = bus - .call( - "state::list", - json!({ "scope": PENDING_SCOPE }), - Some(timeout_ms), - ) - .await?; +pub async fn list_all(iii: &III, timeout_ms: u64) -> Result, IIIError> { + let reply = state::list(iii, PENDING_SCOPE, Some(timeout_ms)).await?; let values = match reply { Value::Array(items) => items, Value::Null => Vec::new(), @@ -140,7 +116,6 @@ pub async fn list_all( #[cfg(test)] mod tests { use super::*; - use crate::testkit::FakeBus; use serde_json::json; fn record(session_id: &str, call_id: &str) -> PendingApprovalRecord { @@ -160,59 +135,15 @@ mod tests { } } - #[tokio::test] - async fn put_get_roundtrip() { - let bus = FakeBus::new(); - let _state = bus.with_memory_state(); - let rec = record("s_1", "c_1"); - assert!(put(&bus, &rec, 100).await.unwrap().is_none()); - let read = get(&bus, "s_1", "c_1", 100).await.unwrap().unwrap(); - assert_eq!(read, rec); - } - - #[tokio::test] - async fn put_reports_prior_value_on_duplicate() { - let bus = FakeBus::new(); - let _state = bus.with_memory_state(); - let rec = record("s_1", "c_1"); - assert!(put(&bus, &rec, 100).await.unwrap().is_none()); - assert!(put(&bus, &rec, 100).await.unwrap().is_some()); - } - - #[tokio::test] - async fn delete_with_gate_returns_the_record_exactly_once() { - let bus = FakeBus::new(); - let state = bus.with_memory_state(); - let rec = record("s_1", "c_1"); - put(&bus, &rec, 100).await.unwrap(); - - let first = delete_with_gate(&bus, "s_1", "c_1", 100).await.unwrap(); - assert_eq!(first, Some(rec)); - // Second deletion path observes no live record: no emission. - let second = delete_with_gate(&bus, "s_1", "c_1", 100).await.unwrap(); - assert!(second.is_none()); - // The tombstone left by `state::set null` was cleaned up. - assert!(state.peek(PENDING_SCOPE, "s_1/c_1").is_none()); - } - - #[tokio::test] - async fn list_all_skips_null_tombstones_and_garbage() { - let bus = FakeBus::new(); - let state = bus.with_memory_state(); - put(&bus, &record("s_1", "c_1"), 100).await.unwrap(); - state.seed(PENDING_SCOPE, "s_2/c_9", Value::Null); - state.seed(PENDING_SCOPE, "s_3/c_8", json!("garbage")); - - let records = list_all(&bus, 100).await.unwrap(); - assert_eq!(records.len(), 1); - assert_eq!(records[0].session_id, "s_1"); - } - - #[tokio::test] - async fn state_outage_surfaces_as_bus_error() { - let bus = FakeBus::new(); - assert!(get(&bus, "s_1", "c_1", 100).await.is_err()); - assert!(list_all(&bus, 100).await.is_err()); - assert!(delete_with_gate(&bus, "s_1", "c_1", 100).await.is_err()); + #[test] + fn parse_record_skips_null_and_garbage() { + assert!(parse_record(&Value::Null).is_none()); + assert!(parse_record(&json!("garbage")).is_none()); + assert_eq!( + parse_record(&serde_json::to_value(record("s_1", "c_1")).unwrap()) + .unwrap() + .session_id, + "s_1" + ); } } diff --git a/approval-gate/src/policy.rs b/approval-gate/src/policy.rs index 0eb40622..2ebaf9d0 100644 --- a/approval-gate/src/policy.rs +++ b/approval-gate/src/policy.rs @@ -7,9 +7,9 @@ use std::time::Duration; +use iii_sdk::{TriggerRequest, III}; use serde_json::{json, Value}; -use crate::bus::Bus; use crate::types::MatchedConstraint; #[derive(Debug, Clone, PartialEq)] @@ -23,18 +23,18 @@ pub enum PolicyOutcome { Unavailable(String), } -pub async fn check( - bus: &dyn Bus, - function_id: &str, - args: &Value, - timeout_ms: u64, -) -> PolicyOutcome { +pub async fn check(iii: &III, function_id: &str, args: &Value, timeout_ms: u64) -> PolicyOutcome { let payload = json!({ "function_id": function_id, "args": args }); - // Belt and braces: the bus timeout should bound the call, but a + // Belt and braces: the trigger timeout should bound the call, but a // misbehaving transport must not stretch the hook past its budget. let reply = tokio::time::timeout( Duration::from_millis(timeout_ms), - bus.call("policy::check_permissions", payload, Some(timeout_ms)), + iii.trigger(TriggerRequest { + function_id: "policy::check_permissions".into(), + payload, + action: None, + timeout_ms: Some(timeout_ms), + }), ) .await; match reply { @@ -65,7 +65,6 @@ pub fn parse_reply(value: &Value) -> PolicyOutcome { #[cfg(test)] mod tests { use super::*; - use crate::testkit::FakeBus; use serde_json::json; #[test] @@ -115,19 +114,4 @@ mod tests { } ); } - - #[tokio::test] - async fn transport_error_maps_to_unavailable() { - let bus = FakeBus::new(); - bus.on_error("policy::check_permissions", "connection refused"); - let outcome = check(&bus, "shell::run", &json!({}), 100).await; - assert!(matches!(outcome, PolicyOutcome::Unavailable(_))); - } - - #[tokio::test] - async fn missing_policy_worker_maps_to_unavailable() { - let bus = FakeBus::new(); - let outcome = check(&bus, "shell::run", &json!({}), 100).await; - assert!(matches!(outcome, PolicyOutcome::Unavailable(_))); - } } diff --git a/approval-gate/src/session.rs b/approval-gate/src/session.rs new file mode 100644 index 00000000..ab37bcd3 --- /dev/null +++ b/approval-gate/src/session.rs @@ -0,0 +1,14 @@ +//! Thin `session::get` wrapper around `iii.trigger()`. + +use iii_sdk::{IIIError, TriggerRequest, III}; +use serde_json::{json, Value}; + +pub async fn get(iii: &III, session_id: &str, timeout_ms: Option) -> Result { + iii.trigger(TriggerRequest { + function_id: "session::get".into(), + payload: json!({ "session_id": session_id }), + action: None, + timeout_ms, + }) + .await +} diff --git a/approval-gate/src/settings.rs b/approval-gate/src/settings.rs index e9c02e1d..54eb3fca 100644 --- a/approval-gate/src/settings.rs +++ b/approval-gate/src/settings.rs @@ -3,11 +3,12 @@ //! Reads never write — the *effective* settings are the stored record //! when one exists, else the configuration defaults computed in memory. -use serde_json::{json, Value}; +use iii_sdk::III; +use serde_json::Value; -use crate::bus::Bus; use crate::error::ApprovalError; use crate::gate_config::GateDefaults; +use crate::state; use crate::types::{ now_ms, validate_id, AlwaysAllowEntry, ApprovalSettings, GrantedBy, SettingsSource, }; @@ -63,17 +64,11 @@ pub fn effective( /// garbage) degrades to `None` → configuration defaults. Safe because the /// default mode never widens beyond what the deployment configured. pub async fn read_tolerant( - bus: &dyn Bus, + iii: &III, session_id: &str, timeout_ms: u64, ) -> Option { - let reply = bus - .call( - "state::get", - json!({ "scope": SETTINGS_SCOPE, "key": session_id }), - Some(timeout_ms), - ) - .await; + let reply = state::get(iii, SETTINGS_SCOPE, session_id, Some(timeout_ms)).await; match reply { Ok(value) => parse_settings(&value), Err(e) => { @@ -86,16 +81,11 @@ pub async fn read_tolerant( /// Strict read for mutations: a state outage is an error — re-seeding /// over an unreadable record would clobber it. pub async fn read_strict( - bus: &dyn Bus, + iii: &III, session_id: &str, timeout_ms: u64, ) -> Result, ApprovalError> { - let reply = bus - .call( - "state::get", - json!({ "scope": SETTINGS_SCOPE, "key": session_id }), - Some(timeout_ms), - ) + let reply = state::get(iii, SETTINGS_SCOPE, session_id, Some(timeout_ms)) .await .map_err(|e| ApprovalError::StateUnavailable(format!("settings read failed: {e}")))?; Ok(parse_settings(&reply)) @@ -107,7 +97,7 @@ pub async fn read_strict( /// written in one `state::set` — mutations are human-driven and rare, so /// read-modify-write of one small record is sufficient. pub async fn materialize_and( - bus: &dyn Bus, + iii: &III, session_id: &str, defaults: &GateDefaults, timeout_ms: u64, @@ -118,13 +108,15 @@ where { validate_id("session_id", session_id)?; let now = now_ms(); - let base = read_strict(bus, session_id, timeout_ms) + let base = read_strict(iii, session_id, timeout_ms) .await? .unwrap_or_else(|| seeded_from(defaults, now)); let next = mutate(base, now); - bus.call( - "state::set", - json!({ "scope": SETTINGS_SCOPE, "key": session_id, "value": next }), + state::set( + iii, + SETTINGS_SCOPE, + session_id, + serde_json::to_value(&next).unwrap_or(Value::Null), Some(timeout_ms), ) .await @@ -134,18 +126,9 @@ where /// Drop the stored record (the session reverts to configuration /// defaults). Returns whether a record existed. -pub async fn clear( - bus: &dyn Bus, - session_id: &str, - timeout_ms: u64, -) -> Result { +pub async fn clear(iii: &III, session_id: &str, timeout_ms: u64) -> Result { validate_id("session_id", session_id)?; - let old = bus - .call( - "state::delete", - json!({ "scope": SETTINGS_SCOPE, "key": session_id }), - Some(timeout_ms), - ) + let old = state::delete(iii, SETTINGS_SCOPE, session_id, Some(timeout_ms)) .await .map_err(|e| ApprovalError::StateUnavailable(format!("settings delete failed: {e}")))?; Ok(!old.is_null()) @@ -184,7 +167,6 @@ pub fn without_grant(entries: &[AlwaysAllowEntry], function_id: &str) -> Vec, +) -> Result { + iii.trigger(TriggerRequest { + function_id: "state::get".into(), + payload: json!({ "scope": scope, "key": key }), + action: None, + timeout_ms, + }) + .await +} + +pub async fn set( + iii: &III, + scope: &str, + key: &str, + value: Value, + timeout_ms: Option, +) -> Result { + iii.trigger(TriggerRequest { + function_id: "state::set".into(), + payload: json!({ "scope": scope, "key": key, "value": value }), + action: None, + timeout_ms, + }) + .await +} + +pub async fn delete( + iii: &III, + scope: &str, + key: &str, + timeout_ms: Option, +) -> Result { + iii.trigger(TriggerRequest { + function_id: "state::delete".into(), + payload: json!({ "scope": scope, "key": key }), + action: None, + timeout_ms, + }) + .await +} + +pub async fn list(iii: &III, scope: &str, timeout_ms: Option) -> Result { + iii.trigger(TriggerRequest { + function_id: "state::list".into(), + payload: json!({ "scope": scope }), + action: None, + timeout_ms, + }) + .await +} diff --git a/approval-gate/src/testkit/engine.rs b/approval-gate/src/testkit/engine.rs new file mode 100644 index 00000000..906069ab --- /dev/null +++ b/approval-gate/src/testkit/engine.rs @@ -0,0 +1,450 @@ +//! Engine-backed test bootstrap (llm-router / integration.rs pattern). +//! Self-skips when no `iii` binary is on PATH or `III_ENGINE_BIN` is set. + +use std::io::Write as _; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use iii_sdk::{ + register_worker, InitOptions, RegisterFunction, RegisterTriggerInput, TriggerRequest, III, +}; +use serde_json::{json, Value}; +use tokio::sync::OnceCell; + +use crate::config::WorkerConfig; +use crate::events::{self, Emitter, PENDING_CREATED, PENDING_RESOLVED}; +use crate::functions::{self, Deps}; +use crate::gate_config::{self, replace, shared_defaults, SharedDefaults, ENTRY_ID}; + +pub struct Engine { + pub url: String, + child: std::process::Child, + dir: std::path::PathBuf, +} + +impl Drop for Engine { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +pub fn engine_bin() -> Option { + if let Ok(p) = std::env::var("III_ENGINE_BIN") { + return Some(p.into()); + } + let on_path = std::process::Command::new("iii") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + on_path.then(|| "iii".into()) +} + +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .expect("bind ephemeral port") + .local_addr() + .expect("local addr") + .port() +} + +pub async fn spawn_engine() -> Option { + let bin = engine_bin()?; + let port = free_port(); + let dir = std::env::temp_dir().join(format!( + "approval-gate-it-{}-{}", + std::process::id(), + Instant::now().elapsed().as_nanos() + )); + std::fs::create_dir_all(&dir).ok()?; + + let config = format!( + r#"workers: + - name: iii-worker-manager + config: + port: {port} + - name: iii-pubsub + config: + adapter: + name: local + - name: configuration + config: + adapter: + name: fs + config: + directory: {dir}/configuration + ttl_seconds: 0 + - name: iii-state + config: + adapter: + name: kv + config: + file_path: {dir}/state_store.db + store_method: file_based +"#, + port = port, + dir = dir.display(), + ); + let config_path = dir.join("config.yaml"); + std::fs::File::create(&config_path) + .and_then(|mut f| f.write_all(config.as_bytes())) + .ok()?; + + let mut child = std::process::Command::new(&bin) + .arg("--no-update-check") + .arg("--config") + .arg(&config_path) + .current_dir(&dir) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .ok()?; + + let url = format!("ws://127.0.0.1:{port}"); + let probe = register_worker(&url, InitOptions::default()); + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let ready = probe + .trigger(TriggerRequest { + function_id: "engine::workers::list".into(), + payload: json!({}), + action: None, + timeout_ms: Some(1000), + }) + .await + .is_ok(); + if ready { + break; + } + if Instant::now() >= deadline { + probe.shutdown(); + let _ = child.kill(); + let _ = child.wait(); + let _ = std::fs::remove_dir_all(&dir); + return None; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + probe.shutdown(); + + Some(Engine { url, child, dir }) +} + +static BOOT_LOCK: OnceCell> = OnceCell::const_new(); + +async fn boot_lock() -> tokio::sync::MutexGuard<'static, ()> { + BOOT_LOCK + .get_or_init(|| async { tokio::sync::Mutex::new(()) }) + .await + .lock() + .await +} + +/// Spawn a fresh isolated engine. Serialized to avoid port races when tests +/// run in parallel; each call gets its own state store. +pub async fn require_engine() -> Option { + let _guard = boot_lock().await; + match spawn_engine().await { + Some(engine) => Some(engine), + None => { + eprintln!("skipping: no iii engine (set III_ENGINE_BIN or put `iii` on PATH)"); + None + } + } +} + +pub type CallLog = Arc>>; + +#[derive(Clone)] +pub enum PolicyStub { + Reply(Value), + Error(String), + Absent, +} + +impl Default for PolicyStub { + fn default() -> Self { + Self::Reply(json!({ "decision": "needs_approval" })) + } +} + +#[derive(Clone, Default)] +pub struct BootOpts { + pub policy: PolicyStub, +} + +impl BootOpts { + pub fn needs_approval() -> Self { + Self { + policy: PolicyStub::Reply(json!({ "decision": "needs_approval" })), + } + } + + pub fn allow() -> Self { + Self { + policy: PolicyStub::Reply(json!({ "decision": "allow" })), + } + } + + pub fn deny() -> Self { + Self { + policy: PolicyStub::Reply(json!({ "decision": "deny", "rule_id": "test" })), + } + } + + pub fn policy_reply(value: Value) -> Self { + Self { + policy: PolicyStub::Reply(value), + } + } + + pub fn policy_error(message: impl Into) -> Self { + Self { + policy: PolicyStub::Error(message.into()), + } + } + + pub fn no_policy() -> Self { + Self { + policy: PolicyStub::Absent, + } + } +} + +pub struct TestStack { + pub iii: Arc, + pub deps: Arc, + pub defaults: SharedDefaults, + /// Requests received by the fake `harness::function::resolve`. + pub harness_calls: CallLog, + /// `approval::pending_created` deliveries. + pub created: CallLog, + /// `approval::pending_resolved` deliveries. + pub resolved: CallLog, +} + +pub fn log_push(log: &CallLog, value: Value) { + log.lock().unwrap_or_else(|p| p.into_inner()).push(value); +} + +pub fn log_snapshot(log: &CallLog) -> Vec { + log.lock().unwrap_or_else(|p| p.into_inner()).clone() +} + +/// Register the production surface + fakes + event recorders on one +/// worker connection against the engine. +pub async fn boot(engine: &Engine, opts: BootOpts) -> TestStack { + let iii = Arc::new(register_worker(&engine.url, InitOptions::default())); + + let sets = events::register_trigger_types(&iii); + let defaults = shared_defaults(); + let deps = Arc::new(Deps { + iii: iii.clone(), + sink: Arc::new(Emitter::new(sets, iii.clone())), + defaults: defaults.clone(), + cfg: Arc::new(WorkerConfig::default()), + }); + functions::register_all(&iii, &deps); + + let policy = opts.policy; + match policy { + PolicyStub::Reply(decision) => { + iii.register_function( + "policy::check_permissions", + RegisterFunction::new_async(move |_req: Value| { + let decision = decision.clone(); + async move { Ok::<_, iii_sdk::IIIError>(decision) } + }), + ); + } + PolicyStub::Error(message) => { + iii.register_function( + "policy::check_permissions", + RegisterFunction::new_async(move |_req: Value| { + let message = message.clone(); + async move { Err::(iii_sdk::IIIError::Handler(message)) } + }), + ); + } + PolicyStub::Absent => {} + } + + let harness_calls: CallLog = Arc::default(); + { + let log = harness_calls.clone(); + iii.register_function( + "harness::function::resolve", + RegisterFunction::new_async(move |req: Value| { + let log = log.clone(); + async move { + log_push(&log, req); + Ok::<_, iii_sdk::IIIError>(json!({ "resolved": true, "turn_resumed": true })) + } + }), + ); + } + + iii.register_function( + "session::get", + RegisterFunction::new_async(move |req: Value| async move { + let session_id = req + .get("session_id") + .and_then(Value::as_str) + .unwrap_or("s_1"); + Ok::<_, iii_sdk::IIIError>(json!({ "meta": { + "session_id": session_id, + "title": "Integration session", + "description": "", + "metadata": { "owner": "u_1" } + }})) + }), + ); + + let created: CallLog = Arc::default(); + { + let log = created.clone(); + iii.register_function( + "recorder::on_created", + RegisterFunction::new_async(move |req: Value| { + let log = log.clone(); + async move { + log_push(&log, req); + Ok::<_, iii_sdk::IIIError>(Value::Null) + } + }), + ); + } + let resolved: CallLog = Arc::default(); + { + let log = resolved.clone(); + iii.register_function( + "recorder::on_resolved", + RegisterFunction::new_async(move |req: Value| { + let log = log.clone(); + async move { + log_push(&log, req); + Ok::<_, iii_sdk::IIIError>(Value::Null) + } + }), + ); + } + iii.register_trigger(RegisterTriggerInput { + trigger_type: PENDING_CREATED.to_string(), + function_id: "recorder::on_created".to_string(), + config: json!({}), + metadata: None, + }) + .expect("bind pending_created"); + iii.register_trigger(RegisterTriggerInput { + trigger_type: PENDING_RESOLVED.to_string(), + function_id: "recorder::on_resolved".to_string(), + config: json!({}), + metadata: None, + }) + .expect("bind pending_resolved"); + + let _ = iii.register_trigger(RegisterTriggerInput { + trigger_type: "configuration".to_string(), + function_id: "approval::on_config_change".to_string(), + config: json!({ + "configuration_id": ENTRY_ID, + "event_types": ["configuration:registered", "configuration:updated"], + }), + metadata: None, + }); + if let Err(e) = gate_config::register_entry(&iii).await { + tracing::debug!(error = %e, "configuration entry register skipped"); + } + replace(&defaults, gate_config::read_defaults(&iii).await); + + TestStack { + iii, + deps, + defaults, + harness_calls, + created, + resolved, + } +} + +pub async fn call( + iii: &III, + function_id: &str, + payload: Value, +) -> Result { + iii.trigger(TriggerRequest { + function_id: function_id.into(), + payload, + action: None, + timeout_ms: Some(30_000), + }) + .await +} + +pub async fn state_get(iii: &III, scope: &str, key: &str) -> Value { + call(iii, "state::get", json!({ "scope": scope, "key": key })) + .await + .expect("state::get") +} + +pub async fn state_set(iii: &III, scope: &str, key: &str, value: Value) { + call( + iii, + "state::set", + json!({ "scope": scope, "key": key, "value": value }), + ) + .await + .expect("state::set"); +} + +pub async fn settle() { + tokio::time::sleep(Duration::from_millis(300)).await; +} + +pub fn hook_input(session_id: &str, call_id: &str, function_id: &str) -> Value { + json!({ + "point": "pre_dispatch", + "session_id": session_id, + "turn_id": "t_1", + "step": 1, + "depth": 0, + "call": { + "id": call_id, + "function_id": function_id, + "arguments": { "cmd": "ls", "api_key": "sk_live_secret" } + } + }) +} + +/// Poll until `predicate` is true or the deadline passes (fire-and-forget +/// deliveries are async). +pub async fn wait_for(deadline_ms: u64, mut predicate: impl FnMut() -> bool) -> bool { + let deadline = Instant::now() + Duration::from_millis(deadline_ms); + loop { + if predicate() { + return true; + } + if Instant::now() > deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +/// Run `f` against a freshly booted stack; skip when no engine is available. +pub async fn with_stack(opts: BootOpts, f: F) +where + F: FnOnce(TestStack) -> Fut, + Fut: std::future::Future, +{ + let Some(engine) = require_engine().await else { + return; + }; + let stack = boot(&engine, opts).await; + f(stack).await; + // `engine` drops here — isolated state cleaned up with the temp dir. +} diff --git a/approval-gate/src/testkit/fake_bus.rs b/approval-gate/src/testkit/fake_bus.rs deleted file mode 100644 index 05426382..00000000 --- a/approval-gate/src/testkit/fake_bus.rs +++ /dev/null @@ -1,221 +0,0 @@ -//! A scripted [`Bus`]: records every call and answers from per-function -//! handlers. [`MemoryState`] mirrors the engine's state worker semantics -//! exactly — including the null-tombstone behavior of `state::set` with -//! `value: null` — so the delete-with-gate logic is tested against the -//! real contract. - -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -use async_trait::async_trait; -use serde_json::{json, Value}; - -use crate::bus::{Bus, BusError}; - -type Handler = Arc Result + Send + Sync>; - -#[derive(Debug, Clone)] -pub struct RecordedCall { - pub function_id: String, - pub payload: Value, - pub void: bool, - pub timeout_ms: Option, -} - -#[derive(Default)] -pub struct FakeBus { - handlers: Mutex>, - calls: Mutex>, -} - -impl FakeBus { - pub fn new() -> Self { - Self::default() - } - - /// Script a handler for one function id. - pub fn on(&self, function_id: &str, handler: F) - where - F: Fn(&Value) -> Result + Send + Sync + 'static, - { - self.lock_handlers() - .insert(function_id.to_string(), Arc::new(handler)); - } - - /// Script a constant reply. - pub fn on_value(&self, function_id: &str, value: Value) { - self.on(function_id, move |_| Ok(value.clone())); - } - - /// Script a constant transport error. - pub fn on_error(&self, function_id: &str, message: &str) { - let message = message.to_string(); - self.on(function_id, move |_| Err(BusError(message.clone()))); - } - - /// Install `state::get/set/delete/list` handlers backed by a shared - /// in-memory store; returns the store for direct assertions. - pub fn with_memory_state(&self) -> MemoryState { - let state = MemoryState::default(); - - let s = state.clone(); - self.on("state::get", move |payload| Ok(s.get(payload))); - let s = state.clone(); - self.on("state::set", move |payload| Ok(s.set(payload))); - let s = state.clone(); - self.on("state::delete", move |payload| Ok(s.delete(payload))); - let s = state.clone(); - self.on("state::list", move |payload| Ok(s.list(payload))); - - state - } - - pub fn calls(&self) -> Vec { - self.lock_calls().clone() - } - - pub fn calls_to(&self, function_id: &str) -> Vec { - self.lock_calls() - .iter() - .filter(|c| c.function_id == function_id) - .cloned() - .collect() - } - - fn lock_handlers(&self) -> std::sync::MutexGuard<'_, HashMap> { - self.handlers - .lock() - .unwrap_or_else(|poison| poison.into_inner()) - } - - fn lock_calls(&self) -> std::sync::MutexGuard<'_, Vec> { - self.calls - .lock() - .unwrap_or_else(|poison| poison.into_inner()) - } - - fn handler_for(&self, function_id: &str) -> Option { - self.lock_handlers().get(function_id).cloned() - } -} - -#[async_trait] -impl Bus for FakeBus { - async fn call( - &self, - function_id: &str, - payload: Value, - timeout_ms: Option, - ) -> Result { - self.lock_calls().push(RecordedCall { - function_id: function_id.to_string(), - payload: payload.clone(), - void: false, - timeout_ms, - }); - match self.handler_for(function_id) { - Some(handler) => handler(&payload), - // An unscripted function behaves like an absent sibling — - // the right default for fail-closed tests. - None => Err(BusError(format!("no handler for {function_id}"))), - } - } - - async fn call_void(&self, function_id: &str, payload: Value) { - self.lock_calls().push(RecordedCall { - function_id: function_id.to_string(), - payload: payload.clone(), - void: true, - timeout_ms: None, - }); - if let Some(handler) = self.handler_for(function_id) { - let _ = handler(&payload); - } - } -} - -/// In-memory mirror of the engine's state worker (builtins/kv.rs): -/// `set` stores the value verbatim (a JSON null IS stored — the tombstone -/// the production delete path must clean up), returning `{old_value, -/// new_value}` atomically; `delete` removes and returns the old value; -/// `list` returns values only. -#[derive(Clone, Default)] -pub struct MemoryState { - inner: Arc>>, -} - -impl MemoryState { - fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<(String, String), Value>> { - self.inner - .lock() - .unwrap_or_else(|poison| poison.into_inner()) - } - - fn scope_key(payload: &Value) -> (String, String) { - ( - payload - .get("scope") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - payload - .get("key") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - ) - } - - fn get(&self, payload: &Value) -> Value { - let key = Self::scope_key(payload); - self.lock().get(&key).cloned().unwrap_or(Value::Null) - } - - fn set(&self, payload: &Value) -> Value { - let key = Self::scope_key(payload); - let value = payload.get("value").cloned().unwrap_or(Value::Null); - let old = self.lock().insert(key, value.clone()); - json!({ "old_value": old.unwrap_or(Value::Null), "new_value": value }) - } - - fn delete(&self, payload: &Value) -> Value { - let key = Self::scope_key(payload); - self.lock().remove(&key).unwrap_or(Value::Null) - } - - fn list(&self, payload: &Value) -> Value { - let scope = payload - .get("scope") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - let values: Vec = self - .lock() - .iter() - .filter(|((s, _), _)| *s == scope) - .map(|(_, v)| v.clone()) - .collect(); - Value::Array(values) - } - - /// Direct read for assertions. - pub fn peek(&self, scope: &str, key: &str) -> Option { - self.lock() - .get(&(scope.to_string(), key.to_string())) - .cloned() - } - - /// Direct write for seeding. - pub fn seed(&self, scope: &str, key: &str, value: Value) { - self.lock() - .insert((scope.to_string(), key.to_string()), value); - } - - pub fn len(&self) -> usize { - self.lock().len() - } - - pub fn is_empty(&self) -> bool { - self.lock().is_empty() - } -} diff --git a/approval-gate/src/testkit/mod.rs b/approval-gate/src/testkit/mod.rs index fbb20cc2..a9864b48 100644 --- a/approval-gate/src/testkit/mod.rs +++ b/approval-gate/src/testkit/mod.rs @@ -1,6 +1,9 @@ -//! Engine-free test doubles. `#[cfg(test)]`-free on purpose: the -//! integration suite (tests/integration.rs) uses these too. +//! Engine-backed test bootstrap. Used by unit tests and `tests/integration.rs`. -pub mod fake_bus; +pub mod engine; -pub use fake_bus::{FakeBus, MemoryState, RecordedCall}; +pub use engine::{ + boot, call, engine_bin, hook_input, log_push, log_snapshot, require_engine, settle, + spawn_engine, state_get, state_set, wait_for, with_stack, BootOpts, CallLog, Engine, + PolicyStub, TestStack, +}; diff --git a/approval-gate/tests/integration.rs b/approval-gate/tests/integration.rs index ed4ab5f9..29111ec5 100644 --- a/approval-gate/tests/integration.rs +++ b/approval-gate/tests/integration.rs @@ -1,668 +1,251 @@ //! Engine-backed integration suite — the worker's production surface -//! registered against a real iii engine (`iii-worker-manager + -//! iii-pubsub + configuration + iii-state`), with the not-yet-built -//! siblings (`policy::check_permissions`, `harness::function::resolve`, -//! `session::get`) registered as in-process fakes. -//! -//! **Self-skips** when no engine is available (llm-router pattern): -//! set `III_ENGINE_BIN=/path/to/iii` or have `iii` on PATH. +//! registered against a real iii engine. -use std::io::Write as _; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; +use serde_json::json; -use iii_sdk::{ - register_worker, InitOptions, RegisterFunction, RegisterTriggerInput, TriggerRequest, III, +use approval_gate::gate_config::snapshot; +use approval_gate::testkit::{ + call, hook_input, log_snapshot, settle, wait_for, with_stack, BootOpts, }; -use serde_json::{json, Value}; +use approval_gate::types::PermissionMode; -use approval_gate::bus::IiiBus; -use approval_gate::config::WorkerConfig; -use approval_gate::events::{self, Emitter}; -use approval_gate::functions::{self, Deps}; -use approval_gate::gate_config::{self, replace, shared_defaults, SharedDefaults}; - -// ── engine bootstrap (llm-router/tests/integration.rs pattern) ───────────── - -struct Engine { - url: String, - child: std::process::Child, - dir: std::path::PathBuf, -} - -impl Drop for Engine { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - let _ = std::fs::remove_dir_all(&self.dir); - } -} - -fn engine_bin() -> Option { - if let Ok(p) = std::env::var("III_ENGINE_BIN") { - return Some(p.into()); - } - let on_path = std::process::Command::new("iii") - .arg("--version") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false); - on_path.then(|| "iii".into()) -} - -fn free_port() -> u16 { - std::net::TcpListener::bind("127.0.0.1:0") - .expect("bind ephemeral port") - .local_addr() - .expect("local addr") - .port() -} - -async fn spawn_engine() -> Option { - let bin = engine_bin()?; - let port = free_port(); - let dir = std::env::temp_dir().join(format!("approval-gate-it-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).expect("temp dir"); - - let config = format!( - r#"workers: - - name: iii-worker-manager - config: - port: {port} - - name: iii-pubsub - config: - adapter: - name: local - - name: configuration - config: - adapter: - name: fs - config: - directory: {dir}/configuration - ttl_seconds: 0 - - name: iii-state - config: - adapter: - name: kv - config: - file_path: {dir}/state_store.db - store_method: file_based -"#, - port = port, - dir = dir.display(), - ); - let config_path = dir.join("config.yaml"); - std::fs::File::create(&config_path) - .and_then(|mut f| f.write_all(config.as_bytes())) - .expect("write config"); +#[tokio::test(flavor = "multi_thread")] +async fn hold_writes_record_emits_once_and_is_idempotent() { + with_stack(BootOpts::needs_approval(), |stack| async move { + let iii = &stack.iii; + + let out = call( + iii, + "approval::gate", + hook_input("s_1", "c_1", "shell::run"), + ) + .await + .expect("gate call"); + assert_eq!(out["decision"], json!("hold")); - let child = std::process::Command::new(&bin) - .arg("--no-update-check") - .arg("--config") - .arg(&config_path) - .current_dir(&dir) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .expect("spawn engine"); + let record = call( + iii, + "state::get", + json!({ "scope": "approval_pending", "key": "s_1/c_1" }), + ) + .await + .expect("state get"); + assert_eq!(record["function_id"], json!("shell::run")); + assert_eq!(record["arguments_excerpt"]["api_key"], json!("")); + assert_eq!(record["session_title"], json!("Integration session")); + + let again = call( + iii, + "approval::gate", + hook_input("s_1", "c_1", "shell::run"), + ) + .await + .expect("gate call"); + assert_eq!(again["decision"], json!("hold")); - let url = format!("ws://127.0.0.1:{port}"); - let probe = register_worker(&url, InitOptions::default()); - let deadline = Instant::now() + Duration::from_secs(15); - loop { - let ready = probe - .trigger(TriggerRequest { - function_id: "engine::workers::list".into(), - payload: json!({}), - action: None, - timeout_ms: Some(1000), - }) - .await - .is_ok(); - if ready { - break; - } assert!( - Instant::now() < deadline, - "engine did not become ready in 15s" + wait_for(3_000, || !log_snapshot(&stack.created).is_empty()).await, + "pending_created should reach the bound recorder" ); - tokio::time::sleep(Duration::from_millis(250)).await; - } - probe.shutdown(); - - Some(Engine { url, child, dir }) + settle().await; + assert_eq!(log_snapshot(&stack.created).len(), 1); + }) + .await; } -macro_rules! engine_or_skip { - () => { - match spawn_engine().await { - Some(e) => e, - None => { - eprintln!("skipping: no iii engine (set III_ENGINE_BIN or put `iii` on PATH)"); - return; - } +#[tokio::test(flavor = "multi_thread")] +async fn resolve_allow_releases_and_deny_delivers_through_the_fake_harness() { + with_stack(BootOpts::needs_approval(), |stack| async move { + let iii = &stack.iii; + + for cid in ["c_allow", "c_deny"] { + let out = call(iii, "approval::gate", hook_input("s_1", cid, "shell::run")) + .await + .unwrap(); + assert_eq!(out["decision"], json!("hold")); } - }; -} - -// ── worker bootstrap + fakes ──────────────────────────────────────────────── - -type CallLog = Arc>>; - -struct Stack { - iii: Arc, - defaults: SharedDefaults, - /// Requests received by the fake harness::function::resolve. - harness_calls: CallLog, - /// approval::pending_created deliveries. - created: CallLog, - /// approval::pending_resolved deliveries. - resolved: CallLog, -} -fn log_push(log: &CallLog, value: Value) { - log.lock().unwrap_or_else(|p| p.into_inner()).push(value); -} - -fn log_snapshot(log: &CallLog) -> Vec { - log.lock().unwrap_or_else(|p| p.into_inner()).clone() -} - -/// Register the production surface + fakes + event recorders on one -/// worker connection against the engine. -async fn boot_stack(engine: &Engine, policy_decision: Value) -> Stack { - let iii = Arc::new(register_worker(&engine.url, InitOptions::default())); - - // Production: trigger types first, then functions (main.rs order). - let sets = events::register_trigger_types(&iii); - let bus = Arc::new(IiiBus::new(iii.clone())); - let sink = Arc::new(Emitter::new(sets, bus.clone())); - let defaults = shared_defaults(); - let deps = Arc::new(Deps { - bus: bus.clone(), - sink, - defaults: defaults.clone(), - cfg: Arc::new(WorkerConfig::default()), - }); - functions::register_all(&iii, &deps); + let res = call( + iii, + "approval::resolve", + json!({ "session_id": "s_1", "function_call_id": "c_allow", "decision": "allow" }), + ) + .await + .unwrap(); + assert_eq!(res["resolved"], json!(true)); - // Fake siblings. - iii.register_function( - "policy::check_permissions", - RegisterFunction::new_async(move |_req: Value| { - let decision = policy_decision.clone(); - async move { Ok::<_, iii_sdk::IIIError>(decision) } - }), - ); - let harness_calls: CallLog = Arc::default(); - { - let log = harness_calls.clone(); - iii.register_function( - "harness::function::resolve", - RegisterFunction::new_async(move |req: Value| { - let log = log.clone(); - async move { - log_push(&log, req); - Ok::<_, iii_sdk::IIIError>(json!({ "resolved": true, "turn_resumed": true })) - } - }), - ); - } - iii.register_function( - "session::get", - RegisterFunction::new_async(move |_req: Value| async move { - Ok::<_, iii_sdk::IIIError>(json!({ "meta": { + let res = call( + iii, + "approval::resolve", + json!({ "session_id": "s_1", - "title": "Integration session", - "description": "", - "metadata": { "owner": "u_1" } - }})) - }), - ); - - // Event recorders, bound through the engine like a real notification - // worker would bind. - let created: CallLog = Arc::default(); - { - let log = created.clone(); - iii.register_function( - "recorder::on_created", - RegisterFunction::new_async(move |req: Value| { - let log = log.clone(); - async move { - log_push(&log, req); - Ok::<_, iii_sdk::IIIError>(Value::Null) - } - }), - ); - } - let resolved: CallLog = Arc::default(); - { - let log = resolved.clone(); - iii.register_function( - "recorder::on_resolved", - RegisterFunction::new_async(move |req: Value| { - let log = log.clone(); - async move { - log_push(&log, req); - Ok::<_, iii_sdk::IIIError>(Value::Null) - } + "function_call_id": "c_deny", + "decision": "deny", + "reason": "not on prod" }), - ); - } - iii.register_trigger(RegisterTriggerInput { - trigger_type: events::PENDING_CREATED.to_string(), - function_id: "recorder::on_created".to_string(), - config: json!({}), - metadata: None, - }) - .expect("bind pending_created"); - iii.register_trigger(RegisterTriggerInput { - trigger_type: events::PENDING_RESOLVED.to_string(), - function_id: "recorder::on_resolved".to_string(), - config: json!({}), - metadata: None, - }) - .expect("bind pending_resolved"); - - // Reactive config reload binding + entry registration + initial read. - iii.register_trigger(RegisterTriggerInput { - trigger_type: "configuration".to_string(), - function_id: "approval::on_config_change".to_string(), - config: json!({ - "configuration_id": gate_config::ENTRY_ID, - "event_types": ["configuration:registered", "configuration:updated"], - }), - metadata: None, - }) - .expect("bind configuration trigger"); - gate_config::register_entry(bus.as_ref()) - .await - .expect("register configuration entry"); - replace(&defaults, gate_config::read_defaults(bus.as_ref()).await); - - Stack { - iii, - defaults, - harness_calls, - created, - resolved, - } -} - -async fn call(iii: &III, function_id: &str, payload: Value) -> Result { - iii.trigger(TriggerRequest { - function_id: function_id.into(), - payload, - action: None, - timeout_ms: Some(10_000), - }) - .await -} - -fn hook_input(session_id: &str, call_id: &str, function_id: &str) -> Value { - json!({ - "point": "pre_dispatch", - "session_id": session_id, - "turn_id": "t_1", - "step": 1, - "depth": 0, - "call": { - "id": call_id, - "function_id": function_id, - "arguments": { "cmd": "ls", "api_key": "sk_live_secret" } - } - }) -} - -/// Poll until `predicate` is true or the deadline passes (fire-and-forget -/// deliveries are async). -async fn wait_for(deadline_ms: u64, mut predicate: impl FnMut() -> bool) -> bool { - let deadline = Instant::now() + Duration::from_millis(deadline_ms); - loop { - if predicate() { - return true; - } - if Instant::now() > deadline { - return false; - } - tokio::time::sleep(Duration::from_millis(50)).await; - } -} - -// ── scenarios ─────────────────────────────────────────────────────────────── - -#[tokio::test(flavor = "multi_thread")] -async fn hold_writes_record_emits_once_and_is_idempotent() { - let engine = engine_or_skip!(); - let stack = boot_stack(&engine, json!({ "decision": "needs_approval" })).await; - let iii = &stack.iii; - - let out = call( - iii, - "approval::gate", - hook_input("s_1", "c_1", "shell::run"), - ) - .await - .expect("gate call"); - assert_eq!(out["decision"], json!("hold")); - assert_eq!(out["pending_timeout_ms"], json!(1_800_000)); - - // The record is in the real state worker, redacted, with session - // context from the fake session-manager. - let record = call( - iii, - "state::get", - json!({ "scope": "approval_pending", "key": "s_1/c_1" }), - ) - .await - .expect("state get"); - assert_eq!(record["function_id"], json!("shell::run")); - assert_eq!(record["arguments_excerpt"]["api_key"], json!("")); - assert_eq!(record["session_title"], json!("Integration session")); - assert_eq!(record["session_metadata"]["owner"], json!("u_1")); - - // Inbox read surfaces it. - let listed = call(iii, "approval::list_pending", json!({})) + ) .await .unwrap(); - assert_eq!(listed["pending"].as_array().unwrap().len(), 1); - - // Duplicate hold (redelivered step): still hold, no second emission. - let again = call( - iii, - "approval::gate", - hook_input("s_1", "c_1", "shell::run"), - ) - .await - .expect("gate call"); - assert_eq!(again["decision"], json!("hold")); + assert_eq!(res["resolved"], json!(true)); - assert!( - wait_for(3_000, || !log_snapshot(&stack.created).is_empty()).await, - "pending_created should reach the bound recorder" - ); - tokio::time::sleep(Duration::from_millis(300)).await; - let created = log_snapshot(&stack.created); - assert_eq!(created.len(), 1, "exactly one pending_created: {created:?}"); - assert_eq!(created[0]["status"], json!("pending")); -} + let harness = log_snapshot(&stack.harness_calls); + assert_eq!(harness.len(), 2); + assert_eq!(harness[0]["action"], json!("execute")); + assert_eq!(harness[1]["action"], json!("deliver")); + assert_eq!(harness[1]["details"]["denied_by"], json!("user")); -#[tokio::test(flavor = "multi_thread")] -async fn resolve_allow_releases_and_deny_delivers_through_the_fake_harness() { - let engine = engine_or_skip!(); - let stack = boot_stack(&engine, json!({ "decision": "needs_approval" })).await; - let iii = &stack.iii; - - // Hold two calls. - for cid in ["c_allow", "c_deny"] { - let out = call(iii, "approval::gate", hook_input("s_1", cid, "shell::run")) + let listed = call(iii, "approval::list_pending", json!({})) .await .unwrap(); - assert_eq!(out["decision"], json!("hold")); - } + assert_eq!(listed["pending"].as_array().unwrap().len(), 0); - // Allow → action execute, no content. - let res = call( - iii, - "approval::resolve", - json!({ "session_id": "s_1", "function_call_id": "c_allow", "decision": "allow" }), - ) - .await - .unwrap(); - assert_eq!(res["resolved"], json!(true)); - assert_eq!(res["turn_resumed"], json!(true)); - - // Deny → action deliver, is_error, envelope in details. - let res = call( - iii, - "approval::resolve", - json!({ - "session_id": "s_1", - "function_call_id": "c_deny", - "decision": "deny", - "reason": "not on prod" - }), - ) - .await - .unwrap(); - assert_eq!(res["resolved"], json!(true)); - - let harness = log_snapshot(&stack.harness_calls); - assert_eq!(harness.len(), 2); - assert_eq!(harness[0]["action"], json!("execute")); - assert_eq!(harness[0]["turn_id"], json!("t_1")); - assert!(harness[0].get("content").is_none()); - assert_eq!(harness[1]["action"], json!("deliver")); - assert_eq!(harness[1]["is_error"], json!(true)); - assert_eq!(harness[1]["details"]["denied_by"], json!("user")); - assert_eq!(harness[1]["content"][0]["text"], json!("not on prod")); - - // Both records gone — and not as tombstones: the real state worker's - // scope list is empty again (risk #2 verification). - let listed = call(iii, "approval::list_pending", json!({})) - .await - .unwrap(); - assert_eq!(listed["pending"].as_array().unwrap().len(), 0); - let raw = call(iii, "state::list", json!({ "scope": "approval_pending" })) - .await - .unwrap(); - let live: Vec<&Value> = raw - .as_array() - .map(|a| a.iter().filter(|v| !v.is_null()).collect()) - .unwrap_or_default(); - assert!(live.is_empty(), "no live or tombstone records: {raw}"); - - // Exactly one pending_resolved per record. - assert!( - wait_for(3_000, || log_snapshot(&stack.resolved).len() >= 2).await, - "pending_resolved should reach the bound recorder" - ); - tokio::time::sleep(Duration::from_millis(300)).await; - let resolved = log_snapshot(&stack.resolved); - assert_eq!(resolved.len(), 2, "{resolved:?}"); - let outcomes: Vec<&str> = resolved - .iter() - .filter_map(|e| e["outcome"].as_str()) - .collect(); - assert!(outcomes.contains(&"allow") && outcomes.contains(&"deny")); - - // Duplicate resolve: benign no-op. - let dup = call( - iii, - "approval::resolve", - json!({ "session_id": "s_1", "function_call_id": "c_allow", "decision": "allow" }), - ) - .await - .unwrap(); - assert_eq!(dup["resolved"], json!(false)); + assert!( + wait_for(3_000, || log_snapshot(&stack.resolved).len() >= 2).await, + "pending_resolved should reach the bound recorder" + ); + settle().await; + assert_eq!(log_snapshot(&stack.resolved).len(), 2); + }) + .await; } #[tokio::test(flavor = "multi_thread")] async fn sweep_expires_records_and_emits_timeout_exactly_once() { - let engine = engine_or_skip!(); - let stack = boot_stack(&engine, json!({ "decision": "needs_approval" })).await; - let iii = &stack.iii; - - // Seed an already-expired record straight into the real state worker. - call( - iii, - "state::set", - json!({ "scope": "approval_pending", "key": "s_9/c_9", "value": { - "session_id": "s_9", - "turn_id": "t_9", - "function_call_id": "c_9", - "function_id": "shell::run", - "arguments_excerpt": {}, - "pending_at": 100, - "expires_at": 200, - "depth": 0, - }}), - ) - .await - .unwrap(); + with_stack(BootOpts::needs_approval(), |stack| async move { + let iii = &stack.iii; + + call( + iii, + "state::set", + json!({ "scope": "approval_pending", "key": "s_9/c_9", "value": { + "session_id": "s_9", + "turn_id": "t_9", + "function_call_id": "c_9", + "function_id": "shell::run", + "arguments_excerpt": {}, + "pending_at": 100, + "expires_at": 200, + "depth": 0, + }}), + ) + .await + .unwrap(); - let swept = call(iii, "approval::sweep", json!({})).await.unwrap(); - assert_eq!(swept["swept"], json!(1)); + let swept = call(iii, "approval::sweep", json!({})).await.unwrap(); + assert_eq!(swept["swept"], json!(1)); - let harness = log_snapshot(&stack.harness_calls); - assert_eq!(harness.len(), 1); - assert_eq!(harness[0]["action"], json!("deliver")); - assert_eq!(harness[0]["is_error"], json!(true)); - assert_eq!(harness[0]["details"]["status"], json!("timeout")); + let harness = log_snapshot(&stack.harness_calls); + assert_eq!(harness.len(), 1); + assert_eq!(harness[0]["details"]["status"], json!("timeout")); - // Second sweep: nothing left, no double resolution, no double event. - let swept = call(iii, "approval::sweep", json!({})).await.unwrap(); - assert_eq!(swept["swept"], json!(0)); + let swept = call(iii, "approval::sweep", json!({})).await.unwrap(); + assert_eq!(swept["swept"], json!(0)); - assert!( - wait_for(3_000, || !log_snapshot(&stack.resolved).is_empty()).await, - "timeout event should reach the recorder" - ); - tokio::time::sleep(Duration::from_millis(300)).await; - let resolved = log_snapshot(&stack.resolved); - assert_eq!(resolved.len(), 1, "{resolved:?}"); - assert_eq!(resolved[0]["outcome"], json!("timeout")); + assert!( + wait_for(3_000, || !log_snapshot(&stack.resolved).is_empty()).await, + "timeout event should reach the recorder" + ); + settle().await; + assert_eq!(log_snapshot(&stack.resolved).len(), 1); + }) + .await; } #[tokio::test(flavor = "multi_thread")] async fn configuration_set_reloads_defaults_reactively() { - let engine = engine_or_skip!(); - let stack = boot_stack(&engine, json!({ "decision": "needs_approval" })).await; - let iii = &stack.iii; - - // Manual default: the gate holds. - let out = call( - iii, - "approval::gate", - hook_input("s_2", "c_1", "shell::run"), - ) - .await - .unwrap(); - assert_eq!(out["decision"], json!("hold")); + with_stack(BootOpts::needs_approval(), |stack| async move { + let iii = &stack.iii; + + let out = call( + iii, + "approval::gate", + hook_input("s_2", "c_1", "shell::run"), + ) + .await + .unwrap(); + assert_eq!(out["decision"], json!("hold")); - // Operator flips the deployment default to full. - call( - iii, - "configuration::set", - json!({ "id": "approval-gate", "value": { "default_mode": "full" } }), - ) - .await - .expect("configuration set"); + call( + iii, + "configuration::set", + json!({ "id": "approval-gate", "value": { "default_mode": "full" } }), + ) + .await + .expect("configuration set"); - // The configuration trigger swaps the in-memory defaults. - assert!( - wait_for(5_000, || { - gate_config::snapshot(&stack.defaults).default_mode - == approval_gate::types::PermissionMode::Full - }) - .await, - "defaults should reload reactively" - ); + assert!( + wait_for(5_000, || { + snapshot(&stack.defaults).default_mode == PermissionMode::Full + }) + .await, + "defaults should reload reactively" + ); - // A session with no stored settings now runs full → continue. - let out = call( - iii, - "approval::gate", - hook_input("s_3", "c_2", "shell::run"), - ) - .await - .unwrap(); - assert_eq!(out["decision"], json!("continue")); + let out = call( + iii, + "approval::gate", + hook_input("s_3", "c_2", "shell::run"), + ) + .await + .unwrap(); + assert_eq!(out["decision"], json!("continue")); + }) + .await; } #[tokio::test(flavor = "multi_thread")] async fn settings_are_lazily_seeded_against_real_state() { - let engine = engine_or_skip!(); - let stack = boot_stack(&engine, json!({ "decision": "needs_approval" })).await; - let iii = &stack.iii; - - // Reads never write. - let before = call( - iii, - "approval::get_settings", - json!({ "session_id": "s_lazy" }), - ) - .await - .unwrap(); - assert_eq!(before["source"], json!("defaults")); - let raw = call( - iii, - "state::get", - json!({ "scope": "approval_settings", "key": "s_lazy" }), - ) - .await - .unwrap(); - assert!(raw.is_null(), "read materialized a record: {raw}"); + with_stack(BootOpts::needs_approval(), |stack| async move { + let iii = &stack.iii; + let before = call( + iii, + "approval::get_settings", + json!({ "session_id": "s_lazy" }), + ) + .await + .unwrap(); + assert_eq!(before["source"], json!("defaults")); - // First mutation materializes. - let res = call( - iii, - "approval::approve_always", - json!({ "session_id": "s_lazy", "function_id": "shell::run" }), - ) - .await - .unwrap(); - assert_eq!( - res["settings"]["approved_always"][0]["function_id"], - json!("shell::run") - ); - let after = call( - iii, - "approval::get_settings", - json!({ "session_id": "s_lazy" }), - ) - .await - .unwrap(); - assert_eq!(after["source"], json!("stored")); + call( + iii, + "approval::approve_always", + json!({ "session_id": "s_lazy", "function_id": "shell::run" }), + ) + .await + .unwrap(); - // approve_always holds in manual mode: the gate now allows. - let out = call( - iii, - "approval::gate", - hook_input("s_lazy", "c_1", "shell::run"), - ) - .await - .unwrap(); - assert_eq!(out["decision"], json!("continue")); + let after = call( + iii, + "approval::get_settings", + json!({ "session_id": "s_lazy" }), + ) + .await + .unwrap(); + assert_eq!(after["source"], json!("stored")); - // clear_settings reverts to defaults. - let cleared = call( - iii, - "approval::clear_settings", - json!({ "session_id": "s_lazy" }), - ) - .await - .unwrap(); - assert_eq!(cleared["cleared"], json!(true)); - let reverted = call( - iii, - "approval::get_settings", - json!({ "session_id": "s_lazy" }), - ) - .await - .unwrap(); - assert_eq!(reverted["source"], json!("defaults")); + let out = call( + iii, + "approval::gate", + hook_input("s_lazy", "c_1", "shell::run"), + ) + .await + .unwrap(); + assert_eq!(out["decision"], json!("continue")); + }) + .await; } #[tokio::test(flavor = "multi_thread")] async fn human_only_targets_are_denied_through_the_full_stack() { - let engine = engine_or_skip!(); - let stack = boot_stack(&engine, json!({ "decision": "allow" })).await; - let iii = &stack.iii; - - let out = call( - iii, - "approval::gate", - hook_input("s_1", "c_1", "approval::set_mode"), - ) - .await - .unwrap(); - assert_eq!(out["decision"], json!("deny")); - assert!(out["reason"] - .as_str() - .unwrap() - .contains("human_only_function")); + with_stack(BootOpts::allow(), |stack| async move { + let out = call( + &stack.iii, + "approval::gate", + hook_input("s_1", "c_1", "approval::set_mode"), + ) + .await + .unwrap(); + assert_eq!(out["decision"], json!("deny")); + }) + .await; } From 4cd8f320b98ac463040fa18b93195e98aea2d24d Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Mon, 15 Jun 2026 09:51:35 -0300 Subject: [PATCH 04/14] ci: install the iii engine in the rust test job Engine-backed tests spawn a real iii engine and self-skip when no binary is on PATH, so the approval-gate behavior suite (fail-closed, exactly-once emission, sweep) passed in CI without actually running. Install the iii CLI before `cargo test` so those tests execute. Pinned to 0.19.2 to match the worker's iii-sdk; workers with only pure-unit tests are unaffected. --- .github/workflows/ci.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22761a13..7060c2a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,6 +158,24 @@ jobs: - name: Run clippy run: cargo clippy --all-targets --all-features -- -D warnings + # Engine-backed tests (e.g. approval-gate) spawn a real `iii` engine and + # self-skip when no binary is on PATH. Install it so those tests actually + # run in CI instead of silently passing. Workers with only pure-unit + # tests are unaffected. Mirrors the install in `interface-smoke` below. + - name: Install iii CLI + engine (for engine-backed tests) + env: + VERSION: '0.19.2' + run: | + set -euo pipefail + curl -fsSL https://install.iii.dev/iii/main/install.sh -o /tmp/install-iii.sh + sh /tmp/install-iii.sh + { + echo "$HOME/.local/bin" + echo "$HOME/.iii/bin" + } >> "$GITHUB_PATH" + export PATH="$HOME/.local/bin:$HOME/.iii/bin:$PATH" + iii --version + - name: Run tests run: cargo test --all-features From 036524912651082875500f4d9ecf1b0e1b23b2bc Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Mon, 15 Jun 2026 09:51:35 -0300 Subject: [PATCH 05/14] fix(approval-gate): leave expired holds to the sweep A human resolve arriving after a hold's deadline raced the timeout sweep: both paths called harness::function::resolve for the same call, and a late timeout could clobber the human decision. Reject a resolve once the record has expired (expires_at <= now), matching the sweep's boundary exactly, so an expired record is owned solely by the sweep. Updates two test seeds that used a past-epoch expires_at to a live deadline, and adds a test that an expired record is left for the sweep. --- approval-gate/src/events.rs | 4 +- approval-gate/src/functions/resolve.rs | 61 +++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/approval-gate/src/events.rs b/approval-gate/src/events.rs index ac1421a1..c8536a39 100644 --- a/approval-gate/src/events.rs +++ b/approval-gate/src/events.rs @@ -381,7 +381,9 @@ mod tests { "function_id": "shell::run", "arguments_excerpt": {}, "pending_at": 100, - "expires_at": 1_800_100, + // Live record so the resolve path (not the sweep) + // owns it and emits the resolved event under test. + "expires_at": crate::types::now_ms() + 1_800_000, "depth": 0, }), ) diff --git a/approval-gate/src/functions/resolve.rs b/approval-gate/src/functions/resolve.rs index 75edbe01..92e35d6d 100644 --- a/approval-gate/src/functions/resolve.rs +++ b/approval-gate/src/functions/resolve.rs @@ -42,6 +42,20 @@ pub async fn handle(deps: &Deps, req: ResolveRequest) -> Result json!({ "session_id": req.session_id, @@ -144,7 +158,9 @@ mod tests { function_id: "shell::run".into(), arguments_excerpt: json!({ "cmd": "ls" }), pending_at: 100, - expires_at: 1_800_100, + // Live record: deadline well in the future so the resolve path + // (not the sweep) owns it. + expires_at: now_ms() + 1_800_000, session_title: None, session_description: None, session_metadata: Some(serde_json::from_value(json!({ "owner": "u_1" })).unwrap()), @@ -217,6 +233,49 @@ mod tests { .await; } + #[tokio::test(flavor = "multi_thread")] + async fn expired_record_is_left_for_the_sweep() { + with_stack(BootOpts::needs_approval(), |stack| async move { + // A record whose deadline has already passed belongs to the + // sweep, not to a late human resolve. + let record = PendingApprovalRecord { + session_id: "s_1".into(), + turn_id: "t_9".into(), + function_call_id: "c_1".into(), + function_id: "shell::run".into(), + arguments_excerpt: json!({ "cmd": "ls" }), + pending_at: 100, + expires_at: 200, + session_title: None, + session_description: None, + session_metadata: None, + depth: 0, + assistant_excerpt: None, + }; + state_set( + &stack.iii, + PENDING_SCOPE, + "s_1/c_1", + serde_json::to_value(record).unwrap(), + ) + .await; + + let res = handle(&stack.deps, req(ResolveDecision::Allow, None)) + .await + .unwrap(); + assert!(!res.resolved, "expired record must not resolve here"); + assert!( + log_snapshot(&stack.harness_calls).is_empty(), + "no harness call: the sweep owns expired records" + ); + // Left intact for the sweep to collect. + assert!(!state_get(&stack.iii, PENDING_SCOPE, "s_1/c_1") + .await + .is_null()); + }) + .await; + } + #[tokio::test(flavor = "multi_thread")] async fn invalid_ids_are_rejected() { with_stack(BootOpts::needs_approval(), |stack| async move { From e08e2e1a946be92999ba7863b9fae16990f5bbf4 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Mon, 15 Jun 2026 09:57:23 -0300 Subject: [PATCH 06/14] ci: run rust tests serially to avoid engine-spawn contention Engine-backed tests each spawn their own iii engine; running them in parallel on a 2-core CI runner starves the engines until state ops hit their timeout. Run with --test-threads=1 so one engine runs at a time. Pure-unit-only workers are unaffected (their tests are fast either way). --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7060c2a2..0342acd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,7 +177,10 @@ jobs: iii --version - name: Run tests - run: cargo test --all-features + # Serial: engine-backed tests each spawn their own iii engine, and + # running them in parallel on a 2-core runner starves the engines + # until state ops time out. One engine at a time is deterministic. + run: cargo test --all-features -- --test-threads=1 # ────────────────────────────────────────────────────────────── # Interface boot smoke: build each changed Rust worker from source, From 5c2363ceaee66c71acddbfa5e704ebf4c13a639a Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Mon, 15 Jun 2026 10:03:43 -0300 Subject: [PATCH 07/14] ci: scope the iii engine install + serial tests to approval-gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior change ran globally in the rust matrix, so it installed iii for every changed worker — which made other workers' engine-gated integration tests (console, iii-directory, session-manager) attempt a real engine dial and fail. Gate both the install and --test-threads=1 on the approval-gate worker; every other worker keeps its prior command and behavior. --- .github/workflows/ci.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0342acd8..fe449b96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,11 +158,13 @@ jobs: - name: Run clippy run: cargo clippy --all-targets --all-features -- -D warnings - # Engine-backed tests (e.g. approval-gate) spawn a real `iii` engine and - # self-skip when no binary is on PATH. Install it so those tests actually - # run in CI instead of silently passing. Workers with only pure-unit - # tests are unaffected. Mirrors the install in `interface-smoke` below. + # approval-gate's tests spawn a real `iii` engine and self-skip when no + # binary is on PATH, so without this the behavior suite passes in CI + # without running. Scoped to that worker by name (not global) so other + # workers' engine-gated tests keep their existing behavior; add a worker + # here when it grows engine-backed tests. Mirrors `interface-smoke` below. - name: Install iii CLI + engine (for engine-backed tests) + if: matrix.worker == 'approval-gate' env: VERSION: '0.19.2' run: | @@ -177,10 +179,11 @@ jobs: iii --version - name: Run tests - # Serial: engine-backed tests each spawn their own iii engine, and - # running them in parallel on a 2-core runner starves the engines - # until state ops time out. One engine at a time is deterministic. - run: cargo test --all-features -- --test-threads=1 + # approval-gate runs serially: its engine-backed tests each spawn their + # own iii engine, and running them in parallel on a 2-core runner + # starves the engines until state ops time out. Other workers keep the + # default parallel run. + run: cargo test --all-features ${{ matrix.worker == 'approval-gate' && '-- --test-threads=1' || '' }} # ────────────────────────────────────────────────────────────── # Interface boot smoke: build each changed Rust worker from source, From b3d584e3e7c8d62640541ed837f9d599c21ee63a Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Mon, 15 Jun 2026 10:47:33 -0300 Subject: [PATCH 08/14] chore: register approval-gate in the release pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs/sops/new-worker.md §3 and §6: add the Modules table row, the create-tag.yml worker option, and the release.yml `approval-gate/v*` tag pattern. Without these the worker has no README entry, can't be picked in the manual release dropdown, and pushing an `approval-gate/vX.Y.Z` tag would trigger nothing. --- .github/workflows/create-tag.yml | 1 + .github/workflows/release.yml | 1 + README.md | 1 + 3 files changed, 3 insertions(+) diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index 3d528248..c912fa29 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -10,6 +10,7 @@ on: type: choice options: - acp + - approval-gate - claude-code - coder - console diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a76b8ec..614ac026 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,7 @@ on: push: tags: - 'acp/v*' + - 'approval-gate/v*' - 'claude-code/v*' - 'coder/v*' - 'console/v*' diff --git a/README.md b/README.md index 7ab89468..0dc3f39f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ npx skills add iii-hq/iii --all | Worker | Kind | Summary | |---|---|---| | [`acp`](acp/) | Rust | Agent Client Protocol surface — stdio JSON-RPC, exposes iii agents as ACP sessions. | +| [`approval-gate`](approval-gate/) | Rust | Human-in-the-loop approval gate — evaluates each function call (continue / deny / hold), holds pending calls for a human, and emits `approval::pending_*` events. Binds the harness `pre_dispatch` hook. See [`approval-gate/architecture/`](approval-gate/architecture/). | | [`harness`](harness/) | Node | TS port of the iii harness stack — bundles `harness` (provider registry + credentials/settings/permissions via the `configuration` worker), `turn-orchestrator`, `approval-gate`, `hook-fanout`, `models-catalog`, the `provider-*` workers, `llm-budget`, and `context-compaction` as one pnpm monorepo. Conversations persist in `session-manager`. See [`harness/README.md`](harness/README.md). | | [`claude-code`](claude-code/) | Node | Claude Code as an iii worker — `claude::*` runs headless Claude Code turns, mirrors raw messages onto `claude::events`, and streams AgentEvent frames onto `agent::events`. | | [`session-manager`](session-manager/) | Rust | Durable, reactive, branching conversation store — fourteen `session::*` functions plus six trigger types; the transcript backend for `harness` and `console`. See [`session-manager/architecture/`](session-manager/architecture/). | From a642027af0d578e2778bec61bacbbbdce594948f Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Mon, 15 Jun 2026 10:47:33 -0300 Subject: [PATCH 09/14] refactor(approval-gate): gate testkit behind a feature, out of the prod build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine-backed tests live inline in src/, so testkit must stay in the crate — but it should not compile into the production lib/binary. Gate `mod testkit` on `any(test, feature = "testkit")` and add the feature; a self dev-dependency enables it for the integration tests so `cargo test` still works without `--all-features`. --- approval-gate/Cargo.lock | 1 + approval-gate/Cargo.toml | 10 ++++++++++ approval-gate/src/lib.rs | 1 + 3 files changed, 12 insertions(+) diff --git a/approval-gate/Cargo.lock b/approval-gate/Cargo.lock index 5c4730c7..423675bc 100644 --- a/approval-gate/Cargo.lock +++ b/approval-gate/Cargo.lock @@ -72,6 +72,7 @@ name = "approval-gate" version = "0.1.0" dependencies = [ "anyhow", + "approval-gate", "async-trait", "base64", "clap", diff --git a/approval-gate/Cargo.toml b/approval-gate/Cargo.toml index 2127b06a..83eea178 100644 --- a/approval-gate/Cargo.toml +++ b/approval-gate/Cargo.toml @@ -32,7 +32,17 @@ async-trait = "0.1" schemars = "0.8" base64 = "0.22" +[features] +# Test-only seam: exposes `testkit` (engine bootstrap for the engine-backed +# tests) without compiling it into the production lib/binary. Off by default, +# so `cargo build` / the release binary exclude it. Enabled for this crate's +# own tests via the self dev-dependency below; CI also runs `--all-features`. +testkit = [] + [dev-dependencies] +# Self-reference enabling the `testkit` feature for the integration tests +# (which link the lib) so `cargo test` works without `--all-features`. +approval-gate = { path = ".", features = ["testkit"] } futures = "0.3" uuid = { version = "1", features = ["v4"] } tempfile = "3" diff --git a/approval-gate/src/lib.rs b/approval-gate/src/lib.rs index 3115ee7c..cb03be48 100644 --- a/approval-gate/src/lib.rs +++ b/approval-gate/src/lib.rs @@ -31,5 +31,6 @@ pub mod redact; pub mod session; pub mod settings; pub mod state; +#[cfg(any(test, feature = "testkit"))] pub mod testkit; pub mod types; From ba9333c6104521e74642787cae13bb26c9696499 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Mon, 15 Jun 2026 10:59:38 -0300 Subject: [PATCH 10/14] test(approval-gate): pin wire schemas with golden snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot the request/response schemas of the 14 approval::* functions and the 2 trigger payloads under tests/golden/schemas/*.json, generated with SchemaSettings::draft07() — the same generator iii-sdk uses at registration, so the goldens pin exactly what the engine publishes. A catalog()/trigger_catalog() is the single source of truth; function ids and descriptions are now shared consts used by both register_all and the catalog. Regenerate with UPDATE_GOLDENS=1. --- approval-gate/src/events.rs | 15 +- approval-gate/src/functions/mod.rs | 241 ++++++++++++++---- approval-gate/src/types.rs | 2 + .../schemas/approval.add_always_allow.json | 119 +++++++++ .../schemas/approval.approve_always.json | 119 +++++++++ .../schemas/approval.clear_settings.json | 30 +++ .../tests/golden/schemas/approval.gate.json | 139 ++++++++++ .../golden/schemas/approval.get_pending.json | 122 +++++++++ .../golden/schemas/approval.get_settings.json | 130 ++++++++++ .../golden/schemas/approval.list_pending.json | 143 +++++++++++ .../schemas/approval.on_config_change.json | 26 ++ .../schemas/approval.on_session_deleted.json | 22 ++ .../schemas/approval.on_turn_completed.json | 22 ++ .../schemas/approval.pending_created.json | 79 ++++++ .../schemas/approval.pending_resolved.json | 66 +++++ .../schemas/approval.remove_always_allow.json | 119 +++++++++ .../golden/schemas/approval.resolve.json | 63 +++++ .../golden/schemas/approval.set_mode.json | 129 ++++++++++ .../tests/golden/schemas/approval.sweep.json | 12 + approval-gate/tests/schemas.rs | 155 +++++++++++ approval-gate/tests/support/mod.rs | 90 +++++++ 21 files changed, 1790 insertions(+), 53 deletions(-) create mode 100644 approval-gate/tests/golden/schemas/approval.add_always_allow.json create mode 100644 approval-gate/tests/golden/schemas/approval.approve_always.json create mode 100644 approval-gate/tests/golden/schemas/approval.clear_settings.json create mode 100644 approval-gate/tests/golden/schemas/approval.gate.json create mode 100644 approval-gate/tests/golden/schemas/approval.get_pending.json create mode 100644 approval-gate/tests/golden/schemas/approval.get_settings.json create mode 100644 approval-gate/tests/golden/schemas/approval.list_pending.json create mode 100644 approval-gate/tests/golden/schemas/approval.on_config_change.json create mode 100644 approval-gate/tests/golden/schemas/approval.on_session_deleted.json create mode 100644 approval-gate/tests/golden/schemas/approval.on_turn_completed.json create mode 100644 approval-gate/tests/golden/schemas/approval.pending_created.json create mode 100644 approval-gate/tests/golden/schemas/approval.pending_resolved.json create mode 100644 approval-gate/tests/golden/schemas/approval.remove_always_allow.json create mode 100644 approval-gate/tests/golden/schemas/approval.resolve.json create mode 100644 approval-gate/tests/golden/schemas/approval.set_mode.json create mode 100644 approval-gate/tests/golden/schemas/approval.sweep.json create mode 100644 approval-gate/tests/schemas.rs create mode 100644 approval-gate/tests/support/mod.rs diff --git a/approval-gate/src/events.rs b/approval-gate/src/events.rs index c8536a39..7fc64dfc 100644 --- a/approval-gate/src/events.rs +++ b/approval-gate/src/events.rs @@ -26,7 +26,15 @@ use serde_json::Value; use crate::types::{metadata_matches, JsonMap, PendingApprovalRecord, PendingResolvedEvent}; pub const PENDING_CREATED: &str = "approval::pending_created"; +pub const PENDING_CREATED_DESC: &str = + "A function call was held for human approval and its inbox record written. \ + Payload: PendingApprovalRecord (redacted args, session context, expiry). \ + Bind notification workers here."; + pub const PENDING_RESOLVED: &str = "approval::pending_resolved"; +pub const PENDING_RESOLVED_DESC: &str = + "A pending approval left the inbox (outcome: allow | deny | timeout | aborted). \ + Emitted exactly once per record; lets UIs clear badges."; /// Config accepted by both trigger types. #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] @@ -193,9 +201,7 @@ pub fn register_trigger_types(iii: &Arc) -> TriggerSets { let _ = iii.register_trigger_type( RegisterTriggerType::new( PENDING_CREATED, - "A function call was held for human approval and its inbox record written. \ - Payload: PendingApprovalRecord (redacted args, session context, expiry). \ - Bind notification workers here.", + PENDING_CREATED_DESC, ApprovalTriggerHandler { set: sets.created.clone(), }, @@ -205,8 +211,7 @@ pub fn register_trigger_types(iii: &Arc) -> TriggerSets { let _ = iii.register_trigger_type( RegisterTriggerType::new( PENDING_RESOLVED, - "A pending approval left the inbox (outcome: allow | deny | timeout | aborted). \ - Emitted exactly once per record; lets UIs clear badges.", + PENDING_RESOLVED_DESC, ApprovalTriggerHandler { set: sets.resolved.clone(), }, diff --git a/approval-gate/src/functions/mod.rs b/approval-gate/src/functions/mod.rs index ac5743a0..5e5f5a06 100644 --- a/approval-gate/src/functions/mod.rs +++ b/approval-gate/src/functions/mod.rs @@ -33,6 +33,60 @@ use crate::error::ApprovalError; use crate::events::EventSink; use crate::gate_config::SharedDefaults; +// --------------------------------------------------------------------------- +// Function ID + description constants — single source of truth consumed by +// both register_all and catalog(). +// --------------------------------------------------------------------------- + +pub const GATE_ID: &str = "approval::gate"; +pub const GATE_DESC: &str = "pre_dispatch hook: evaluate the permission model and answer continue / deny / hold; writes the pending inbox record on hold. Called by the harness only."; + +pub const RESOLVE_ID: &str = "approval::resolve"; +pub const RESOLVE_DESC: &str = "Apply a human decision to a held call: release it for execution (allow) or deliver a denial (deny). Human/console-only."; + +pub const LIST_PENDING_ID: &str = "approval::list_pending"; +pub const LIST_PENDING_DESC: &str = "The pending inbox across sessions, with tenancy filters; the catch-up path for notification workers after a restart."; + +pub const GET_PENDING_ID: &str = "approval::get_pending"; +pub const GET_PENDING_DESC: &str = "Read one pending record; null when resolved or unknown."; + +pub const SET_MODE_ID: &str = "approval::set_mode"; +pub const SET_MODE_DESC: &str = + "Set the session's permission mode (manual / auto / full). Human/console-only."; + +pub const ADD_ALWAYS_ALLOW_ID: &str = "approval::add_always_allow"; +pub const ADD_ALWAYS_ALLOW_DESC: &str = + "Add a function to the session's auto-mode trust list (idempotent). Human/console-only."; + +pub const REMOVE_ALWAYS_ALLOW_ID: &str = "approval::remove_always_allow"; +pub const REMOVE_ALWAYS_ALLOW_DESC: &str = "Remove a function from the session's auto-mode trust list (no-op when absent). Human/console-only."; + +pub const APPROVE_ALWAYS_ID: &str = "approval::approve_always"; +pub const APPROVE_ALWAYS_DESC: &str = + "Record a per-session 'approve always' grant (honoured in every mode). Human/console-only."; + +pub const GET_SETTINGS_ID: &str = "approval::get_settings"; +pub const GET_SETTINGS_DESC: &str = "Read the session's effective settings (stored record or configuration defaults); never writes."; + +pub const CLEAR_SETTINGS_ID: &str = "approval::clear_settings"; +pub const CLEAR_SETTINGS_DESC: &str = + "Drop the session's stored settings record (revert to configuration defaults)."; + +pub const ON_CONFIG_CHANGE_ID: &str = "approval::on_config_change"; +pub const ON_CONFIG_CHANGE_DESC: &str = + "Internal: configuration trigger handler (reload deployment defaults)."; + +pub const ON_SESSION_DELETED_ID: &str = "approval::on_session_deleted"; +pub const ON_SESSION_DELETED_DESC: &str = + "Internal: session::deleted handler (purge the session's settings and pending records)."; + +pub const ON_TURN_COMPLETED_ID: &str = "approval::on_turn_completed"; +pub const ON_TURN_COMPLETED_DESC: &str = + "Internal: harness::turn_completed handler (purge the turn's pending records)."; + +pub const SWEEP_ID: &str = "approval::sweep"; +pub const SWEEP_DESC: &str = "Internal: cron handler (expire pending records past expires_at)."; + /// Everything a function handler needs. pub struct Deps { pub iii: Arc, @@ -68,104 +122,195 @@ fn register( } pub fn register_all(iii: &Arc, deps: &Arc) { + register(iii, deps, GATE_ID, GATE_DESC, |d, r| async move { + gate::handle(&d, r).await + }); + register(iii, deps, RESOLVE_ID, RESOLVE_DESC, |d, r| async move { + resolve::handle(&d, r).await + }); register( iii, deps, - "approval::gate", - "pre_dispatch hook: evaluate the permission model and answer continue / deny / hold; writes the pending inbox record on hold. Called by the harness only.", - |d, r| async move { gate::handle(&d, r).await }, - ); - register( - iii, - deps, - "approval::resolve", - "Apply a human decision to a held call: release it for execution (allow) or deliver a denial (deny). Human/console-only.", - |d, r| async move { resolve::handle(&d, r).await }, - ); - register( - iii, - deps, - "approval::list_pending", - "The pending inbox across sessions, with tenancy filters; the catch-up path for notification workers after a restart.", + LIST_PENDING_ID, + LIST_PENDING_DESC, |d, r| async move { list_pending::handle(&d, r).await }, ); register( iii, deps, - "approval::get_pending", - "Read one pending record; null when resolved or unknown.", + GET_PENDING_ID, + GET_PENDING_DESC, |d, r| async move { get_pending::handle(&d, r).await }, ); + register(iii, deps, SET_MODE_ID, SET_MODE_DESC, |d, r| async move { + set_mode::handle(&d, r).await + }); register( iii, deps, - "approval::set_mode", - "Set the session's permission mode (manual / auto / full). Human/console-only.", - |d, r| async move { set_mode::handle(&d, r).await }, - ); - register( - iii, - deps, - "approval::add_always_allow", - "Add a function to the session's auto-mode trust list (idempotent). Human/console-only.", + ADD_ALWAYS_ALLOW_ID, + ADD_ALWAYS_ALLOW_DESC, |d, r| async move { add_always_allow::handle(&d, r).await }, ); register( iii, deps, - "approval::remove_always_allow", - "Remove a function from the session's auto-mode trust list (no-op when absent). Human/console-only.", + REMOVE_ALWAYS_ALLOW_ID, + REMOVE_ALWAYS_ALLOW_DESC, |d, r| async move { remove_always_allow::handle(&d, r).await }, ); register( iii, deps, - "approval::approve_always", - "Record a per-session 'approve always' grant (honoured in every mode). Human/console-only.", + APPROVE_ALWAYS_ID, + APPROVE_ALWAYS_DESC, |d, r| async move { approve_always::handle(&d, r).await }, ); register( iii, deps, - "approval::get_settings", - "Read the session's effective settings (stored record or configuration defaults); never writes.", + GET_SETTINGS_ID, + GET_SETTINGS_DESC, |d, r| async move { get_settings::handle(&d, r).await }, ); register( iii, deps, - "approval::clear_settings", - "Drop the session's stored settings record (revert to configuration defaults).", + CLEAR_SETTINGS_ID, + CLEAR_SETTINGS_DESC, |d, r| async move { clear_settings::handle(&d, r).await }, ); register( iii, deps, - "approval::on_config_change", - "Internal: configuration trigger handler (reload deployment defaults).", + ON_CONFIG_CHANGE_ID, + ON_CONFIG_CHANGE_DESC, |d, r| async move { on_config_change::handle(&d, r).await }, ); register( iii, deps, - "approval::on_session_deleted", - "Internal: session::deleted handler (purge the session's settings and pending records).", + ON_SESSION_DELETED_ID, + ON_SESSION_DELETED_DESC, |d, r| async move { on_session_deleted::handle(&d, r).await }, ); register( iii, deps, - "approval::on_turn_completed", - "Internal: harness::turn_completed handler (purge the turn's pending records).", + ON_TURN_COMPLETED_ID, + ON_TURN_COMPLETED_DESC, |d, r| async move { on_turn_completed::handle(&d, r).await }, ); - register( - iii, - deps, - "approval::sweep", - "Internal: cron handler (expire pending records past expires_at).", - |d, r| async move { sweep::handle(&d, r).await }, - ); + register(iii, deps, SWEEP_ID, SWEEP_DESC, |d, r| async move { + sweep::handle(&d, r).await + }); tracing::info!("all approval::* functions registered"); } + +// --------------------------------------------------------------------------- +// Wire-surface catalog — golden-tested in tests/schemas.rs. +// --------------------------------------------------------------------------- + +/// One function's complete agent-facing wire surface: id, registration +/// description, and the schemars-derived request/response schemas. +pub struct FunctionSpec { + pub function_id: &'static str, + pub description: &'static str, + pub request_schema: schemars::schema::RootSchema, + pub response_schema: schemars::schema::RootSchema, +} + +/// Schema generation MUST mirror iii-sdk's internal `json_schema_for` +/// (`SchemaSettings::draft07()` on the handler's request/response types): +/// `RegisterFunction::new_async` auto-extracts schemas from the SAME structs +/// referenced here, with the same schemars 0.8 generator settings, so a +/// catalog snapshot pins exactly what registration emits. +fn schema_of() -> schemars::schema::RootSchema { + schemars::r#gen::SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::() +} + +fn spec(function_id: &'static str, description: &'static str) -> FunctionSpec +where + Req: schemars::JsonSchema, + Resp: schemars::JsonSchema, +{ + FunctionSpec { + function_id, + description, + request_schema: schema_of::(), + response_schema: schema_of::(), + } +} + +/// The full wire-surface catalog, in registration order. Golden-tested in +/// `tests/schemas.rs`; keep in lockstep with `register_all`. +pub fn catalog() -> Vec { + use crate::types::{ + AlwaysAllowMutationRequest, ApproveAlwaysRequest, ClearSettingsRequest, + ClearSettingsResponse, GetPendingRequest, GetPendingResponse, GetSettingsRequest, + GetSettingsResponse, HookInput, HookOutput, ListPendingRequest, ListPendingResponse, + ResolveRequest, ResolveResponse, SetModeRequest, SettingsResponse, + }; + use on_config_change::ConfigChangeEvent; + use on_session_deleted::SessionDeletedEvent; + use on_turn_completed::TurnCompletedEvent; + use serde_json::Value; + + vec![ + spec::(GATE_ID, GATE_DESC), + spec::(RESOLVE_ID, RESOLVE_DESC), + spec::(LIST_PENDING_ID, LIST_PENDING_DESC), + spec::>(GET_PENDING_ID, GET_PENDING_DESC), + spec::(SET_MODE_ID, SET_MODE_DESC), + spec::( + ADD_ALWAYS_ALLOW_ID, + ADD_ALWAYS_ALLOW_DESC, + ), + spec::( + REMOVE_ALWAYS_ALLOW_ID, + REMOVE_ALWAYS_ALLOW_DESC, + ), + spec::(APPROVE_ALWAYS_ID, APPROVE_ALWAYS_DESC), + spec::(GET_SETTINGS_ID, GET_SETTINGS_DESC), + spec::(CLEAR_SETTINGS_ID, CLEAR_SETTINGS_DESC), + spec::(ON_CONFIG_CHANGE_ID, ON_CONFIG_CHANGE_DESC), + spec::(ON_SESSION_DELETED_ID, ON_SESSION_DELETED_DESC), + spec::(ON_TURN_COMPLETED_ID, ON_TURN_COMPLETED_DESC), + spec::(SWEEP_ID, SWEEP_DESC), + ] +} + +// --------------------------------------------------------------------------- +// Trigger wire-surface catalog. +// --------------------------------------------------------------------------- + +/// One trigger type's complete agent-facing wire surface: id, description, +/// and the schemars-derived payload schema. +pub struct TriggerSpec { + pub trigger_id: &'static str, + pub description: &'static str, + pub payload_schema: schemars::schema::RootSchema, +} + +/// The full trigger wire-surface catalog, in registration order. Golden-tested +/// in `tests/schemas.rs`; keep in lockstep with `events::register_trigger_types`. +pub fn trigger_catalog() -> Vec { + use crate::events::{PENDING_CREATED, PENDING_RESOLVED}; + use crate::types::{PendingApprovalRecord, PendingResolvedEvent}; + + vec![ + TriggerSpec { + trigger_id: PENDING_CREATED, + description: crate::events::PENDING_CREATED_DESC, + payload_schema: schema_of::(), + }, + TriggerSpec { + trigger_id: PENDING_RESOLVED, + description: crate::events::PENDING_RESOLVED_DESC, + payload_schema: schema_of::(), + }, + ] +} diff --git a/approval-gate/src/types.rs b/approval-gate/src/types.rs index 4c376994..99072c0d 100644 --- a/approval-gate/src/types.rs +++ b/approval-gate/src/types.rs @@ -309,7 +309,9 @@ pub struct ListPendingResponse { #[derive(Debug, Clone, Deserialize, JsonSchema)] pub struct GetPendingRequest { + /// The session the pending record belongs to. pub session_id: String, + /// The function call id of the held call. pub function_call_id: String, } diff --git a/approval-gate/tests/golden/schemas/approval.add_always_allow.json b/approval-gate/tests/golden/schemas/approval.add_always_allow.json new file mode 100644 index 00000000..3ecf06b4 --- /dev/null +++ b/approval-gate/tests/golden/schemas/approval.add_always_allow.json @@ -0,0 +1,119 @@ +{ + "description": "Add a function to the session's auto-mode trust list (idempotent). Human/console-only.", + "function_id": "approval::add_always_allow", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "function_id": { + "type": "string" + }, + "session_id": { + "type": "string" + } + }, + "required": [ + "function_id", + "session_id" + ], + "title": "AlwaysAllowMutationRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AlwaysAllowEntry": { + "properties": { + "function_id": { + "type": "string" + }, + "granted_at": { + "description": "ms epoch", + "format": "int64", + "type": "integer" + }, + "granted_by": { + "$ref": "#/definitions/GrantedBy" + } + }, + "required": [ + "function_id", + "granted_at", + "granted_by" + ], + "type": "object" + }, + "ApprovalSettings": { + "description": "The stored per-session record (scope `approval_settings`). Reads compute the effective settings from configuration defaults when no record exists — see settings.rs.", + "properties": { + "always_allow": { + "default": [], + "description": "Consulted only in auto mode.", + "items": { + "$ref": "#/definitions/AlwaysAllowEntry" + }, + "type": "array" + }, + "approved_always": { + "default": [], + "description": "Consulted in every mode — remembered human decisions.", + "items": { + "$ref": "#/definitions/AlwaysAllowEntry" + }, + "type": "array" + }, + "mode": { + "allOf": [ + { + "$ref": "#/definitions/PermissionMode" + } + ], + "default": "manual" + }, + "mode_set_at": { + "default": 0, + "description": "ms epoch", + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, + "GrantedBy": { + "oneOf": [ + { + "enum": [ + "user_click" + ], + "type": "string" + }, + { + "description": "Copied from the deployment's `always_allow_seed` on first mutation.", + "enum": [ + "seed" + ], + "type": "string" + } + ] + }, + "PermissionMode": { + "enum": [ + "manual", + "auto", + "full" + ], + "type": "string" + } + }, + "description": "Shared by every settings mutation RPC: the post-mutation record.", + "properties": { + "settings": { + "$ref": "#/definitions/ApprovalSettings" + } + }, + "required": [ + "settings" + ], + "title": "SettingsResponse", + "type": "object" + } +} diff --git a/approval-gate/tests/golden/schemas/approval.approve_always.json b/approval-gate/tests/golden/schemas/approval.approve_always.json new file mode 100644 index 00000000..a6ba432b --- /dev/null +++ b/approval-gate/tests/golden/schemas/approval.approve_always.json @@ -0,0 +1,119 @@ +{ + "description": "Record a per-session 'approve always' grant (honoured in every mode). Human/console-only.", + "function_id": "approval::approve_always", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "function_id": { + "type": "string" + }, + "session_id": { + "type": "string" + } + }, + "required": [ + "function_id", + "session_id" + ], + "title": "ApproveAlwaysRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AlwaysAllowEntry": { + "properties": { + "function_id": { + "type": "string" + }, + "granted_at": { + "description": "ms epoch", + "format": "int64", + "type": "integer" + }, + "granted_by": { + "$ref": "#/definitions/GrantedBy" + } + }, + "required": [ + "function_id", + "granted_at", + "granted_by" + ], + "type": "object" + }, + "ApprovalSettings": { + "description": "The stored per-session record (scope `approval_settings`). Reads compute the effective settings from configuration defaults when no record exists — see settings.rs.", + "properties": { + "always_allow": { + "default": [], + "description": "Consulted only in auto mode.", + "items": { + "$ref": "#/definitions/AlwaysAllowEntry" + }, + "type": "array" + }, + "approved_always": { + "default": [], + "description": "Consulted in every mode — remembered human decisions.", + "items": { + "$ref": "#/definitions/AlwaysAllowEntry" + }, + "type": "array" + }, + "mode": { + "allOf": [ + { + "$ref": "#/definitions/PermissionMode" + } + ], + "default": "manual" + }, + "mode_set_at": { + "default": 0, + "description": "ms epoch", + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, + "GrantedBy": { + "oneOf": [ + { + "enum": [ + "user_click" + ], + "type": "string" + }, + { + "description": "Copied from the deployment's `always_allow_seed` on first mutation.", + "enum": [ + "seed" + ], + "type": "string" + } + ] + }, + "PermissionMode": { + "enum": [ + "manual", + "auto", + "full" + ], + "type": "string" + } + }, + "description": "Shared by every settings mutation RPC: the post-mutation record.", + "properties": { + "settings": { + "$ref": "#/definitions/ApprovalSettings" + } + }, + "required": [ + "settings" + ], + "title": "SettingsResponse", + "type": "object" + } +} diff --git a/approval-gate/tests/golden/schemas/approval.clear_settings.json b/approval-gate/tests/golden/schemas/approval.clear_settings.json new file mode 100644 index 00000000..931dbe34 --- /dev/null +++ b/approval-gate/tests/golden/schemas/approval.clear_settings.json @@ -0,0 +1,30 @@ +{ + "description": "Drop the session's stored settings record (revert to configuration defaults).", + "function_id": "approval::clear_settings", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "session_id": { + "type": "string" + } + }, + "required": [ + "session_id" + ], + "title": "ClearSettingsRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cleared": { + "type": "boolean" + } + }, + "required": [ + "cleared" + ], + "title": "ClearSettingsResponse", + "type": "object" + } +} diff --git a/approval-gate/tests/golden/schemas/approval.gate.json b/approval-gate/tests/golden/schemas/approval.gate.json new file mode 100644 index 00000000..d7d0b31f --- /dev/null +++ b/approval-gate/tests/golden/schemas/approval.gate.json @@ -0,0 +1,139 @@ +{ + "description": "pre_dispatch hook: evaluate the permission model and answer continue / deny / hold; writes the pending inbox record on hold. Called by the harness only.", + "function_id": "approval::gate", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "HookCall": { + "properties": { + "arguments": { + "default": null + }, + "function_id": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "function_id", + "id" + ], + "type": "object" + } + }, + "properties": { + "call": { + "anyOf": [ + { + "$ref": "#/definitions/HookCall" + }, + { + "type": "null" + } + ], + "description": "pre_dispatch payload." + }, + "depth": { + "default": 0, + "description": "Sub-agent depth (hooks run for child turns too).", + "format": "int64", + "type": "integer" + }, + "metadata": { + "additionalProperties": true, + "default": null, + "description": "The per-send tracing metadata.", + "type": [ + "object", + "null" + ] + }, + "point": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string" + }, + "step": { + "default": null, + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "turn_id": { + "type": "string" + } + }, + "required": [ + "session_id", + "turn_id" + ], + "title": "HookInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "oneOf": [ + { + "properties": { + "decision": { + "enum": [ + "continue" + ], + "type": "string" + } + }, + "required": [ + "decision" + ], + "type": "object" + }, + { + "properties": { + "decision": { + "enum": [ + "deny" + ], + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "decision", + "reason" + ], + "type": "object" + }, + { + "properties": { + "decision": { + "enum": [ + "hold" + ], + "type": "string" + }, + "pending_timeout_ms": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "decision", + "pending_timeout_ms" + ], + "type": "object" + } + ], + "title": "HookOutput" + } +} diff --git a/approval-gate/tests/golden/schemas/approval.get_pending.json b/approval-gate/tests/golden/schemas/approval.get_pending.json new file mode 100644 index 00000000..56caa02a --- /dev/null +++ b/approval-gate/tests/golden/schemas/approval.get_pending.json @@ -0,0 +1,122 @@ +{ + "description": "Read one pending record; null when resolved or unknown.", + "function_id": "approval::get_pending", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "function_call_id": { + "description": "The function call id of the held call.", + "type": "string" + }, + "session_id": { + "description": "The session the pending record belongs to.", + "type": "string" + } + }, + "required": [ + "function_call_id", + "session_id" + ], + "title": "GetPendingRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "anyOf": [ + { + "$ref": "#/definitions/GetPendingResponse" + }, + { + "type": "null" + } + ], + "definitions": { + "GetPendingResponse": { + "description": "`null` (handler returns `Option`) when resolved or unknown.", + "properties": { + "pending": { + "$ref": "#/definitions/PendingApprovalRecord" + } + }, + "required": [ + "pending" + ], + "type": "object" + }, + "PendingApprovalRecord": { + "description": "The inbox payload — shared by both triggers, `list_pending`, and `get_pending`. Self-describing (all ids inside the value) and notification-safe (arguments pass through redaction).", + "properties": { + "arguments_excerpt": { + "default": null, + "description": "Redacted — safe to forward to notification channels." + }, + "assistant_excerpt": { + "description": "First text block of the assistant message that contained this function_call. Best-effort; not derivable from `pre_dispatch` `HookInput` in v1, so always omitted today.", + "type": [ + "string", + "null" + ] + }, + "depth": { + "default": 0, + "description": "Sub-agent depth (0 = top-level), from `HookInput`.", + "format": "int64", + "type": "integer" + }, + "expires_at": { + "description": "`pending_at + pending_timeout_ms`", + "format": "int64", + "type": "integer" + }, + "function_call_id": { + "type": "string" + }, + "function_id": { + "type": "string" + }, + "pending_at": { + "description": "ms epoch", + "format": "int64", + "type": "integer" + }, + "session_description": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string" + }, + "session_metadata": { + "additionalProperties": true, + "description": "Tenancy + routing (trigger config filter target).", + "type": [ + "object", + "null" + ] + }, + "session_title": { + "type": [ + "string", + "null" + ] + }, + "turn_id": { + "type": "string" + } + }, + "required": [ + "expires_at", + "function_call_id", + "function_id", + "pending_at", + "session_id", + "turn_id" + ], + "type": "object" + } + }, + "title": "Nullable_GetPendingResponse" + } +} diff --git a/approval-gate/tests/golden/schemas/approval.get_settings.json b/approval-gate/tests/golden/schemas/approval.get_settings.json new file mode 100644 index 00000000..daf3187e --- /dev/null +++ b/approval-gate/tests/golden/schemas/approval.get_settings.json @@ -0,0 +1,130 @@ +{ + "description": "Read the session's effective settings (stored record or configuration defaults); never writes.", + "function_id": "approval::get_settings", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "session_id": { + "type": "string" + } + }, + "required": [ + "session_id" + ], + "title": "GetSettingsRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AlwaysAllowEntry": { + "properties": { + "function_id": { + "type": "string" + }, + "granted_at": { + "description": "ms epoch", + "format": "int64", + "type": "integer" + }, + "granted_by": { + "$ref": "#/definitions/GrantedBy" + } + }, + "required": [ + "function_id", + "granted_at", + "granted_by" + ], + "type": "object" + }, + "ApprovalSettings": { + "description": "The stored per-session record (scope `approval_settings`). Reads compute the effective settings from configuration defaults when no record exists — see settings.rs.", + "properties": { + "always_allow": { + "default": [], + "description": "Consulted only in auto mode.", + "items": { + "$ref": "#/definitions/AlwaysAllowEntry" + }, + "type": "array" + }, + "approved_always": { + "default": [], + "description": "Consulted in every mode — remembered human decisions.", + "items": { + "$ref": "#/definitions/AlwaysAllowEntry" + }, + "type": "array" + }, + "mode": { + "allOf": [ + { + "$ref": "#/definitions/PermissionMode" + } + ], + "default": "manual" + }, + "mode_set_at": { + "default": 0, + "description": "ms epoch", + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, + "GrantedBy": { + "oneOf": [ + { + "enum": [ + "user_click" + ], + "type": "string" + }, + { + "description": "Copied from the deployment's `always_allow_seed` on first mutation.", + "enum": [ + "seed" + ], + "type": "string" + } + ] + }, + "PermissionMode": { + "enum": [ + "manual", + "auto", + "full" + ], + "type": "string" + }, + "SettingsSource": { + "enum": [ + "stored", + "defaults" + ], + "type": "string" + } + }, + "properties": { + "settings": { + "$ref": "#/definitions/ApprovalSettings" + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/SettingsSource" + } + ], + "description": "Whether a per-session record exists." + } + }, + "required": [ + "settings", + "source" + ], + "title": "GetSettingsResponse", + "type": "object" + } +} diff --git a/approval-gate/tests/golden/schemas/approval.list_pending.json b/approval-gate/tests/golden/schemas/approval.list_pending.json new file mode 100644 index 00000000..5abed7ed --- /dev/null +++ b/approval-gate/tests/golden/schemas/approval.list_pending.json @@ -0,0 +1,143 @@ +{ + "description": "The pending inbox across sessions, with tenancy filters; the catch-up path for notification workers after a restart.", + "function_id": "approval::list_pending", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "default": null, + "description": "Opaque.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "default": null, + "description": "Default 50.", + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "metadata": { + "additionalProperties": true, + "default": null, + "description": "Equality match against `session_metadata` (tenancy).", + "type": [ + "object", + "null" + ] + }, + "session_id": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "title": "ListPendingRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PendingApprovalRecord": { + "description": "The inbox payload — shared by both triggers, `list_pending`, and `get_pending`. Self-describing (all ids inside the value) and notification-safe (arguments pass through redaction).", + "properties": { + "arguments_excerpt": { + "default": null, + "description": "Redacted — safe to forward to notification channels." + }, + "assistant_excerpt": { + "description": "First text block of the assistant message that contained this function_call. Best-effort; not derivable from `pre_dispatch` `HookInput` in v1, so always omitted today.", + "type": [ + "string", + "null" + ] + }, + "depth": { + "default": 0, + "description": "Sub-agent depth (0 = top-level), from `HookInput`.", + "format": "int64", + "type": "integer" + }, + "expires_at": { + "description": "`pending_at + pending_timeout_ms`", + "format": "int64", + "type": "integer" + }, + "function_call_id": { + "type": "string" + }, + "function_id": { + "type": "string" + }, + "pending_at": { + "description": "ms epoch", + "format": "int64", + "type": "integer" + }, + "session_description": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string" + }, + "session_metadata": { + "additionalProperties": true, + "description": "Tenancy + routing (trigger config filter target).", + "type": [ + "object", + "null" + ] + }, + "session_title": { + "type": [ + "string", + "null" + ] + }, + "turn_id": { + "type": "string" + } + }, + "required": [ + "expires_at", + "function_call_id", + "function_id", + "pending_at", + "session_id", + "turn_id" + ], + "type": "object" + } + }, + "properties": { + "next_cursor": { + "type": [ + "string", + "null" + ] + }, + "pending": { + "description": "Ordered by pending_at ascending.", + "items": { + "$ref": "#/definitions/PendingApprovalRecord" + }, + "type": "array" + } + }, + "required": [ + "pending" + ], + "title": "ListPendingResponse", + "type": "object" + } +} diff --git a/approval-gate/tests/golden/schemas/approval.on_config_change.json b/approval-gate/tests/golden/schemas/approval.on_config_change.json new file mode 100644 index 00000000..153aa88e --- /dev/null +++ b/approval-gate/tests/golden/schemas/approval.on_config_change.json @@ -0,0 +1,26 @@ +{ + "description": "Internal: configuration trigger handler (reload deployment defaults).", + "function_id": "approval::on_config_change", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "The configuration trigger payload (only the fields we read; the engine owns the shape).", + "properties": { + "id": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "new_value": { + "default": null + } + }, + "title": "ConfigChangeEvent", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AnyValue" + } +} diff --git a/approval-gate/tests/golden/schemas/approval.on_session_deleted.json b/approval-gate/tests/golden/schemas/approval.on_session_deleted.json new file mode 100644 index 00000000..33650aae --- /dev/null +++ b/approval-gate/tests/golden/schemas/approval.on_session_deleted.json @@ -0,0 +1,22 @@ +{ + "description": "Internal: session::deleted handler (purge the session's settings and pending records).", + "function_id": "approval::on_session_deleted", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "`session::deleted` payload (only the field we read).", + "properties": { + "session_id": { + "type": "string" + } + }, + "required": [ + "session_id" + ], + "title": "SessionDeletedEvent", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AnyValue" + } +} diff --git a/approval-gate/tests/golden/schemas/approval.on_turn_completed.json b/approval-gate/tests/golden/schemas/approval.on_turn_completed.json new file mode 100644 index 00000000..6802768d --- /dev/null +++ b/approval-gate/tests/golden/schemas/approval.on_turn_completed.json @@ -0,0 +1,22 @@ +{ + "description": "Internal: harness::turn_completed handler (purge the turn's pending records).", + "function_id": "approval::on_turn_completed", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "`harness::turn_completed` payload (only the field we read).", + "properties": { + "turn_id": { + "type": "string" + } + }, + "required": [ + "turn_id" + ], + "title": "TurnCompletedEvent", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AnyValue" + } +} diff --git a/approval-gate/tests/golden/schemas/approval.pending_created.json b/approval-gate/tests/golden/schemas/approval.pending_created.json new file mode 100644 index 00000000..f5059ae2 --- /dev/null +++ b/approval-gate/tests/golden/schemas/approval.pending_created.json @@ -0,0 +1,79 @@ +{ + "description": "A function call was held for human approval and its inbox record written. Payload: PendingApprovalRecord (redacted args, session context, expiry). Bind notification workers here.", + "payload_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "The inbox payload — shared by both triggers, `list_pending`, and `get_pending`. Self-describing (all ids inside the value) and notification-safe (arguments pass through redaction).", + "properties": { + "arguments_excerpt": { + "default": null, + "description": "Redacted — safe to forward to notification channels." + }, + "assistant_excerpt": { + "description": "First text block of the assistant message that contained this function_call. Best-effort; not derivable from `pre_dispatch` `HookInput` in v1, so always omitted today.", + "type": [ + "string", + "null" + ] + }, + "depth": { + "default": 0, + "description": "Sub-agent depth (0 = top-level), from `HookInput`.", + "format": "int64", + "type": "integer" + }, + "expires_at": { + "description": "`pending_at + pending_timeout_ms`", + "format": "int64", + "type": "integer" + }, + "function_call_id": { + "type": "string" + }, + "function_id": { + "type": "string" + }, + "pending_at": { + "description": "ms epoch", + "format": "int64", + "type": "integer" + }, + "session_description": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string" + }, + "session_metadata": { + "additionalProperties": true, + "description": "Tenancy + routing (trigger config filter target).", + "type": [ + "object", + "null" + ] + }, + "session_title": { + "type": [ + "string", + "null" + ] + }, + "turn_id": { + "type": "string" + } + }, + "required": [ + "expires_at", + "function_call_id", + "function_id", + "pending_at", + "session_id", + "turn_id" + ], + "title": "PendingApprovalRecord", + "type": "object" + }, + "trigger_id": "approval::pending_created" +} diff --git a/approval-gate/tests/golden/schemas/approval.pending_resolved.json b/approval-gate/tests/golden/schemas/approval.pending_resolved.json new file mode 100644 index 00000000..01e3ca39 --- /dev/null +++ b/approval-gate/tests/golden/schemas/approval.pending_resolved.json @@ -0,0 +1,66 @@ +{ + "description": "A pending approval left the inbox (outcome: allow | deny | timeout | aborted). Emitted exactly once per record; lets UIs clear badges.", + "payload_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ResolvedOutcome": { + "enum": [ + "allow", + "deny", + "timeout", + "aborted" + ], + "type": "string" + } + }, + "description": "Payload of `approval::pending_resolved` — a pending call left the inbox. Emitted exactly once per record (gated on the record delete).", + "properties": { + "function_call_id": { + "type": "string" + }, + "function_id": { + "type": "string" + }, + "outcome": { + "$ref": "#/definitions/ResolvedOutcome" + }, + "reason": { + "description": "Operator-supplied on deny.", + "type": [ + "string", + "null" + ] + }, + "resolved_at": { + "description": "ms epoch", + "format": "int64", + "type": "integer" + }, + "session_id": { + "type": "string" + }, + "session_metadata": { + "additionalProperties": true, + "description": "Tenancy routing, from the record.", + "type": [ + "object", + "null" + ] + }, + "turn_id": { + "type": "string" + } + }, + "required": [ + "function_call_id", + "function_id", + "outcome", + "resolved_at", + "session_id", + "turn_id" + ], + "title": "PendingResolvedEvent", + "type": "object" + }, + "trigger_id": "approval::pending_resolved" +} diff --git a/approval-gate/tests/golden/schemas/approval.remove_always_allow.json b/approval-gate/tests/golden/schemas/approval.remove_always_allow.json new file mode 100644 index 00000000..c7795b83 --- /dev/null +++ b/approval-gate/tests/golden/schemas/approval.remove_always_allow.json @@ -0,0 +1,119 @@ +{ + "description": "Remove a function from the session's auto-mode trust list (no-op when absent). Human/console-only.", + "function_id": "approval::remove_always_allow", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "function_id": { + "type": "string" + }, + "session_id": { + "type": "string" + } + }, + "required": [ + "function_id", + "session_id" + ], + "title": "AlwaysAllowMutationRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AlwaysAllowEntry": { + "properties": { + "function_id": { + "type": "string" + }, + "granted_at": { + "description": "ms epoch", + "format": "int64", + "type": "integer" + }, + "granted_by": { + "$ref": "#/definitions/GrantedBy" + } + }, + "required": [ + "function_id", + "granted_at", + "granted_by" + ], + "type": "object" + }, + "ApprovalSettings": { + "description": "The stored per-session record (scope `approval_settings`). Reads compute the effective settings from configuration defaults when no record exists — see settings.rs.", + "properties": { + "always_allow": { + "default": [], + "description": "Consulted only in auto mode.", + "items": { + "$ref": "#/definitions/AlwaysAllowEntry" + }, + "type": "array" + }, + "approved_always": { + "default": [], + "description": "Consulted in every mode — remembered human decisions.", + "items": { + "$ref": "#/definitions/AlwaysAllowEntry" + }, + "type": "array" + }, + "mode": { + "allOf": [ + { + "$ref": "#/definitions/PermissionMode" + } + ], + "default": "manual" + }, + "mode_set_at": { + "default": 0, + "description": "ms epoch", + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, + "GrantedBy": { + "oneOf": [ + { + "enum": [ + "user_click" + ], + "type": "string" + }, + { + "description": "Copied from the deployment's `always_allow_seed` on first mutation.", + "enum": [ + "seed" + ], + "type": "string" + } + ] + }, + "PermissionMode": { + "enum": [ + "manual", + "auto", + "full" + ], + "type": "string" + } + }, + "description": "Shared by every settings mutation RPC: the post-mutation record.", + "properties": { + "settings": { + "$ref": "#/definitions/ApprovalSettings" + } + }, + "required": [ + "settings" + ], + "title": "SettingsResponse", + "type": "object" + } +} diff --git a/approval-gate/tests/golden/schemas/approval.resolve.json b/approval-gate/tests/golden/schemas/approval.resolve.json new file mode 100644 index 00000000..bbc927f4 --- /dev/null +++ b/approval-gate/tests/golden/schemas/approval.resolve.json @@ -0,0 +1,63 @@ +{ + "description": "Apply a human decision to a held call: release it for execution (allow) or deliver a denial (deny). Human/console-only.", + "function_id": "approval::resolve", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ResolveDecision": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + } + }, + "properties": { + "decision": { + "$ref": "#/definitions/ResolveDecision" + }, + "function_call_id": { + "type": "string" + }, + "reason": { + "default": null, + "description": "Surfaced to the model on deny.", + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string" + } + }, + "required": [ + "decision", + "function_call_id", + "session_id" + ], + "title": "ResolveRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "resolved": { + "description": "false: unknown/already-resolved pending call.", + "type": "boolean" + }, + "turn_resumed": { + "description": "Passthrough from harness::function::resolve.", + "type": [ + "boolean", + "null" + ] + } + }, + "required": [ + "resolved" + ], + "title": "ResolveResponse", + "type": "object" + } +} diff --git a/approval-gate/tests/golden/schemas/approval.set_mode.json b/approval-gate/tests/golden/schemas/approval.set_mode.json new file mode 100644 index 00000000..ab8d69b6 --- /dev/null +++ b/approval-gate/tests/golden/schemas/approval.set_mode.json @@ -0,0 +1,129 @@ +{ + "description": "Set the session's permission mode (manual / auto / full). Human/console-only.", + "function_id": "approval::set_mode", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PermissionMode": { + "enum": [ + "manual", + "auto", + "full" + ], + "type": "string" + } + }, + "properties": { + "mode": { + "$ref": "#/definitions/PermissionMode" + }, + "session_id": { + "type": "string" + } + }, + "required": [ + "mode", + "session_id" + ], + "title": "SetModeRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AlwaysAllowEntry": { + "properties": { + "function_id": { + "type": "string" + }, + "granted_at": { + "description": "ms epoch", + "format": "int64", + "type": "integer" + }, + "granted_by": { + "$ref": "#/definitions/GrantedBy" + } + }, + "required": [ + "function_id", + "granted_at", + "granted_by" + ], + "type": "object" + }, + "ApprovalSettings": { + "description": "The stored per-session record (scope `approval_settings`). Reads compute the effective settings from configuration defaults when no record exists — see settings.rs.", + "properties": { + "always_allow": { + "default": [], + "description": "Consulted only in auto mode.", + "items": { + "$ref": "#/definitions/AlwaysAllowEntry" + }, + "type": "array" + }, + "approved_always": { + "default": [], + "description": "Consulted in every mode — remembered human decisions.", + "items": { + "$ref": "#/definitions/AlwaysAllowEntry" + }, + "type": "array" + }, + "mode": { + "allOf": [ + { + "$ref": "#/definitions/PermissionMode" + } + ], + "default": "manual" + }, + "mode_set_at": { + "default": 0, + "description": "ms epoch", + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, + "GrantedBy": { + "oneOf": [ + { + "enum": [ + "user_click" + ], + "type": "string" + }, + { + "description": "Copied from the deployment's `always_allow_seed` on first mutation.", + "enum": [ + "seed" + ], + "type": "string" + } + ] + }, + "PermissionMode": { + "enum": [ + "manual", + "auto", + "full" + ], + "type": "string" + } + }, + "description": "Shared by every settings mutation RPC: the post-mutation record.", + "properties": { + "settings": { + "$ref": "#/definitions/ApprovalSettings" + } + }, + "required": [ + "settings" + ], + "title": "SettingsResponse", + "type": "object" + } +} diff --git a/approval-gate/tests/golden/schemas/approval.sweep.json b/approval-gate/tests/golden/schemas/approval.sweep.json new file mode 100644 index 00000000..12e4cc63 --- /dev/null +++ b/approval-gate/tests/golden/schemas/approval.sweep.json @@ -0,0 +1,12 @@ +{ + "description": "Internal: cron handler (expire pending records past expires_at).", + "function_id": "approval::sweep", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AnyValue" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AnyValue" + } +} diff --git a/approval-gate/tests/schemas.rs b/approval-gate/tests/schemas.rs new file mode 100644 index 00000000..b93e04ab --- /dev/null +++ b/approval-gate/tests/schemas.rs @@ -0,0 +1,155 @@ +//! Wire-schema snapshots for the `approval::*` functions and trigger types. +//! +//! `approval_gate::functions::catalog()` is the single source of truth for +//! each function's id, registration description, and schemars-derived +//! request/response schemas (generated with the same +//! `SchemaSettings::draft07()` construction iii-sdk uses at registration, +//! from the same input/output structs). Each entry is serialized to pretty +//! JSON and compared against `tests/golden/schemas/.json` (`::` maps to +//! `.` in filenames). +//! +//! `approval_gate::functions::trigger_catalog()` covers the two custom +//! trigger types with the same pattern. +//! +//! These snapshots ARE the product surface consumed by callers and agents — +//! any schema or description change must land as an explicit golden diff. +//! Regenerate with `UPDATE_GOLDENS=1 cargo test`. + +mod support; + +use approval_gate::functions::{catalog, trigger_catalog, FunctionSpec, TriggerSpec}; + +fn golden_file_name(id: &str) -> String { + format!("schemas/{}.json", id.replace("::", ".")) +} + +fn spec_to_pretty_json(spec: &FunctionSpec) -> String { + let value = serde_json::json!({ + "function_id": spec.function_id, + "description": spec.description, + "request_schema": spec.request_schema, + "response_schema": spec.response_schema, + }); + let mut pretty = serde_json::to_string_pretty(&value).expect("spec serializes"); + pretty.push('\n'); + pretty +} + +fn trigger_spec_to_pretty_json(spec: &TriggerSpec) -> String { + let value = serde_json::json!({ + "trigger_id": spec.trigger_id, + "description": spec.description, + "payload_schema": spec.payload_schema, + }); + let mut pretty = serde_json::to_string_pretty(&value).expect("trigger spec serializes"); + pretty.push('\n'); + pretty +} + +/// The catalog must cover exactly the 14 registered functions, in +/// registration order (kept in lockstep with `register_all`). +#[test] +fn catalog_lists_all_functions_in_registration_order() { + let ids: Vec<&str> = catalog().iter().map(|s| s.function_id).collect(); + assert_eq!( + ids, + vec![ + "approval::gate", + "approval::resolve", + "approval::list_pending", + "approval::get_pending", + "approval::set_mode", + "approval::add_always_allow", + "approval::remove_always_allow", + "approval::approve_always", + "approval::get_settings", + "approval::clear_settings", + "approval::on_config_change", + "approval::on_session_deleted", + "approval::on_turn_completed", + "approval::sweep", + ] + ); +} + +/// The trigger catalog must cover exactly the 2 registered trigger types, +/// in registration order (kept in lockstep with `register_trigger_types`). +#[test] +fn trigger_catalog_lists_both_trigger_types_in_registration_order() { + let ids: Vec<&str> = trigger_catalog().iter().map(|s| s.trigger_id).collect(); + assert_eq!( + ids, + vec!["approval::pending_created", "approval::pending_resolved",] + ); +} + +/// Every catalog entry matches its committed golden. Mismatches are +/// collected across ALL functions before failing so one run shows the +/// full drift, not just the first file. +#[test] +fn wire_schema_snapshots_match_goldens() { + let mut failures = Vec::new(); + for spec in catalog() { + let rel = golden_file_name(spec.function_id); + let actual = spec_to_pretty_json(&spec); + if let Err(msg) = support::check_golden(&rel, &actual) { + failures.push(msg); + } + } + assert!( + failures.is_empty(), + "{} wire-schema golden(s) drifted:\n\n{}", + failures.len(), + failures.join("\n") + ); +} + +/// Every trigger catalog entry matches its committed golden. +#[test] +fn trigger_schema_snapshots_match_goldens() { + let mut failures = Vec::new(); + for spec in trigger_catalog() { + let rel = golden_file_name(spec.trigger_id); + let actual = trigger_spec_to_pretty_json(&spec); + if let Err(msg) = support::check_golden(&rel, &actual) { + failures.push(msg); + } + } + assert!( + failures.is_empty(), + "{} trigger wire-schema golden(s) drifted:\n\n{}", + failures.len(), + failures.join("\n") + ); +} + +/// Request schemas that carry field-level doc comments must keep them — a +/// rename or accidental removal of `///` comments is a breaking API change +/// even if types still compile. Only the functions whose request types +/// actually have doc-commented fields are checked here; the full wire surface +/// is already pinned by the golden snapshots above. +#[test] +fn schemas_carry_field_descriptions() { + // Functions whose request types carry at least one "description" key in + // their generated schema (struct-level or field-level doc comments): + let must_have_descriptions = [ + "approval::gate", + "approval::resolve", + "approval::list_pending", + "approval::get_pending", + "approval::on_config_change", + "approval::on_session_deleted", + "approval::on_turn_completed", + ]; + for spec in catalog() { + if !must_have_descriptions.contains(&spec.function_id) { + continue; + } + let rendered = serde_json::to_string(&spec.request_schema).expect("schema serializes"); + assert!( + rendered.contains("description"), + "{}: request schema lost its field descriptions", + spec.function_id + ); + } +} diff --git a/approval-gate/tests/support/mod.rs b/approval-gate/tests/support/mod.rs new file mode 100644 index 00000000..63e879cc --- /dev/null +++ b/approval-gate/tests/support/mod.rs @@ -0,0 +1,90 @@ +//! Hand-rolled golden-file harness (deliberately no `insta`/snapshot +//! dependency). Goldens live under `tests/golden/` and are committed; +//! any wire-surface change must show up as an explicit, reviewed diff. +//! +//! Workflow: +//! - `cargo test` compares actual output against the committed goldens. +//! - `UPDATE_GOLDENS=1 cargo test` regenerates the files; review the git +//! diff, then commit the new goldens alongside the change that caused +//! them. + +#![allow(dead_code)] + +use std::fs; +use std::path::PathBuf; + +/// Root of the committed golden files. +pub fn golden_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/golden") +} + +fn update_mode() -> bool { + std::env::var("UPDATE_GOLDENS") + .map(|v| v == "1") + .unwrap_or(false) +} + +/// Compare `actual` against the golden file at `tests/golden/`. +/// Returns `Err(readable diff hint)` on mismatch or missing golden; +/// with `UPDATE_GOLDENS=1` the file is (re)written and the check passes. +pub fn check_golden(rel: &str, actual: &str) -> Result<(), String> { + let path = golden_root().join(rel); + if update_mode() { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; + } + fs::write(&path, actual).map_err(|e| format!("write {}: {e}", path.display()))?; + return Ok(()); + } + let expected = fs::read_to_string(&path).map_err(|e| { + format!( + "golden file {} unreadable ({e}).\n\ + Run `UPDATE_GOLDENS=1 cargo test` to (re)generate, then review \ + and commit the diff.", + path.display() + ) + })?; + if expected == actual { + return Ok(()); + } + Err(diff_hint(rel, &expected, actual)) +} + +/// Readable first-divergence diff hint: line number, expected vs actual +/// around the mismatch, and the regeneration instructions. +fn diff_hint(rel: &str, expected: &str, actual: &str) -> String { + let exp_lines: Vec<&str> = expected.lines().collect(); + let act_lines: Vec<&str> = actual.lines().collect(); + let first_diff = exp_lines + .iter() + .zip(act_lines.iter()) + .position(|(e, a)| e != a) + .unwrap_or_else(|| exp_lines.len().min(act_lines.len())); + + const CONTEXT: usize = 3; + let lo = first_diff.saturating_sub(CONTEXT); + let hi = (first_diff + CONTEXT + 1).max(first_diff + 1); + + let mut out = format!( + "golden mismatch: tests/golden/{rel}\n\ + first divergence at line {} (expected {} lines, actual {} lines)\n", + first_diff + 1, + exp_lines.len(), + act_lines.len() + ); + out.push_str("--- expected (golden) ---\n"); + for (i, line) in exp_lines.iter().enumerate().skip(lo).take(hi - lo) { + let marker = if i == first_diff { ">" } else { " " }; + out.push_str(&format!("{marker} {:>4} | {line}\n", i + 1)); + } + out.push_str("--- actual ---\n"); + for (i, line) in act_lines.iter().enumerate().skip(lo).take(hi - lo) { + let marker = if i == first_diff { ">" } else { " " }; + out.push_str(&format!("{marker} {:>4} | {line}\n", i + 1)); + } + out.push_str( + "If this change is intentional, run `UPDATE_GOLDENS=1 cargo test`, \ + review the git diff, and commit the updated goldens.\n", + ); + out +} From c41e48aa8890b7982932b0a5a26d872750c5f969 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Mon, 15 Jun 2026 11:06:26 -0300 Subject: [PATCH 11/14] ci: retry the iii install to ride out transient release-index hiccups install.iii.dev's release index can briefly 404 an existing engine version (observed right after a new release published), which failed the job even though 0.19.2 is valid. Retry the install up to 3x; the final `iii --version` still hard-fails if all attempts miss. --- .github/workflows/ci.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe449b96..b07cb898 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,8 +169,15 @@ jobs: VERSION: '0.19.2' run: | set -euo pipefail - curl -fsSL https://install.iii.dev/iii/main/install.sh -o /tmp/install-iii.sh - sh /tmp/install-iii.sh + # Retry: install.iii.dev's release index can transiently 404 an + # existing version (e.g. right after a new release publishes), which + # would otherwise fail the whole job. + for attempt in 1 2 3; do + curl -fsSL https://install.iii.dev/iii/main/install.sh -o /tmp/install-iii.sh \ + && sh /tmp/install-iii.sh && break + echo "::warning::iii install attempt ${attempt} failed (transient release-index hiccup?); retrying in 10s" + sleep 10 + done { echo "$HOME/.local/bin" echo "$HOME/.iii/bin" From d8bcddb59449c90fa602b011717d7408a3f183a2 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Mon, 15 Jun 2026 11:11:31 -0300 Subject: [PATCH 12/14] ci: authenticate the iii install's GitHub API lookup The install script resolves the engine release via the GitHub API; with no token it shares the 60/hr unauthenticated limit, so under CI load it fails with "rate limit hit" (and earlier surfaced as a misleading "release tag not found"). Pass the Actions GITHUB_TOKEN (5000/hr) to both the rust-job engine install and the pre-existing interface-smoke install, which flaked across workers for the same reason. The rust-step retry stays as a genuine-network-blip guard. --- .github/workflows/ci.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b07cb898..c16bc6d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,15 +167,19 @@ jobs: if: matrix.worker == 'approval-gate' env: VERSION: '0.19.2' + # The install script resolves the release via the GitHub API; without + # a token it shares the 60/hr unauthenticated limit and fails CI under + # load (surfaces as "rate limit hit" or a misleading "release tag not + # found"). The Actions token raises that to 5000/hr. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - # Retry: install.iii.dev's release index can transiently 404 an - # existing version (e.g. right after a new release publishes), which - # would otherwise fail the whole job. + # The token above is what prevents the rate-limit failures; the retry + # only guards a genuine transient network blip. for attempt in 1 2 3; do curl -fsSL https://install.iii.dev/iii/main/install.sh -o /tmp/install-iii.sh \ && sh /tmp/install-iii.sh && break - echo "::warning::iii install attempt ${attempt} failed (transient release-index hiccup?); retrying in 10s" + echo "::warning::iii install attempt ${attempt} failed; retrying in 10s" sleep 10 done { @@ -288,6 +292,10 @@ jobs: if: steps.optout.outputs.skip != 'true' env: VERSION: '0.17.0' + # Authenticate the install script's GitHub API release lookup; without + # this the step shares the 60/hr unauthenticated limit and flakes + # under load (whichever workers' jobs hit it after exhaustion fail). + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail curl -fsSL https://install.iii.dev/iii/main/install.sh -o /tmp/install-iii.sh From 749b0ca4b2e32058f26ca633a025cbf9eb88aa51 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Mon, 15 Jun 2026 11:25:07 -0300 Subject: [PATCH 13/14] ci: revert engine-install changes, keep this PR off shared ci.yml Restore .github/workflows/ci.yml to main. The engine-backed tests will self-skip in CI again (no iii on PATH); they still run locally / via `cargo test` with an engine available, and the pure unit + golden-schema tests still run in CI. Engine-test CI can be handled outside this worker PR. --- .github/workflows/ci.yml | 41 +--------------------------------------- 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c16bc6d2..22761a13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,43 +158,8 @@ jobs: - name: Run clippy run: cargo clippy --all-targets --all-features -- -D warnings - # approval-gate's tests spawn a real `iii` engine and self-skip when no - # binary is on PATH, so without this the behavior suite passes in CI - # without running. Scoped to that worker by name (not global) so other - # workers' engine-gated tests keep their existing behavior; add a worker - # here when it grows engine-backed tests. Mirrors `interface-smoke` below. - - name: Install iii CLI + engine (for engine-backed tests) - if: matrix.worker == 'approval-gate' - env: - VERSION: '0.19.2' - # The install script resolves the release via the GitHub API; without - # a token it shares the 60/hr unauthenticated limit and fails CI under - # load (surfaces as "rate limit hit" or a misleading "release tag not - # found"). The Actions token raises that to 5000/hr. - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - # The token above is what prevents the rate-limit failures; the retry - # only guards a genuine transient network blip. - for attempt in 1 2 3; do - curl -fsSL https://install.iii.dev/iii/main/install.sh -o /tmp/install-iii.sh \ - && sh /tmp/install-iii.sh && break - echo "::warning::iii install attempt ${attempt} failed; retrying in 10s" - sleep 10 - done - { - echo "$HOME/.local/bin" - echo "$HOME/.iii/bin" - } >> "$GITHUB_PATH" - export PATH="$HOME/.local/bin:$HOME/.iii/bin:$PATH" - iii --version - - name: Run tests - # approval-gate runs serially: its engine-backed tests each spawn their - # own iii engine, and running them in parallel on a 2-core runner - # starves the engines until state ops time out. Other workers keep the - # default parallel run. - run: cargo test --all-features ${{ matrix.worker == 'approval-gate' && '-- --test-threads=1' || '' }} + run: cargo test --all-features # ────────────────────────────────────────────────────────────── # Interface boot smoke: build each changed Rust worker from source, @@ -292,10 +257,6 @@ jobs: if: steps.optout.outputs.skip != 'true' env: VERSION: '0.17.0' - # Authenticate the install script's GitHub API release lookup; without - # this the step shares the 60/hr unauthenticated limit and flakes - # under load (whichever workers' jobs hit it after exhaustion fail). - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail curl -fsSL https://install.iii.dev/iii/main/install.sh -o /tmp/install-iii.sh From 145e9bba151058d212c34fce4843bc8b76c255ee Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Mon, 15 Jun 2026 19:31:38 -0300 Subject: [PATCH 14/14] refactor(approval-gate): kebab-case function and trigger ids per naming SOP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conform the worker's wire surface to the new-worker / binary-worker SOP (§7 Naming: multi-word segments are kebab-case, never snake_case), matching context-manager (context::count-tokens, context::on-config-change). Renamed function ids: approval::{list_pending, get_pending, set_mode, add_always_allow, remove_always_allow, approve_always, get_settings, clear_settings, on_config_change, on_session_deleted, on_turn_completed} and trigger ids approval::{pending_created, pending_resolved} to their kebab equivalents. Updated the harness trigger bindings the worker reads (harness::hook::pre-dispatch, harness::turn-completed). Golden wire-schema files renamed and regenerated; gate/resolve/sweep are single-word and unchanged. The hook payload discriminant value point:"pre_dispatch" is data, not an id, and is left as-is. --- README.md | 2 +- approval-gate/README.md | 18 +++++----- approval-gate/architecture/README.md | 4 +-- approval-gate/architecture/integration.md | 34 +++++++++---------- approval-gate/architecture/internals.md | 6 ++-- approval-gate/config.yaml | 2 +- approval-gate/src/config.rs | 2 +- approval-gate/src/decision.rs | 2 +- approval-gate/src/denial.rs | 2 +- approval-gate/src/events.rs | 6 ++-- .../src/functions/add_always_allow.rs | 2 +- approval-gate/src/functions/approve_always.rs | 2 +- approval-gate/src/functions/clear_settings.rs | 4 +-- approval-gate/src/functions/gate.rs | 2 +- approval-gate/src/functions/get_pending.rs | 2 +- approval-gate/src/functions/get_settings.rs | 2 +- approval-gate/src/functions/list_pending.rs | 2 +- approval-gate/src/functions/mod.rs | 24 ++++++------- .../src/functions/on_config_change.rs | 2 +- .../src/functions/on_session_deleted.rs | 2 +- .../src/functions/on_turn_completed.rs | 6 ++-- .../src/functions/remove_always_allow.rs | 2 +- approval-gate/src/functions/set_mode.rs | 2 +- approval-gate/src/lib.rs | 8 ++--- approval-gate/src/main.rs | 18 +++++----- approval-gate/src/testkit/engine.rs | 6 ++-- approval-gate/src/types.rs | 2 +- ...ow.json => approval.add-always-allow.json} | 2 +- ...ways.json => approval.approve-always.json} | 2 +- ...ings.json => approval.clear-settings.json} | 2 +- ...pending.json => approval.get-pending.json} | 2 +- ...ttings.json => approval.get-settings.json} | 2 +- ...ending.json => approval.list-pending.json} | 2 +- ...ge.json => approval.on-config-change.json} | 2 +- ....json => approval.on-session-deleted.json} | 2 +- ...d.json => approval.on-turn-completed.json} | 6 ++-- ...ted.json => approval.pending-created.json} | 2 +- ...ed.json => approval.pending-resolved.json} | 4 +-- ...json => approval.remove-always-allow.json} | 2 +- ...l.set_mode.json => approval.set-mode.json} | 2 +- approval-gate/tests/integration.rs | 10 +++--- approval-gate/tests/schemas.rs | 34 +++++++++---------- 42 files changed, 121 insertions(+), 121 deletions(-) rename approval-gate/tests/golden/schemas/{approval.add_always_allow.json => approval.add-always-allow.json} (98%) rename approval-gate/tests/golden/schemas/{approval.approve_always.json => approval.approve-always.json} (98%) rename approval-gate/tests/golden/schemas/{approval.clear_settings.json => approval.clear-settings.json} (93%) rename approval-gate/tests/golden/schemas/{approval.get_pending.json => approval.get-pending.json} (98%) rename approval-gate/tests/golden/schemas/{approval.get_settings.json => approval.get-settings.json} (98%) rename approval-gate/tests/golden/schemas/{approval.list_pending.json => approval.list-pending.json} (98%) rename approval-gate/tests/golden/schemas/{approval.on_config_change.json => approval.on-config-change.json} (93%) rename approval-gate/tests/golden/schemas/{approval.on_session_deleted.json => approval.on-session-deleted.json} (91%) rename approval-gate/tests/golden/schemas/{approval.on_turn_completed.json => approval.on-turn-completed.json} (70%) rename approval-gate/tests/golden/schemas/{approval.pending_created.json => approval.pending-created.json} (98%) rename approval-gate/tests/golden/schemas/{approval.pending_resolved.json => approval.pending-resolved.json} (93%) rename approval-gate/tests/golden/schemas/{approval.remove_always_allow.json => approval.remove-always-allow.json} (98%) rename approval-gate/tests/golden/schemas/{approval.set_mode.json => approval.set-mode.json} (98%) diff --git a/README.md b/README.md index 0dc3f39f..8bd31fcd 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ npx skills add iii-hq/iii --all | Worker | Kind | Summary | |---|---|---| | [`acp`](acp/) | Rust | Agent Client Protocol surface — stdio JSON-RPC, exposes iii agents as ACP sessions. | -| [`approval-gate`](approval-gate/) | Rust | Human-in-the-loop approval gate — evaluates each function call (continue / deny / hold), holds pending calls for a human, and emits `approval::pending_*` events. Binds the harness `pre_dispatch` hook. See [`approval-gate/architecture/`](approval-gate/architecture/). | +| [`approval-gate`](approval-gate/) | Rust | Human-in-the-loop approval gate — evaluates each function call (continue / deny / hold), holds pending calls for a human, and emits `approval::pending-*` events. Binds the harness `pre_dispatch` hook. See [`approval-gate/architecture/`](approval-gate/architecture/). | | [`harness`](harness/) | Node | TS port of the iii harness stack — bundles `harness` (provider registry + credentials/settings/permissions via the `configuration` worker), `turn-orchestrator`, `approval-gate`, `hook-fanout`, `models-catalog`, the `provider-*` workers, `llm-budget`, and `context-compaction` as one pnpm monorepo. Conversations persist in `session-manager`. See [`harness/README.md`](harness/README.md). | | [`claude-code`](claude-code/) | Node | Claude Code as an iii worker — `claude::*` runs headless Claude Code turns, mirrors raw messages onto `claude::events`, and streams AgentEvent frames onto `agent::events`. | | [`session-manager`](session-manager/) | Rust | Durable, reactive, branching conversation store — fourteen `session::*` functions plus six trigger types; the transcript backend for `harness` and `console`. See [`session-manager/architecture/`](session-manager/architecture/). | diff --git a/approval-gate/README.md b/approval-gate/README.md index a7a43723..793d2b20 100644 --- a/approval-gate/README.md +++ b/approval-gate/README.md @@ -5,15 +5,15 @@ The policy and decision surface for human-held function calls worker: 1. **The gate** — `approval::gate`, a `pre_dispatch` hook the worker binds - itself at startup on the harness's `harness::hook::pre_dispatch` trigger + itself at startup on the harness's `harness::hook::pre-dispatch` trigger type. It evaluates per-session mode, allow-lists, and the yaml policy, and answers `continue`, `deny`, or `hold`. 2. **The decision plane** — `approval::resolve` plus the per-session settings RPCs (`set_mode`, `add_always_allow`, `approve_always`, …). Human/console only. 3. **The pending inbox** — an **ephemeral** index of held calls - (`approval::list_pending` / `approval::get_pending`) plus two trigger types - (`approval::pending_created` / `approval::pending_resolved`) that + (`approval::list-pending` / `approval::get-pending`) plus two trigger types + (`approval::pending-created` / `approval::pending-resolved`) that notification workers and UIs bind to. The worker keeps **no resolved-approval history**: a record exists only while @@ -24,8 +24,8 @@ event are the audit trail. ## Standalone caveat This worker codes against the greenfield harness contracts -(`harness::hook::pre_dispatch`, `harness::function::resolve`, -`harness::turn_completed` — see harness.md § Hooks / § API Reference), which +(`harness::hook::pre-dispatch`, `harness::function::resolve`, +`harness::turn-completed` — see harness.md § Hooks / § API Reference), which are **not implemented by the current harness yet**. All trigger bindings are best-effort: on an engine without those trigger types the worker still boots, serves its RPCs, registers its configuration entry, and logs @@ -54,7 +54,7 @@ Hold → decide → release, from any client: ```bash # A held call shows up in the inbox… -iii call approval::list_pending '{}' +iii call approval::list-pending '{}' # …a human allows it (the harness re-runs it through dispatch)… iii call approval::resolve '{"session_id": "s_1", "function_call_id": "c_1", "decision": "allow"}' # …or denies it with a reason the model can adapt to. @@ -85,13 +85,13 @@ modes. | Type | Fires | Payload | |---|---|---| -| `approval::pending_created` | a call was held and its inbox record written (async, off the hot path) | `PendingApprovalRecord & { status: "pending" }` — redacted args, session context, expiry: self-sufficient for notification copy | -| `approval::pending_resolved` | a pending call left the inbox (exactly once per record) | ids + `outcome: "allow" \| "deny" \| "timeout" \| "aborted"`, operator `reason` on deny | +| `approval::pending-created` | a call was held and its inbox record written (async, off the hot path) | `PendingApprovalRecord & { status: "pending" }` — redacted args, session context, expiry: self-sufficient for notification copy | +| `approval::pending-resolved` | a pending call left the inbox (exactly once per record) | ids + `outcome: "allow" \| "deny" \| "timeout" \| "aborted"`, operator `reason` on deny | Binding config (both types): `{ session_id?, metadata? }` — `metadata` is a subset-equality match against the record's denormalized `session_metadata`, so a multi-tenant notification worker binds to only its own sessions. After a -restart, reconcile with one `approval::list_pending` call. +restart, reconcile with one `approval::list-pending` call. ## Configuration diff --git a/approval-gate/architecture/README.md b/approval-gate/architecture/README.md index c858fe45..fdb7a854 100644 --- a/approval-gate/architecture/README.md +++ b/approval-gate/architecture/README.md @@ -64,8 +64,8 @@ flowchart LR ## Standalone status -The harness contracts this worker binds (`harness::hook::pre_dispatch`, -`harness::function::resolve`, `harness::turn_completed`) are specified in +The harness contracts this worker binds (`harness::hook::pre-dispatch`, +`harness::function::resolve`, `harness::turn-completed`) are specified in harness.md but not implemented by the current harness. All bindings are best-effort: the worker boots and serves its full RPC + trigger surface without them, and the integration suite fakes them. See diff --git a/approval-gate/architecture/integration.md b/approval-gate/architecture/integration.md index 1ab5e64e..c79a03a9 100644 --- a/approval-gate/architecture/integration.md +++ b/approval-gate/architecture/integration.md @@ -12,16 +12,16 @@ operational contract). | Function | Caller | Purpose | |---|---|---| -| `approval::gate` | harness only (via the `harness::hook::pre_dispatch` binding) | The hook: `HookInput` → `{ decision: "continue" \| "deny" \| "hold" }`. Never call directly. | +| `approval::gate` | harness only (via the `harness::hook::pre-dispatch` binding) | The hook: `HookInput` → `{ decision: "continue" \| "deny" \| "hold" }`. Never call directly. | | `approval::resolve` | console / inbox UI (human-only) | Apply a decision to a held call: `{ session_id, function_call_id, decision: "allow" \| "deny", reason? }` → `{ resolved, turn_resumed? }`. | -| `approval::list_pending` | console / notification workers | The inbox: filters `session_id?`, `metadata?` (subset-equality tenancy match), `limit?` (default 50), opaque `cursor?`; ordered by `pending_at` asc. | -| `approval::get_pending` | console | One record or `null`. | -| `approval::set_mode` | console (human-only) | `manual` / `auto` / `full`. | -| `approval::add_always_allow` / `remove_always_allow` | console (human-only) | Curate the auto-mode trust list (idempotent add / no-op remove). | -| `approval::approve_always` | console (human-only) | Per-session grant honoured in **every** mode; call it right before `resolve { decision: "allow" }` for an "Approve always" button. | -| `approval::get_settings` | console | Effective settings + `source: "stored" \| "defaults"`. Never writes. | -| `approval::clear_settings` | console | Drop the stored record; revert to deployment defaults. | -| `approval::on_config_change` / `on_session_deleted` / `on_turn_completed` / `approval::sweep` | trigger handlers | Internal — never call directly. | +| `approval::list-pending` | console / notification workers | The inbox: filters `session_id?`, `metadata?` (subset-equality tenancy match), `limit?` (default 50), opaque `cursor?`; ordered by `pending_at` asc. | +| `approval::get-pending` | console | One record or `null`. | +| `approval::set-mode` | console (human-only) | `manual` / `auto` / `full`. | +| `approval::add-always-allow` / `remove_always_allow` | console (human-only) | Curate the auto-mode trust list (idempotent add / no-op remove). | +| `approval::approve-always` | console (human-only) | Per-session grant honoured in **every** mode; call it right before `resolve { decision: "allow" }` for an "Approve always" button. | +| `approval::get-settings` | console | Effective settings + `source: "stored" \| "defaults"`. Never writes. | +| `approval::clear-settings` | console | Drop the stored record; revert to deployment defaults. | +| `approval::on-config-change` / `on_session_deleted` / `on_turn_completed` / `approval::sweep` | trigger handlers | Internal — never call directly. | Errors use `code: message` with codes `approval/invalid_payload`, `approval/state_unavailable`, `approval/harness_unavailable`. An unknown @@ -33,10 +33,10 @@ returns `{ resolved: false }` (duplicate decisions race benignly). Bind with the standard two-step pattern (register your handler function, then `registerTrigger` with the type). Delivery is fire-and-forget, -at-least-once, **unordered** — reconcile with one `approval::list_pending` +at-least-once, **unordered** — reconcile with one `approval::list-pending` call after a restart. -### `approval::pending_created` +### `approval::pending-created` A call was held and its inbox record written. Fires asynchronously after the hook returns `hold` — never on the dispatch hot path. @@ -49,7 +49,7 @@ session-manager was unreachable at hold time), sub-agent `depth`. Self-sufficient for notification copy — no follow-up reads needed, and safe to forward to push/Slack payloads (arguments are redacted and clipped). -### `approval::pending_resolved` +### `approval::pending-resolved` A pending call left the inbox. Emitted **exactly once per record** — your badge-clearing logic can trust it. Payload: ids plus @@ -77,11 +77,11 @@ sequenceDiagram participant N as notify worker H->>AG: approval::gate (pre_dispatch hook) AG-->>H: { decision: "hold", pending_timeout_ms } - AG--)N: approval::pending_created + AG--)N: approval::pending-created UI->>AG: approval::resolve { decision: "allow" } AG->>H: harness::function::resolve { action: "execute" } Note over H: re-enqueue turn; run the released call through the remaining dispatch pipeline - AG--)N: approval::pending_resolved { outcome: "allow" } + AG--)N: approval::pending-resolved { outcome: "allow" } ``` On `deny`, the gate calls `harness::function::resolve` with @@ -94,7 +94,7 @@ and can adapt. What the (future) harness must provide — and what this worker already assumes, faked today by `tests/integration.rs`: -- **`harness::hook::pre_dispatch` trigger type.** The worker binds +- **`harness::hook::pre-dispatch` trigger type.** The worker binds `approval::gate` at startup with `{ functions, timeout_ms, on_error: "fail_closed" }` from its `config.yaml`. The hook is an ordinary registered function: the harness @@ -104,7 +104,7 @@ assumes, faked today by `tests/integration.rs`: held call) and `{ ..., action: "deliver", is_error, content, details }` (settle with a result), idempotent on the deterministic entry id, returning `{ resolved, turn_resumed }`. -- **`harness::turn_completed` trigger type** with at least `turn_id` in the +- **`harness::turn-completed` trigger type** with at least `turn_id` in the payload (terminal-turn purge). Until those exist the bindings log `trigger_type_not_found` at boot @@ -123,7 +123,7 @@ gated sets a broad dispatch policy and lets the gate hold/deny. worker or rely on modes/allow-lists. - **session-manager** (soft): provides hold-time context and the `session::deleted` cascade. Without it, records carry no session context - and settings cleanup relies on `approval::clear_settings`. + and settings cleanup relies on `approval::clear-settings`. - **Configuration**: deployment defaults live in the `approval-gate` configuration entry (`default_mode`, `always_allow_seed`, `pending_timeout_ms`); `configuration::set` replaces the **whole** value — diff --git a/approval-gate/architecture/internals.md b/approval-gate/architecture/internals.md index 210ee038..0099d49b 100644 --- a/approval-gate/architecture/internals.md +++ b/approval-gate/architecture/internals.md @@ -67,8 +67,8 @@ Two scopes, both with explicit deletion paths: | Scope/key | Created | Deleted by | |---|---|---| -| `approval_pending//` | in-hook, before `hold` returns | resolve · `harness::turn_completed` · `session::deleted` · sweep on `expires_at` | -| `approval_settings/` | first user mutation (lazy; reads never write) | `session::deleted` · `approval::clear_settings` | +| `approval_pending//` | in-hook, before `hold` returns | resolve · `harness::turn-completed` · `session::deleted` · sweep on `expires_at` | +| `approval_settings/` | first user mutation (lazy; reads never write) | `session::deleted` · `approval::clear-settings` | All four pending-deletion paths funnel through `pending::delete_with_gate`, which is where exactly-once emission is decided: @@ -117,7 +117,7 @@ Mutation helpers are immutable: `with_grant` (idempotent on exact built-in defaults (`manual`, `[]`, 30 min) apply in memory whenever the entry value is null or the configuration worker is absent. Parsing is field-wise tolerant — one malformed field degrades to its default, never -fails the gate open. `approval::on_config_change` guards on +fails the gate open. `approval::on-config-change` guards on `id == "approval-gate"` and swaps the shared `RwLock`. Boot order: bind the configuration trigger, register the entry, then one initial `configuration::get` — an update landing in the gap is caught by either the diff --git a/approval-gate/config.yaml b/approval-gate/config.yaml index 82378732..771e914f 100644 --- a/approval-gate/config.yaml +++ b/approval-gate/config.yaml @@ -2,7 +2,7 @@ # always_allow_seed, pending_timeout_ms) live in the engine's # configuration entry "approval-gate", not here — see README § Configuration. -# The harness::hook::pre_dispatch binding the worker registers for itself +# The harness::hook::pre-dispatch binding the worker registers for itself # at startup (tech-specs/2026-06-agentic/approval-gate.md § The approval::gate hook). hook: functions: ["*"] diff --git a/approval-gate/src/config.rs b/approval-gate/src/config.rs index 0850fdae..119d50c1 100644 --- a/approval-gate/src/config.rs +++ b/approval-gate/src/config.rs @@ -17,7 +17,7 @@ fn default_on_error() -> String { "fail_closed".to_string() } -/// The `harness::hook::pre_dispatch` binding the worker registers for +/// The `harness::hook::pre-dispatch` binding the worker registers for /// itself at startup. #[derive(Debug, Clone, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] diff --git a/approval-gate/src/decision.rs b/approval-gate/src/decision.rs index 0f33de25..0bec08b7 100644 --- a/approval-gate/src/decision.rs +++ b/approval-gate/src/decision.rs @@ -103,7 +103,7 @@ mod tests { #[test] fn human_only_covers_approval_and_configuration_prefixes() { - assert!(is_human_only("approval::set_mode")); + assert!(is_human_only("approval::set-mode")); assert!(is_human_only("approval::resolve")); assert!(is_human_only("configuration::set")); assert!(!is_human_only("shell::run")); diff --git a/approval-gate/src/denial.rs b/approval-gate/src/denial.rs index d74956de..1a3170c2 100644 --- a/approval-gate/src/denial.rs +++ b/approval-gate/src/denial.rs @@ -149,7 +149,7 @@ mod tests { #[test] fn human_only_uses_the_reserved_rule_id() { - let envelope = human_only_denial("approval::set_mode", &json!({})); + let envelope = human_only_denial("approval::set-mode", &json!({})); assert_eq!(envelope.rule_id.as_deref(), Some(HUMAN_ONLY_RULE_ID)); assert_eq!(envelope.denied_by, DeniedBy::Permissions); } diff --git a/approval-gate/src/events.rs b/approval-gate/src/events.rs index 7fc64dfc..44a4df45 100644 --- a/approval-gate/src/events.rs +++ b/approval-gate/src/events.rs @@ -1,5 +1,5 @@ //! The two custom trigger types this worker emits — -//! `approval::pending_created` / `approval::pending_resolved` — and the +//! `approval::pending-created` / `approval::pending-resolved` — and the //! fan-out machinery behind them (the session-manager reactivity model: //! consumers bind handlers with the standard two-step pattern; the engine //! routes each registration to our [`TriggerHandler`]s; delivery is @@ -25,13 +25,13 @@ use serde_json::Value; use crate::types::{metadata_matches, JsonMap, PendingApprovalRecord, PendingResolvedEvent}; -pub const PENDING_CREATED: &str = "approval::pending_created"; +pub const PENDING_CREATED: &str = "approval::pending-created"; pub const PENDING_CREATED_DESC: &str = "A function call was held for human approval and its inbox record written. \ Payload: PendingApprovalRecord (redacted args, session context, expiry). \ Bind notification workers here."; -pub const PENDING_RESOLVED: &str = "approval::pending_resolved"; +pub const PENDING_RESOLVED: &str = "approval::pending-resolved"; pub const PENDING_RESOLVED_DESC: &str = "A pending approval left the inbox (outcome: allow | deny | timeout | aborted). \ Emitted exactly once per record; lets UIs clear badges."; diff --git a/approval-gate/src/functions/add_always_allow.rs b/approval-gate/src/functions/add_always_allow.rs index 0b64baee..3c6792c3 100644 --- a/approval-gate/src/functions/add_always_allow.rs +++ b/approval-gate/src/functions/add_always_allow.rs @@ -1,4 +1,4 @@ -//! `approval::add_always_allow` — curate the session's auto-mode trust +//! `approval::add-always-allow` — curate the session's auto-mode trust //! list (idempotent add). use super::Deps; diff --git a/approval-gate/src/functions/approve_always.rs b/approval-gate/src/functions/approve_always.rs index 35d64594..9789eca0 100644 --- a/approval-gate/src/functions/approve_always.rs +++ b/approval-gate/src/functions/approve_always.rs @@ -1,4 +1,4 @@ -//! `approval::approve_always` — record a per-session "approve always" +//! `approval::approve-always` — record a per-session "approve always" //! grant (honoured in **every** mode). Typically called by the console //! from an approval prompt, immediately before //! `approval::resolve { decision: "allow" }`. diff --git a/approval-gate/src/functions/clear_settings.rs b/approval-gate/src/functions/clear_settings.rs index 177543b7..00efd9f4 100644 --- a/approval-gate/src/functions/clear_settings.rs +++ b/approval-gate/src/functions/clear_settings.rs @@ -1,6 +1,6 @@ -//! `approval::clear_settings` — drop the session's stored settings record +//! `approval::clear-settings` — drop the session's stored settings record //! (the session reverts to configuration defaults). Also invoked -//! internally by `approval::on_session_deleted`. +//! internally by `approval::on-session-deleted`. use super::Deps; use crate::error::ApprovalError; diff --git a/approval-gate/src/functions/gate.rs b/approval-gate/src/functions/gate.rs index c81fdda8..401adf53 100644 --- a/approval-gate/src/functions/gate.rs +++ b/approval-gate/src/functions/gate.rs @@ -291,7 +291,7 @@ mod tests { }, ) .await; - for target in ["approval::set_mode", "approval::resolve"] { + for target in ["approval::set-mode", "approval::resolve"] { let HookOutput::Deny { reason } = handle(&stack.deps, hook_input(target)).await.unwrap() else { diff --git a/approval-gate/src/functions/get_pending.rs b/approval-gate/src/functions/get_pending.rs index 40e4f5d5..a69c9dbd 100644 --- a/approval-gate/src/functions/get_pending.rs +++ b/approval-gate/src/functions/get_pending.rs @@ -1,4 +1,4 @@ -//! `approval::get_pending` — read one pending record; `null` when +//! `approval::get-pending` — read one pending record; `null` when //! resolved or unknown. use super::Deps; diff --git a/approval-gate/src/functions/get_settings.rs b/approval-gate/src/functions/get_settings.rs index 1bbc7507..cadf865b 100644 --- a/approval-gate/src/functions/get_settings.rs +++ b/approval-gate/src/functions/get_settings.rs @@ -1,4 +1,4 @@ -//! `approval::get_settings` — read the session's **effective** settings. +//! `approval::get-settings` — read the session's **effective** settings. //! Never writes (lazy seeding happens on mutation, not on read). use super::Deps; diff --git a/approval-gate/src/functions/list_pending.rs b/approval-gate/src/functions/list_pending.rs index dc859066..4b5e359a 100644 --- a/approval-gate/src/functions/list_pending.rs +++ b/approval-gate/src/functions/list_pending.rs @@ -1,4 +1,4 @@ -//! `approval::list_pending` — the pending inbox. Filters apply +//! `approval::list-pending` — the pending inbox. Filters apply //! worker-side over the live scope (cheap — the scope only ever holds //! live records). Ordered by `pending_at` ascending with a stable //! tie-break so the opaque cursor paginates deterministically. diff --git a/approval-gate/src/functions/mod.rs b/approval-gate/src/functions/mod.rs index 5e5f5a06..0219f3c4 100644 --- a/approval-gate/src/functions/mod.rs +++ b/approval-gate/src/functions/mod.rs @@ -44,45 +44,45 @@ pub const GATE_DESC: &str = "pre_dispatch hook: evaluate the permission model an pub const RESOLVE_ID: &str = "approval::resolve"; pub const RESOLVE_DESC: &str = "Apply a human decision to a held call: release it for execution (allow) or deliver a denial (deny). Human/console-only."; -pub const LIST_PENDING_ID: &str = "approval::list_pending"; +pub const LIST_PENDING_ID: &str = "approval::list-pending"; pub const LIST_PENDING_DESC: &str = "The pending inbox across sessions, with tenancy filters; the catch-up path for notification workers after a restart."; -pub const GET_PENDING_ID: &str = "approval::get_pending"; +pub const GET_PENDING_ID: &str = "approval::get-pending"; pub const GET_PENDING_DESC: &str = "Read one pending record; null when resolved or unknown."; -pub const SET_MODE_ID: &str = "approval::set_mode"; +pub const SET_MODE_ID: &str = "approval::set-mode"; pub const SET_MODE_DESC: &str = "Set the session's permission mode (manual / auto / full). Human/console-only."; -pub const ADD_ALWAYS_ALLOW_ID: &str = "approval::add_always_allow"; +pub const ADD_ALWAYS_ALLOW_ID: &str = "approval::add-always-allow"; pub const ADD_ALWAYS_ALLOW_DESC: &str = "Add a function to the session's auto-mode trust list (idempotent). Human/console-only."; -pub const REMOVE_ALWAYS_ALLOW_ID: &str = "approval::remove_always_allow"; +pub const REMOVE_ALWAYS_ALLOW_ID: &str = "approval::remove-always-allow"; pub const REMOVE_ALWAYS_ALLOW_DESC: &str = "Remove a function from the session's auto-mode trust list (no-op when absent). Human/console-only."; -pub const APPROVE_ALWAYS_ID: &str = "approval::approve_always"; +pub const APPROVE_ALWAYS_ID: &str = "approval::approve-always"; pub const APPROVE_ALWAYS_DESC: &str = "Record a per-session 'approve always' grant (honoured in every mode). Human/console-only."; -pub const GET_SETTINGS_ID: &str = "approval::get_settings"; +pub const GET_SETTINGS_ID: &str = "approval::get-settings"; pub const GET_SETTINGS_DESC: &str = "Read the session's effective settings (stored record or configuration defaults); never writes."; -pub const CLEAR_SETTINGS_ID: &str = "approval::clear_settings"; +pub const CLEAR_SETTINGS_ID: &str = "approval::clear-settings"; pub const CLEAR_SETTINGS_DESC: &str = "Drop the session's stored settings record (revert to configuration defaults)."; -pub const ON_CONFIG_CHANGE_ID: &str = "approval::on_config_change"; +pub const ON_CONFIG_CHANGE_ID: &str = "approval::on-config-change"; pub const ON_CONFIG_CHANGE_DESC: &str = "Internal: configuration trigger handler (reload deployment defaults)."; -pub const ON_SESSION_DELETED_ID: &str = "approval::on_session_deleted"; +pub const ON_SESSION_DELETED_ID: &str = "approval::on-session-deleted"; pub const ON_SESSION_DELETED_DESC: &str = "Internal: session::deleted handler (purge the session's settings and pending records)."; -pub const ON_TURN_COMPLETED_ID: &str = "approval::on_turn_completed"; +pub const ON_TURN_COMPLETED_ID: &str = "approval::on-turn-completed"; pub const ON_TURN_COMPLETED_DESC: &str = - "Internal: harness::turn_completed handler (purge the turn's pending records)."; + "Internal: harness::turn-completed handler (purge the turn's pending records)."; pub const SWEEP_ID: &str = "approval::sweep"; pub const SWEEP_DESC: &str = "Internal: cron handler (expire pending records past expires_at)."; diff --git a/approval-gate/src/functions/on_config_change.rs b/approval-gate/src/functions/on_config_change.rs index e66c6891..22b7eea3 100644 --- a/approval-gate/src/functions/on_config_change.rs +++ b/approval-gate/src/functions/on_config_change.rs @@ -1,4 +1,4 @@ -//! `approval::on_config_change` — bound to the engine's `configuration` +//! `approval::on-config-change` — bound to the engine's `configuration` //! trigger on `configuration_id: "approval-gate"`. Reactive reload, no //! polling: swaps the in-memory deployment defaults. diff --git a/approval-gate/src/functions/on_session_deleted.rs b/approval-gate/src/functions/on_session_deleted.rs index 5cc274b2..e1092f21 100644 --- a/approval-gate/src/functions/on_session_deleted.rs +++ b/approval-gate/src/functions/on_session_deleted.rs @@ -1,4 +1,4 @@ -//! `approval::on_session_deleted` — bound to session-manager's +//! `approval::on-session-deleted` — bound to session-manager's //! `session::deleted` trigger type. Purges the session's settings record //! and every pending record (the cascade the prior deployment lacked). diff --git a/approval-gate/src/functions/on_turn_completed.rs b/approval-gate/src/functions/on_turn_completed.rs index bb5459f8..96bbb7ae 100644 --- a/approval-gate/src/functions/on_turn_completed.rs +++ b/approval-gate/src/functions/on_turn_completed.rs @@ -1,5 +1,5 @@ -//! `approval::on_turn_completed` — bound to the harness's -//! `harness::turn_completed` trigger type. Purges the turn's pending +//! `approval::on-turn-completed` — bound to the harness's +//! `harness::turn-completed` trigger type. Purges the turn's pending //! records when it goes terminal (covers `harness::stop` cancellation //! cascades and failed turns). A `completed` turn has no live holds by //! construction — the purge is then a harmless stale-record cleanup. @@ -10,7 +10,7 @@ use serde_json::Value; use super::{purge, Deps}; -/// `harness::turn_completed` payload (only the field we read). +/// `harness::turn-completed` payload (only the field we read). #[derive(Debug, Clone, Deserialize, JsonSchema)] pub struct TurnCompletedEvent { pub turn_id: String, diff --git a/approval-gate/src/functions/remove_always_allow.rs b/approval-gate/src/functions/remove_always_allow.rs index 001721d8..aaca8d39 100644 --- a/approval-gate/src/functions/remove_always_allow.rs +++ b/approval-gate/src/functions/remove_always_allow.rs @@ -1,4 +1,4 @@ -//! `approval::remove_always_allow` — remove a function from the session's +//! `approval::remove-always-allow` — remove a function from the session's //! auto-mode trust list (no-op when absent; seed entries removable like //! any other — the stored record overrides the deployment seed from first //! mutation on). diff --git a/approval-gate/src/functions/set_mode.rs b/approval-gate/src/functions/set_mode.rs index 0023597f..6c230c9d 100644 --- a/approval-gate/src/functions/set_mode.rs +++ b/approval-gate/src/functions/set_mode.rs @@ -1,4 +1,4 @@ -//! `approval::set_mode` — set the session's permission mode. First +//! `approval::set-mode` — set the session's permission mode. First //! mutation materializes the settings record from the current //! configuration defaults (lazy seeding). diff --git a/approval-gate/src/lib.rs b/approval-gate/src/lib.rs index cb03be48..b23d30e3 100644 --- a/approval-gate/src/lib.rs +++ b/approval-gate/src/lib.rs @@ -7,13 +7,13 @@ //! 2. The decision plane — `approval::resolve` plus the per-session //! settings RPCs (human/console-only). //! 3. The pending inbox — an ephemeral index of held calls -//! (`approval::list_pending` / `approval::get_pending`) plus the -//! `approval::pending_created` / `approval::pending_resolved` trigger +//! (`approval::list-pending` / `approval::get-pending`) plus the +//! `approval::pending-created` / `approval::pending-resolved` trigger //! types notification workers bind to. //! //! The worker codes against the greenfield harness contracts -//! (`harness::hook::pre_dispatch`, `harness::function::resolve`, -//! `harness::turn_completed` — harness.md § Hooks, § API Reference); +//! (`harness::hook::pre-dispatch`, `harness::function::resolve`, +//! `harness::turn-completed` — harness.md § Hooks, § API Reference); //! those bindings are best-effort so the worker also boots standalone. pub mod config; diff --git a/approval-gate/src/main.rs b/approval-gate/src/main.rs index c9d8d8ed..348527e1 100644 --- a/approval-gate/src/main.rs +++ b/approval-gate/src/main.rs @@ -4,12 +4,12 @@ //! 1. Parse CLI / load YAML config (a missing file falls back to //! defaults; a file that exists but doesn't parse is fatal). //! 2. Connect to the local iii engine over WebSocket. -//! 3. Register the two custom trigger types (`approval::pending_created`, -//! `approval::pending_resolved`) — first, because the function +//! 3. Register the two custom trigger types (`approval::pending-created`, +//! `approval::pending-resolved`) — first, because the function //! handlers capture the subscriber sets they fan out to. //! 4. Register the 14 `approval::*` functions. -//! 5. Bind triggers, all best-effort (`harness::hook::pre_dispatch`, -//! `configuration`, `session::deleted`, `harness::turn_completed`, +//! 5. Bind triggers, all best-effort (`harness::hook::pre-dispatch`, +//! `configuration`, `session::deleted`, `harness::turn-completed`, //! `cron`) — in a standalone deployment some of these trigger types //! don't exist yet; the worker still boots and serves its RPCs. //! 6. Register the `approval-gate` configuration entry and read the @@ -141,7 +141,7 @@ async fn main() -> Result<()> { // the hook (approval-gate.md § The approval::gate hook). bind_best_effort( &iii, - "harness::hook::pre_dispatch", + "harness::hook::pre-dispatch", "approval::gate", json!({ "functions": cfg.hook.functions, @@ -152,7 +152,7 @@ async fn main() -> Result<()> { bind_best_effort( &iii, "configuration", - "approval::on_config_change", + "approval::on-config-change", json!({ "configuration_id": gate_config::ENTRY_ID, "event_types": ["configuration:registered", "configuration:updated"], @@ -161,13 +161,13 @@ async fn main() -> Result<()> { bind_best_effort( &iii, "session::deleted", - "approval::on_session_deleted", + "approval::on-session-deleted", json!({}), ); bind_best_effort( &iii, - "harness::turn_completed", - "approval::on_turn_completed", + "harness::turn-completed", + "approval::on-turn-completed", json!({}), ); bind_best_effort( diff --git a/approval-gate/src/testkit/engine.rs b/approval-gate/src/testkit/engine.rs index 906069ab..d7a50104 100644 --- a/approval-gate/src/testkit/engine.rs +++ b/approval-gate/src/testkit/engine.rs @@ -221,9 +221,9 @@ pub struct TestStack { pub defaults: SharedDefaults, /// Requests received by the fake `harness::function::resolve`. pub harness_calls: CallLog, - /// `approval::pending_created` deliveries. + /// `approval::pending-created` deliveries. pub created: CallLog, - /// `approval::pending_resolved` deliveries. + /// `approval::pending-resolved` deliveries. pub resolved: CallLog, } @@ -349,7 +349,7 @@ pub async fn boot(engine: &Engine, opts: BootOpts) -> TestStack { let _ = iii.register_trigger(RegisterTriggerInput { trigger_type: "configuration".to_string(), - function_id: "approval::on_config_change".to_string(), + function_id: "approval::on-config-change".to_string(), config: json!({ "configuration_id": ENTRY_ID, "event_types": ["configuration:registered", "configuration:updated"], diff --git a/approval-gate/src/types.rs b/approval-gate/src/types.rs index 99072c0d..7134c3e8 100644 --- a/approval-gate/src/types.rs +++ b/approval-gate/src/types.rs @@ -175,7 +175,7 @@ pub enum ResolvedOutcome { Aborted, } -/// Payload of `approval::pending_resolved` — a pending call left the +/// Payload of `approval::pending-resolved` — a pending call left the /// inbox. Emitted exactly once per record (gated on the record delete). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct PendingResolvedEvent { diff --git a/approval-gate/tests/golden/schemas/approval.add_always_allow.json b/approval-gate/tests/golden/schemas/approval.add-always-allow.json similarity index 98% rename from approval-gate/tests/golden/schemas/approval.add_always_allow.json rename to approval-gate/tests/golden/schemas/approval.add-always-allow.json index 3ecf06b4..633adf08 100644 --- a/approval-gate/tests/golden/schemas/approval.add_always_allow.json +++ b/approval-gate/tests/golden/schemas/approval.add-always-allow.json @@ -1,6 +1,6 @@ { "description": "Add a function to the session's auto-mode trust list (idempotent). Human/console-only.", - "function_id": "approval::add_always_allow", + "function_id": "approval::add-always-allow", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/approval-gate/tests/golden/schemas/approval.approve_always.json b/approval-gate/tests/golden/schemas/approval.approve-always.json similarity index 98% rename from approval-gate/tests/golden/schemas/approval.approve_always.json rename to approval-gate/tests/golden/schemas/approval.approve-always.json index a6ba432b..5f14af49 100644 --- a/approval-gate/tests/golden/schemas/approval.approve_always.json +++ b/approval-gate/tests/golden/schemas/approval.approve-always.json @@ -1,6 +1,6 @@ { "description": "Record a per-session 'approve always' grant (honoured in every mode). Human/console-only.", - "function_id": "approval::approve_always", + "function_id": "approval::approve-always", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/approval-gate/tests/golden/schemas/approval.clear_settings.json b/approval-gate/tests/golden/schemas/approval.clear-settings.json similarity index 93% rename from approval-gate/tests/golden/schemas/approval.clear_settings.json rename to approval-gate/tests/golden/schemas/approval.clear-settings.json index 931dbe34..ce7db169 100644 --- a/approval-gate/tests/golden/schemas/approval.clear_settings.json +++ b/approval-gate/tests/golden/schemas/approval.clear-settings.json @@ -1,6 +1,6 @@ { "description": "Drop the session's stored settings record (revert to configuration defaults).", - "function_id": "approval::clear_settings", + "function_id": "approval::clear-settings", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/approval-gate/tests/golden/schemas/approval.get_pending.json b/approval-gate/tests/golden/schemas/approval.get-pending.json similarity index 98% rename from approval-gate/tests/golden/schemas/approval.get_pending.json rename to approval-gate/tests/golden/schemas/approval.get-pending.json index 56caa02a..8623590b 100644 --- a/approval-gate/tests/golden/schemas/approval.get_pending.json +++ b/approval-gate/tests/golden/schemas/approval.get-pending.json @@ -1,6 +1,6 @@ { "description": "Read one pending record; null when resolved or unknown.", - "function_id": "approval::get_pending", + "function_id": "approval::get-pending", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/approval-gate/tests/golden/schemas/approval.get_settings.json b/approval-gate/tests/golden/schemas/approval.get-settings.json similarity index 98% rename from approval-gate/tests/golden/schemas/approval.get_settings.json rename to approval-gate/tests/golden/schemas/approval.get-settings.json index daf3187e..557271cb 100644 --- a/approval-gate/tests/golden/schemas/approval.get_settings.json +++ b/approval-gate/tests/golden/schemas/approval.get-settings.json @@ -1,6 +1,6 @@ { "description": "Read the session's effective settings (stored record or configuration defaults); never writes.", - "function_id": "approval::get_settings", + "function_id": "approval::get-settings", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/approval-gate/tests/golden/schemas/approval.list_pending.json b/approval-gate/tests/golden/schemas/approval.list-pending.json similarity index 98% rename from approval-gate/tests/golden/schemas/approval.list_pending.json rename to approval-gate/tests/golden/schemas/approval.list-pending.json index 5abed7ed..c89c32df 100644 --- a/approval-gate/tests/golden/schemas/approval.list_pending.json +++ b/approval-gate/tests/golden/schemas/approval.list-pending.json @@ -1,6 +1,6 @@ { "description": "The pending inbox across sessions, with tenancy filters; the catch-up path for notification workers after a restart.", - "function_id": "approval::list_pending", + "function_id": "approval::list-pending", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/approval-gate/tests/golden/schemas/approval.on_config_change.json b/approval-gate/tests/golden/schemas/approval.on-config-change.json similarity index 93% rename from approval-gate/tests/golden/schemas/approval.on_config_change.json rename to approval-gate/tests/golden/schemas/approval.on-config-change.json index 153aa88e..545c322c 100644 --- a/approval-gate/tests/golden/schemas/approval.on_config_change.json +++ b/approval-gate/tests/golden/schemas/approval.on-config-change.json @@ -1,6 +1,6 @@ { "description": "Internal: configuration trigger handler (reload deployment defaults).", - "function_id": "approval::on_config_change", + "function_id": "approval::on-config-change", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "The configuration trigger payload (only the fields we read; the engine owns the shape).", diff --git a/approval-gate/tests/golden/schemas/approval.on_session_deleted.json b/approval-gate/tests/golden/schemas/approval.on-session-deleted.json similarity index 91% rename from approval-gate/tests/golden/schemas/approval.on_session_deleted.json rename to approval-gate/tests/golden/schemas/approval.on-session-deleted.json index 33650aae..b2c2b7b4 100644 --- a/approval-gate/tests/golden/schemas/approval.on_session_deleted.json +++ b/approval-gate/tests/golden/schemas/approval.on-session-deleted.json @@ -1,6 +1,6 @@ { "description": "Internal: session::deleted handler (purge the session's settings and pending records).", - "function_id": "approval::on_session_deleted", + "function_id": "approval::on-session-deleted", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "`session::deleted` payload (only the field we read).", diff --git a/approval-gate/tests/golden/schemas/approval.on_turn_completed.json b/approval-gate/tests/golden/schemas/approval.on-turn-completed.json similarity index 70% rename from approval-gate/tests/golden/schemas/approval.on_turn_completed.json rename to approval-gate/tests/golden/schemas/approval.on-turn-completed.json index 6802768d..31b14bd4 100644 --- a/approval-gate/tests/golden/schemas/approval.on_turn_completed.json +++ b/approval-gate/tests/golden/schemas/approval.on-turn-completed.json @@ -1,9 +1,9 @@ { - "description": "Internal: harness::turn_completed handler (purge the turn's pending records).", - "function_id": "approval::on_turn_completed", + "description": "Internal: harness::turn-completed handler (purge the turn's pending records).", + "function_id": "approval::on-turn-completed", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "`harness::turn_completed` payload (only the field we read).", + "description": "`harness::turn-completed` payload (only the field we read).", "properties": { "turn_id": { "type": "string" diff --git a/approval-gate/tests/golden/schemas/approval.pending_created.json b/approval-gate/tests/golden/schemas/approval.pending-created.json similarity index 98% rename from approval-gate/tests/golden/schemas/approval.pending_created.json rename to approval-gate/tests/golden/schemas/approval.pending-created.json index f5059ae2..d8710884 100644 --- a/approval-gate/tests/golden/schemas/approval.pending_created.json +++ b/approval-gate/tests/golden/schemas/approval.pending-created.json @@ -75,5 +75,5 @@ "title": "PendingApprovalRecord", "type": "object" }, - "trigger_id": "approval::pending_created" + "trigger_id": "approval::pending-created" } diff --git a/approval-gate/tests/golden/schemas/approval.pending_resolved.json b/approval-gate/tests/golden/schemas/approval.pending-resolved.json similarity index 93% rename from approval-gate/tests/golden/schemas/approval.pending_resolved.json rename to approval-gate/tests/golden/schemas/approval.pending-resolved.json index 01e3ca39..064da58e 100644 --- a/approval-gate/tests/golden/schemas/approval.pending_resolved.json +++ b/approval-gate/tests/golden/schemas/approval.pending-resolved.json @@ -13,7 +13,7 @@ "type": "string" } }, - "description": "Payload of `approval::pending_resolved` — a pending call left the inbox. Emitted exactly once per record (gated on the record delete).", + "description": "Payload of `approval::pending-resolved` — a pending call left the inbox. Emitted exactly once per record (gated on the record delete).", "properties": { "function_call_id": { "type": "string" @@ -62,5 +62,5 @@ "title": "PendingResolvedEvent", "type": "object" }, - "trigger_id": "approval::pending_resolved" + "trigger_id": "approval::pending-resolved" } diff --git a/approval-gate/tests/golden/schemas/approval.remove_always_allow.json b/approval-gate/tests/golden/schemas/approval.remove-always-allow.json similarity index 98% rename from approval-gate/tests/golden/schemas/approval.remove_always_allow.json rename to approval-gate/tests/golden/schemas/approval.remove-always-allow.json index c7795b83..e0799fbc 100644 --- a/approval-gate/tests/golden/schemas/approval.remove_always_allow.json +++ b/approval-gate/tests/golden/schemas/approval.remove-always-allow.json @@ -1,6 +1,6 @@ { "description": "Remove a function from the session's auto-mode trust list (no-op when absent). Human/console-only.", - "function_id": "approval::remove_always_allow", + "function_id": "approval::remove-always-allow", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/approval-gate/tests/golden/schemas/approval.set_mode.json b/approval-gate/tests/golden/schemas/approval.set-mode.json similarity index 98% rename from approval-gate/tests/golden/schemas/approval.set_mode.json rename to approval-gate/tests/golden/schemas/approval.set-mode.json index ab8d69b6..c5bbab01 100644 --- a/approval-gate/tests/golden/schemas/approval.set_mode.json +++ b/approval-gate/tests/golden/schemas/approval.set-mode.json @@ -1,6 +1,6 @@ { "description": "Set the session's permission mode (manual / auto / full). Human/console-only.", - "function_id": "approval::set_mode", + "function_id": "approval::set-mode", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { diff --git a/approval-gate/tests/integration.rs b/approval-gate/tests/integration.rs index 29111ec5..38255ac9 100644 --- a/approval-gate/tests/integration.rs +++ b/approval-gate/tests/integration.rs @@ -94,7 +94,7 @@ async fn resolve_allow_releases_and_deny_delivers_through_the_fake_harness() { assert_eq!(harness[1]["action"], json!("deliver")); assert_eq!(harness[1]["details"]["denied_by"], json!("user")); - let listed = call(iii, "approval::list_pending", json!({})) + let listed = call(iii, "approval::list-pending", json!({})) .await .unwrap(); assert_eq!(listed["pending"].as_array().unwrap().len(), 0); @@ -199,7 +199,7 @@ async fn settings_are_lazily_seeded_against_real_state() { let iii = &stack.iii; let before = call( iii, - "approval::get_settings", + "approval::get-settings", json!({ "session_id": "s_lazy" }), ) .await @@ -208,7 +208,7 @@ async fn settings_are_lazily_seeded_against_real_state() { call( iii, - "approval::approve_always", + "approval::approve-always", json!({ "session_id": "s_lazy", "function_id": "shell::run" }), ) .await @@ -216,7 +216,7 @@ async fn settings_are_lazily_seeded_against_real_state() { let after = call( iii, - "approval::get_settings", + "approval::get-settings", json!({ "session_id": "s_lazy" }), ) .await @@ -241,7 +241,7 @@ async fn human_only_targets_are_denied_through_the_full_stack() { let out = call( &stack.iii, "approval::gate", - hook_input("s_1", "c_1", "approval::set_mode"), + hook_input("s_1", "c_1", "approval::set-mode"), ) .await .unwrap(); diff --git a/approval-gate/tests/schemas.rs b/approval-gate/tests/schemas.rs index b93e04ab..e76c1ddf 100644 --- a/approval-gate/tests/schemas.rs +++ b/approval-gate/tests/schemas.rs @@ -56,17 +56,17 @@ fn catalog_lists_all_functions_in_registration_order() { vec![ "approval::gate", "approval::resolve", - "approval::list_pending", - "approval::get_pending", - "approval::set_mode", - "approval::add_always_allow", - "approval::remove_always_allow", - "approval::approve_always", - "approval::get_settings", - "approval::clear_settings", - "approval::on_config_change", - "approval::on_session_deleted", - "approval::on_turn_completed", + "approval::list-pending", + "approval::get-pending", + "approval::set-mode", + "approval::add-always-allow", + "approval::remove-always-allow", + "approval::approve-always", + "approval::get-settings", + "approval::clear-settings", + "approval::on-config-change", + "approval::on-session-deleted", + "approval::on-turn-completed", "approval::sweep", ] ); @@ -79,7 +79,7 @@ fn trigger_catalog_lists_both_trigger_types_in_registration_order() { let ids: Vec<&str> = trigger_catalog().iter().map(|s| s.trigger_id).collect(); assert_eq!( ids, - vec!["approval::pending_created", "approval::pending_resolved",] + vec!["approval::pending-created", "approval::pending-resolved",] ); } @@ -135,11 +135,11 @@ fn schemas_carry_field_descriptions() { let must_have_descriptions = [ "approval::gate", "approval::resolve", - "approval::list_pending", - "approval::get_pending", - "approval::on_config_change", - "approval::on_session_deleted", - "approval::on_turn_completed", + "approval::list-pending", + "approval::get-pending", + "approval::on-config-change", + "approval::on-session-deleted", + "approval::on-turn-completed", ]; for spec in catalog() { if !must_have_descriptions.contains(&spec.function_id) {