From ba19b6bcff359918d3d2599b51e1d2d0d0f92bac Mon Sep 17 00:00:00 2001 From: "K. Hodges" Date: Mon, 8 Jun 2026 00:52:58 -0700 Subject: [PATCH] Updates for command shell --- Cargo.lock | 460 +++++++++++++------------------ Cargo.toml | 2 +- docs/phase2_interaction_model.md | 16 +- docs/quickstart.md | 28 ++ src/app.rs | 17 +- src/commands.rs | 349 ++++++++++++++++++----- src/config.rs | 96 +++++++ src/formatting.rs | 10 +- src/repl.rs | 3 +- 9 files changed, 637 insertions(+), 344 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7d2b3bc..3873a2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,30 +66,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "core-foundation" -version = "0.9.4" +name = "cfg_aliases" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "displaydoc" @@ -102,15 +82,6 @@ dependencies = [ "syn", ] -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -154,33 +125,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -257,8 +207,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] @@ -269,30 +235,11 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] -[[package]] -name = "h2" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -363,7 +310,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -387,22 +333,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", + "webpki-roots", ] [[package]] @@ -423,11 +354,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -605,18 +534,18 @@ version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "memchr" version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - [[package]] name = "mio" version = "1.2.1" @@ -628,72 +557,12 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "openssl" -version = "0.10.80" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.116" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -706,12 +575,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - [[package]] name = "potential_utf" version = "0.1.5" @@ -721,6 +584,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -740,6 +612,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -749,12 +676,47 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -763,30 +725,27 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", - "encoding_rs", "futures-core", "futures-util", - "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -796,6 +755,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots", ] [[package]] @@ -812,6 +772,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustix" version = "1.1.4" @@ -832,6 +798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -844,6 +811,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -870,38 +838,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -1053,27 +989,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -1117,6 +1032,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" @@ -1144,16 +1074,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -1322,12 +1242,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "want" version = "0.3.1" @@ -1473,41 +1387,31 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -1725,6 +1629,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index d31759b..7298dd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ license = "GPL-3.0-or-later" [dependencies] async-trait = "0.1" futures-util = "0.3" -reqwest = { version = "0.12", features = ["json", "stream"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" diff --git a/docs/phase2_interaction_model.md b/docs/phase2_interaction_model.md index 6f56fc3..d76d91e 100644 --- a/docs/phase2_interaction_model.md +++ b/docs/phase2_interaction_model.md @@ -89,7 +89,21 @@ Available actions: ## Risk Detection -Phase 2 includes a simple non-blocking detector for obvious risky commands. It flags patterns such as recursive forced deletion, disk formatting, recursive permission changes, downloaded content piped to an interpreter, credential exposure, and package removal. +Phase 2 includes a simple non-blocking detector for obvious risky commands. The detector is policy-driven: Exoshell ships with conservative defaults, and users can add or replace rules at runtime through config. + +Default rules flag patterns such as recursive forced deletion, disk formatting, recursive permission changes, downloaded content piped to an interpreter, credential exposure, and package removal. + +Example custom rule: + +```toml +[commands.risk] +include_defaults = true + +[[commands.risk.rules]] +match_all = ["kubectl delete", "--all"] +reason = "cluster-wide deletion" +shell = "posix" +``` False positives are acceptable. A warning means "review this carefully," not "this command is definitely harmful." Lack of a warning does not mean a command is safe. diff --git a/docs/quickstart.md b/docs/quickstart.md index a000ffe..7b4f657 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -54,6 +54,34 @@ stance = "operator" Local provider URLs such as `localhost` and `127.0.0.1` do not require an API key. +## Configure Command Risk Rules + +Exoshell ships with conservative default rules for obvious risky command suggestions. You can add your own rules at runtime: + +```toml +[commands.risk] +include_defaults = true + +[[commands.risk.rules]] +match_all = ["kubectl delete", "--all"] +reason = "cluster-wide deletion" +shell = "posix" + +[[commands.risk.rules]] +match_all = ["terraform apply"] +reason = "infrastructure mutation" +shell = "posix" +``` + +`match_all` is a list of case-insensitive substrings that must all appear in the suggested command. `shell` is optional; use `posix` or `powershell`. + +To replace the built-in defaults entirely: + +```toml +[commands.risk] +include_defaults = false +``` + ## Start Exoshell Run with defaults: diff --git a/src/app.rs b/src/app.rs index 7a31675..174a781 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,13 +1,14 @@ use std::path::PathBuf; use std::time::Duration; -use crate::commands::{CommandSuggestion, parse_command_suggestions}; +use crate::commands::{CommandSuggestion, parse_command_suggestions_with_policy}; use crate::config::Config; use crate::context::{ ContextError, ContextPriority, ContextProviderRegistry, ContextProviderRequest, SessionContextStore, budget_warning, prune_context, register_default_context_providers, render_context_details, render_context_list, render_context_stats, }; +use crate::formatting::render_assistant_output_with_policy; use crate::prompts::{Stance, assemble_prompt, render_prompt_estimate}; use crate::providers::{ChatMessage, ChatRequest, ChatResponse, ChatRole, Provider, ProviderError}; use crate::repl::ReplError; @@ -78,7 +79,8 @@ impl App { self.conversation .push(ChatMessage::new(ChatRole::Assistant, response.clone())); self.transcript.record_assistant(&response); - self.last_command_suggestions = parse_command_suggestions(&response); + self.last_command_suggestions = + parse_command_suggestions_with_policy(&response, &self.config.commands.risk); for suggestion in &self.last_command_suggestions { self.transcript.record_command_suggestion(suggestion); } @@ -277,6 +279,10 @@ impl App { Ok(Some(path)) } + pub fn render_assistant_output(&self, response: &str) -> String { + render_assistant_output_with_policy(response, &self.config.commands.risk) + } + fn assembled_messages(&mut self) -> Result, AppError> { let size = self.context_store.total_size(); let budget = self.config.context.budget(); @@ -627,7 +633,9 @@ impl CliOptions { #[cfg(test)] mod tests { use super::*; - use crate::config::{InteractionConfig, ProviderConfig, ShellConfig, TranscriptConfig}; + use crate::config::{ + CommandConfig, InteractionConfig, ProviderConfig, ShellConfig, TranscriptConfig, + }; use std::sync::{Arc, Mutex}; #[test] @@ -959,6 +967,9 @@ mod tests { interaction: InteractionConfig { stance: Stance::Operator, }, + commands: CommandConfig { + risk: crate::commands::CommandRiskPolicy::default(), + }, transcript: TranscriptConfig { directory: PathBuf::from("transcripts"), enabled: false, diff --git a/src/commands.rs b/src/commands.rs index afc0000..f9d4677 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -11,7 +11,8 @@ pub struct CommandSuggestion { pub discarded: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] pub enum CommandShell { PowerShell, Posix, @@ -28,6 +29,25 @@ impl fmt::Display for CommandShell { } } +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum CommandShellError { + #[error("unknown command shell '{0}', expected powershell, posix, or unknown")] + Unknown(String), +} + +impl std::str::FromStr for CommandShell { + type Err = CommandShellError; + + fn from_str(value: &str) -> Result { + match value { + "powershell" | "pwsh" => Ok(Self::PowerShell), + "posix" | "sh" | "bash" | "zsh" => Ok(Self::Posix), + "unknown" => Ok(Self::Unknown), + other => Err(CommandShellError::Unknown(other.to_string())), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RiskLevel { Low, @@ -51,14 +71,82 @@ pub struct CommandRisk { pub reasons: Vec, } -impl CommandRisk { - pub fn none() -> Self { +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandRiskPolicy { + pub include_defaults: bool, + pub rules: Vec, +} + +impl CommandRiskPolicy { + pub fn evaluate(&self, command: &str, shell: CommandShell) -> CommandRisk { + let command = command.to_ascii_lowercase(); + let mut reasons = Vec::new(); + + let default_rules; + let rules: Box + '_> = if self.include_defaults { + default_rules = default_command_risk_rules(); + Box::new(default_rules.iter().chain(self.rules.iter())) + } else { + Box::new(self.rules.iter()) + }; + + for rule in rules { + if rule.matches(&command, shell) && !reasons.contains(&rule.reason) { + reasons.push(rule.reason.clone()); + } + } + + CommandRisk { + level: if reasons.is_empty() { + RiskLevel::Low + } else { + RiskLevel::High + }, + reasons, + } + } +} + +impl Default for CommandRiskPolicy { + fn default() -> Self { Self { - level: RiskLevel::Low, - reasons: Vec::new(), + include_defaults: true, + rules: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct CommandRiskRule { + pub match_all: Vec, + pub reason: String, + #[serde(default)] + pub shell: Option, +} + +impl CommandRiskRule { + pub fn new(match_all: Vec<&str>, reason: &str, shell: Option) -> Self { + Self { + match_all: match_all.into_iter().map(str::to_string).collect(), + reason: reason.to_string(), + shell, } } + pub fn matches(&self, command: &str, shell: CommandShell) -> bool { + if self.shell.is_some_and(|expected| expected != shell) { + return false; + } + + !self.match_all.is_empty() + && self + .match_all + .iter() + .all(|pattern| command.contains(&pattern.to_ascii_lowercase())) + } +} + +impl CommandRisk { pub fn warning(&self) -> Option { if self.reasons.is_empty() { None @@ -69,6 +157,13 @@ impl CommandRisk { } pub fn parse_command_suggestions(response: &str) -> Vec { + parse_command_suggestions_with_policy(response, &CommandRiskPolicy::default()) +} + +pub fn parse_command_suggestions_with_policy( + response: &str, + policy: &CommandRiskPolicy, +) -> Vec { let mut suggestions = Vec::new(); let mut lines = response.lines().peekable(); let mut previous_text = Vec::new(); @@ -107,7 +202,7 @@ pub fn parse_command_suggestions(response: &str) -> Vec { .rev() .find(|line| !line.contains("risk:") && !line.contains("[risk:")) .cloned(); - let detected_risk = detect_command_risk(&command, shell); + let detected_risk = detect_command_risk_with_policy(&command, shell, policy); suggestions.push(CommandSuggestion { id, @@ -124,68 +219,15 @@ pub fn parse_command_suggestions(response: &str) -> Vec { } pub fn detect_command_risk(command: &str, shell: CommandShell) -> CommandRisk { - let lowered = command.to_ascii_lowercase(); - let mut reasons = Vec::new(); + detect_command_risk_with_policy(command, shell, &CommandRiskPolicy::default()) +} - if lowered.contains("rm -rf") - || lowered.contains("rm -fr") - || lowered.contains("remove-item") - && lowered.contains("-recurse") - && lowered.contains("-force") - || lowered.contains("del /s") - { - reasons.push("recursive or forced deletion".into()); - } - if lowered.contains("format-volume") - || lowered.contains("format ") - || lowered.contains("mkfs") - || lowered.contains("diskpart") - { - reasons.push("disk formatting or partition operation".into()); - } - if lowered.contains("chmod -r") - || lowered.contains("chown -r") - || lowered.contains("icacls ") && lowered.contains("/grant") - { - reasons.push("recursive permission change".into()); - } - if lowered.contains("curl ") && lowered.contains("| sh") - || lowered.contains("wget ") && lowered.contains("| sh") - || lowered.contains("irm ") && lowered.contains("iex") - || lowered.contains("invoke-restmethod") && lowered.contains("invoke-expression") - { - reasons.push("downloaded content piped to an interpreter".into()); - } - if lowered.contains("api_key") - || lowered.contains("apikey") - || lowered.contains("password") - || lowered.contains("secret") - || lowered.contains("token") - { - reasons.push("possible credential exposure".into()); - } - if lowered.contains("apt remove") - || lowered.contains("apt purge") - || lowered.contains("dnf remove") - || lowered.contains("yum remove") - || lowered.contains("pacman -r") - || lowered.contains("uninstall-package") - { - reasons.push("package removal".into()); - } - - if shell == CommandShell::PowerShell && lowered.contains("set-executionpolicy") { - reasons.push("PowerShell execution policy change".into()); - } - - CommandRisk { - level: if reasons.is_empty() { - RiskLevel::Low - } else { - RiskLevel::High - }, - reasons, - } +pub fn detect_command_risk_with_policy( + command: &str, + shell: CommandShell, + policy: &CommandRiskPolicy, +) -> CommandRisk { + policy.evaluate(command, shell) } pub fn render_suggestions(suggestions: &[CommandSuggestion]) -> String { @@ -241,6 +283,122 @@ fn parse_risk_marker(line: &str) -> Option { } } +fn default_command_risk_rules() -> Vec { + vec![ + CommandRiskRule::new( + vec!["rm -rf"], + "recursive or forced deletion", + Some(CommandShell::Posix), + ), + CommandRiskRule::new( + vec!["rm -fr"], + "recursive or forced deletion", + Some(CommandShell::Posix), + ), + CommandRiskRule::new( + vec!["remove-item", "-recurse", "-force"], + "recursive or forced deletion", + Some(CommandShell::PowerShell), + ), + CommandRiskRule::new(vec!["del /s"], "recursive or forced deletion", None), + CommandRiskRule::new( + vec!["format-volume"], + "disk formatting or partition operation", + Some(CommandShell::PowerShell), + ), + CommandRiskRule::new( + vec!["format "], + "disk formatting or partition operation", + None, + ), + CommandRiskRule::new( + vec!["mkfs"], + "disk formatting or partition operation", + Some(CommandShell::Posix), + ), + CommandRiskRule::new( + vec!["diskpart"], + "disk formatting or partition operation", + None, + ), + CommandRiskRule::new( + vec!["chmod -r"], + "recursive permission change", + Some(CommandShell::Posix), + ), + CommandRiskRule::new( + vec!["chown -r"], + "recursive permission change", + Some(CommandShell::Posix), + ), + CommandRiskRule::new( + vec!["icacls ", "/grant"], + "recursive permission change", + Some(CommandShell::PowerShell), + ), + CommandRiskRule::new( + vec!["curl ", "| sh"], + "downloaded content piped to an interpreter", + Some(CommandShell::Posix), + ), + CommandRiskRule::new( + vec!["wget ", "| sh"], + "downloaded content piped to an interpreter", + Some(CommandShell::Posix), + ), + CommandRiskRule::new( + vec!["irm ", "iex"], + "downloaded content piped to an interpreter", + Some(CommandShell::PowerShell), + ), + CommandRiskRule::new( + vec!["invoke-restmethod", "invoke-expression"], + "downloaded content piped to an interpreter", + Some(CommandShell::PowerShell), + ), + CommandRiskRule::new(vec!["api_key"], "possible credential exposure", None), + CommandRiskRule::new(vec!["apikey"], "possible credential exposure", None), + CommandRiskRule::new(vec!["password"], "possible credential exposure", None), + CommandRiskRule::new(vec!["secret"], "possible credential exposure", None), + CommandRiskRule::new(vec!["token"], "possible credential exposure", None), + CommandRiskRule::new( + vec!["apt remove"], + "package removal", + Some(CommandShell::Posix), + ), + CommandRiskRule::new( + vec!["apt purge"], + "package removal", + Some(CommandShell::Posix), + ), + CommandRiskRule::new( + vec!["dnf remove"], + "package removal", + Some(CommandShell::Posix), + ), + CommandRiskRule::new( + vec!["yum remove"], + "package removal", + Some(CommandShell::Posix), + ), + CommandRiskRule::new( + vec!["pacman -r"], + "package removal", + Some(CommandShell::Posix), + ), + CommandRiskRule::new( + vec!["uninstall-package"], + "package removal", + Some(CommandShell::PowerShell), + ), + CommandRiskRule::new( + vec!["set-executionpolicy"], + "PowerShell execution policy change", + Some(CommandShell::PowerShell), + ), + ] +} + #[cfg(test)] mod tests { use super::*; @@ -285,4 +443,61 @@ mod tests { RiskLevel::Low ); } + + #[test] + fn custom_policy_rules_extend_defaults() { + let policy = CommandRiskPolicy { + include_defaults: true, + rules: vec![CommandRiskRule::new( + vec!["kubectl delete", "--all"], + "cluster-wide deletion", + Some(CommandShell::Posix), + )], + }; + + let risk = detect_command_risk_with_policy( + "kubectl delete pods --all", + CommandShell::Posix, + &policy, + ); + + assert_eq!(risk.level, RiskLevel::High); + assert_eq!(risk.reasons, vec!["cluster-wide deletion".to_string()]); + } + + #[test] + fn custom_policy_can_disable_defaults() { + let policy = CommandRiskPolicy { + include_defaults: false, + rules: Vec::new(), + }; + + let risk = detect_command_risk_with_policy("rm -rf build", CommandShell::Posix, &policy); + + assert_eq!(risk.level, RiskLevel::Low); + assert!(risk.reasons.is_empty()); + } + + #[test] + fn parser_uses_supplied_policy() { + let policy = CommandRiskPolicy { + include_defaults: false, + rules: vec![CommandRiskRule::new( + vec!["terraform apply"], + "infrastructure mutation", + Some(CommandShell::Posix), + )], + }; + + let suggestions = parse_command_suggestions_with_policy( + "Apply infra:\n```sh\nterraform apply\n```", + &policy, + ); + + assert_eq!(suggestions[0].detected_risk.level, RiskLevel::High); + assert_eq!( + suggestions[0].detected_risk.reasons, + vec!["infrastructure mutation".to_string()] + ); + } } diff --git a/src/config.rs b/src/config.rs index 564306f..c9bacbc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; use crate::app::CliOptions; +use crate::commands::{CommandRiskPolicy, CommandRiskRule}; use crate::context::ContextBudget; use crate::prompts::Stance; use crate::shell::ShellFamily; @@ -14,6 +15,7 @@ pub struct Config { pub provider: ProviderConfig, pub shell: ShellConfig, pub interaction: InteractionConfig, + pub commands: CommandConfig, pub transcript: TranscriptConfig, pub context: ContextConfig, } @@ -37,6 +39,11 @@ pub struct InteractionConfig { pub stance: Stance, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandConfig { + pub risk: CommandRiskPolicy, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TranscriptConfig { pub directory: PathBuf, @@ -63,6 +70,7 @@ struct RawConfig { provider: Option, shell: Option, interaction: Option, + commands: Option, transcript: Option, context: Option, } @@ -85,6 +93,17 @@ struct RawInteractionConfig { stance: Option, } +#[derive(Debug, Deserialize, Default)] +struct RawCommandConfig { + risk: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct RawCommandRiskConfig { + include_defaults: Option, + rules: Option>, +} + #[derive(Debug, Deserialize, Default)] struct RawTranscriptConfig { directory: Option, @@ -111,6 +130,7 @@ impl Config { let provider = raw.provider.unwrap_or_default(); let shell = raw.shell.unwrap_or_default(); let interaction = raw.interaction.unwrap_or_default(); + let commands = raw.commands.unwrap_or_default(); let transcript = raw.transcript.unwrap_or_default(); let context = raw.context.unwrap_or_default(); @@ -131,6 +151,7 @@ impl Config { .unwrap_or_else(|| Stance::default().to_string()) .parse::() .map_err(|error| ConfigError::Invalid(error.to_string()))?; + let risk = command_risk_policy(commands.risk)?; Ok(Self { provider: ProviderConfig { @@ -142,6 +163,7 @@ impl Config { }, shell: ShellConfig { family }, interaction: InteractionConfig { stance }, + commands: CommandConfig { risk }, transcript: TranscriptConfig { directory: transcript.directory.unwrap_or_else(default_transcript_dir), enabled: transcript.enabled.unwrap_or(true), @@ -174,6 +196,42 @@ impl Config { } } +fn command_risk_policy( + raw: Option, +) -> Result { + let Some(raw) = raw else { + return Ok(CommandRiskPolicy::default()); + }; + + let rules = raw.rules.unwrap_or_default(); + for rule in &rules { + if rule.match_all.is_empty() { + return Err(ConfigError::Invalid( + "commands.risk.rules entries require at least one match_all pattern".into(), + )); + } + if rule + .match_all + .iter() + .any(|pattern| pattern.trim().is_empty()) + { + return Err(ConfigError::Invalid( + "commands.risk.rules match_all patterns cannot be empty".into(), + )); + } + if rule.reason.trim().is_empty() { + return Err(ConfigError::Invalid( + "commands.risk.rules reason cannot be empty".into(), + )); + } + } + + Ok(CommandRiskPolicy { + include_defaults: raw.include_defaults.unwrap_or(true), + rules, + }) +} + impl RawConfig { fn from_path(path: &Path) -> Result { let contents = fs::read_to_string(path).map_err(|error| ConfigError::Read { @@ -328,6 +386,7 @@ mod tests { family: Some("cmd".into()), }), interaction: None, + commands: None, transcript: None, context: None, }) @@ -353,6 +412,14 @@ family = "posix" [interaction] stance = "audit" +[commands.risk] +include_defaults = true + +[[commands.risk.rules]] +match_all = ["kubectl delete", "--all"] +reason = "cluster-wide deletion" +shell = "posix" + [transcript] enabled = false @@ -371,6 +438,12 @@ max_estimated_tokens = 3000 assert_eq!(config.provider.request_timeout_seconds, 45); assert_eq!(config.shell.family, ShellFamily::Posix); assert_eq!(config.interaction.stance, Stance::Audit); + assert!(config.commands.risk.include_defaults); + assert_eq!(config.commands.risk.rules.len(), 1); + assert_eq!( + config.commands.risk.rules[0].reason, + "cluster-wide deletion" + ); assert!(!config.transcript.enabled); assert_eq!(config.context.max_characters, Some(12000)); assert_eq!(config.context.max_estimated_tokens, Some(3000)); @@ -395,6 +468,29 @@ max_estimated_tokens = 3000 assert_eq!(config.context.max_characters, None); assert_eq!(config.context.max_estimated_tokens, None); assert_eq!(config.provider.request_timeout_seconds, 120); + assert!(config.commands.risk.include_defaults); + assert!(config.commands.risk.rules.is_empty()); + } + + #[test] + fn command_risk_defaults_can_be_disabled() { + let mut file = tempfile::NamedTempFile::new().expect("temp config"); + write!( + file, + r#" +[provider] +base_url = "http://localhost:11434/v1" + +[commands.risk] +include_defaults = false +"# + ) + .expect("write config"); + + let config = Config::load(Some(file.path())).expect("config loads"); + + assert!(!config.commands.risk.include_defaults); + assert!(config.commands.risk.rules.is_empty()); } #[test] diff --git a/src/formatting.rs b/src/formatting.rs index 1a1658f..e13a5a1 100644 --- a/src/formatting.rs +++ b/src/formatting.rs @@ -1,6 +1,12 @@ -use crate::commands::{parse_command_suggestions, render_suggestions}; +use crate::commands::{ + CommandRiskPolicy, parse_command_suggestions_with_policy, render_suggestions, +}; pub fn render_assistant_output(response: &str) -> String { + render_assistant_output_with_policy(response, &CommandRiskPolicy::default()) +} + +pub fn render_assistant_output_with_policy(response: &str, policy: &CommandRiskPolicy) -> String { let mut rendered = String::new(); let mut in_command_block = false; @@ -27,7 +33,7 @@ pub fn render_assistant_output(response: &str) -> String { rendered.push('\n'); } - let suggestions = parse_command_suggestions(response); + let suggestions = parse_command_suggestions_with_policy(response, policy); if !suggestions.is_empty() { rendered.push_str(&render_suggestions(&suggestions)); rendered.push('\n'); diff --git a/src/repl.rs b/src/repl.rs index 5a7817b..3757e47 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -1,7 +1,6 @@ use std::io::{self, Write}; use crate::app::{App, AppError}; -use crate::formatting::render_assistant_output; pub struct Repl { app: App, @@ -75,7 +74,7 @@ impl Repl { println!("waiting for provider response..."); match self.app.send(input).await { - Ok(response) => println!("\n{}\n", render_assistant_output(&response)), + Ok(response) => println!("\n{}\n", self.app.render_assistant_output(&response)), Err(error) => eprintln!("request failed: {error}"), } }