From 2a76e98be3d514f6d9f59ecb0722282808ed93c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Dec 2025 14:14:13 +0000 Subject: [PATCH] feat(contract): add testable gating contract with minimal runner Add the gating-contract crate which formalizes the policy enforcement contract with: - Inputs: GatingRequest with proposal, context, and metadata - Outputs: GatingDecision with verdict, refusal details, evaluations - Refusal Taxonomy: 11 categories with 25+ specific refusal codes - Language violations (100-199) - Toolchain violations (200-299) - Security violations (300-399) - Pattern violations (400-499) - Spirit violations (500-599) - System errors (900-999) - Audit Log: AuditEntry with full decision traceability - Minimal Runner: ContractRunner and TestHarness for validation CLI additions: - conative contract schema: Display contract specification - conative contract test: Run tests from training data - conative contract eval: Evaluate gating requests with audit output All 8 contract unit tests pass. Test harness exposes known Oracle limitations (marker-based detection has false positives for TypeScript and Python markers in Rust/Elixir code). --- Cargo.lock | 1026 +++++++++++++++++++++++++++++++++ Cargo.toml | 3 + src/contract/Cargo.toml | 24 + src/contract/src/lib.rs | 1183 +++++++++++++++++++++++++++++++++++++++ src/main.rs | 521 ++++++++++++++++- 5 files changed, 2756 insertions(+), 1 deletion(-) create mode 100644 Cargo.lock create mode 100644 src/contract/Cargo.toml create mode 100644 src/contract/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7af406d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1026 @@ +# 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 = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +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 = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +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 = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", + "unicase", + "unicode-width", +] + +[[package]] +name = "clap_complete" +version = "4.5.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "004eef6b14ce34759aa7de4aea3217e368f463f46a3ed3764ca4b5a4404003b4" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "clap_mangen" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ea63a92086df93893164221ad4f24142086d535b3a0957b9b9bea2dc86301" +dependencies = [ + "clap", + "roff", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "conative-gating" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "clap_complete", + "clap_mangen", + "gating-contract", + "policy-oracle", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[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 = "find-msvc-tools" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" + +[[package]] +name = "gating-contract" +version = "0.1.0" +dependencies = [ + "chrono", + "policy-oracle", + "serde", + "serde_json", + "slm-evaluator", + "thiserror", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[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 = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "policy-oracle" +version = "0.1.0" +dependencies = [ + "glob", + "regex", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +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 = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "roff" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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_json" +version = "1.0.148" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "slm-evaluator" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix", + "windows-sys 0.60.2", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +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 = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom", + "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 = "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.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[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-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-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.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[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.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "zmij" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d6085d62852e35540689d1f97ad663e3971fc19cf5eceab364d62c646ea167" diff --git a/Cargo.toml b/Cargo.toml index a8486d8..a1ca704 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ repository = "https://github.com/hyperpolymath/conative-gating" members = [ "src/oracle", "src/slm", + "src/contract", ] [workspace.dependencies] @@ -30,7 +31,9 @@ clap_mangen = "0.2" [dependencies] policy-oracle = { path = "src/oracle" } +gating-contract = { path = "src/contract" } clap.workspace = true +chrono.workspace = true clap_complete.workspace = true clap_mangen.workspace = true serde.workspace = true diff --git a/src/contract/Cargo.toml b/src/contract/Cargo.toml new file mode 100644 index 0000000..368a86f --- /dev/null +++ b/src/contract/Cargo.toml @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2025 Jonathan D.A. Jewell + +[package] +name = "gating-contract" +version = "0.1.0" +edition = "2021" +authors = ["Jonathan D.A. Jewell "] +description = "Testable gating contract: inputs, outputs, refusal taxonomy, audit logging" +license = "AGPL-3.0-or-later" +repository = "https://github.com/hyperpolymath/conative-gating" + +[dependencies] +policy-oracle = { path = "../oracle" } +slm-evaluator = { path = "../slm" } +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +thiserror.workspace = true +tracing.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/src/contract/src/lib.rs b/src/contract/src/lib.rs new file mode 100644 index 0000000..f93cc9b --- /dev/null +++ b/src/contract/src/lib.rs @@ -0,0 +1,1183 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2025 Jonathan D.A. Jewell + +//! Gating Contract - Testable specification for conative gating decisions +//! +//! This module defines the complete contract for policy enforcement: +//! - **Inputs**: What the gating system receives (`GatingRequest`) +//! - **Outputs**: What the gating system returns (`GatingDecision`) +//! - **Refusal Taxonomy**: Categorization of all refusal types +//! - **Audit Log Format**: Structured logging for compliance and debugging +//! +//! The contract is designed to be: +//! - Testable with deterministic behavior +//! - Auditable with comprehensive logging +//! - Extensible for future SLM integration + +use chrono::{DateTime, Utc}; +use policy_oracle::{ + ConcernType, OracleError, OracleEvaluation, Policy, PolicyVerdict, Proposal, Severity, + ViolationType, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use thiserror::Error; +use uuid::Uuid; + +// ============================================================================ +// CONTRACT VERSION +// ============================================================================ + +/// Contract version for compatibility checking +pub const CONTRACT_VERSION: &str = "0.1.0"; + +/// Contract schema identifier +pub const CONTRACT_SCHEMA: &str = "conative-gating-contract-v1"; + +// ============================================================================ +// INPUTS - What the gating system receives +// ============================================================================ + +/// Complete gating request - the primary input to the gating system +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GatingRequest { + /// Unique request identifier for tracing + pub request_id: Uuid, + + /// Timestamp when request was created + pub timestamp: DateTime, + + /// The proposal to evaluate + pub proposal: Proposal, + + /// Request context and metadata + pub context: RequestContext, + + /// Optional policy override (uses default if None) + pub policy_override: Option, +} + +/// Context surrounding the gating request +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RequestContext { + /// Source of the request (e.g., "claude-code", "github-action", "api") + pub source: String, + + /// Session or conversation identifier + pub session_id: Option, + + /// User or agent identifier (anonymized) + pub agent_id: Option, + + /// Repository or project context + pub repository: Option, + + /// Previous decisions in this session (for pattern detection) + pub session_history: Vec, + + /// Custom metadata key-value pairs + pub metadata: HashMap, +} + +/// Repository context for evaluating proposals +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepositoryContext { + /// Repository name or path + pub name: String, + + /// Default branch + pub default_branch: Option, + + /// Policy configuration file path (if any) + pub policy_file: Option, + + /// Whether this is a new repository (no history) + pub is_new: bool, +} + +impl GatingRequest { + /// Create a new gating request with minimal required fields + pub fn new(proposal: Proposal) -> Self { + Self { + request_id: Uuid::new_v4(), + timestamp: Utc::now(), + proposal, + context: RequestContext::default(), + policy_override: None, + } + } + + /// Builder: set request context + pub fn with_context(mut self, context: RequestContext) -> Self { + self.context = context; + self + } + + /// Builder: set policy override + pub fn with_policy(mut self, policy: Policy) -> Self { + self.policy_override = Some(policy); + self + } +} + +// ============================================================================ +// OUTPUTS - What the gating system returns +// ============================================================================ + +/// Complete gating decision - the primary output of the gating system +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GatingDecision { + /// Original request ID for correlation + pub request_id: Uuid, + + /// Unique decision identifier + pub decision_id: Uuid, + + /// Timestamp when decision was made + pub timestamp: DateTime, + + /// The final verdict + pub verdict: Verdict, + + /// Refusal details (if verdict is not Allow) + pub refusal: Option, + + /// Evaluation details from each stage + pub evaluations: EvaluationChain, + + /// Processing metadata + pub processing: ProcessingMetadata, +} + +/// Final verdict of the gating decision +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum Verdict { + /// Proposal is allowed to proceed + Allow, + + /// Proposal triggers a warning but is allowed + Warn, + + /// Proposal requires human escalation + Escalate, + + /// Proposal is blocked + Block, +} + +impl Verdict { + /// Convert to exit code for CLI usage + pub fn exit_code(&self) -> i32 { + match self { + Verdict::Allow => 0, + Verdict::Warn => 2, + Verdict::Escalate => 3, + Verdict::Block => 1, + } + } + + /// Whether the proposal can proceed + pub fn is_allowed(&self) -> bool { + matches!(self, Verdict::Allow | Verdict::Warn) + } +} + +/// Chain of evaluations from all stages +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct EvaluationChain { + /// Oracle (deterministic) evaluation result + pub oracle: Option, + + /// SLM (neural) evaluation result (when implemented) + pub slm: Option, + + /// Arbiter consensus result (when implemented) + pub arbiter: Option, +} + +/// Placeholder for SLM evaluation result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlmEvaluationResult { + pub spirit_score: f64, + pub confidence: f64, + pub reasoning: String, + pub should_block: bool, +} + +/// Placeholder for arbiter consensus result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArbiterResult { + pub consensus_reached: bool, + pub oracle_vote: Verdict, + pub slm_vote: Verdict, + pub final_verdict: Verdict, + pub slm_weight: f64, +} + +/// Processing metadata for observability +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProcessingMetadata { + /// Processing duration in microseconds + pub duration_us: u64, + + /// Contract version used + pub contract_version: String, + + /// Policy name used + pub policy_name: String, + + /// Number of rules checked + pub rules_checked: usize, + + /// Stages that were executed + pub stages_executed: Vec, +} + +impl Default for ProcessingMetadata { + fn default() -> Self { + Self { + duration_us: 0, + contract_version: CONTRACT_VERSION.to_string(), + policy_name: String::new(), + rules_checked: 0, + stages_executed: Vec::new(), + } + } +} + +// ============================================================================ +// REFUSAL TAXONOMY - Categorization of all refusal types +// ============================================================================ + +/// Complete refusal information when a proposal is not allowed +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Refusal { + /// Primary refusal category + pub category: RefusalCategory, + + /// Specific refusal code for programmatic handling + pub code: RefusalCode, + + /// Human-readable message + pub message: String, + + /// Suggested remediation (if applicable) + pub remediation: Option, + + /// Evidence supporting the refusal + pub evidence: Vec, + + /// Whether this refusal can be overridden + pub overridable: bool, + + /// Required authorization level for override + pub override_level: Option, +} + +/// Top-level refusal categories +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum RefusalCategory { + // === Hard Policy Violations (Oracle) === + /// Forbidden programming language detected + ForbiddenLanguage, + + /// Forbidden toolchain or dependency management + ForbiddenToolchain, + + /// Security pattern violation (secrets, unsafe patterns) + SecurityViolation, + + /// Forbidden code pattern detected + ForbiddenPattern, + + // === Spirit Violations (SLM) === + /// Excessive verbosity or documentation bloat + VerbositySmell, + + /// Unusual code structure or anti-patterns + StructuralAnomaly, + + /// Intent violation (technically compliant but spirit-violating) + IntentViolation, + + /// Adversarial input detection + AdversarialInput, + + // === System Refusals === + /// Request validation failed + InvalidRequest, + + /// Rate limiting or quota exceeded + RateLimited, + + /// System error during processing + SystemError, +} + +impl RefusalCategory { + /// Human-readable display name + pub fn display_name(&self) -> &'static str { + match self { + RefusalCategory::ForbiddenLanguage => "Forbidden Language", + RefusalCategory::ForbiddenToolchain => "Forbidden Toolchain", + RefusalCategory::SecurityViolation => "Security Violation", + RefusalCategory::ForbiddenPattern => "Forbidden Pattern", + RefusalCategory::VerbositySmell => "Verbosity Smell", + RefusalCategory::StructuralAnomaly => "Structural Anomaly", + RefusalCategory::IntentViolation => "Intent Violation", + RefusalCategory::AdversarialInput => "Adversarial Input", + RefusalCategory::InvalidRequest => "Invalid Request", + RefusalCategory::RateLimited => "Rate Limited", + RefusalCategory::SystemError => "System Error", + } + } + + /// Whether this is a hard (deterministic) or soft (neural) refusal + pub fn is_hard(&self) -> bool { + matches!( + self, + RefusalCategory::ForbiddenLanguage + | RefusalCategory::ForbiddenToolchain + | RefusalCategory::SecurityViolation + | RefusalCategory::ForbiddenPattern + | RefusalCategory::InvalidRequest + | RefusalCategory::SystemError + ) + } + + /// Severity level of this category + pub fn severity(&self) -> Severity { + match self { + RefusalCategory::SecurityViolation => Severity::Critical, + RefusalCategory::ForbiddenLanguage => Severity::Critical, + RefusalCategory::ForbiddenToolchain => Severity::High, + RefusalCategory::ForbiddenPattern => Severity::High, + RefusalCategory::AdversarialInput => Severity::Critical, + RefusalCategory::IntentViolation => Severity::Medium, + RefusalCategory::VerbositySmell => Severity::Low, + RefusalCategory::StructuralAnomaly => Severity::Medium, + RefusalCategory::InvalidRequest => Severity::Medium, + RefusalCategory::RateLimited => Severity::Low, + RefusalCategory::SystemError => Severity::High, + } + } +} + +/// Specific refusal codes for programmatic handling +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum RefusalCode { + // Language codes (1xx) + Lang100TypeScript, + Lang101Python, + Lang102Go, + Lang103Java, + Lang104Kotlin, + Lang105Swift, + Lang199OtherForbidden, + + // Toolchain codes (2xx) + Tool200NpmWithoutDeno, + Tool201YarnWithoutDeno, + Tool202NodeModules, + Tool203PackageJson, + Tool299OtherToolchain, + + // Security codes (3xx) + Sec300HardcodedSecret, + Sec301InsecureHash, + Sec302HttpUrl, + Sec303CommandInjection, + Sec304SqlInjection, + Sec399OtherSecurity, + + // Pattern codes (4xx) + Pat400ForbiddenImport, + Pat401UnsafeBlock, + Pat499OtherPattern, + + // Spirit codes (5xx) + Spirit500Verbosity, + Spirit501OverDocumentation, + Spirit502RedundantComments, + Spirit503BoilerplateCode, + Spirit504MetaCommentary, + Spirit505IntentMismatch, + Spirit599OtherSpirit, + + // System codes (9xx) + Sys900InvalidRequest, + Sys901RateLimited, + Sys902InternalError, + Sys999Unknown, +} + +impl RefusalCode { + /// Numeric code for logging and metrics + pub fn numeric(&self) -> u16 { + match self { + RefusalCode::Lang100TypeScript => 100, + RefusalCode::Lang101Python => 101, + RefusalCode::Lang102Go => 102, + RefusalCode::Lang103Java => 103, + RefusalCode::Lang104Kotlin => 104, + RefusalCode::Lang105Swift => 105, + RefusalCode::Lang199OtherForbidden => 199, + RefusalCode::Tool200NpmWithoutDeno => 200, + RefusalCode::Tool201YarnWithoutDeno => 201, + RefusalCode::Tool202NodeModules => 202, + RefusalCode::Tool203PackageJson => 203, + RefusalCode::Tool299OtherToolchain => 299, + RefusalCode::Sec300HardcodedSecret => 300, + RefusalCode::Sec301InsecureHash => 301, + RefusalCode::Sec302HttpUrl => 302, + RefusalCode::Sec303CommandInjection => 303, + RefusalCode::Sec304SqlInjection => 304, + RefusalCode::Sec399OtherSecurity => 399, + RefusalCode::Pat400ForbiddenImport => 400, + RefusalCode::Pat401UnsafeBlock => 401, + RefusalCode::Pat499OtherPattern => 499, + RefusalCode::Spirit500Verbosity => 500, + RefusalCode::Spirit501OverDocumentation => 501, + RefusalCode::Spirit502RedundantComments => 502, + RefusalCode::Spirit503BoilerplateCode => 503, + RefusalCode::Spirit504MetaCommentary => 504, + RefusalCode::Spirit505IntentMismatch => 505, + RefusalCode::Spirit599OtherSpirit => 599, + RefusalCode::Sys900InvalidRequest => 900, + RefusalCode::Sys901RateLimited => 901, + RefusalCode::Sys902InternalError => 902, + RefusalCode::Sys999Unknown => 999, + } + } +} + +/// Evidence supporting a refusal decision +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Evidence { + /// Type of evidence + pub evidence_type: EvidenceType, + + /// File path (if applicable) + pub file: Option, + + /// Line number (if applicable) + pub line: Option, + + /// Matched pattern or content + pub match_content: String, + + /// Explanation of why this is evidence + pub explanation: String, +} + +/// Types of evidence that can support a refusal +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum EvidenceType { + FileExtension, + ContentMarker, + RegexMatch, + SyntaxPattern, + SlmAnalysis, + HistoricalPattern, +} + +/// Authorization levels for override +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum AuthorizationLevel { + /// Can be overridden by any user + User = 1, + /// Requires maintainer authorization + Maintainer = 2, + /// Requires admin authorization + Admin = 3, + /// Cannot be overridden + None = 100, +} + +// ============================================================================ +// AUDIT LOG FORMAT - Structured logging for compliance +// ============================================================================ + +/// Audit log entry for every gating decision +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditEntry { + /// Entry schema version + pub schema: String, + + /// Unique audit entry ID + pub audit_id: Uuid, + + /// Request ID for correlation + pub request_id: Uuid, + + /// Decision ID for correlation + pub decision_id: Uuid, + + /// Timestamp of the audit entry + pub timestamp: DateTime, + + /// Final verdict + pub verdict: Verdict, + + /// Refusal code (if any) + pub refusal_code: Option, + + /// Refusal category (if any) + pub refusal_category: Option, + + /// Source of the request + pub source: String, + + /// Repository context (anonymized if needed) + pub repository: Option, + + /// Session ID for pattern detection + pub session_id: Option, + + /// Rules that were checked + pub rules_checked: Vec, + + /// Rules that triggered + pub rules_triggered: Vec, + + /// Processing duration in microseconds + pub duration_us: u64, + + /// Stages executed + pub stages: Vec, + + /// Contract version + pub contract_version: String, + + /// Hash of the proposal content (for verification without storing content) + pub content_hash: String, +} + +impl AuditEntry { + /// Create an audit entry from a request and decision + pub fn from_decision(request: &GatingRequest, decision: &GatingDecision) -> Self { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + request.proposal.content.hash(&mut hasher); + let content_hash = format!("{:016x}", hasher.finish()); + + let rules_triggered: Vec = decision + .evaluations + .oracle + .as_ref() + .map(|o| { + o.violations + .iter() + .map(|v| v.rule.clone()) + .collect::>() + }) + .unwrap_or_default(); + + Self { + schema: CONTRACT_SCHEMA.to_string(), + audit_id: Uuid::new_v4(), + request_id: request.request_id, + decision_id: decision.decision_id, + timestamp: Utc::now(), + verdict: decision.verdict, + refusal_code: decision.refusal.as_ref().map(|r| r.code.numeric()), + refusal_category: decision.refusal.as_ref().map(|r| r.category), + source: request.context.source.clone(), + repository: request.context.repository.as_ref().map(|r| r.name.clone()), + session_id: request.context.session_id.clone(), + rules_checked: decision + .evaluations + .oracle + .as_ref() + .map(|o| o.rules_checked.clone()) + .unwrap_or_default(), + rules_triggered, + duration_us: decision.processing.duration_us, + stages: decision.processing.stages_executed.clone(), + contract_version: CONTRACT_VERSION.to_string(), + content_hash, + } + } + + /// Serialize to JSON for logging + pub fn to_json(&self) -> Result { + serde_json::to_string(self) + } + + /// Serialize to compact JSON for high-volume logging + pub fn to_json_compact(&self) -> Result { + serde_json::to_string(self) + } + + /// Serialize to pretty JSON for debugging + pub fn to_json_pretty(&self) -> Result { + serde_json::to_string_pretty(self) + } +} + +// ============================================================================ +// CONTRACT ERRORS +// ============================================================================ + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("Invalid request: {0}")] + InvalidRequest(String), + + #[error("Oracle error: {0}")] + OracleError(#[from] OracleError), + + #[error("Serialization error: {0}")] + SerializationError(#[from] serde_json::Error), + + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), +} + +// ============================================================================ +// CONTRACT EVALUATOR - The minimal runner +// ============================================================================ + +/// Contract evaluator - processes gating requests according to the contract +pub struct ContractRunner { + oracle: policy_oracle::Oracle, + policy: Policy, +} + +impl ContractRunner { + /// Create a new contract runner with RSR defaults + pub fn new() -> Self { + let policy = Policy::rsr_default(); + Self { + oracle: policy_oracle::Oracle::new(policy.clone()), + policy, + } + } + + /// Create a new contract runner with a custom policy + pub fn with_policy(policy: Policy) -> Self { + Self { + oracle: policy_oracle::Oracle::new(policy.clone()), + policy, + } + } + + /// Evaluate a gating request and return a decision + pub fn evaluate(&self, request: &GatingRequest) -> Result { + let start = std::time::Instant::now(); + let mut stages_executed = Vec::new(); + + // Stage 1: Oracle evaluation + stages_executed.push("oracle".to_string()); + let oracle_eval = self.oracle.check_proposal(&request.proposal)?; + + // Determine verdict based on oracle result + let (verdict, refusal) = self.process_oracle_result(&oracle_eval); + + let duration = start.elapsed(); + + Ok(GatingDecision { + request_id: request.request_id, + decision_id: Uuid::new_v4(), + timestamp: Utc::now(), + verdict, + refusal, + evaluations: EvaluationChain { + oracle: Some(oracle_eval.clone()), + slm: None, // Not yet implemented + arbiter: None, // Not yet implemented + }, + processing: ProcessingMetadata { + duration_us: duration.as_micros() as u64, + contract_version: CONTRACT_VERSION.to_string(), + policy_name: self.policy.name.clone(), + rules_checked: oracle_eval.rules_checked.len(), + stages_executed, + }, + }) + } + + /// Process oracle evaluation into verdict and refusal + fn process_oracle_result( + &self, + eval: &OracleEvaluation, + ) -> (Verdict, Option) { + match &eval.verdict { + PolicyVerdict::Compliant => (Verdict::Allow, None), + + PolicyVerdict::SoftConcern(concern) => { + let (category, code, message) = self.map_concern(concern); + ( + Verdict::Warn, + Some(Refusal { + category, + code, + message, + remediation: Some("Consider refactoring to address the concern".to_string()), + evidence: Vec::new(), + overridable: true, + override_level: Some(AuthorizationLevel::User), + }), + ) + } + + PolicyVerdict::HardViolation(violation) => { + let (category, code, message, evidence, remediation) = + self.map_violation(violation); + ( + Verdict::Block, + Some(Refusal { + category, + code, + message, + remediation, + evidence, + overridable: false, + override_level: Some(AuthorizationLevel::None), + }), + ) + } + } + } + + fn map_concern(&self, concern: &ConcernType) -> (RefusalCategory, RefusalCode, String) { + match concern { + ConcernType::VerbositySmell => ( + RefusalCategory::VerbositySmell, + RefusalCode::Spirit500Verbosity, + "Excessive verbosity detected".to_string(), + ), + ConcernType::PatternDeviation => ( + RefusalCategory::StructuralAnomaly, + RefusalCode::Spirit505IntentMismatch, + "Unusual pattern deviation detected".to_string(), + ), + ConcernType::UnusualStructure => ( + RefusalCategory::StructuralAnomaly, + RefusalCode::Spirit505IntentMismatch, + "Unusual code structure detected".to_string(), + ), + ConcernType::Tier2Language { language } => ( + RefusalCategory::ForbiddenLanguage, + RefusalCode::Lang199OtherForbidden, + format!("Tier 2 language '{}' - consider Tier 1 alternative", language), + ), + } + } + + fn map_violation( + &self, + violation: &ViolationType, + ) -> ( + RefusalCategory, + RefusalCode, + String, + Vec, + Option, + ) { + match violation { + ViolationType::ForbiddenLanguage { + language, + file, + context, + } => { + let code = match language.to_lowercase().as_str() { + "typescript" => RefusalCode::Lang100TypeScript, + "python" => RefusalCode::Lang101Python, + "go" => RefusalCode::Lang102Go, + "java" => RefusalCode::Lang103Java, + "kotlin" => RefusalCode::Lang104Kotlin, + "swift" => RefusalCode::Lang105Swift, + _ => RefusalCode::Lang199OtherForbidden, + }; + + let remediation = match language.to_lowercase().as_str() { + "typescript" => Some("Use ReScript instead of TypeScript".to_string()), + "python" => Some( + "Python is only allowed in salt/ for SaltStack configs".to_string(), + ), + "go" => Some("Use Rust instead of Go".to_string()), + "java" => Some("Use Rust/Tauri/Dioxus instead of Java".to_string()), + _ => None, + }; + + ( + RefusalCategory::ForbiddenLanguage, + code, + format!("Forbidden language '{}' detected", language), + vec![Evidence { + evidence_type: EvidenceType::ContentMarker, + file: Some(file.clone()), + line: None, + match_content: context.clone(), + explanation: format!("{} code detected", language), + }], + remediation, + ) + } + + ViolationType::ForbiddenToolchain { tool, missing } => ( + RefusalCategory::ForbiddenToolchain, + RefusalCode::Tool200NpmWithoutDeno, + format!("Toolchain violation: {} requires {}", tool, missing), + vec![Evidence { + evidence_type: EvidenceType::FileExtension, + file: None, + line: None, + match_content: tool.clone(), + explanation: format!("{} detected without {}", tool, missing), + }], + Some(format!("Add {} to use {}", missing, tool)), + ), + + ViolationType::SecurityViolation { description } => ( + RefusalCategory::SecurityViolation, + RefusalCode::Sec300HardcodedSecret, + format!("Security violation: {}", description), + Vec::new(), + Some("Remove hardcoded secrets and use environment variables".to_string()), + ), + + ViolationType::ForbiddenPattern { pattern, file } => ( + RefusalCategory::ForbiddenPattern, + RefusalCode::Pat499OtherPattern, + format!("Forbidden pattern '{}' detected", pattern), + vec![Evidence { + evidence_type: EvidenceType::RegexMatch, + file: Some(file.clone()), + line: None, + match_content: pattern.clone(), + explanation: "Pattern matched forbidden regex".to_string(), + }], + None, + ), + } + } + + /// Create an audit entry for a decision + pub fn audit(&self, request: &GatingRequest, decision: &GatingDecision) -> AuditEntry { + AuditEntry::from_decision(request, decision) + } +} + +impl Default for ContractRunner { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================ +// TEST HARNESS +// ============================================================================ + +/// Test case for contract validation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestCase { + /// Test case name + pub name: String, + + /// Description of what this tests + pub description: String, + + /// Input request + pub request: GatingRequest, + + /// Expected verdict + pub expected_verdict: Verdict, + + /// Expected refusal category (if any) + pub expected_category: Option, + + /// Expected refusal code (if any) + pub expected_code: Option, +} + +/// Test result from running a test case +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestResult { + /// Test case name + pub name: String, + + /// Whether the test passed + pub passed: bool, + + /// Actual verdict received + pub actual_verdict: Verdict, + + /// Expected verdict + pub expected_verdict: Verdict, + + /// Actual refusal category (if any) + pub actual_category: Option, + + /// Error message if test failed + pub error: Option, + + /// Duration of test execution in microseconds + pub duration_us: u64, +} + +/// Test harness for running contract tests +pub struct TestHarness { + runner: ContractRunner, + results: Vec, +} + +impl TestHarness { + pub fn new() -> Self { + Self { + runner: ContractRunner::new(), + results: Vec::new(), + } + } + + pub fn with_runner(runner: ContractRunner) -> Self { + Self { + runner, + results: Vec::new(), + } + } + + /// Run a single test case + pub fn run_test(&mut self, test: &TestCase) -> TestResult { + let start = std::time::Instant::now(); + + let result = match self.runner.evaluate(&test.request) { + Ok(decision) => { + let verdict_matches = decision.verdict == test.expected_verdict; + let category_matches = match (&test.expected_category, &decision.refusal) { + (Some(expected), Some(refusal)) => refusal.category == *expected, + (None, None) => true, + _ => false, + }; + + let passed = verdict_matches && category_matches; + let error = if !passed { + Some(format!( + "Expected {:?} with {:?}, got {:?} with {:?}", + test.expected_verdict, + test.expected_category, + decision.verdict, + decision.refusal.as_ref().map(|r| &r.category) + )) + } else { + None + }; + + TestResult { + name: test.name.clone(), + passed, + actual_verdict: decision.verdict, + expected_verdict: test.expected_verdict, + actual_category: decision.refusal.map(|r| r.category), + error, + duration_us: start.elapsed().as_micros() as u64, + } + } + Err(e) => TestResult { + name: test.name.clone(), + passed: false, + actual_verdict: Verdict::Block, + expected_verdict: test.expected_verdict, + actual_category: None, + error: Some(e.to_string()), + duration_us: start.elapsed().as_micros() as u64, + }, + }; + + self.results.push(result.clone()); + result + } + + /// Run all test cases + pub fn run_all(&mut self, tests: &[TestCase]) -> Vec { + tests.iter().map(|t| self.run_test(t)).collect() + } + + /// Get summary of test results + pub fn summary(&self) -> TestSummary { + let passed = self.results.iter().filter(|r| r.passed).count(); + let failed = self.results.len() - passed; + let total_duration_us: u64 = self.results.iter().map(|r| r.duration_us).sum(); + + TestSummary { + total: self.results.len(), + passed, + failed, + total_duration_us, + results: self.results.clone(), + } + } + + /// Clear results + pub fn clear(&mut self) { + self.results.clear(); + } +} + +impl Default for TestHarness { + fn default() -> Self { + Self::new() + } +} + +/// Summary of test execution +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestSummary { + pub total: usize, + pub passed: usize, + pub failed: usize, + pub total_duration_us: u64, + pub results: Vec, +} + +impl TestSummary { + /// Check if all tests passed + pub fn all_passed(&self) -> bool { + self.failed == 0 + } + + /// Get failed test names + pub fn failed_tests(&self) -> Vec<&str> { + self.results + .iter() + .filter(|r| !r.passed) + .map(|r| r.name.as_str()) + .collect() + } +} + +// ============================================================================ +// UNIT TESTS +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use policy_oracle::ActionType; + + fn create_proposal(path: &str, content: &str) -> Proposal { + Proposal { + id: Uuid::new_v4(), + action_type: ActionType::CreateFile { + path: path.to_string(), + }, + content: content.to_string(), + files_affected: vec![path.to_string()], + llm_confidence: 0.9, + } + } + + #[test] + fn test_contract_allows_rust() { + let runner = ContractRunner::new(); + let request = GatingRequest::new(create_proposal( + "src/main.rs", + "fn main() { println!(\"Hello\"); }", + )); + + let decision = runner.evaluate(&request).unwrap(); + assert_eq!(decision.verdict, Verdict::Allow); + assert!(decision.refusal.is_none()); + } + + #[test] + fn test_contract_blocks_typescript() { + let runner = ContractRunner::new(); + let request = GatingRequest::new(create_proposal( + "src/utils.ts", + "export const foo: string = 'bar';", + )); + + let decision = runner.evaluate(&request).unwrap(); + assert_eq!(decision.verdict, Verdict::Block); + assert!(decision.refusal.is_some()); + + let refusal = decision.refusal.unwrap(); + assert_eq!(refusal.category, RefusalCategory::ForbiddenLanguage); + assert_eq!(refusal.code, RefusalCode::Lang100TypeScript); + } + + #[test] + fn test_contract_blocks_hardcoded_secrets() { + let runner = ContractRunner::new(); + let request = GatingRequest::new(create_proposal( + "config.rs", + r#"let password = "supersecret123456""#, + )); + + let decision = runner.evaluate(&request).unwrap(); + assert_eq!(decision.verdict, Verdict::Block); + + let refusal = decision.refusal.unwrap(); + assert_eq!(refusal.category, RefusalCategory::ForbiddenPattern); + } + + #[test] + fn test_audit_entry_creation() { + let runner = ContractRunner::new(); + let request = GatingRequest::new(create_proposal("src/lib.rs", "pub fn hello() {}")); + + let decision = runner.evaluate(&request).unwrap(); + let audit = runner.audit(&request, &decision); + + assert_eq!(audit.request_id, request.request_id); + assert_eq!(audit.decision_id, decision.decision_id); + assert_eq!(audit.verdict, Verdict::Allow); + assert!(audit.refusal_code.is_none()); + assert!(!audit.content_hash.is_empty()); + } + + #[test] + fn test_test_harness() { + let mut harness = TestHarness::new(); + + let test_case = TestCase { + name: "allows_rust".to_string(), + description: "Rust files should be allowed".to_string(), + request: GatingRequest::new(create_proposal("lib.rs", "pub fn foo() {}")), + expected_verdict: Verdict::Allow, + expected_category: None, + expected_code: None, + }; + + let result = harness.run_test(&test_case); + assert!(result.passed); + + let summary = harness.summary(); + assert_eq!(summary.total, 1); + assert_eq!(summary.passed, 1); + assert!(summary.all_passed()); + } + + #[test] + fn test_refusal_code_numeric() { + assert_eq!(RefusalCode::Lang100TypeScript.numeric(), 100); + assert_eq!(RefusalCode::Tool200NpmWithoutDeno.numeric(), 200); + assert_eq!(RefusalCode::Sec300HardcodedSecret.numeric(), 300); + assert_eq!(RefusalCode::Spirit500Verbosity.numeric(), 500); + assert_eq!(RefusalCode::Sys999Unknown.numeric(), 999); + } + + #[test] + fn test_verdict_exit_codes() { + assert_eq!(Verdict::Allow.exit_code(), 0); + assert_eq!(Verdict::Block.exit_code(), 1); + assert_eq!(Verdict::Warn.exit_code(), 2); + assert_eq!(Verdict::Escalate.exit_code(), 3); + } + + #[test] + fn test_python_exception_in_salt() { + let runner = ContractRunner::new(); + let request = GatingRequest::new(create_proposal( + "salt/config.py", + "import os\ndef configure(): pass", + )); + + let decision = runner.evaluate(&request).unwrap(); + assert_eq!(decision.verdict, Verdict::Allow); + } +} diff --git a/src/main.rs b/src/main.rs index d3601ad..a44bf81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,9 @@ //! All operations are safe to run repeatedly. use clap::{Parser, Subcommand, ValueEnum}; +use gating_contract::{ + AuditEntry, ContractRunner, GatingRequest, TestCase, TestHarness, Verdict, +}; use policy_oracle::{ActionType, DirectoryScanResult, Oracle, Policy, Proposal}; use std::path::{Path, PathBuf}; use uuid::Uuid; @@ -165,7 +168,7 @@ enum Commands { #[command(visible_alias = "c")] Check { /// File path to check - #[arg(short, long, group = "input")] + #[arg(short = 'F', long, group = "input")] file: Option, /// Content string to check (use '-' for stdin) @@ -272,6 +275,77 @@ enum Commands { /// EXAMPLE /// conative man > /usr/local/share/man/man1/conative.1 Man, + + /// Run the gating contract test runner + /// + /// Executes contract tests from JSON files or evaluates requests + /// using the formal gating contract specification. + /// + /// The contract defines: + /// - Inputs (GatingRequest): What the gating system receives + /// - Outputs (GatingDecision): What it returns + /// - Refusal Taxonomy: Categorization of all refusals + /// - Audit Log Format: Structured logging for compliance + /// + /// EXAMPLES + /// conative contract test training/ # Run all tests in directory + /// conative contract eval request.json # Evaluate single request + /// conative contract eval request.json --audit # With audit log output + #[command(visible_alias = "ct")] + Contract { + #[command(subcommand)] + action: ContractAction, + }, +} + +#[derive(Subcommand)] +enum ContractAction { + /// Run contract tests from test case files + /// + /// Reads JSON test case files and validates contract behavior. + /// Returns non-zero exit code if any tests fail. + Test { + /// Directory or file containing test cases + #[arg(default_value = "training")] + path: PathBuf, + + /// Output format + #[arg(short, long, value_enum, default_value = "text")] + format: OutputFormat, + + /// Stop on first failure + #[arg(long)] + fail_fast: bool, + }, + + /// Evaluate a gating request through the contract + /// + /// Processes a GatingRequest JSON and returns a GatingDecision. + Eval { + /// Request JSON file (use '-' for stdin) + request: PathBuf, + + /// Output format + #[arg(short, long, value_enum, default_value = "json")] + format: OutputFormat, + + /// Include audit log entry in output + #[arg(long)] + audit: bool, + }, + + /// Display contract schema information + /// + /// Shows the contract version, input/output schemas, and refusal codes. + Schema { + /// Output format + #[arg(short, long, value_enum, default_value = "text")] + format: OutputFormat, + + /// Show only specific section (inputs, outputs, refusals, audit) + #[arg(short, long)] + section: Option, + }, } fn main() { @@ -343,6 +417,36 @@ fn main() { generate_man_page(); 0 } + Commands::Contract { action } => match action { + ContractAction::Test { + path, + format, + fail_fast, + } => { + if cli.dry_run { + println!("[dry-run] Would run contract tests from: {}", path.display()); + 0 + } else { + run_contract_tests(&path, &format, fail_fast, &cli.verbosity) + } + } + ContractAction::Eval { + request, + format, + audit, + } => { + if cli.dry_run { + println!("[dry-run] Would evaluate request: {}", request.display()); + 0 + } else { + eval_contract_request(&request, &format, audit) + } + } + ContractAction::Schema { format, section } => { + show_contract_schema(&format, section.as_deref()); + 0 + } + }, }; std::process::exit(exit_code); @@ -729,3 +833,418 @@ impl IntoString for policy_oracle::ConcernType { } } } + +// ============ Contract Runner Functions ============ + +fn run_contract_tests(path: &Path, format: &OutputFormat, fail_fast: bool, verbosity: &Verbosity) -> i32 { + let mut harness = TestHarness::new(); + let test_cases = match load_test_cases(path, verbosity) { + Ok(cases) => cases, + Err(e) => { + eprintln!("Error loading test cases: {}", e); + return 3; + } + }; + + if test_cases.is_empty() { + eprintln!("No test cases found in: {}", path.display()); + return 3; + } + + if matches!(verbosity, Verbosity::Verbose | Verbosity::Debug) { + eprintln!("Running {} test cases...", test_cases.len()); + } + + for test in &test_cases { + let result = harness.run_test(test); + + if matches!(verbosity, Verbosity::Verbose | Verbosity::Debug) { + let status = if result.passed { "PASS" } else { "FAIL" }; + eprintln!(" {} {} ({}μs)", status, test.name, result.duration_us); + } + + if fail_fast && !result.passed { + break; + } + } + + let summary = harness.summary(); + + match format { + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&summary).unwrap()); + } + OutputFormat::Compact => { + println!( + "tests={} passed={} failed={} duration={}μs", + summary.total, summary.passed, summary.failed, summary.total_duration_us + ); + } + OutputFormat::Text => { + println!("=== Contract Test Results ===\n"); + println!("Total: {}", summary.total); + println!("Passed: {}", summary.passed); + println!("Failed: {}", summary.failed); + println!("Duration: {}μs\n", summary.total_duration_us); + + if !summary.all_passed() { + println!("Failed tests:"); + for name in summary.failed_tests() { + println!(" - {}", name); + } + + // Show details of failures + for result in &summary.results { + if !result.passed { + println!("\n {} ERROR:", result.name); + if let Some(err) = &result.error { + println!(" {}", err); + } + } + } + } else { + println!("All tests passed!"); + } + } + } + + if summary.all_passed() { + 0 + } else { + 1 + } +} + +/// Load test cases from a file or directory +fn load_test_cases(path: &Path, verbosity: &Verbosity) -> Result, String> { + let mut cases = Vec::new(); + + if path.is_file() { + cases.push(load_test_case_file(path)?); + } else if path.is_dir() { + for entry in std::fs::read_dir(path).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let entry_path = entry.path(); + + if entry_path.is_dir() { + // Recurse into subdirectories + cases.extend(load_test_cases(&entry_path, verbosity)?); + } else if entry_path.extension().map(|s| s == "json").unwrap_or(false) { + match load_test_case_file(&entry_path) { + Ok(case) => cases.push(case), + Err(e) => { + if matches!(verbosity, Verbosity::Debug) { + eprintln!("Skipping {}: {}", entry_path.display(), e); + } + } + } + } + } + } else { + return Err(format!("Path does not exist: {}", path.display())); + } + + Ok(cases) +} + +/// Load a single test case from a training data JSON file +fn load_test_case_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?; + + // Parse the training data format + #[derive(serde::Deserialize)] + #[allow(dead_code)] + struct TrainingData { + proposal: Proposal, + expected_verdict: String, + #[serde(default)] + reasoning: String, + #[serde(default)] + category: String, + #[serde(default)] + violation_type: Option, + #[serde(default)] + concern_type: Option, + #[serde(default)] + spirit_violation: bool, + } + + let data: TrainingData = serde_json::from_str(&content).map_err(|e| e.to_string())?; + + let expected_verdict = match data.expected_verdict.as_str() { + "Compliant" => Verdict::Allow, + "HardViolation" => Verdict::Block, + "SoftConcern" => Verdict::Warn, + other => return Err(format!("Unknown verdict: {}", other)), + }; + + // Map the expected category based on violation_type, concern_type, or category + let expected_category = if data.spirit_violation { + // Spirit violations require SLM - these will fail until SLM is implemented + Some(gating_contract::RefusalCategory::VerbositySmell) + } else if let Some(ref vtype) = data.violation_type { + match vtype.as_str() { + "ForbiddenLanguage" => Some(gating_contract::RefusalCategory::ForbiddenLanguage), + "ForbiddenToolchain" => Some(gating_contract::RefusalCategory::ForbiddenToolchain), + "SecurityViolation" => Some(gating_contract::RefusalCategory::SecurityViolation), + "ForbiddenPattern" => Some(gating_contract::RefusalCategory::ForbiddenPattern), + _ => None, + } + } else if let Some(ref ctype) = data.concern_type { + match ctype.as_str() { + "VerbositySmell" => Some(gating_contract::RefusalCategory::VerbositySmell), + "PatternDeviation" | "UnusualStructure" => { + Some(gating_contract::RefusalCategory::StructuralAnomaly) + } + _ => None, + } + } else { + match data.category.as_str() { + "language" => { + if data.expected_verdict == "HardViolation" { + Some(gating_contract::RefusalCategory::ForbiddenLanguage) + } else { + None + } + } + "toolchain" => Some(gating_contract::RefusalCategory::ForbiddenToolchain), + "pattern" | "security" => Some(gating_contract::RefusalCategory::ForbiddenPattern), + "spirit" => Some(gating_contract::RefusalCategory::VerbositySmell), + _ => None, + } + }; + + Ok(TestCase { + name: path.file_stem().unwrap_or_default().to_string_lossy().to_string(), + description: data.reasoning, + request: GatingRequest::new(data.proposal), + expected_verdict, + expected_category, + expected_code: None, + }) +} + +fn eval_contract_request(request_path: &Path, format: &OutputFormat, include_audit: bool) -> i32 { + let content = match std::fs::read_to_string(request_path) { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to read request file: {}", e); + return 3; + } + }; + + let request: GatingRequest = match serde_json::from_str(&content) { + Ok(r) => r, + Err(e) => { + eprintln!("Failed to parse request JSON: {}", e); + return 3; + } + }; + + let runner = ContractRunner::new(); + let decision = match runner.evaluate(&request) { + Ok(d) => d, + Err(e) => { + eprintln!("Error evaluating request: {}", e); + return 3; + } + }; + + match format { + OutputFormat::Json => { + if include_audit { + let audit = runner.audit(&request, &decision); + #[derive(serde::Serialize)] + struct Output { + decision: gating_contract::GatingDecision, + audit: AuditEntry, + } + println!( + "{}", + serde_json::to_string_pretty(&Output { + decision: decision.clone(), + audit + }) + .unwrap() + ); + } else { + println!("{}", serde_json::to_string_pretty(&decision).unwrap()); + } + } + OutputFormat::Compact => { + let refusal_code = decision + .refusal + .as_ref() + .map(|r| r.code.numeric()) + .unwrap_or(0); + println!( + "verdict={:?} code={} duration={}μs", + decision.verdict, refusal_code, decision.processing.duration_us + ); + } + OutputFormat::Text => { + println!("=== Gating Decision ===\n"); + println!("Request ID: {}", decision.request_id); + println!("Decision ID: {}", decision.decision_id); + println!("Verdict: {:?}", decision.verdict); + println!("Duration: {}μs", decision.processing.duration_us); + + if let Some(ref refusal) = decision.refusal { + println!("\nRefusal Details:"); + println!(" Category: {}", refusal.category.display_name()); + println!(" Code: {}", refusal.code.numeric()); + println!(" Message: {}", refusal.message); + if let Some(ref remediation) = refusal.remediation { + println!(" Fix: {}", remediation); + } + } + + if include_audit { + let audit = runner.audit(&request, &decision); + println!("\nAudit Log Entry:"); + println!("{}", serde_json::to_string_pretty(&audit).unwrap()); + } + } + } + + decision.verdict.exit_code() +} + +fn show_contract_schema(format: &OutputFormat, section: Option<&str>) { + match format { + OutputFormat::Json => { + #[derive(serde::Serialize)] + struct Schema { + version: &'static str, + schema: &'static str, + inputs: InputSchema, + outputs: OutputSchema, + refusal_codes: Vec, + } + + #[derive(serde::Serialize)] + struct InputSchema { + gating_request: Vec<&'static str>, + } + + #[derive(serde::Serialize)] + struct OutputSchema { + gating_decision: Vec<&'static str>, + verdicts: Vec<&'static str>, + } + + #[derive(serde::Serialize)] + struct RefusalCodeInfo { + code: u16, + name: &'static str, + category: &'static str, + } + + let schema = Schema { + version: gating_contract::CONTRACT_VERSION, + schema: gating_contract::CONTRACT_SCHEMA, + inputs: InputSchema { + gating_request: vec![ + "request_id: UUID", + "timestamp: DateTime", + "proposal: Proposal", + "context: RequestContext", + "policy_override: Option", + ], + }, + outputs: OutputSchema { + gating_decision: vec![ + "request_id: UUID", + "decision_id: UUID", + "timestamp: DateTime", + "verdict: Verdict", + "refusal: Option", + "evaluations: EvaluationChain", + "processing: ProcessingMetadata", + ], + verdicts: vec!["Allow", "Warn", "Escalate", "Block"], + }, + refusal_codes: vec![ + RefusalCodeInfo { code: 100, name: "Lang100TypeScript", category: "ForbiddenLanguage" }, + RefusalCodeInfo { code: 101, name: "Lang101Python", category: "ForbiddenLanguage" }, + RefusalCodeInfo { code: 102, name: "Lang102Go", category: "ForbiddenLanguage" }, + RefusalCodeInfo { code: 103, name: "Lang103Java", category: "ForbiddenLanguage" }, + RefusalCodeInfo { code: 200, name: "Tool200NpmWithoutDeno", category: "ForbiddenToolchain" }, + RefusalCodeInfo { code: 300, name: "Sec300HardcodedSecret", category: "SecurityViolation" }, + RefusalCodeInfo { code: 500, name: "Spirit500Verbosity", category: "VerbositySmell" }, + ], + }; + + println!("{}", serde_json::to_string_pretty(&schema).unwrap()); + } + OutputFormat::Compact | OutputFormat::Text => { + let show_all = section.is_none(); + let section = section.unwrap_or(""); + + println!("=== Gating Contract Schema ===\n"); + println!("Version: {}", gating_contract::CONTRACT_VERSION); + println!("Schema: {}", gating_contract::CONTRACT_SCHEMA); + + if show_all || section == "inputs" { + println!("\n--- INPUTS ---\n"); + println!("GatingRequest:"); + println!(" request_id: UUID (unique request identifier)"); + println!(" timestamp: DateTime (when request was created)"); + println!(" proposal: Proposal (action_type, content, files_affected)"); + println!(" context: RequestContext (source, session, repository)"); + println!(" policy_override: Option (custom policy if needed)"); + } + + if show_all || section == "outputs" { + println!("\n--- OUTPUTS ---\n"); + println!("GatingDecision:"); + println!(" request_id: UUID (correlation with request)"); + println!(" decision_id: UUID (unique decision identifier)"); + println!(" timestamp: DateTime (when decision was made)"); + println!(" verdict: Verdict (Allow | Warn | Escalate | Block)"); + println!(" refusal: Option (details if not allowed)"); + println!(" evaluations: EvaluationChain (oracle, slm, arbiter results)"); + println!(" processing: ProcessingMetadata (duration, rules checked)"); + println!("\nVerdicts:"); + println!(" Allow (0) - Proposal proceeds"); + println!(" Warn (2) - Proceed with warning"); + println!(" Escalate (3) - Requires human review"); + println!(" Block (1) - Proposal rejected"); + } + + if show_all || section == "refusals" { + println!("\n--- REFUSAL TAXONOMY ---\n"); + println!("Hard Policy Violations (Oracle):"); + println!(" 100-199 ForbiddenLanguage (TypeScript, Python, Go, Java...)"); + println!(" 200-299 ForbiddenToolchain (npm without deno, yarn...)"); + println!(" 300-399 SecurityViolation (hardcoded secrets, insecure hash...)"); + println!(" 400-499 ForbiddenPattern (forbidden imports, unsafe blocks...)"); + println!("\nSpirit Violations (SLM):"); + println!(" 500-599 SpiritViolation (verbosity, over-documentation...)"); + println!("\nSystem Codes:"); + println!(" 900-999 SystemError (invalid request, rate limited...)"); + } + + if show_all || section == "audit" { + println!("\n--- AUDIT LOG FORMAT ---\n"); + println!("AuditEntry:"); + println!(" schema: String (contract schema identifier)"); + println!(" audit_id: UUID"); + println!(" request_id: UUID"); + println!(" decision_id: UUID"); + println!(" timestamp: DateTime"); + println!(" verdict: Verdict"); + println!(" refusal_code: Option"); + println!(" refusal_category: Option"); + println!(" source: String"); + println!(" repository: Option"); + println!(" session_id: Option"); + println!(" rules_checked: Vec"); + println!(" rules_triggered: Vec"); + println!(" duration_us: u64"); + println!(" contract_version: String"); + println!(" content_hash: String (SHA for verification)"); + } + } + } +}