Updates for command shell

This commit is contained in:
K. Hodges 2026-06-08 00:52:58 -07:00
parent 40216fb635
commit ba19b6bcff
9 changed files with 637 additions and 344 deletions

460
Cargo.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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.

View File

@ -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:

View File

@ -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<Vec<ChatMessage>, 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,

View File

@ -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<Self, Self::Err> {
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<String>,
}
impl CommandRisk {
pub fn none() -> Self {
Self {
level: RiskLevel::Low,
reasons: Vec::new(),
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandRiskPolicy {
pub include_defaults: bool,
pub rules: Vec<CommandRiskRule>,
}
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<dyn Iterator<Item = &CommandRiskRule> + '_> = 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 {
include_defaults: true,
rules: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CommandRiskRule {
pub match_all: Vec<String>,
pub reason: String,
#[serde(default)]
pub shell: Option<CommandShell>,
}
impl CommandRiskRule {
pub fn new(match_all: Vec<&str>, reason: &str, shell: Option<CommandShell>) -> 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<String> {
if self.reasons.is_empty() {
None
@ -69,6 +157,13 @@ impl CommandRisk {
}
pub fn parse_command_suggestions(response: &str) -> Vec<CommandSuggestion> {
parse_command_suggestions_with_policy(response, &CommandRiskPolicy::default())
}
pub fn parse_command_suggestions_with_policy(
response: &str,
policy: &CommandRiskPolicy,
) -> Vec<CommandSuggestion> {
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<CommandSuggestion> {
.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<CommandSuggestion> {
}
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<RiskLevel> {
}
}
fn default_command_risk_rules() -> Vec<CommandRiskRule> {
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()]
);
}
}

View File

@ -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<RawProviderConfig>,
shell: Option<RawShellConfig>,
interaction: Option<RawInteractionConfig>,
commands: Option<RawCommandConfig>,
transcript: Option<RawTranscriptConfig>,
context: Option<RawContextConfig>,
}
@ -85,6 +93,17 @@ struct RawInteractionConfig {
stance: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct RawCommandConfig {
risk: Option<RawCommandRiskConfig>,
}
#[derive(Debug, Deserialize, Default)]
struct RawCommandRiskConfig {
include_defaults: Option<bool>,
rules: Option<Vec<CommandRiskRule>>,
}
#[derive(Debug, Deserialize, Default)]
struct RawTranscriptConfig {
directory: Option<PathBuf>,
@ -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::<Stance>()
.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<RawCommandRiskConfig>,
) -> Result<CommandRiskPolicy, ConfigError> {
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<Self, ConfigError> {
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]

View File

@ -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');

View File

@ -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}"),
}
}