From c1556f505a743818186c1e8d8421ae8258a09792 Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Thu, 23 Apr 2026 16:16:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Wave=2013=20cleanup=20=E2=80=94=20HttpDr?= =?UTF-8?q?iver=20+=20agent=5Fid=20validator=20+=20safe=5Fjoin=20+=204=20M?= =?UTF-8?q?EDIUM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the remaining v0.29.0 follow-ups + post-audit MEDIUMs. ## HttpDriver (kei-spawn http-driver feature) - Real reqwest::blocking POST to api.anthropic.com/v1/messages - Feature flag `http-driver = ["dep:reqwest"]` (default off, zero breaking) - KEI_ANTHROPIC_KEY read at invoke time (rotation-friendly) - 5 httpmock tests (missing key, 200, 4xx, 5xx, malformed json) - Endpoint override via KEI_ANTHROPIC_ENDPOINT env for tests - Files: drive.rs, drive_http.rs (new), drive_http_parse.rs (new), tests/http_driver.rs ## agent_id path-traversal validator (HIGH) - New validate.rs with validate_agent_id() — whitelist grammar, 64-char cap, rejects /, \, .., leading dot/dash, NUL, :, whitespace, non-ASCII, Windows-reserved (CON/PRN/AUX/NUL/COM1-9/LPT1-9) - Wired into all 5 agent_id→path sinks: load_task, resolve_agent_id, prepare, simulated_merge, verify_task - autogen_agent_id moved to validate.rs with slugify_role helper — output passes validator by construction (100-draw property test) - 33 new tests in agent_id_validator.rs ## safe_join symlink escape (MEDIUM) - Base must canonicalize (nonexistent → Canonicalize error) - Joined must start_with base_canon OR joined.parent() must start_with base_canon - Blocks symlink-to-outside-base with non-existent tail file - walk.rs refactored into 5 ≤17-LOC helpers - 7 new tests in safe_join_hardening.rs ## entity-store 4 MEDIUM fixes - ddl.rs: panic on unsupported FieldKind → typed DdlError::UnsupportedExtraColumn propagated through Store::open as VerbError::InvalidInput (exit 2). Extracted ddl_edge.rs + ddl_error.rs modules. Backward-compat shim preserved. - search.rs: FTS5 empty-tokenization → typed InvalidInput on queries with no alphanumeric tokens (was opaque rusqlite error). Unicode-aware via char::is_alphanumeric. - engine.rs: WAL pragma failure now logged to stderr with path + rusqlite source; fallback to rollback journal preserved (exit-code contract intact). - bug_fixes_smoke: added fts5_phrase_quoting_preserves_legitimate_queries — catches over-broad sanitizer that passes injection test alone. ## Verified - cargo check --workspace clean (both with and without http-driver feature) - cargo test --workspace: 668 tests green (up from 620) - substrate_integration.sh ✓, hook_wiring_integration.sh ✓ Co-Authored-By: Claude Opus 4.7 (1M context) --- _primitives/_rust/Cargo.lock | 800 +++++++++++++++++- .../_rust/kei-agent-runtime/src/lib.rs | 1 + .../_rust/kei-agent-runtime/src/prepare.rs | 13 +- .../kei-agent-runtime/src/simulated_merge.rs | 8 +- .../_rust/kei-agent-runtime/src/spawn.rs | 28 +- .../_rust/kei-agent-runtime/src/validate.rs | 175 ++++ .../_rust/kei-agent-runtime/src/verify.rs | 5 +- .../tests/agent_id_validator.rs | 270 ++++++ .../_rust/kei-atom-discovery/src/walk.rs | 77 +- .../tests/safe_join_hardening.rs | 115 +++ _primitives/_rust/kei-entity-store/src/ddl.rs | 113 +-- .../_rust/kei-entity-store/src/ddl_edge.rs | 132 +++ .../_rust/kei-entity-store/src/ddl_error.rs | 25 + .../_rust/kei-entity-store/src/engine.rs | 20 +- .../_rust/kei-entity-store/src/error.rs | 10 + _primitives/_rust/kei-entity-store/src/lib.rs | 2 + .../kei-entity-store/src/verbs/search.rs | 47 +- .../kei-entity-store/tests/bug_fixes_smoke.rs | 119 +++ _primitives/_rust/kei-spawn/Cargo.toml | 8 + _primitives/_rust/kei-spawn/src/drive.rs | 56 +- _primitives/_rust/kei-spawn/src/drive_http.rs | 162 ++++ .../_rust/kei-spawn/src/drive_http_parse.rs | 185 ++++ _primitives/_rust/kei-spawn/src/lib.rs | 7 +- .../_rust/kei-spawn/tests/http_driver.rs | 181 ++++ 24 files changed, 2361 insertions(+), 198 deletions(-) create mode 100644 _primitives/_rust/kei-agent-runtime/src/validate.rs create mode 100644 _primitives/_rust/kei-agent-runtime/tests/agent_id_validator.rs create mode 100644 _primitives/_rust/kei-atom-discovery/tests/safe_join_hardening.rs create mode 100644 _primitives/_rust/kei-entity-store/src/ddl_edge.rs create mode 100644 _primitives/_rust/kei-entity-store/src/ddl_error.rs create mode 100644 _primitives/_rust/kei-spawn/src/drive_http.rs create mode 100644 _primitives/_rust/kei-spawn/src/drive_http_parse.rs create mode 100644 _primitives/_rust/kei-spawn/tests/http_driver.rs diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index 5fe3574..8c2037f 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -102,6 +102,195 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ascii-canvas" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" +dependencies = [ + "term", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-attributes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-object-pool" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333c456b97c3f2d50604e8b2624253b7f787208cb72eb75e64b0ad11b221652c" +dependencies = [ + "async-std", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-attributes", + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -110,7 +299,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -506,7 +695,7 @@ checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -641,6 +830,17 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-cookies" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67bd8fd42c16bdb08688243dc5f0cc117a3ca9efeeaba3a345a18a6159ad96f7" +dependencies = [ + "lalrpop", + "lalrpop-util", + "regex", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -689,6 +889,19 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -753,6 +966,12 @@ 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 = "chrono" version = "0.4.44" @@ -796,7 +1015,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -944,6 +1163,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -1016,6 +1241,27 @@ dependencies = [ "ctutils", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1024,7 +1270,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1048,6 +1294,15 @@ dependencies = [ "serde", ] +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1075,6 +1330,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + [[package]] name = "event-listener" version = "5.4.1" @@ -1086,6 +1347,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1141,6 +1412,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.9" @@ -1252,6 +1529,30 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[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 2.0.117", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -1272,6 +1573,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -1309,9 +1611,11 @@ 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]] @@ -1340,6 +1644,18 @@ dependencies = [ "url", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "h2" version = "0.3.27" @@ -1420,6 +1736,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1529,6 +1851,34 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "httpmock" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ec9586ee0910472dec1a1f0f8acf52f0fdde93aea74d70d4a3107b4be0fd5b" +dependencies = [ + "assert-json-diff", + "async-object-pool", + "async-std", + "async-trait", + "base64 0.21.7", + "basic-cookies", + "crossbeam-utils", + "form_urlencoded", + "futures-util", + "hyper 0.14.32", + "lazy_static", + "levenshtein", + "log", + "regex", + "serde", + "serde_json", + "serde_regex", + "similar", + "tokio", + "url", +] + [[package]] name = "hybrid-array" version = "0.4.10" @@ -1613,6 +1963,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tower-service", + "webpki-roots 1.0.7", ] [[package]] @@ -1822,6 +2173,16 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1837,6 +2198,15 @@ dependencies = [ "nom 8.0.0", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1859,6 +2229,8 @@ version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -2292,7 +2664,9 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "httpmock", "kei-agent-runtime", + "reqwest", "serde", "serde_json", "sha2 0.10.9", @@ -2377,6 +2751,46 @@ dependencies = [ "libc", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lalrpop" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "string_cache", + "term", + "tiny-keccak", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" +dependencies = [ + "regex-automata", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2392,6 +2806,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "levenshtein" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" + [[package]] name = "libc" version = "0.2.185" @@ -2477,6 +2897,9 @@ name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "value-bag", +] [[package]] name = "lru" @@ -2487,6 +2910,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchit" version = "0.7.3" @@ -2563,6 +2992,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nom" version = "7.1.3" @@ -2794,6 +3229,31 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2806,6 +3266,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -2852,6 +3323,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2876,6 +3361,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "prettyplease" version = "0.2.37" @@ -2883,7 +3374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -2901,6 +3392,61 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[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 0.23.39", + "socket2 0.6.3", + "thiserror 2.0.18", + "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 0.9.4", + "ring", + "rustc-hash", + "rustls 0.23.39", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "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 0.6.3", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2999,6 +3545,17 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "regex" version = "1.12.3" @@ -3034,6 +3591,46 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-rustls 0.27.9", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.39", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.7", +] + [[package]] name = "ring" version = "0.17.14" @@ -3082,6 +3679,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3124,6 +3727,7 @@ checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki 0.103.13", "subtle", @@ -3157,6 +3761,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -3284,7 +3889,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3311,6 +3916,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_regex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" +dependencies = [ + "regex", + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -3438,6 +4053,12 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" @@ -3527,7 +4148,7 @@ dependencies = [ "crc", "crossbeam-queue", "either", - "event-listener", + "event-listener 5.4.1", "futures-channel", "futures-core", "futures-intrusive", @@ -3554,7 +4175,7 @@ dependencies = [ "tokio-stream", "tracing", "url", - "webpki-roots", + "webpki-roots 0.25.4", ] [[package]] @@ -3567,7 +4188,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.117", ] [[package]] @@ -3590,7 +4211,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.117", "tempfile", "tokio", "url", @@ -3715,6 +4336,18 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -3738,6 +4371,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -3754,6 +4398,9 @@ 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" @@ -3763,7 +4410,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3779,6 +4426,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3805,7 +4463,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3816,7 +4474,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3858,6 +4516,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -3917,7 +4584,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4021,6 +4688,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -4053,7 +4738,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4201,6 +4886,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + [[package]] name = "vcpkg" version = "0.2.15" @@ -4289,6 +4980,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.118" @@ -4308,7 +5009,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -4355,12 +5056,41 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +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 = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +[[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 = "whoami" version = "1.6.1" @@ -4371,6 +5101,22 @@ dependencies = [ "wasite", ] +[[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-util" version = "0.1.11" @@ -4380,6 +5126,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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-core" version = "0.62.2" @@ -4401,7 +5153,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4412,7 +5164,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4715,7 +5467,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -4731,7 +5483,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4804,7 +5556,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4825,7 +5577,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4845,7 +5597,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4885,7 +5637,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/_primitives/_rust/kei-agent-runtime/src/lib.rs b/_primitives/_rust/kei-agent-runtime/src/lib.rs index 9a6a352..d437663 100644 --- a/_primitives/_rust/kei-agent-runtime/src/lib.rs +++ b/_primitives/_rust/kei-agent-runtime/src/lib.rs @@ -22,5 +22,6 @@ pub mod registry; pub mod role; pub mod simulated_merge; pub mod spawn; +pub mod validate; pub mod verifies; pub mod verify; diff --git a/_primitives/_rust/kei-agent-runtime/src/prepare.rs b/_primitives/_rust/kei-agent-runtime/src/prepare.rs index 8c7fe1f..8df086b 100644 --- a/_primitives/_rust/kei-agent-runtime/src/prepare.rs +++ b/_primitives/_rust/kei-agent-runtime/src/prepare.rs @@ -15,10 +15,10 @@ use crate::capability::TaskSpec; use crate::compose::compose_prompt; use crate::dna::Dna; use crate::role::resolve_role; +use crate::validate::{autogen_agent_id, validate_agent_id}; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; use std::path::Path; -use std::time::{SystemTime, UNIX_EPOCH}; /// Everything the orchestrator needs to hand the Claude `Agent` tool. #[derive(Debug, Clone, Serialize)] @@ -50,6 +50,8 @@ pub fn prepare(task: &TaskSpec, kit_root: &Path) -> Result { } else { task.task.agent_id.clone() }; + validate_agent_id(&agent_id) + .map_err(|e| anyhow!("agent-id rejected: {e}"))?; let role_file = load_role_meta(kit_root, &task.task.role)?; if !role_file.role.spawnable { return Err(anyhow!( @@ -87,15 +89,6 @@ pub fn prepare(task: &TaskSpec, kit_root: &Path) -> Result { }) } -fn autogen_agent_id(role: &str) -> String { - let ts_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0); - let rand_hex = format!("{:04x}", rand::random::()); - format!("ag-{}-{:x}-{}", role, ts_ms, rand_hex) -} - /// Human-readable block — copy into Claude Code's Agent-tool dialog. pub fn render_human(inv: &AgentInvocation) -> String { let iso = inv.isolation.as_deref().unwrap_or(""); diff --git a/_primitives/_rust/kei-agent-runtime/src/simulated_merge.rs b/_primitives/_rust/kei-agent-runtime/src/simulated_merge.rs index dce76bc..307053e 100644 --- a/_primitives/_rust/kei-agent-runtime/src/simulated_merge.rs +++ b/_primitives/_rust/kei-agent-runtime/src/simulated_merge.rs @@ -5,17 +5,23 @@ //! verifies from that vantage to catch integration regressions invisible //! in agent's isolated worktree. -use anyhow::{Context, Result}; +use crate::validate::validate_agent_id; +use anyhow::{anyhow, Context, Result}; use std::path::{Path, PathBuf}; use std::process::Command; /// Create a temp worktree off `main_repo` at HEAD of `main`, apply the agent's /// diff, return the temp worktree path. Caller cleans up. +/// +/// Validates `agent_id` before constructing any tmp path — path-traversal +/// defence per the HIGH-risk agent_id sink audit. pub fn run_simulated_merge( agent_id: &str, agent_worktree: &Path, main_repo: &Path, ) -> Result { + validate_agent_id(agent_id) + .map_err(|e| anyhow!("agent_id rejected in run_simulated_merge: {e}"))?; let tmp = std::env::temp_dir().join(format!("kei-test-merge-{agent_id}")); let _ = std::fs::remove_dir_all(&tmp); run_git(main_repo, &["worktree", "add", "-d", tmp.to_str().unwrap(), "main"]) diff --git a/_primitives/_rust/kei-agent-runtime/src/spawn.rs b/_primitives/_rust/kei-agent-runtime/src/spawn.rs index a643b6a..a1598cd 100644 --- a/_primitives/_rust/kei-agent-runtime/src/spawn.rs +++ b/_primitives/_rust/kei-agent-runtime/src/spawn.rs @@ -4,16 +4,26 @@ use crate::capability::TaskSpec; use crate::compose::compose_prompt; +use crate::validate::validate_agent_id; use anyhow::{anyhow, Context, Result}; use std::fs; use std::path::{Path, PathBuf}; /// Parse a task.toml file into `TaskSpec`. +/// +/// Validates the embedded `task.agent-id` (if non-empty) before returning — +/// a hostile task.toml with `agent-id = "../../../etc/foo"` is rejected at +/// the parse boundary so it never reaches a downstream path sink. pub fn load_task(path: &Path) -> Result { let text = fs::read_to_string(path) .with_context(|| format!("read task file {}", path.display()))?; - toml::from_str::(&text) - .with_context(|| format!("parse task TOML {}", path.display())) + let spec: TaskSpec = toml::from_str(&text) + .with_context(|| format!("parse task TOML {}", path.display()))?; + if !spec.task.agent_id.is_empty() { + validate_agent_id(&spec.task.agent_id) + .map_err(|e| anyhow!("task.agent-id rejected: {e}"))?; + } + Ok(spec) } /// Prepare a spawnable agent directory. @@ -45,9 +55,15 @@ pub struct PreparedAgent { pub task_path: PathBuf, } -fn resolve_agent_id(task: &TaskSpec) -> Result { - if !task.task.agent_id.is_empty() { - return Ok(task.task.agent_id.clone()); +/// Resolve the effective `agent_id` — validator-checked, never creates +/// files as a side effect. +pub fn resolve_agent_id(task: &TaskSpec) -> Result { + if task.task.agent_id.is_empty() { + return Err(anyhow!( + "task.agent-id is empty — orchestrator must allocate via kei-ledger" + )); } - Err(anyhow!("task.agent-id is empty — orchestrator must allocate via kei-ledger")) + validate_agent_id(&task.task.agent_id) + .map_err(|e| anyhow!("task.agent-id rejected: {e}"))?; + Ok(task.task.agent_id.clone()) } diff --git a/_primitives/_rust/kei-agent-runtime/src/validate.rs b/_primitives/_rust/kei-agent-runtime/src/validate.rs new file mode 100644 index 0000000..49cd47d --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/validate.rs @@ -0,0 +1,175 @@ +//! Agent-id validator — HIGH-security path-traversal defence. +//! +//! Every `agent_id` flowing from task.toml (or auto-gen) into a filesystem +//! path sink MUST pass `validate_agent_id` first. Without this gate, a +//! hostile task.toml with `agent-id = "../../../etc/foo"` reaches +//! `tasks//` and writes arbitrary paths. +//! +//! Rules (enforced in order, first failure wins): +//! - non-empty, length ≤ 64 +//! - ASCII-only, matches `^[A-Za-z0-9][A-Za-z0-9_.-]*$` +//! - rejects `/`, `\`, `..`, leading `.`, leading `-`, NUL, `:`, +//! whitespace, non-ASCII +//! - rejects Windows-reserved names (case-insensitive): +//! CON, PRN, AUX, NUL, COM1-9, LPT1-9 +//! +//! Also hosts `autogen_agent_id` (moved from prepare.rs) so the auto-gen +//! output passes the validator by construction. + +use std::time::{SystemTime, UNIX_EPOCH}; +use thiserror::Error; + +/// Maximum permitted `agent_id` length (bytes = chars, since ASCII-only). +pub const MAX_AGENT_ID_LEN: usize = 64; + +/// Typed error — the sole failure variant of `validate_agent_id`. +#[derive(Debug, Clone, Error, PartialEq, Eq)] +#[error("invalid agent-id: {reason}")] +pub struct InvalidAgentId { + pub reason: String, +} + +impl InvalidAgentId { + fn new(reason: impl Into) -> Self { + Self { reason: reason.into() } + } +} + +/// Validate an `agent_id` before it reaches any filesystem path. +pub fn validate_agent_id(raw: &str) -> Result<&str, InvalidAgentId> { + check_basic_shape(raw)?; + check_no_traversal_bytes(raw)?; + check_character_class(raw)?; + check_not_windows_reserved(raw)?; + Ok(raw) +} + +fn check_basic_shape(raw: &str) -> Result<(), InvalidAgentId> { + if raw.is_empty() { + return Err(InvalidAgentId::new("empty")); + } + if raw.len() > MAX_AGENT_ID_LEN { + return Err(InvalidAgentId::new(format!( + "length {} exceeds max {}", + raw.len(), + MAX_AGENT_ID_LEN + ))); + } + if !raw.is_ascii() { + return Err(InvalidAgentId::new("contains non-ASCII")); + } + Ok(()) +} + +fn check_no_traversal_bytes(raw: &str) -> Result<(), InvalidAgentId> { + if raw.contains("..") { + return Err(InvalidAgentId::new("contains parent sequence '..'")); + } + if raw.contains('/') { + return Err(InvalidAgentId::new("contains '/'")); + } + if raw.contains('\\') { + return Err(InvalidAgentId::new("contains '\\'")); + } + if raw.contains('\0') { + return Err(InvalidAgentId::new("contains NUL")); + } + if raw.contains(':') { + return Err(InvalidAgentId::new("contains ':'")); + } + if raw.chars().any(char::is_whitespace) { + return Err(InvalidAgentId::new("contains whitespace")); + } + Ok(()) +} + +fn check_character_class(raw: &str) -> Result<(), InvalidAgentId> { + let first = raw.chars().next().expect("non-empty checked earlier"); + if !first.is_ascii_alphanumeric() { + return Err(InvalidAgentId::new(format!( + "must start with [A-Za-z0-9], got '{first}'" + ))); + } + for c in raw.chars() { + if !(c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-') { + return Err(InvalidAgentId::new(format!( + "disallowed character '{c}' (allowed: [A-Za-z0-9_.-])" + ))); + } + } + Ok(()) +} + +fn check_not_windows_reserved(raw: &str) -> Result<(), InvalidAgentId> { + let stem = raw.split('.').next().unwrap_or(raw); + let up = stem.to_ascii_uppercase(); + if is_windows_reserved(&up) { + return Err(InvalidAgentId::new(format!( + "Windows-reserved name: '{stem}'" + ))); + } + Ok(()) +} + +fn is_windows_reserved(up: &str) -> bool { + matches!(up, "CON" | "PRN" | "AUX" | "NUL") || is_com_or_lpt(up) +} + +fn is_com_or_lpt(up: &str) -> bool { + let (prefix, n) = match up.len() { + 4 if up.starts_with("COM") => ("COM", &up[3..]), + 4 if up.starts_with("LPT") => ("LPT", &up[3..]), + _ => return false, + }; + let _ = prefix; // already matched + matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") +} + +/// Auto-generate a fresh `agent_id` whose output is validator-clean. +/// +/// Format: `ag---<4-hex-rand>` +pub fn autogen_agent_id(role: &str) -> String { + let slug = slugify_role(role); + let ts_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let rand_hex = format!("{:04x}", rand::random::()); + let candidate = format!("ag-{slug}-{ts_ms:x}-{rand_hex}"); + // Truncate to cap while preserving the rand-hex suffix. + truncate_agent_id(&candidate, &rand_hex) +} + +/// Slugify a role name into the validator's allowed class. +/// +/// Non-allowed characters collapse to `_`; empty result becomes `x` so the +/// auto-gen output is never `ag---` (leading-dash after `ag-`). +pub fn slugify_role(role: &str) -> String { + let mut out = String::with_capacity(role.len()); + for c in role.chars() { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { + out.push(c); + } else { + out.push('_'); + } + } + if out.is_empty() { + return "x".to_string(); + } + let trimmed = out.trim_matches(|c: char| c == '-' || c == '.' || c == '_'); + if trimmed.is_empty() { + "x".to_string() + } else { + trimmed.to_string() + } +} + +fn truncate_agent_id(candidate: &str, rand_hex: &str) -> String { + if candidate.len() <= MAX_AGENT_ID_LEN { + return candidate.to_string(); + } + let keep = MAX_AGENT_ID_LEN.saturating_sub(rand_hex.len() + 1); + let head = &candidate[..keep.min(candidate.len())]; + let head_trimmed = head.trim_end_matches(|c: char| c == '-' || c == '.' || c == '_'); + format!("{head_trimmed}-{rand_hex}") +} diff --git a/_primitives/_rust/kei-agent-runtime/src/verify.rs b/_primitives/_rust/kei-agent-runtime/src/verify.rs index e8cbba1..9e61068 100644 --- a/_primitives/_rust/kei-agent-runtime/src/verify.rs +++ b/_primitives/_rust/kei-agent-runtime/src/verify.rs @@ -8,7 +8,8 @@ use crate::capability::{RunMode, TaskSpec, VerifyContext, VerifyResult}; use crate::registry; -use anyhow::Result; +use crate::validate::validate_agent_id; +use anyhow::{anyhow, Result}; use serde::Serialize; use std::path::{Path, PathBuf}; @@ -42,6 +43,8 @@ pub fn verify_task( capability_names: &[String], simulated_merge_path: Option, ) -> Result { + validate_agent_id(agent_id) + .map_err(|e| anyhow!("agent_id rejected in verify_task: {e}"))?; let mut report = VerifyReport::default(); for name in capability_names { let cap = match registry::get_verify(name) { diff --git a/_primitives/_rust/kei-agent-runtime/tests/agent_id_validator.rs b/_primitives/_rust/kei-agent-runtime/tests/agent_id_validator.rs new file mode 100644 index 0000000..0d088a3 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/tests/agent_id_validator.rs @@ -0,0 +1,270 @@ +//! HIGH-security agent-id validator tests. +//! +//! Covers every documented rejection class + the happy path for the shapes +//! actually produced in `autogen_agent_id` and used in fixtures. + +use kei_agent_runtime::spawn::{load_task, resolve_agent_id}; +use kei_agent_runtime::validate::{ + autogen_agent_id, slugify_role, validate_agent_id, InvalidAgentId, MAX_AGENT_ID_LEN, +}; +use kei_agent_runtime::capability::TaskSpec; +use std::fs; +use tempfile::tempdir; + +// ---- basic shape --------------------------------------------------------- + +#[test] +fn empty_rejected() { + let err = validate_agent_id("").unwrap_err(); + assert!(err.reason.contains("empty"), "got: {}", err.reason); +} + +#[test] +fn too_long_rejected() { + let raw = "a".repeat(MAX_AGENT_ID_LEN + 1); + let err = validate_agent_id(&raw).unwrap_err(); + assert!(err.reason.contains("length"), "got: {}", err.reason); +} + +#[test] +fn exactly_max_length_ok() { + let raw = "a".repeat(MAX_AGENT_ID_LEN); + assert!(validate_agent_id(&raw).is_ok()); +} + +#[test] +fn non_ascii_rejected() { + let err = validate_agent_id("agent-кириллица").unwrap_err(); + assert!(err.reason.to_lowercase().contains("ascii") || err.reason.contains("character")); +} + +// ---- traversal-class bytes ----------------------------------------------- + +#[test] +fn parent_dir_rejected() { + let err = validate_agent_id("foo..bar").unwrap_err(); + assert!(err.reason.contains(".."), "got: {}", err.reason); +} + +#[test] +fn literal_double_dot_rejected() { + let err = validate_agent_id("..").unwrap_err(); + // "..": starts with '.', so leading-dot rule OR traversal rule fires first + // — implementation currently flags `..` first; either class is fine. + assert!(err.reason.contains("..") || err.reason.contains("start")); +} + +#[test] +fn slash_rejected() { + let err = validate_agent_id("foo/bar").unwrap_err(); + assert!(err.reason.contains('/'), "got: {}", err.reason); +} + +#[test] +fn backslash_rejected() { + let err = validate_agent_id("foo\\bar").unwrap_err(); + assert!(err.reason.contains('\\'), "got: {}", err.reason); +} + +#[test] +fn leading_dot_rejected() { + let err = validate_agent_id(".secret").unwrap_err(); + assert!(err.reason.contains("start"), "got: {}", err.reason); +} + +#[test] +fn leading_dash_rejected() { + let err = validate_agent_id("-xyz").unwrap_err(); + assert!(err.reason.contains("start"), "got: {}", err.reason); +} + +#[test] +fn nul_rejected() { + let err = validate_agent_id("foo\0bar").unwrap_err(); + assert!(err.reason.contains("NUL"), "got: {}", err.reason); +} + +#[test] +fn colon_rejected() { + let err = validate_agent_id("foo:bar").unwrap_err(); + assert!(err.reason.contains(':'), "got: {}", err.reason); +} + +#[test] +fn whitespace_rejected() { + let err = validate_agent_id("foo bar").unwrap_err(); + assert!(err.reason.contains("whitespace"), "got: {}", err.reason); +} + +#[test] +fn tab_rejected() { + let err = validate_agent_id("foo\tbar").unwrap_err(); + assert!(err.reason.contains("whitespace"), "got: {}", err.reason); +} + +// ---- valid shapes -------------------------------------------------------- + +#[test] +fn valid_simple_passes() { + assert!(validate_agent_id("abc123").is_ok()); +} + +#[test] +fn valid_with_dashes_and_underscores_passes() { + assert!(validate_agent_id("ag-edit-local-xyz_1").is_ok()); + assert!(validate_agent_id("ag-code.impl-abc").is_ok()); +} + +#[test] +fn fixture_edit_local_forge_abc123_passes() { + // Exact shape used in prepare_smoke.rs `happy_path_yields_full_invocation`. + assert!(validate_agent_id("edit-local-forge-abc123").is_ok()); +} + +// ---- Windows-reserved (case-insensitive) --------------------------------- + +#[test] +fn windows_reserved_con_rejected() { + assert!(validate_agent_id("CON").is_err()); + assert!(validate_agent_id("con").is_err()); + assert!(validate_agent_id("Con").is_err()); +} + +#[test] +fn windows_reserved_nul_prn_aux_rejected() { + for n in ["NUL", "nul", "PRN", "prn", "AUX", "aux"] { + assert!(validate_agent_id(n).is_err(), "expected {n} to be rejected"); + } +} + +#[test] +fn windows_reserved_com_lpt_rejected() { + for n in ["COM1", "com2", "COM9", "LPT1", "lpt5", "LPT9"] { + assert!(validate_agent_id(n).is_err(), "expected {n} to be rejected"); + } +} + +#[test] +fn windows_reserved_with_extension_rejected() { + assert!(validate_agent_id("CON.txt").is_err()); + assert!(validate_agent_id("com1.log").is_err()); +} + +#[test] +fn windows_com0_or_com10_not_reserved() { + // Only COM1..COM9 and LPT1..LPT9 are reserved. + assert!(validate_agent_id("com0").is_ok()); + assert!(validate_agent_id("com10").is_ok()); + assert!(validate_agent_id("lpt0").is_ok()); +} + +#[test] +fn not_reserved_similar_prefixes_ok() { + assert!(validate_agent_id("console").is_ok()); + assert!(validate_agent_id("comedy").is_ok()); + assert!(validate_agent_id("auxiliary").is_ok()); +} + +// ---- autogen agrees with validator --------------------------------------- + +#[test] +fn autogen_output_passes_validator_100_draws() { + for role in ["edit-local", "edit-shared", "explorer", "read-only", "weird role!!"] { + for _ in 0..100 { + let id = autogen_agent_id(role); + validate_agent_id(&id).unwrap_or_else(|e| { + panic!("autogen produced invalid id '{id}' for role '{role}': {e}") + }); + } + } +} + +#[test] +fn autogen_prefix_is_ag_and_within_cap() { + let id = autogen_agent_id("edit-local"); + assert!(id.starts_with("ag-")); + assert!(id.len() <= MAX_AGENT_ID_LEN, "len={}", id.len()); +} + +#[test] +fn slugify_empty_becomes_x() { + assert_eq!(slugify_role(""), "x"); + assert_eq!(slugify_role("!!!"), "x"); + assert_eq!(slugify_role("---"), "x"); +} + +#[test] +fn slugify_collapses_disallowed_but_keeps_identity() { + assert_eq!(slugify_role("edit-local"), "edit-local"); + assert_eq!(slugify_role("Edit/Local"), "Edit_Local"); +} + +// ---- integration: resolve_agent_id + load_task propagate typed error ---- + +#[test] +fn resolve_agent_id_rejects_traversal_without_file_side_effect() { + let mut task = TaskSpec::default(); + task.task.agent_id = "../../../etc/passwd".into(); + let err = resolve_agent_id(&task).expect_err("must reject"); + let msg = format!("{err:#}"); + assert!(msg.contains("rejected"), "error should mention rejection: {msg}"); +} + +#[test] +fn resolve_agent_id_rejects_slash() { + let mut task = TaskSpec::default(); + task.task.agent_id = "foo/bar".into(); + assert!(resolve_agent_id(&task).is_err()); +} + +#[test] +fn resolve_agent_id_passes_valid() { + let mut task = TaskSpec::default(); + task.task.agent_id = "edit-local-forge-abc123".into(); + let resolved = resolve_agent_id(&task).unwrap(); + assert_eq!(resolved, "edit-local-forge-abc123"); +} + +#[test] +fn load_task_rejects_hostile_agent_id() { + let tmp = tempdir().unwrap(); + let path = tmp.path().join("task.toml"); + fs::write( + &path, + r#" +[task] +role = "edit-local" +agent-id = "../../../etc/shadow" +"#, + ) + .unwrap(); + let err = load_task(&path).expect_err("hostile agent-id must be rejected at load"); + let msg = format!("{err:#}"); + assert!(msg.contains("rejected"), "got: {msg}"); +} + +#[test] +fn load_task_accepts_empty_agent_id() { + // Empty agent-id is allowed at load (auto-gen happens in prepare()). + let tmp = tempdir().unwrap(); + let path = tmp.path().join("task.toml"); + fs::write( + &path, + r#" +[task] +role = "edit-local" +"#, + ) + .unwrap(); + let spec = load_task(&path).expect("empty agent-id should parse"); + assert_eq!(spec.task.agent_id, ""); +} + +// ---- InvalidAgentId is a typed, structured error ------------------------ + +#[test] +fn invalid_agent_id_is_thiserror_displayable() { + let err: InvalidAgentId = validate_agent_id("foo/bar").unwrap_err(); + let display = format!("{err}"); + assert!(display.starts_with("invalid agent-id")); +} diff --git a/_primitives/_rust/kei-atom-discovery/src/walk.rs b/_primitives/_rust/kei-atom-discovery/src/walk.rs index 303e058..a373915 100644 --- a/_primitives/_rust/kei-atom-discovery/src/walk.rs +++ b/_primitives/_rust/kei-atom-discovery/src/walk.rs @@ -147,9 +147,31 @@ fn normalize_rule_slug(rest: &str) -> String { r.to_string() } -/// Safe base+rel path join. Rejects absolute paths, parent (`..`) components, -/// and post-canonicalise escapes from `base`. +/// Safe base+rel path join. +/// +/// Rejects absolute paths, parent (`..`) components, non-existent bases, +/// and post-canonicalise escapes from `base` (including symlink escapes). +/// +/// Contract: +/// - `base` MUST canonicalize (i.e. must exist as a real directory). A +/// non-existent base means the caller is not in a well-defined sandbox +/// and we refuse to construct a join. +/// - If `joined` canonicalizes, its real path MUST start with `base_canon`. +/// - If `joined` does not exist, we canonicalize `joined.parent()` and +/// require that to start with `base_canon`. This catches symlinked +/// parent directories that redirect outside the sandbox. +/// - If neither `joined` nor `joined.parent()` exist, no symlink can +/// possibly live there — the lexical (absolute + parent-free) check +/// already completed is sufficient. pub fn safe_join(base: &Path, rel: &str) -> Result { + let rel_path = reject_bad_rel(rel)?; + let joined = base.join(rel_path); + let base_canon = canonicalize_base(base)?; + assert_joined_inside_base(&joined, &base_canon, rel)?; + Ok(joined) +} + +fn reject_bad_rel(rel: &str) -> Result<&Path, Error> { let rel_path = Path::new(rel); if rel_path.is_absolute() { return Err(Error::PathAbsolute(rel.to_string())); @@ -159,18 +181,41 @@ pub fn safe_join(base: &Path, rel: &str) -> Result { return Err(Error::PathParent(rel.to_string())); } } - let joined = base.join(rel_path); - // Canonicalise lazily — if either path doesn't exist yet, fall back to - // the lexical check we already did (absolute + parent-free is enough). - let base_canon = base.canonicalize().ok(); - let joined_canon = joined.canonicalize().ok(); - if let (Some(bc), Some(jc)) = (base_canon, joined_canon) { - if !jc.starts_with(&bc) { - return Err(Error::PathEscape { - base: bc, - rel: rel.to_string(), - }); - } - } - Ok(joined) + Ok(rel_path) +} + +fn canonicalize_base(base: &Path) -> Result { + base.canonicalize().map_err(|source| Error::Canonicalize { + path: base.to_path_buf(), + source, + }) +} + +fn assert_joined_inside_base( + joined: &Path, + base_canon: &Path, + rel: &str, +) -> Result<(), Error> { + if let Ok(jc) = joined.canonicalize() { + return check_contained(&jc, base_canon, rel); + } + let Some(parent) = joined.parent() else { + return Ok(()); + }; + let Ok(pc) = parent.canonicalize() else { + // Grand-parent also doesn't exist — no symlink can live here. + return Ok(()); + }; + check_contained(&pc, base_canon, rel) +} + +fn check_contained(candidate: &Path, base_canon: &Path, rel: &str) -> Result<(), Error> { + if candidate.starts_with(base_canon) { + Ok(()) + } else { + Err(Error::PathEscape { + base: base_canon.to_path_buf(), + rel: rel.to_string(), + }) + } } diff --git a/_primitives/_rust/kei-atom-discovery/tests/safe_join_hardening.rs b/_primitives/_rust/kei-atom-discovery/tests/safe_join_hardening.rs new file mode 100644 index 0000000..ecc8639 --- /dev/null +++ b/_primitives/_rust/kei-atom-discovery/tests/safe_join_hardening.rs @@ -0,0 +1,115 @@ +//! MEDIUM-severity hardening of `safe_join`. +//! +//! Covers two regressions that the original lexical-fallback implementation +//! missed: +//! 1. Accepting a non-existent `base` (no well-defined sandbox). +//! 2. Accepting a symlinked target that escapes `base`. + +use kei_atom_discovery::{safe_join, Error}; +use std::fs; +use tempfile::tempdir; + +#[test] +fn safe_join_rejects_nonexistent_base() { + let tmp = tempdir().unwrap(); + let ghost = tmp.path().join("does-not-exist"); + // `ghost` was never created → canonicalize fails → safe_join rejects. + let err = safe_join(&ghost, "schemas/foo.json").expect_err("must reject ghost base"); + assert!( + matches!(err, Error::Canonicalize { .. }), + "expected Canonicalize, got {err:?}" + ); +} + +#[test] +fn safe_join_accepts_valid_existing_base_and_rel() { + let tmp = tempdir().unwrap(); + let target = tmp.path().join("schemas"); + fs::create_dir_all(&target).unwrap(); + let joined = safe_join(tmp.path(), "schemas").expect("valid join"); + assert!(joined.ends_with("schemas")); +} + +#[test] +fn safe_join_accepts_nonexistent_rel_when_parent_exists() { + // Parent-dir canonicalize succeeds → no symlink can redirect → accept. + let tmp = tempdir().unwrap(); + let joined = + safe_join(tmp.path(), "not-yet-created.json").expect("nonexistent rel should join"); + assert!(joined.ends_with("not-yet-created.json")); +} + +#[test] +fn safe_join_accepts_deeply_nonexistent_rel() { + // Neither the file nor its parent dir exists → no symlink can live here. + let tmp = tempdir().unwrap(); + let joined = safe_join(tmp.path(), "brand/new/tree/file.json") + .expect("deeply nonexistent rel should join"); + assert!(joined.ends_with("brand/new/tree/file.json")); +} + +#[cfg(unix)] +#[test] +fn safe_join_rejects_symlink_escape() { + use std::os::unix::fs::symlink as unix_symlink; + + // Layout: + // outside_root/secret.json ← the attacker target + // sandbox/ ← our safe base + // sandbox/escape -> ../outside_root ← symlinked dir + // + // `safe_join(sandbox, "escape/secret.json")` must REJECT: after + // canonicalisation, the resolved path leaves `sandbox`. + let tmp = tempdir().unwrap(); + let outside_root = tmp.path().join("outside_root"); + let sandbox = tmp.path().join("sandbox"); + fs::create_dir_all(&outside_root).unwrap(); + fs::create_dir_all(&sandbox).unwrap(); + fs::write(outside_root.join("secret.json"), "pwned").unwrap(); + unix_symlink(&outside_root, sandbox.join("escape")).unwrap(); + + let err = safe_join(&sandbox, "escape/secret.json") + .expect_err("symlink-escape must be rejected"); + assert!( + matches!(err, Error::PathEscape { .. }), + "expected PathEscape, got {err:?}" + ); +} + +#[cfg(unix)] +#[test] +fn safe_join_rejects_symlink_escape_to_nonexistent_target() { + // Same shape as above, but the dangling target inside outside_root doesn't + // exist. The parent (`escape`) still canonicalizes into `outside_root`, so + // the escape must still be detected. + use std::os::unix::fs::symlink as unix_symlink; + + let tmp = tempdir().unwrap(); + let outside_root = tmp.path().join("outside_root2"); + let sandbox = tmp.path().join("sandbox2"); + fs::create_dir_all(&outside_root).unwrap(); + fs::create_dir_all(&sandbox).unwrap(); + unix_symlink(&outside_root, sandbox.join("escape")).unwrap(); + + let err = safe_join(&sandbox, "escape/not-yet.json") + .expect_err("symlink-escape with nonexistent tail must be rejected"); + assert!( + matches!(err, Error::PathEscape { .. }), + "expected PathEscape, got {err:?}" + ); +} + +#[cfg(unix)] +#[test] +fn safe_join_accepts_symlink_that_stays_inside_base() { + // A symlink that resolves BACK INTO the sandbox must still be accepted. + use std::os::unix::fs::symlink as unix_symlink; + + let tmp = tempdir().unwrap(); + let sandbox = tmp.path().join("sandbox3"); + fs::create_dir_all(sandbox.join("schemas")).unwrap(); + unix_symlink(sandbox.join("schemas"), sandbox.join("alias")).unwrap(); + + let ok = safe_join(&sandbox, "alias").expect("inside-base symlink is fine"); + assert!(ok.ends_with("alias")); +} diff --git a/_primitives/_rust/kei-entity-store/src/ddl.rs b/_primitives/_rust/kei-entity-store/src/ddl.rs index 20c91f7..01bbbeb 100644 --- a/_primitives/_rust/kei-entity-store/src/ddl.rs +++ b/_primitives/_rust/kei-entity-store/src/ddl.rs @@ -2,8 +2,14 @@ //! under the Constructor-Pattern 200-LOC cap. One function per emitted //! `CREATE` statement; the engine's `run_migrations` orchestrates the //! calls and stamps `user_version`. +//! +//! Edge-table DDL lives in `ddl_edge.rs` and is re-exported below; +//! `DdlError` lives in `ddl_error.rs`. Split preserves the 200-LOC cap +//! per Constructor Pattern. -use crate::schema::{EdgeKeyKind, EntitySchema, FieldDef, FieldKind}; +pub use crate::ddl_edge::{edge_table_for, try_edge_table_for}; +pub use crate::ddl_error::DdlError; +use crate::schema::{EntitySchema, FieldDef, FieldKind}; pub fn primary_table(schema: &EntitySchema) -> String { let cols: Vec = schema.fields.iter().map(column).collect(); @@ -83,108 +89,3 @@ pub fn fts_table(table: &str, cols: &[&str]) -> String { ) } -/// Dispatcher — picks edge-table DDL for a given `EdgeKeyKind`. Added -/// for kei-sage migration; `IntegerPair` branch preserves legacy body. -pub fn edge_table_for(edge: &str, kind: EdgeKeyKind) -> String { - match kind { - EdgeKeyKind::IntegerPair => edge_integer(edge), - EdgeKeyKind::TextPair => edge_text(edge), - EdgeKeyKind::TextPairWithMetadata { - from_col, - to_col, - has_id, - has_weight, - has_created_at, - extra_columns, - } => edge_text_meta( - edge, - from_col, - to_col, - has_id, - has_weight, - has_created_at, - extra_columns, - ), - } -} - -fn edge_integer(edge: &str) -> String { - format!( - "CREATE TABLE IF NOT EXISTS {edge} (\n \ - from_id INTEGER NOT NULL,\n \ - to_id INTEGER NOT NULL,\n \ - edge_type TEXT NOT NULL DEFAULT 'links',\n \ - PRIMARY KEY(from_id, to_id, edge_type)\n\ - );\n\ - CREATE INDEX IF NOT EXISTS idx_{edge}_to ON {edge}(to_id);" - ) -} - -/// Text-keyed edge DDL: `(src_path TEXT, dst_path TEXT, edge_type TEXT)`. -fn edge_text(edge: &str) -> String { - format!( - "CREATE TABLE IF NOT EXISTS {edge} (\n \ - src_path TEXT NOT NULL,\n \ - dst_path TEXT NOT NULL,\n \ - edge_type TEXT NOT NULL DEFAULT 'links',\n \ - PRIMARY KEY(src_path, dst_path, edge_type)\n\ - );\n\ - CREATE INDEX IF NOT EXISTS idx_{edge}_dst ON {edge}(dst_path);" - ) -} - -/// Text-keyed edge DDL with optional metadata columns + caller-chosen -/// key column names + arbitrary extra columns. -fn edge_text_meta( - edge: &str, - from_col: &str, - to_col: &str, - has_id: bool, - has_weight: bool, - has_created_at: bool, - extras: &[(&str, FieldKind)], -) -> String { - let mut cols: Vec = Vec::new(); - if has_id { - cols.push("edge_id INTEGER PRIMARY KEY AUTOINCREMENT".to_string()); - } - cols.push(format!("{from_col} TEXT NOT NULL")); - cols.push(format!("{to_col} TEXT NOT NULL")); - cols.push("edge_type TEXT NOT NULL DEFAULT 'links'".to_string()); - if has_weight { - cols.push("weight REAL NOT NULL DEFAULT 1.0".to_string()); - } - for (name, kind) in extras { - cols.push(extra_column(name, *kind)); - } - if has_created_at { - cols.push("created_at INTEGER NOT NULL".to_string()); - } - // Without an autoincrement PK we still want `INSERT OR IGNORE` - // idempotent over the triple; with one we emit a UNIQUE instead. - if has_id { - cols.push(format!("UNIQUE({from_col}, {to_col}, edge_type)")); - } else { - cols.push(format!("PRIMARY KEY({from_col}, {to_col}, edge_type)")); - } - let body = cols.join(",\n "); - format!( - "CREATE TABLE IF NOT EXISTS {edge} (\n {body}\n);\n\ - CREATE INDEX IF NOT EXISTS idx_{edge}_dst ON {edge}({to_col});" - ) -} - -/// DDL for one extra edge column. Limited subset of `FieldKind` — edge -/// extras can't be PKs, archive enums, or auto-stamped timestamps. -fn extra_column(name: &str, kind: FieldKind) -> String { - match kind { - FieldKind::Text => format!("{name} TEXT DEFAULT ''"), - FieldKind::TextNotNull => format!("{name} TEXT NOT NULL"), - FieldKind::Integer => format!("{name} INTEGER DEFAULT 0"), - FieldKind::IntegerNotNull => format!("{name} INTEGER NOT NULL"), - FieldKind::Real => format!("{name} REAL NOT NULL DEFAULT 0.0"), - other => panic!( - "edge extra_columns: unsupported FieldKind {other:?} for column '{name}'" - ), - } -} diff --git a/_primitives/_rust/kei-entity-store/src/ddl_edge.rs b/_primitives/_rust/kei-entity-store/src/ddl_edge.rs new file mode 100644 index 0000000..38bd375 --- /dev/null +++ b/_primitives/_rust/kei-entity-store/src/ddl_edge.rs @@ -0,0 +1,132 @@ +//! Edge-table DDL generators. Split out of `ddl.rs` to keep each file +//! inside the Constructor Pattern 200-LOC cap. `ddl.rs` retains the +//! entity-table, index, and FTS DDL; this module owns edge-table DDL +//! in all three variants (`IntegerPair`, `TextPair`, +//! `TextPairWithMetadata`). + +use crate::ddl_error::DdlError; +use crate::schema::{EdgeKeyKind, FieldKind}; + +/// Dispatcher — picks edge-table DDL for a given `EdgeKeyKind`. Added +/// for kei-sage migration; `IntegerPair` branch preserves legacy body. +/// +/// Backward-compat shim — prefer `try_edge_table_for` from new code. +/// This variant panics on unsupported `extra_columns` FieldKinds; the +/// engine's migration path uses the fallible variant to surface typed +/// errors without panicking. +pub fn edge_table_for(edge: &str, kind: EdgeKeyKind) -> String { + try_edge_table_for(edge, kind).expect("edge_table_for: unsupported extra_column FieldKind") +} + +/// Fallible dispatcher — same as `edge_table_for` but returns +/// `DdlError::UnsupportedExtraColumn` instead of panicking when an +/// `extra_columns` entry carries a FieldKind outside the supported +/// subset. This is the path `Store::open` takes. +pub fn try_edge_table_for(edge: &str, kind: EdgeKeyKind) -> Result { + match kind { + EdgeKeyKind::IntegerPair => Ok(edge_integer(edge)), + EdgeKeyKind::TextPair => Ok(edge_text(edge)), + EdgeKeyKind::TextPairWithMetadata { + from_col, + to_col, + has_id, + has_weight, + has_created_at, + extra_columns, + } => edge_text_meta( + edge, + from_col, + to_col, + has_id, + has_weight, + has_created_at, + extra_columns, + ), + } +} + +fn edge_integer(edge: &str) -> String { + format!( + "CREATE TABLE IF NOT EXISTS {edge} (\n \ + from_id INTEGER NOT NULL,\n \ + to_id INTEGER NOT NULL,\n \ + edge_type TEXT NOT NULL DEFAULT 'links',\n \ + PRIMARY KEY(from_id, to_id, edge_type)\n\ + );\n\ + CREATE INDEX IF NOT EXISTS idx_{edge}_to ON {edge}(to_id);" + ) +} + +/// Text-keyed edge DDL: `(src_path TEXT, dst_path TEXT, edge_type TEXT)`. +fn edge_text(edge: &str) -> String { + format!( + "CREATE TABLE IF NOT EXISTS {edge} (\n \ + src_path TEXT NOT NULL,\n \ + dst_path TEXT NOT NULL,\n \ + edge_type TEXT NOT NULL DEFAULT 'links',\n \ + PRIMARY KEY(src_path, dst_path, edge_type)\n\ + );\n\ + CREATE INDEX IF NOT EXISTS idx_{edge}_dst ON {edge}(dst_path);" + ) +} + +/// Text-keyed edge DDL with optional metadata columns + caller-chosen +/// key column names + arbitrary extra columns. Fallible — returns +/// `DdlError::UnsupportedExtraColumn` if any `extras` entry uses a +/// disallowed `FieldKind`. +fn edge_text_meta( + edge: &str, + from_col: &str, + to_col: &str, + has_id: bool, + has_weight: bool, + has_created_at: bool, + extras: &[(&str, FieldKind)], +) -> Result { + let mut cols: Vec = Vec::new(); + if has_id { + cols.push("edge_id INTEGER PRIMARY KEY AUTOINCREMENT".to_string()); + } + cols.push(format!("{from_col} TEXT NOT NULL")); + cols.push(format!("{to_col} TEXT NOT NULL")); + cols.push("edge_type TEXT NOT NULL DEFAULT 'links'".to_string()); + if has_weight { + cols.push("weight REAL NOT NULL DEFAULT 1.0".to_string()); + } + for (name, kind) in extras { + cols.push(try_extra_column(name, *kind)?); + } + if has_created_at { + cols.push("created_at INTEGER NOT NULL".to_string()); + } + // Without an autoincrement PK we still want `INSERT OR IGNORE` + // idempotent over the triple; with one we emit a UNIQUE instead. + if has_id { + cols.push(format!("UNIQUE({from_col}, {to_col}, edge_type)")); + } else { + cols.push(format!("PRIMARY KEY({from_col}, {to_col}, edge_type)")); + } + let body = cols.join(",\n "); + Ok(format!( + "CREATE TABLE IF NOT EXISTS {edge} (\n {body}\n);\n\ + CREATE INDEX IF NOT EXISTS idx_{edge}_dst ON {edge}({to_col});" + )) +} + +/// DDL for one extra edge column. Limited subset of `FieldKind` — edge +/// extras can't be PKs, archive enums, or auto-stamped timestamps. +/// Fallible — returns `DdlError::UnsupportedExtraColumn` outside the +/// supported set instead of panicking. +fn try_extra_column(name: &str, kind: FieldKind) -> Result { + match kind { + FieldKind::Text => Ok(format!("{name} TEXT DEFAULT ''")), + FieldKind::TextNotNull => Ok(format!("{name} TEXT NOT NULL")), + FieldKind::Integer => Ok(format!("{name} INTEGER DEFAULT 0")), + FieldKind::IntegerNotNull => Ok(format!("{name} INTEGER NOT NULL")), + FieldKind::Real => Ok(format!("{name} REAL NOT NULL DEFAULT 0.0")), + other => Err(DdlError::UnsupportedExtraColumn { + kind_debug: format!("{other:?}"), + column_name: name.to_string(), + }), + } +} diff --git a/_primitives/_rust/kei-entity-store/src/ddl_error.rs b/_primitives/_rust/kei-entity-store/src/ddl_error.rs new file mode 100644 index 0000000..0440f0d --- /dev/null +++ b/_primitives/_rust/kei-entity-store/src/ddl_error.rs @@ -0,0 +1,25 @@ +//! `DdlError` — typed DDL-generation failures surfaced by the fallible +//! edge-table dispatcher in `ddl::try_edge_table_for`. +//! +//! Split out of `ddl.rs` to keep each file inside the Constructor +//! Pattern 200-LOC cap (1 file = 1 responsibility). `ddl.rs` owns DDL +//! string emission; this module owns the error type only. + +use thiserror::Error; + +/// Typed DDL-generation failure. Surfaces caller-input problems (e.g. +/// an unsupported `FieldKind` passed as an `edge.extra_columns` entry) +/// as `Result` errors instead of panicking from library code. +#[derive(Debug, Error)] +pub enum DdlError { + /// Caller passed a `FieldKind` that edge-column DDL cannot emit + /// (PKs, archive enums, auto-stamped timestamps are disallowed — + /// see `ddl::try_extra_column` for the supported subset). + #[error( + "edge extra_columns: unsupported FieldKind {kind_debug} for column '{column_name}'" + )] + UnsupportedExtraColumn { + kind_debug: String, + column_name: String, + }, +} diff --git a/_primitives/_rust/kei-entity-store/src/engine.rs b/_primitives/_rust/kei-entity-store/src/engine.rs index fea42ca..9a93dd0 100644 --- a/_primitives/_rust/kei-entity-store/src/engine.rs +++ b/_primitives/_rust/kei-entity-store/src/engine.rs @@ -31,12 +31,26 @@ pub struct Store { impl Store { /// Open (creates parent dirs, enables WAL, runs migrations for all /// schemas in a single transaction). + /// + /// WAL mode is a best-effort optimisation — some filesystems (NFS, + /// read-only mounts, certain FUSE backends) refuse the pragma. On + /// failure we emit a single-line stderr notice and fall back to the + /// default rollback journal instead of swallowing the error; the + /// store still opens correctly and the exit-code contract is + /// preserved (WAL unavailability is not fatal by design). pub fn open(path: &Path, schemas: &[&EntitySchema]) -> Result { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } let conn = Connection::open(path).context("open sqlite")?; - conn.pragma_update(None, "journal_mode", "WAL").ok(); + if let Err(e) = conn.pragma_update(None, "journal_mode", "WAL") { + eprintln!( + "kei-entity-store: WAL mode unavailable at {} ({}); \ + falling back to rollback journal", + path.display(), + e + ); + } run_migrations(&conn, schemas)?; Ok(Self { conn }) } @@ -85,7 +99,9 @@ fn apply_schema( tx.execute_batch(&ddl::fts_table(schema.table, cols))?; } if let Some(edge) = schema.edge_table { - tx.execute_batch(&ddl::edge_table_for(edge, schema.edge_key_kind))?; + // Fallible path: unsupported `extra_columns` FieldKinds surface + // as `VerbError::InvalidInput` (exit 2), never a panic. + tx.execute_batch(&ddl::try_edge_table_for(edge, schema.edge_key_kind)?)?; } for stmt in schema.custom_migrations { tx.execute_batch(stmt)?; diff --git a/_primitives/_rust/kei-entity-store/src/error.rs b/_primitives/_rust/kei-entity-store/src/error.rs index 6234c4b..12cc85f 100644 --- a/_primitives/_rust/kei-entity-store/src/error.rs +++ b/_primitives/_rust/kei-entity-store/src/error.rs @@ -1,6 +1,7 @@ //! Verb error type. Distinguishes user-input / validation failures //! (map to CLI exit 2 in callers) from storage / IO failures (exit 1). +use crate::ddl_error::DdlError; use thiserror::Error; #[derive(Debug, Error)] @@ -60,3 +61,12 @@ impl VerbError { Self::NotFound { entity: entity.into(), id: id.into() } } } + +/// Map DDL-generation failures into verb errors. An unsupported +/// `extra_columns` FieldKind is caller-configuration input, so it maps +/// to `InvalidInput` (exit code 2) rather than the storage path. +impl From for VerbError { + fn from(e: DdlError) -> Self { + VerbError::InvalidInput(e.to_string()) + } +} diff --git a/_primitives/_rust/kei-entity-store/src/lib.rs b/_primitives/_rust/kei-entity-store/src/lib.rs index 4db6341..ecab1e5 100644 --- a/_primitives/_rust/kei-entity-store/src/lib.rs +++ b/_primitives/_rust/kei-entity-store/src/lib.rs @@ -15,6 +15,8 @@ //! `bin`. Each sibling crate remains the user-facing binary. pub mod ddl; +pub mod ddl_edge; +pub mod ddl_error; pub mod engine; pub mod error; pub mod field; diff --git a/_primitives/_rust/kei-entity-store/src/verbs/search.rs b/_primitives/_rust/kei-entity-store/src/verbs/search.rs index c451e28..632aab8 100644 --- a/_primitives/_rust/kei-entity-store/src/verbs/search.rs +++ b/_primitives/_rust/kei-entity-store/src/verbs/search.rs @@ -9,6 +9,14 @@ //! search — attackers cannot address unindexed columns or craft //! pathological scan expressions. Embedded `"` chars in the user query //! are escaped per FTS5 grammar by doubling (`"" → "`). +//! +//! Tokenization guard: a query with ZERO searchable tokens (e.g. all +//! punctuation, only whitespace once trimmed) is rejected with +//! `InvalidInput` (exit 2) BEFORE reaching SQLite. This preserves the +//! documented exit-code contract — otherwise the porter/unicode61 +//! tokenizer produces an empty token stream and FTS5 emits an opaque +//! `fts5: syntax error` that would propagate as `VerbError::Sqlite` +//! (exit 1). use crate::error::VerbError; use crate::schema::EntitySchema; @@ -43,6 +51,11 @@ pub fn run( if query.trim().is_empty() { return Err(VerbError::InvalidInput("search: query must be non-empty".into())); } + if !has_searchable_token(query) { + return Err(VerbError::InvalidInput( + "search: query has no searchable tokens".into(), + )); + } let limit = clamp(input.get("limit").and_then(|v| v.as_i64())); let safe_query = fts5_quote(query); @@ -72,6 +85,14 @@ fn fts5_quote(raw: &str) -> String { format!("\"{escaped}\"") } +/// True if `raw` contains at least one character the FTS5 porter / +/// unicode61 tokenizer will emit as a token (alphabetic or numeric). +/// Punctuation- and whitespace-only queries produce zero tokens and +/// would trip an opaque `fts5: syntax error` at MATCH time. +fn has_searchable_token(raw: &str) -> bool { + raw.chars().any(|c| c.is_alphanumeric()) +} + fn clamp(raw: Option) -> i64 { match raw { Some(n) if n > 0 && n <= MAX_LIMIT => n, @@ -81,7 +102,7 @@ fn clamp(raw: Option) -> i64 { #[cfg(test)] mod tests { - use super::fts5_quote; + use super::{fts5_quote, has_searchable_token}; #[test] fn quote_basic() { @@ -100,4 +121,28 @@ mod tests { // literal tokens `title:evil` across the configured columns. assert_eq!(fts5_quote("title:evil"), "\"title:evil\""); } + + #[test] + fn has_token_accepts_alpha() { + assert!(has_searchable_token("hello")); + assert!(has_searchable_token(" hi! ")); + } + + #[test] + fn has_token_accepts_digits() { + assert!(has_searchable_token("2026")); + } + + #[test] + fn has_token_rejects_punct_only() { + assert!(!has_searchable_token("!@#$")); + assert!(!has_searchable_token("...")); + assert!(!has_searchable_token("---")); + } + + #[test] + fn has_token_accepts_unicode_alpha() { + // Porter/unicode61 tokenises Cyrillic; our gate must too. + assert!(has_searchable_token("привет")); + } } diff --git a/_primitives/_rust/kei-entity-store/tests/bug_fixes_smoke.rs b/_primitives/_rust/kei-entity-store/tests/bug_fixes_smoke.rs index f947405..989cd0b 100644 --- a/_primitives/_rust/kei-entity-store/tests/bug_fixes_smoke.rs +++ b/_primitives/_rust/kei-entity-store/tests/bug_fixes_smoke.rs @@ -133,6 +133,125 @@ fn fts5_injection_neutralized_by_phrase_quoting() { assert_eq!(count_hits(&s, "secr*"), 0, "wildcard leaked"); } +#[test] +fn fts5_phrase_quoting_preserves_legitimate_queries() { + // Inverse failure mode of the sanitizer: over-broad escape would + // also destroy real tokens, so the injection test alone (hits==0) + // would pass even for a broken `fts5_quote` that returns "". This + // pins: a real token MUST still match the seeded row. + let s = mk(); + create::run(s.conn(), &SCHEMA, json!({ + "title": "ordinary record", "description": "nothing special" + })).unwrap(); + create::run(s.conn(), &SCHEMA, json!({ + "title": "secret handshake", "description": "hidden" + })).unwrap(); + + assert_eq!(count_hits(&s, "secret"), 1, "plain token must match"); + assert_eq!(count_hits(&s, "handshake"), 1, "second plain token must match"); + assert_eq!(count_hits(&s, "nothing"), 1, "description-side token must match"); +} + +#[test] +fn search_rejects_query_with_no_searchable_tokens() { + // Punctuation-only query passes the trim().is_empty() check but + // produces zero FTS5 tokens. Without the guard this would surface + // as an opaque rusqlite syntax error (exit code 1). The typed + // `InvalidInput` response keeps the exit-code-2 contract. + let s = mk(); + create::run(s.conn(), &SCHEMA, json!({ "title": "anything" })).unwrap(); + + let err = search::run(s.conn(), &SCHEMA, json!({ "query": "!@#$" })).unwrap_err(); + assert_eq!(err.exit_code(), 2, "must map to validation exit code"); + match err { + VerbError::InvalidInput(ref msg) => assert!( + msg.contains("no searchable tokens"), + "message should identify the tokenization failure, got: {msg}" + ), + other => panic!("expected InvalidInput, got {other:?}"), + } + + // Also cover whitespace + punctuation combo and long punctuation. + let err = search::run(s.conn(), &SCHEMA, json!({ "query": " ... " })).unwrap_err(); + assert_eq!(err.exit_code(), 2); + let err = search::run(s.conn(), &SCHEMA, json!({ "query": "-+=*/" })).unwrap_err(); + assert_eq!(err.exit_code(), 2); +} + +// ---------- DdlError — unsupported extra_column FieldKind ---------- + +#[test] +fn ddl_try_edge_table_for_rejects_unsupported_kind() { + // Reachable from public API: `EdgeKeyKind::TextPairWithMetadata + // { extra_columns: [("x", FieldKind::TextDefault)] }`. Must return + // a typed DdlError, not panic. Integration-level proof that + // Store::open's migration path maps this to InvalidInput (exit 2). + use kei_entity_store::ddl::try_edge_table_for; + use kei_entity_store::ddl_error::DdlError; + use kei_entity_store::schema::FieldKind; + + static BAD_EXTRAS: &[(&str, FieldKind)] = &[("bogus", FieldKind::TextDefault)]; + let kind = EdgeKeyKind::TextPairWithMetadata { + from_col: "from_uri", + to_col: "to_uri", + has_id: true, + has_weight: true, + has_created_at: true, + extra_columns: BAD_EXTRAS, + }; + + let err = try_edge_table_for("edges_bad", kind).unwrap_err(); + match err { + DdlError::UnsupportedExtraColumn { ref column_name, ref kind_debug } => { + assert_eq!(column_name, "bogus"); + assert!( + kind_debug.contains("TextDefault"), + "kind_debug should name the offending FieldKind, got {kind_debug}" + ); + } + } +} + +#[test] +fn store_open_maps_ddl_error_to_verb_error() { + // End-to-end: Store::open_memory on a schema with a bad + // extra_columns kind must surface the error through the + // `anyhow::Error` chain rather than panicking the thread. + use kei_entity_store::schema::FieldKind; + + static BAD_EXTRAS: &[(&str, FieldKind)] = &[("bogus", FieldKind::TextDefault)]; + static BAD_FIELDS: &[FieldDef] = &[FieldDef::pk("id")]; + static BAD_SCHEMA: EntitySchema = EntitySchema { + name: "bad", + table: "bad_nodes", + fields: BAD_FIELDS, + enabled_verbs: &[], + fts_columns: None, + edge_table: Some("bad_edges"), + edge_key_kind: EdgeKeyKind::TextPairWithMetadata { + from_col: "from_uri", + to_col: "to_uri", + has_id: true, + has_weight: true, + has_created_at: true, + extra_columns: BAD_EXTRAS, + }, + archived_field: None, + custom_migrations: &[], + }; + + let res = Store::open_memory(&[&BAD_SCHEMA]); + let err = match res { + Ok(_) => panic!("Store::open_memory must reject bad schema, not panic / succeed"), + Err(e) => e, + }; + let msg = format!("{err:#}"); + assert!( + msg.contains("bogus") && msg.contains("TextDefault"), + "error chain should mention column + kind, got: {msg}" + ); +} + // ---------- TEXT size cap ---------- #[test] diff --git a/_primitives/_rust/kei-spawn/Cargo.toml b/_primitives/_rust/kei-spawn/Cargo.toml index e4cb5b1..3ab82a3 100644 --- a/_primitives/_rust/kei-spawn/Cargo.toml +++ b/_primitives/_rust/kei-spawn/Cargo.toml @@ -13,6 +13,12 @@ path = "src/main.rs" name = "kei_spawn" path = "src/lib.rs" +[features] +default = [] +# Enables the real reqwest-backed HttpDriver for `kei-spawn drive`. +# Off by default: v0.1 ships with ManualDriver only (no network deps). +http-driver = ["dep:reqwest"] + [dependencies] kei-agent-runtime = { path = "../kei-agent-runtime" } clap = { version = "4", features = ["derive"] } @@ -20,9 +26,11 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" sha2 = { workspace = true } +reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls"], optional = true } [dev-dependencies] tempfile = "3" +httpmock = "0.7" [package.metadata.keisei] backend = "none" diff --git a/_primitives/_rust/kei-spawn/src/drive.rs b/_primitives/_rust/kei-spawn/src/drive.rs index 4afacd7..22912d4 100644 --- a/_primitives/_rust/kei-spawn/src/drive.rs +++ b/_primitives/_rust/kei-spawn/src/drive.rs @@ -1,22 +1,24 @@ -//! drive — design-as-stubbed Anthropic-API driver for `kei-spawn drive`. +//! drive — driver trait + shared types + ManualDriver for `kei-spawn drive`. //! -//! The `drive` subcommand is the future one-call replacement for the current +//! The `drive` subcommand is the one-call replacement for the current //! two-step dance (`kei-spawn spawn` → orchestrator pastes Agent invocation). -//! Wiring it to a live Anthropic HTTP endpoint is a breaking change (adds -//! `reqwest` + tokio + a secrets contract), so v0.1 ships a stub: the -//! pipeline, types, and trait are defined; the HTTP impl returns -//! `NotImplemented` via `ManualDriver`. +//! +//! Two drivers live here: +//! - `ManualDriver` — always returns `NotImplemented` (v0.1 default path). +//! - `HttpDriver` — real impl lives in `drive_http` behind feature +//! `http-driver`; without the feature a stub returning +//! `NotImplemented` preserves the v0.1 API surface. //! //! Exit-code contract (mirrors `kei-runtime::InvokeError::NotImplemented`): //! - 64 (EX_USAGE range) when the driver yields `NotImplemented` //! - 1 on spawn failure (same as `kei-spawn spawn`) -//! - 0 only when a real driver returns Ok (HttpDriver future path) +//! - 0 only when a real driver returns Ok //! //! Constructor Pattern: one trait + two zero-state impls + one helper fn. use serde::Serialize; -/// Success envelope for a future `HttpDriver` (and the contract +/// Success envelope for the `HttpDriver` (and the contract /// `ManualDriver` deliberately never fulfils). #[derive(Debug, Clone, Serialize)] pub struct AgentResult { @@ -25,8 +27,7 @@ pub struct AgentResult { pub finish_reason: String, } -/// Errors surfaced from driver invocation. `NotImplemented` is retained as -/// the v0.1 escape hatch; `Transport` is reserved for the HTTP impl. +/// Errors surfaced from driver invocation. #[derive(Debug)] pub enum DriveError { NotImplemented { reason: String }, @@ -49,10 +50,6 @@ impl std::fmt::Display for DriveError { impl std::error::Error for DriveError {} /// Abstraction over "how does an agent invocation actually happen." -/// -/// v0.1 has one impl: `ManualDriver` (prints instructions, returns -/// `NotImplemented`). Future: `HttpDriver` backed by `reqwest` + -/// `KEI_ANTHROPIC_KEY` + POST `https://api.anthropic.com/v1/messages`. pub trait AnthropicDriver { fn invoke( &self, @@ -63,9 +60,6 @@ pub trait AnthropicDriver { } /// v0.1 driver — returns `NotImplemented` unconditionally. -/// -/// Intentional: lets `kei-spawn drive` ship a complete CLI surface -/// (help, argument parsing, JSON emission) before the HTTP dep is taken. pub struct ManualDriver; impl AnthropicDriver for ManualDriver { @@ -81,14 +75,15 @@ impl AnthropicDriver for ManualDriver { } } -/// Placeholder for the future HTTP-backed driver. +/// Stub `HttpDriver` used when the `http-driver` feature is OFF. /// -/// Deliberately kept dep-free: adding `reqwest` + tokio here would force a -/// breaking change on every consumer of `kei-spawn` today. When the HTTP -/// impl lands, this struct gains fields (`api_key`, `endpoint`, `client`) -/// and the `invoke` body is replaced. +/// Keeps the public API stable so downstream crates can name the type +/// unconditionally. Returns `NotImplemented` with a clear message pointing +/// to the feature flag. +#[cfg(not(feature = "http-driver"))] pub struct HttpDriver; +#[cfg(not(feature = "http-driver"))] impl AnthropicDriver for HttpDriver { fn invoke( &self, @@ -97,15 +92,18 @@ impl AnthropicDriver for HttpDriver { _isolation: Option<&str>, ) -> Result { Err(DriveError::NotImplemented { - reason: "HttpDriver not wired in v0.1 — add reqwest + tokio in a dedicated PR" + reason: "HttpDriver requires `--features http-driver`; \ + rebuild with it to enable Anthropic-API calls" .to_string(), }) } } -/// Canonical stderr message for the v0.1 stub. Kept as a fn so both the -/// driver impl and the CLI layer emit the exact same string (and so tests -/// can assert on one fixture). +/// Re-export real `HttpDriver` when feature is ON. +#[cfg(feature = "http-driver")] +pub use crate::drive_http::HttpDriver; + +/// Canonical stderr message for the v0.1 stub. pub fn not_implemented_message() -> String { "HTTP Anthropic-API integration not yet wired; use spawn then manual \ Agent-tool invocation (see printed instructions)" @@ -113,9 +111,6 @@ pub fn not_implemented_message() -> String { } /// Drive helper — orchestrator-facing entry that dispatches to a driver. -/// -/// Kept thin on purpose: the real work (prepare + ledger fork) happens in -/// `spawn_from_task`. `drive` only layers the driver call on top. pub fn drive_with( driver: &D, prompt: &str, @@ -141,8 +136,9 @@ mod tests { } } + #[cfg(not(feature = "http-driver"))] #[test] - fn http_driver_also_not_implemented_in_v01() { + fn http_driver_stub_returns_not_implemented_without_feature() { let d = HttpDriver; assert!(matches!( d.invoke("p", "x", None), diff --git a/_primitives/_rust/kei-spawn/src/drive_http.rs b/_primitives/_rust/kei-spawn/src/drive_http.rs new file mode 100644 index 0000000..f71da4d --- /dev/null +++ b/_primitives/_rust/kei-spawn/src/drive_http.rs @@ -0,0 +1,162 @@ +//! drive_http — reqwest::blocking-backed Anthropic driver. +//! +//! Gated behind the `http-driver` Cargo feature. Reads `KEI_ANTHROPIC_KEY` +//! at every `invoke` call (so key rotation takes effect without rebuilds). +//! +//! Endpoint defaults to and can be +//! overridden via `KEI_ANTHROPIC_ENDPOINT` (test hook for httpmock). +//! +//! Constructor Pattern: one struct + one impl + small helpers, every fn +//! ≤30 LOC, file ≤200 LOC. + +#![cfg(feature = "http-driver")] + +use std::time::Duration; + +use crate::drive::{AgentResult, AnthropicDriver, DriveError}; +use crate::drive_http_parse::{ + compose_user_content, excerpt, parse_response, Message, MessagesRequest, ANTHROPIC_VERSION, + DEFAULT_ENDPOINT, MAX_TOKENS, MODEL_ID, +}; + +const ENV_API_KEY: &str = "KEI_ANTHROPIC_KEY"; +const ENV_ENDPOINT: &str = "KEI_ANTHROPIC_ENDPOINT"; +const TIMEOUT_TOTAL: Duration = Duration::from_secs(300); +// reqwest 0.12 blocking ClientBuilder exposes `connect_timeout` but not +// a per-read timeout; we cap the TCP+TLS handshake at 60s (matches the +// "60s read" intent — request-body read is bounded by the 300s total). +const TIMEOUT_CONNECT: Duration = Duration::from_secs(60); +const ERR_BODY_EXCERPT: usize = 512; + +/// Real Anthropic-backed driver. Zero-state: key + endpoint read per call. +pub struct HttpDriver; + +impl AnthropicDriver for HttpDriver { + fn invoke( + &self, + prompt: &str, + subagent_type: &str, + isolation: Option<&str>, + ) -> Result { + let key = read_key()?; + let endpoint = read_endpoint(); + let client = build_client()?; + let user_content = compose_user_content(prompt, subagent_type, isolation); + let body = build_request_body(&user_content); + send_and_parse(&client, &endpoint, &key, &body) + } +} + +fn read_key() -> Result { + std::env::var(ENV_API_KEY).map_err(|_| DriveError::Transport { + message: format!("{ENV_API_KEY} is not set in the environment"), + }) +} + +fn read_endpoint() -> String { + std::env::var(ENV_ENDPOINT).unwrap_or_else(|_| DEFAULT_ENDPOINT.to_string()) +} + +fn build_client() -> Result { + reqwest::blocking::Client::builder() + .timeout(TIMEOUT_TOTAL) + .connect_timeout(TIMEOUT_CONNECT) + .build() + .map_err(|e| DriveError::Transport { + message: format!("build reqwest client: {e}"), + }) +} + +fn build_request_body(user_content: &str) -> String { + let req = MessagesRequest { + model: MODEL_ID, + max_tokens: MAX_TOKENS, + messages: vec![Message { + role: "user", + content: user_content, + }], + }; + // Safe: types are `Serialize` with only `&str`/`u32`/`Vec`. + serde_json::to_string(&req).unwrap_or_else(|_| "{}".to_string()) +} + +fn send_and_parse( + client: &reqwest::blocking::Client, + endpoint: &str, + key: &str, + body: &str, +) -> Result { + let resp = client + .post(endpoint) + .header("x-api-key", key) + .header("anthropic-version", ANTHROPIC_VERSION) + .header("content-type", "application/json") + .body(body.to_string()) + .send() + .map_err(map_network_error)?; + let status = resp.status(); + let text = resp.text().map_err(|e| DriveError::Transport { + message: format!("read response body: {e}"), + })?; + if status.is_success() { + parse_response(&text) + } else { + Err(http_error(status.as_u16(), &text)) + } +} + +fn map_network_error(e: reqwest::Error) -> DriveError { + DriveError::Transport { + message: format!("network error: {e}"), + } +} + +fn http_error(status: u16, body: &str) -> DriveError { + DriveError::Transport { + message: format!( + "HTTP {status}: body[:{ERR_BODY_EXCERPT}]={}", + excerpt(body, ERR_BODY_EXCERPT) + ), + } +} + +#[cfg(test)] +mod tests { + //! Unit-level tests for helpers. End-to-end tests (with httpmock) + //! live in `tests/http_driver.rs`. + use super::*; + + #[test] + fn build_request_body_contains_model_and_prompt() { + let body = build_request_body("hello"); + assert!(body.contains("\"model\":\"claude-opus-4-7\"")); + assert!(body.contains("\"max_tokens\":4096")); + assert!(body.contains("\"role\":\"user\"")); + assert!(body.contains("\"content\":\"hello\"")); + } + + #[test] + fn http_error_truncates_long_body() { + let long = "x".repeat(5_000); + let err = http_error(429, &long); + match err { + DriveError::Transport { message } => { + assert!(message.contains("HTTP 429")); + assert!(message.len() < 5_000); + } + other => panic!("expected Transport, got {other}"), + } + } + + #[test] + fn read_endpoint_returns_default_when_unset() { + // Save + clear so the assertion is deterministic. + let prev = std::env::var(ENV_ENDPOINT).ok(); + std::env::remove_var(ENV_ENDPOINT); + let got = read_endpoint(); + if let Some(p) = prev { + std::env::set_var(ENV_ENDPOINT, p); + } + assert_eq!(got, DEFAULT_ENDPOINT); + } +} diff --git a/_primitives/_rust/kei-spawn/src/drive_http_parse.rs b/_primitives/_rust/kei-spawn/src/drive_http_parse.rs new file mode 100644 index 0000000..ca595a3 --- /dev/null +++ b/_primitives/_rust/kei-spawn/src/drive_http_parse.rs @@ -0,0 +1,185 @@ +//! drive_http_parse — request / response DTOs for Anthropic `/v1/messages`. +//! +//! Kept in its own module so the `drive_http` HTTP glue stays under the +//! Constructor Pattern ≤200 LOC budget and the DTO surface is unit-testable +//! without a live reqwest client. + +#![cfg(feature = "http-driver")] + +use serde::{Deserialize, Serialize}; + +use crate::drive::{AgentResult, DriveError}; + +/// Model id used for every `kei-spawn drive` request. +pub const MODEL_ID: &str = "claude-opus-4-7"; + +/// max_tokens limit per Anthropic spec (plenty for report envelopes). +pub const MAX_TOKENS: u32 = 4096; + +/// Anthropic API version header value. +pub const ANTHROPIC_VERSION: &str = "2023-06-01"; + +/// Default endpoint; overridable via `KEI_ANTHROPIC_ENDPOINT` for tests. +pub const DEFAULT_ENDPOINT: &str = "https://api.anthropic.com/v1/messages"; + +/// Outbound POST body. +#[derive(Debug, Serialize)] +pub struct MessagesRequest<'a> { + pub model: &'a str, + pub max_tokens: u32, + pub messages: Vec>, +} + +#[derive(Debug, Serialize)] +pub struct Message<'a> { + pub role: &'a str, + pub content: &'a str, +} + +/// Inbound response shape. +#[derive(Debug, Deserialize)] +pub struct MessagesResponse { + pub id: String, + #[serde(default)] + pub content: Vec, + #[serde(default)] + pub stop_reason: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ContentBlock { + #[serde(rename = "type")] + pub kind: String, + #[serde(default)] + pub text: Option, +} + +/// Fold the parsed response into the public `AgentResult` envelope. +/// +/// Concatenates every `text`-typed content block; non-text blocks +/// (tool_use, image, etc.) are silently skipped — kei-spawn drive only +/// surfaces transcript text. +pub fn to_agent_result(r: MessagesResponse) -> AgentResult { + let transcript = r + .content + .into_iter() + .filter(|b| b.kind == "text") + .filter_map(|b| b.text) + .collect::>() + .join(""); + AgentResult { + agent_id: r.id, + transcript, + finish_reason: r.stop_reason.unwrap_or_else(|| "unknown".to_string()), + } +} + +/// Build the `[kei-spawn routing] …` preamble required by the task spec. +pub fn build_preamble(subagent_type: &str, isolation: Option<&str>) -> String { + format!( + "[kei-spawn routing] subagent_type={}, isolation={}\n\n", + subagent_type, + isolation.unwrap_or("") + ) +} + +/// Build the full user message (preamble + prompt). +pub fn compose_user_content(prompt: &str, subagent_type: &str, isolation: Option<&str>) -> String { + let mut s = build_preamble(subagent_type, isolation); + s.push_str(prompt); + s +} + +/// Parse a JSON response body. Errors map to `Transport` with the +/// parse error message and the first 512 bytes of the body as context. +pub fn parse_response(body: &str) -> Result { + match serde_json::from_str::(body) { + Ok(r) => Ok(to_agent_result(r)), + Err(e) => Err(DriveError::Transport { + message: format!("parse response: {e}; body[:512]={}", excerpt(body, 512)), + }), + } +} + +/// Truncate `s` to at most `n` bytes at a char boundary. +pub fn excerpt(s: &str, n: usize) -> String { + if s.len() <= n { + return s.to_string(); + } + let mut end = n; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + s[..end].to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preamble_format_matches_spec() { + let p = build_preamble("code-implementer", Some("worktree")); + assert_eq!( + p, + "[kei-spawn routing] subagent_type=code-implementer, isolation=worktree\n\n" + ); + } + + #[test] + fn preamble_without_isolation_falls_back() { + let p = build_preamble("critic", None); + assert!(p.contains("isolation=")); + } + + #[test] + fn compose_appends_prompt() { + let c = compose_user_content("hi", "x", Some("w")); + assert!(c.starts_with("[kei-spawn routing]")); + assert!(c.ends_with("hi")); + } + + #[test] + fn parse_ok_multi_text_blocks() { + let body = r#"{ + "id": "msg_01", + "content": [ + {"type":"text","text":"hello "}, + {"type":"tool_use","id":"t1"}, + {"type":"text","text":"world"} + ], + "stop_reason": "end_turn" + }"#; + let r = parse_response(body).unwrap(); + assert_eq!(r.agent_id, "msg_01"); + assert_eq!(r.transcript, "hello world"); + assert_eq!(r.finish_reason, "end_turn"); + } + + #[test] + fn parse_missing_stop_reason_defaults() { + let body = r#"{"id":"x","content":[{"type":"text","text":"y"}]}"#; + let r = parse_response(body).unwrap(); + assert_eq!(r.finish_reason, "unknown"); + } + + #[test] + fn parse_malformed_maps_to_transport() { + let err = parse_response("{not json").unwrap_err(); + match err { + DriveError::Transport { message } => { + assert!(message.contains("parse response")); + assert!(message.contains("body[:512]=")); + } + other => panic!("expected Transport, got {other}"), + } + } + + #[test] + fn excerpt_respects_char_boundary() { + let s = "αβγδ"; // 2 bytes each + let out = excerpt(s, 3); + // should truncate to a valid boundary (2 bytes = "α") + assert!(s.starts_with(&out)); + } +} diff --git a/_primitives/_rust/kei-spawn/src/lib.rs b/_primitives/_rust/kei-spawn/src/lib.rs index 0f3935e..9384d2e 100644 --- a/_primitives/_rust/kei-spawn/src/lib.rs +++ b/_primitives/_rust/kei-spawn/src/lib.rs @@ -16,7 +16,8 @@ //! Design constraints: //! - Constructor Pattern: one module = one responsibility, ≤200 LOC file, //! ≤30 LOC fn. -//! - No HTTP / no Anthropic API — that's a later `kei-spawn drive` iteration. +//! - Optional HTTP via the `http-driver` Cargo feature (reqwest::blocking + +//! rustls). Off by default — v0.1 ships `ManualDriver` only. //! - No git / no shell — ledger interactions go through `kei-ledger` as a //! subprocess to avoid adding kei-ledger as a direct dep while it still //! lacks a lib.rs (can't link to a bin-only crate). @@ -26,6 +27,10 @@ //! `kei-ledger` (which itself only writes to SQLite). pub mod drive; +#[cfg(feature = "http-driver")] +pub mod drive_http; +#[cfg(feature = "http-driver")] +pub mod drive_http_parse; pub mod ledger_sh; pub mod spawn; pub mod verify; diff --git a/_primitives/_rust/kei-spawn/tests/http_driver.rs b/_primitives/_rust/kei-spawn/tests/http_driver.rs new file mode 100644 index 0000000..0d2c06c --- /dev/null +++ b/_primitives/_rust/kei-spawn/tests/http_driver.rs @@ -0,0 +1,181 @@ +//! http_driver — end-to-end tests for the `http-driver` feature. +//! +//! Uses `httpmock` to stand up a local HTTP server and `KEI_ANTHROPIC_ENDPOINT` +//! to redirect the driver at it. `KEI_ANTHROPIC_KEY` is set per-test so the +//! tests never require real credentials. +//! +//! Every test is self-contained: fresh MockServer + per-test env vars. The +//! env_lock mutex below ensures concurrent tests don't trample each other's +//! process-global env. + +#![cfg(feature = "http-driver")] + +use std::sync::Mutex; + +use httpmock::prelude::*; +use kei_spawn::{AnthropicDriver, DriveError, HttpDriver}; + +/// Cargo test harness runs tests in parallel by default — env vars are +/// process-global, so serialize access. +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct EnvGuard { + key_prev: Option, + endpoint_prev: Option, + _guard: std::sync::MutexGuard<'static, ()>, +} + +impl EnvGuard { + fn new(key: Option<&str>, endpoint: Option<&str>) -> Self { + let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let key_prev = std::env::var("KEI_ANTHROPIC_KEY").ok(); + let endpoint_prev = std::env::var("KEI_ANTHROPIC_ENDPOINT").ok(); + match key { + Some(v) => std::env::set_var("KEI_ANTHROPIC_KEY", v), + None => std::env::remove_var("KEI_ANTHROPIC_KEY"), + } + match endpoint { + Some(v) => std::env::set_var("KEI_ANTHROPIC_ENDPOINT", v), + None => std::env::remove_var("KEI_ANTHROPIC_ENDPOINT"), + } + Self { + key_prev, + endpoint_prev, + _guard: guard, + } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.key_prev { + Some(v) => std::env::set_var("KEI_ANTHROPIC_KEY", v), + None => std::env::remove_var("KEI_ANTHROPIC_KEY"), + } + match &self.endpoint_prev { + Some(v) => std::env::set_var("KEI_ANTHROPIC_ENDPOINT", v), + None => std::env::remove_var("KEI_ANTHROPIC_ENDPOINT"), + } + } +} + +#[test] +fn missing_key_returns_transport_error() { + let _env = EnvGuard::new(None, Some("http://127.0.0.1:1/never")); + let d = HttpDriver; + let err = d.invoke("hi", "code-implementer", Some("worktree")).unwrap_err(); + match err { + DriveError::Transport { message } => { + assert!(message.contains("KEI_ANTHROPIC_KEY"), "msg: {message}"); + } + other => panic!("expected Transport, got {other}"), + } +} + +#[test] +fn ok_200_roundtrip_populates_agent_result() { + let server = MockServer::start(); + let _env = EnvGuard::new(Some("test-key-xxx"), Some(&server.url("/v1/messages"))); + + let m = server.mock(|when, then| { + when.method(POST) + .path("/v1/messages") + .header("x-api-key", "test-key-xxx") + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .body_contains("[kei-spawn routing] subagent_type=code-implementer") + .body_contains("claude-opus-4-7"); + then.status(200) + .header("content-type", "application/json") + .body( + r#"{ + "id": "msg_test_01", + "content": [ + {"type":"text","text":"hello "}, + {"type":"text","text":"world"} + ], + "stop_reason": "end_turn" + }"#, + ); + }); + + let d = HttpDriver; + let out = d + .invoke("please do X", "code-implementer", Some("worktree")) + .expect("ok roundtrip"); + + m.assert(); + assert_eq!(out.agent_id, "msg_test_01"); + assert_eq!(out.transcript, "hello world"); + assert_eq!(out.finish_reason, "end_turn"); +} + +#[test] +fn http_4xx_maps_to_transport_with_body_excerpt() { + let server = MockServer::start(); + let _env = EnvGuard::new(Some("bad-key"), Some(&server.url("/v1/messages"))); + + let body_msg = "{\"type\":\"error\",\"error\":{\"type\":\"invalid_api_key\",\"message\":\"bad key\"}}"; + server.mock(|when, then| { + when.method(POST).path("/v1/messages"); + then.status(401) + .header("content-type", "application/json") + .body(body_msg); + }); + + let d = HttpDriver; + let err = d.invoke("x", "code-implementer", None).unwrap_err(); + match err { + DriveError::Transport { message } => { + assert!(message.contains("HTTP 401"), "msg: {message}"); + assert!(message.contains("invalid_api_key"), "msg: {message}"); + } + other => panic!("expected Transport, got {other}"), + } +} + +#[test] +fn http_5xx_maps_to_transport() { + let server = MockServer::start(); + let _env = EnvGuard::new(Some("k"), Some(&server.url("/v1/messages"))); + + server.mock(|when, then| { + when.method(POST).path("/v1/messages"); + then.status(503) + .header("content-type", "text/plain") + .body("upstream overloaded"); + }); + + let d = HttpDriver; + let err = d.invoke("x", "y", None).unwrap_err(); + match err { + DriveError::Transport { message } => { + assert!(message.contains("HTTP 503"), "msg: {message}"); + assert!(message.contains("upstream overloaded"), "msg: {message}"); + } + other => panic!("expected Transport, got {other}"), + } +} + +#[test] +fn malformed_json_on_200_maps_to_transport() { + let server = MockServer::start(); + let _env = EnvGuard::new(Some("k"), Some(&server.url("/v1/messages"))); + + server.mock(|when, then| { + when.method(POST).path("/v1/messages"); + then.status(200) + .header("content-type", "application/json") + .body("{not-json"); + }); + + let d = HttpDriver; + let err = d.invoke("x", "y", None).unwrap_err(); + match err { + DriveError::Transport { message } => { + assert!(message.contains("parse response"), "msg: {message}"); + assert!(message.contains("body[:512]="), "msg: {message}"); + } + other => panic!("expected Transport, got {other}"), + } +}