diff --git a/CHANGELOG.md b/CHANGELOG.md index ae21d09..8bb9019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0] - 2026-04-17 + +Security audit + UX pass. Breaking in a few narrow places but safer everywhere. + +### Security + +- **Risk gate rewritten** with segment-aware parsing so command substitutions (`$(rm ...)`, `<(rm ...)`, `>(rm ...)`, backticks) and process substitution now classify correctly instead of silently bypassing as Safe. +- **Whitespace obfuscation blocked** — `rm` hidden by tabs, `$IFS`, `${IFS}` is caught. +- **Interpreter guard** — `python -c`, `perl -e`, `node -e`, `ruby -e`, `awk 'BEGIN{...'` surface Warn so obfuscated destructive calls don't land as Safe. +- **Decoded shell pipes blocked** — `base64 -d | sh`, `xxd -r | bash`, `openssl enc -d | sh` now Block. +- **More destructive primitives blocked** — `truncate`, `: > file`, `true > file`, `cp /dev/null target`, `git reset --hard`, `git clean -fd`, `git push --force`. +- **`rm` false-positive fixed** — v0.3 blocked anything containing the token `rm` (including `grep rm logfile`). v0.4 only blocks when `rm` is a command-position first token of a segment. +- **Content filter expanded** — fine-grained GitHub PATs (`ghu_`, `ghs_`, `ghr_`, `github_pat_`), Stripe, SendGrid, HuggingFace, JWT, Google service-account JSON, Azure storage, and any URL with embedded credentials (`scheme://user:pass@host`). +- **Codex tempfile hardened** — replaced predictable `/tmp/ait-codex-.txt` with `tempfile::NamedTempFile` (O_EXCL, mode 0600, auto-cleanup on Drop). Closes a symlink/TOCTOU vector. +- **Codex prompt via stdin** — was visible in `ps` / `/proc//cmdline`; now piped via stdin. +- **HTTP response size cap (1 MiB)** — OpenRouter and direct Anthropic API responses are Content-Length-checked and capped during read. +- **ANSI escape sanitization** — model output and backend error text is stripped of ESC sequences (CSI / OSC) before display, closing OSC-52 clipboard-injection. +- **`hey doctor` no longer leaks key body** — shows only the public prefix (`sk-or-v1-****`). +- 19 unit tests added for the risk gate. + +### UX + +- **`hey doctor`** — new diagnostic subcommand. Shows detected backends, preset tools, shell/TTY state, env-var config, and the active auto chain. +- **`hey init `** — emits a shell completion script (bash / zsh / fish / powershell / elvish). +- **Stdin prompt support** — `echo "list docker containers" | hey --yes`. Requires `--yes` or `--dry-run` since confirm can't read. +- **EOF = abort** — pressing Ctrl-D (or piping `hey foo < /dev/null`) now aborts with `aborted (no input)` instead of silently running. +- **Warn requires explicit `y`** — for `Risk::Warn` commands the default is capital `N`; blank Enter aborts. +- **Stdout-not-TTY refusal** — `hey foo | head` used to hang; now errors with a suggestion to pass `--yes` or `--dry-run`. +- **Edit → copy & edit in shell** — `e` at the confirm prompt copies the command to your clipboard, reports whether the copy succeeded, and aborts. Paste in your shell for readline editing. +- **Richer help text and error messages** — `--help` documents subcommand-style backend selection; `OPENROUTER_API_KEY not set` points at `openrouter.ai/keys`; Claude `not authenticated` surfaces `claude login`. +- **Narrow-terminal-safe thinking animation** — uses `\x1b[J` so wrapped lines don't leave cruft on <70-col terminals. + +### Breaking changes + +- `or` shorthand for `openrouter` removed — too ambiguous with the English preposition. +- Backend subcommand matching is now case-sensitive lowercase — `hey Claude is fast` is a prompt. +- `Risk::Warn` no longer defaults to Yes on blank Enter. +- EOF on the confirm prompt aborts instead of running. +- `hey foo | head` refuses without `--yes` / `--dry-run`. + ## [0.2.2] - 2026-04-16 ### Changed @@ -60,6 +100,7 @@ Initial release. - Fenced-code-block sanitizer so model responses with triple-backtick wrappers are parsed correctly - Strict prose detection — bail out if the backend returns non-command text +[0.4.0]: https://github.com/subinium/hey-cli/releases/tag/v0.4.0 [0.2.2]: https://github.com/subinium/hey-cli/releases/tag/v0.2.2 [0.2.1]: https://github.com/subinium/hey-cli/releases/tag/v0.2.1 [0.2.0]: https://github.com/subinium/hey-cli/releases/tag/v0.2.0 diff --git a/Cargo.lock b/Cargo.lock index eb3b712..6bf6ffc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,14 +4,16 @@ version = 4 [[package]] name = "ai-in-terminal" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "clap", + "clap_complete", "ctrlc", "reqwest", "serde", "serde_json", + "tempfile", "tokio", ] @@ -154,6 +156,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff7a1dccbdd8b078c2bdebff47e404615151534d5043da397ec50286816f9cb" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.0" @@ -222,6 +233,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -550,6 +567,12 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -820,6 +843,19 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.38" @@ -1009,6 +1045,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" diff --git a/Cargo.toml b/Cargo.toml index 6794bab..a5ac381 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ai-in-terminal" -version = "0.3.0" +version = "0.4.0" edition = "2021" description = "hey — natural language to shell commands, with personality. Speaks Claude, Codex, and OpenRouter." license = "MIT" @@ -17,12 +17,14 @@ path = "src/main.rs" [dependencies] clap = { version = "4.5", features = ["derive", "env"] } +clap_complete = "4" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt", "macros", "io-util", "process"] } anyhow = "1" ctrlc = "3" +tempfile = "3" [profile.release] lto = "thin" diff --git a/src/backend/claude.rs b/src/backend/claude.rs index bbb5174..3aba7b1 100644 --- a/src/backend/claude.rs +++ b/src/backend/claude.rs @@ -1,7 +1,7 @@ use anyhow::{anyhow, Context, Result}; use std::env; use std::io::Write; -use std::process::{Command, Stdio}; +use std::process::{Command, Output, Stdio}; use crate::prompt::SYSTEM_PROMPT; use crate::sanitize::sanitize_command; @@ -10,6 +10,7 @@ use crate::sanitize::sanitize_command; const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages"; const ANTHROPIC_VERSION: &str = "2023-06-01"; const ANTHROPIC_MODEL: &str = "claude-haiku-4-5-20251001"; +const MAX_RESPONSE_BYTES: u64 = 1_048_576; // 1 MiB cap on API response /// Claude backend: prefer direct Anthropic API (~2s) when ANTHROPIC_API_KEY is /// available, fall back to `claude -p` subprocess (~6s) when it's not. @@ -42,13 +43,36 @@ async fn ask_anthropic_direct(api_key: &str, user_prompt: &str) -> Result MAX_RESPONSE_BYTES { + return Err(anyhow!( + "Anthropic response too large ({len} bytes, cap {MAX_RESPONSE_BYTES})" + )); + } + } + let bytes = resp.bytes().await.context("read Anthropic response")?; + if bytes.len() as u64 > MAX_RESPONSE_BYTES { + return Err(anyhow!( + "Anthropic response exceeded {MAX_RESPONSE_BYTES} bytes" + )); + } + + if !status.is_success() { + let text = String::from_utf8_lossy(&bytes); + let safe = crate::sanitize::strip_ansi(&text); + // 401 / 403 are almost always an invalid or expired API key. + if status.as_u16() == 401 || status.as_u16() == 403 { + return Err(anyhow!( + "Anthropic API rejected the key ({status}). Check ANTHROPIC_API_KEY, or unset it to use `claude login` subprocess mode.\n{}", + safe.trim() + )); + } + return Err(anyhow!("Anthropic API error {status}: {safe}")); } - let parsed: serde_json::Value = resp.json().await.context("invalid Anthropic response")?; + let parsed: serde_json::Value = + serde_json::from_slice(&bytes).context("invalid Anthropic response")?; let text = parsed["content"] .as_array() .and_then(|arr| arr.iter().find(|b| b["type"] == "text")) @@ -82,5 +106,35 @@ fn ask_claude_code(user_prompt: &str) -> Result { let output = child .wait_with_output() .context("failed to wait on claude")?; - super::post_cli_backend_output("claude", output) + + if !output.status.success() { + return Err(classify_claude_error(&output)); + } + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + Ok(sanitize_command(&stdout)) +} + +/// Parse `claude -p` stderr/stdout for common failure modes and return a +/// friendlier error. Falls back to the generic exit-status message. +fn classify_claude_error(output: &Output) -> anyhow::Error { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let combined = format!("{stderr}\n{stdout}").to_lowercase(); + + if combined.contains("not authenticated") + || combined.contains("please login") + || combined.contains("please log in") + || combined.contains("no api key") + || combined.contains("anthropic api key") + || combined.contains("unauthorized") + { + return anyhow!( + "claude is not authenticated — run `claude login`, or set ANTHROPIC_API_KEY to use the direct API" + ); + } + if combined.contains("rate limit") || combined.contains("quota") { + return anyhow!("claude is rate-limited — try `hey codex ...` or `hey openrouter ...`"); + } + + anyhow!("claude exited with {}: {}", output.status, stderr.trim()) } diff --git a/src/backend/codex.rs b/src/backend/codex.rs index 0c4c220..89123b0 100644 --- a/src/backend/codex.rs +++ b/src/backend/codex.rs @@ -1,29 +1,53 @@ use anyhow::{anyhow, Context, Result}; -use std::env; +use std::io::Write as _; use std::process::{Command, Stdio}; +use tempfile::NamedTempFile; use crate::prompt::SYSTEM_PROMPT; use crate::sanitize::sanitize_command; +// Cap on the output file size so a rogue codex run can't OOM us. +const MAX_OUTPUT_BYTES: u64 = 1_048_576; // 1 MiB + pub(crate) fn ask_codex(user_prompt: &str) -> Result { - let tmp = env::temp_dir().join(format!("ait-codex-{}.txt", std::process::id())); + // Use `tempfile::NamedTempFile` so the output file is created with + // O_CREAT|O_EXCL and mode 0600 — prevents symlink pre-creation attacks + // and makes the file unreadable to other local users. The file is auto- + // deleted when the `NamedTempFile` guard is dropped (including on panic + // and normal returns), but we drop cleanup is best-effort on SIGKILL. + let tmp = NamedTempFile::new().context("failed to create secure temp file for codex output")?; + let full_prompt = format!("{SYSTEM_PROMPT}\n\n---\n\n{user_prompt}"); - let output = Command::new("codex") + + // Pass the prompt via stdin ("-" tells codex exec to read PROMPT from stdin). + // This keeps the prompt text out of /proc//cmdline and `ps` output. + let mut child = Command::new("codex") .arg("exec") .arg("--skip-git-repo-check") .arg("-o") - .arg(&tmp) - .arg(&full_prompt) - .stdin(Stdio::null()) + .arg(tmp.path()) + .arg("-") + .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .output() + .spawn() .context("failed to spawn `codex` (is Codex CLI installed?)")?; + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(full_prompt.as_bytes()) + .context("failed to write prompt to codex stdin")?; + } + + let output = child + .wait_with_output() + .context("failed to wait on codex")?; + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - let content = std::fs::read_to_string(&tmp).unwrap_or_default(); - let _ = std::fs::remove_file(&tmp); + + // Bounded read from the output file. + let content = read_bounded(tmp.path(), MAX_OUTPUT_BYTES); let haystack = format!("{}\n{}\n{}", stderr, stdout, content).to_lowercase(); if haystack.contains("usage limit") @@ -35,7 +59,7 @@ pub(crate) fn ask_codex(user_prompt: &str) -> Result { "codex is rate-limited — try `hey claude ...` or `hey openrouter ...` instead" )); } - if !haystack.is_empty() && haystack.contains("not authenticated") { + if haystack.contains("not authenticated") || haystack.contains("not logged in") { return Err(anyhow!("codex not authenticated — run `codex login` first")); } @@ -59,3 +83,16 @@ pub(crate) fn ask_codex(user_prompt: &str) -> Result { Ok(sanitize_command(&content)) } + +fn read_bounded(path: &std::path::Path, cap: u64) -> String { + use std::io::Read as _; + match std::fs::File::open(path) { + Ok(f) => { + let mut reader = f.take(cap); + let mut buf = String::new(); + let _ = reader.read_to_string(&mut buf); + buf + } + Err(_) => String::new(), + } +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs index c9ba549..a6b16de 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -124,6 +124,7 @@ pub(crate) fn resolve_auto_chain() -> Result> { Ok(chain) } +#[allow(dead_code)] pub(crate) fn post_cli_backend_output(name: &str, output: std::process::Output) -> Result { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/src/backend/openrouter.rs b/src/backend/openrouter.rs index 7a1a340..757c38f 100644 --- a/src/backend/openrouter.rs +++ b/src/backend/openrouter.rs @@ -6,6 +6,9 @@ use crate::sanitize::sanitize_command; pub(crate) const DEFAULT_MODEL: &str = "anthropic/claude-haiku-4.5"; const API_URL: &str = "https://openrouter.ai/api/v1/chat/completions"; +// Cap on the response body so a compromised or rogue endpoint can't exhaust +// memory. 1 MiB is ~4000× the size of a typical 256-token completion. +const MAX_RESPONSE_BYTES: u64 = 1_048_576; #[derive(Serialize)] struct ChatRequest<'a> { @@ -70,13 +73,32 @@ pub(crate) async fn ask_openrouter( .await .context("failed to reach OpenRouter")?; - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - return Err(anyhow!("API error {status}: {text}")); + let status = resp.status(); + // Refuse oversized bodies up front via Content-Length (best-effort). + if let Some(len) = resp.content_length() { + if len > MAX_RESPONSE_BYTES { + return Err(anyhow!( + "OpenRouter response too large ({len} bytes, cap {MAX_RESPONSE_BYTES})" + )); + } } - let parsed: ChatResponse = resp.json().await.context("invalid API response")?; + let bytes = resp.bytes().await.context("read response body")?; + if bytes.len() as u64 > MAX_RESPONSE_BYTES { + return Err(anyhow!( + "OpenRouter response exceeded {MAX_RESPONSE_BYTES} bytes" + )); + } + + if !status.is_success() { + let text = String::from_utf8_lossy(&bytes); + return Err(anyhow!( + "API error {status}: {}", + crate::sanitize::strip_ansi(&text) + )); + } + + let parsed: ChatResponse = serde_json::from_slice(&bytes).context("invalid API response")?; let text = parsed .choices diff --git a/src/cli.rs b/src/cli.rs index c584187..71ea2e0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,11 +1,32 @@ use clap::{Parser, ValueEnum}; #[derive(Parser, Debug)] -#[command(name = "hey", version, about = "hey — natural language → shell command", long_about = None)] +#[command( + name = "hey", + version, + about = "natural language to shell commands, with personality", + long_about = "natural-language to shell commands, with personality.\n\n\ + Your request goes to the first available backend (claude \u{2192} codex \u{2192} openrouter) \ + and comes back as a shell command ready to run. No quotes needed — just type what you want.\n\n\ + The first word may be a backend selector (`claude`, `codex`, `openrouter`, `auto`) or a \ + subcommand (`doctor`, `init `). Otherwise everything is the prompt.", + after_help = "Examples:\n \ + hey find files bigger than 100mb\n \ + hey claude explain this regex '^[a-z]+$'\n \ + hey codex list the 3 largest pdfs in ~/Downloads\n \ + hey openrouter show git commits from last week\n \ + echo 'list my docker containers' | hey --yes\n \ + hey --dry-run tar the src folder\n \ + hey doctor # print environment diagnostics\n \ + hey init zsh > ~/.zsh/completions/_hey" +)] pub(crate) struct Cli { - /// Natural-language request. Prefix with `claude`, `codex`, or `openrouter` to pick a backend. - /// e.g. `hey claude list big files`, `hey find files newer than a week` - #[arg(trailing_var_arg = true, required = true)] + /// Natural-language request, or a subcommand (`doctor`, `init `). + /// + /// The first word may optionally be `claude`, `codex`, `openrouter`, or `auto` + /// to pick a backend — e.g. `hey claude list big files`. Otherwise the whole + /// argument list is treated as the prompt: `hey find files newer than a week`. + #[arg(trailing_var_arg = true, required = false)] pub prompt: Vec, /// Skip confirmation and run immediately @@ -16,7 +37,7 @@ pub(crate) struct Cli { #[arg(short = 'n', long = "dry-run")] pub dry_run: bool, - /// Also ask Claude to explain the command + /// Also ask the backend to explain the command #[arg(short = 'e', long = "explain")] pub explain: bool, diff --git a/src/completions.rs b/src/completions.rs new file mode 100644 index 0000000..e5bd78d --- /dev/null +++ b/src/completions.rs @@ -0,0 +1,33 @@ +//! `hey init ` — print a shell-completion script to stdout so the user +//! can redirect it into their completion path. Supports bash, zsh, fish, +//! elvish, and PowerShell via `clap_complete`. + +use anyhow::{anyhow, Result}; +use clap::CommandFactory; +use clap_complete::{generate, Shell}; + +use crate::cli::Cli; + +pub(crate) fn run(shell_name: Option<&str>) -> Result<()> { + let name = shell_name.ok_or_else(|| { + anyhow!("missing shell name — try `hey init zsh`, `hey init bash`, or `hey init fish`") + })?; + let shell = parse_shell(name)?; + let mut cmd = Cli::command(); + let bin_name = cmd.get_name().to_string(); + generate(shell, &mut cmd, bin_name, &mut std::io::stdout()); + Ok(()) +} + +fn parse_shell(name: &str) -> Result { + match name.to_lowercase().as_str() { + "bash" => Ok(Shell::Bash), + "zsh" => Ok(Shell::Zsh), + "fish" => Ok(Shell::Fish), + "elvish" => Ok(Shell::Elvish), + "powershell" | "pwsh" => Ok(Shell::PowerShell), + other => Err(anyhow!( + "unsupported shell: `{other}` — try one of: bash, zsh, fish, elvish, powershell" + )), + } +} diff --git a/src/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..aeee32e --- /dev/null +++ b/src/doctor.rs @@ -0,0 +1,207 @@ +//! `hey doctor` — environment diagnostics. Prints a colored report of detected +//! backends, pretty-preset tools, shell / TTY state, and config env-vars. +//! +//! No network calls. Pure file-system and env-var reads. + +use std::env; +use std::io::IsTerminal; +use std::path::PathBuf; + +use crate::backend::{backend_persona, resolve_auto_chain}; +use crate::cli::Backend; +use crate::style::*; + +/// Runs the doctor report and returns Ok(()). Exits with 0 on success — the +/// report itself never fails, missing tools are just reported as missing. +pub(crate) fn run() -> anyhow::Result<()> { + println!(); + println!(" {BOLD_WHITE_BG}hey doctor{RESET}"); + println!(); + + // Backends + println!(" {BOLD_WHITE}backends:{RESET}"); + print_backend_line(Backend::Claude, find_on_path("claude"), claude_auth_hint()); + print_backend_line(Backend::Codex, find_on_path("codex"), codex_auth_hint()); + print_openrouter_line(); + println!(); + + // Tools for pretty presets + println!(" {BOLD_WHITE}tools for pretty presets:{RESET}"); + print_tool_line("eza", "brew install eza"); + print_tool_line("bat", "brew install bat"); + print_tool_line("jq", "brew install jq"); + print_tool_line("delta", "brew install git-delta"); + print_tool_line("fd", "brew install fd"); + println!(); + + // Shell / TTY + println!(" {BOLD_WHITE}shell:{RESET}"); + let shell = env::var("SHELL").unwrap_or_else(|_| "(unset)".into()); + let term = env::var("TERM").unwrap_or_else(|_| "(unset)".into()); + let stdin_tty = std::io::stdin().is_terminal(); + let stdout_tty = std::io::stdout().is_terminal(); + let stderr_tty = std::io::stderr().is_terminal(); + println!(" SHELL={shell}"); + println!( + " TERM={term} {DIM}(is_stdin_tty: {}, is_stdout_tty: {}, is_stderr_tty: {}){RESET}", + yes_no(stdin_tty), + yes_no(stdout_tty), + yes_no(stderr_tty), + ); + println!(); + + // Config + println!(" {BOLD_WHITE}config:{RESET}"); + print_env_line("AIT_BACKEND", "auto"); + print_env_line("AIT_MODEL", crate::backend::openrouter::DEFAULT_MODEL); + match env::var("ANTHROPIC_API_KEY") { + Ok(v) if !v.is_empty() => { + println!( + " ANTHROPIC_API_KEY={DIM}set ({}){RESET} {DIM_GRAY}— claude uses direct API{RESET}", + mask_key(&v) + ); + } + _ => { + println!( + " ANTHROPIC_API_KEY={DIM}(unset){RESET} {DIM_GRAY}— claude uses subprocess path{RESET}" + ); + } + } + println!(); + + // Active chain + match resolve_auto_chain() { + Ok(chain) => { + let parts: Vec = chain + .iter() + .map(|&b| { + let (_, color, name, _) = backend_persona(b); + format!("{color}{name}{RESET}") + }) + .collect(); + println!( + " {BOLD_WHITE}active chain{RESET} {DIM}(auto){RESET}: {}", + parts.join(&format!(" {GRAY}\u{2192}{RESET} ")) + ); + } + Err(e) => { + println!(" {BOLD_RED}active chain:{RESET} {DIM_ITALIC}{e}{RESET}"); + } + } + println!(); + Ok(()) +} + +fn find_on_path(bin: &str) -> Option { + let path = env::var("PATH").ok()?; + for dir in path.split(':') { + let p = std::path::Path::new(dir).join(bin); + if p.is_file() { + return Some(p); + } + } + None +} + +fn print_backend_line(backend: Backend, path: Option, hint: String) { + let (icon, color, name, _) = backend_persona(backend); + match path { + Some(p) => { + println!( + " {color}{icon}{RESET} {BOLD_WHITE}{name:<10}{RESET} {DIM}{}{RESET}{hint}", + p.display() + ); + } + None => { + println!( + " {DIM}{icon}{RESET} {DIM}{name:<10}{RESET} {DIM_ITALIC}(not found){RESET}{hint}" + ); + } + } +} + +fn print_openrouter_line() { + let (icon, color, name, _) = backend_persona(Backend::Openrouter); + match env::var("OPENROUTER_API_KEY") { + Ok(v) if !v.is_empty() => { + println!( + " {color}{icon}{RESET} {BOLD_WHITE}{name:<10}{RESET} OPENROUTER_API_KEY {DIM}set ({}){RESET}", + mask_key(&v) + ); + } + _ => { + println!( + " {DIM}{icon}{RESET} {DIM}{name:<10}{RESET} {DIM_ITALIC}OPENROUTER_API_KEY not set{RESET}" + ); + } + } +} + +fn claude_auth_hint() -> String { + if env::var("ANTHROPIC_API_KEY") + .ok() + .filter(|v| !v.is_empty()) + .is_some() + { + format!(" {DIM_GRAY}(ANTHROPIC_API_KEY set — direct API){RESET}") + } else if find_on_path("claude").is_some() { + format!(" {DIM_GRAY}(subprocess — run `claude login` if calls fail){RESET}") + } else { + String::new() + } +} + +fn codex_auth_hint() -> String { + if find_on_path("codex").is_some() { + format!(" {DIM_GRAY}(run `codex login` if calls fail){RESET}") + } else { + String::new() + } +} + +fn print_tool_line(bin: &str, install_hint: &str) { + match find_on_path(bin) { + Some(p) => { + println!( + " {BOLD_GREEN}\u{2713}{RESET} {BOLD_WHITE}{bin:<6}{RESET} {DIM}{}{RESET}", + p.display() + ); + } + None => { + println!( + " {DIM}\u{2715}{RESET} {DIM}{bin:<6}{RESET} {DIM_ITALIC}(not found \u{2014} install with `{install_hint}`){RESET}" + ); + } + } +} + +fn print_env_line(name: &str, default: &str) { + match env::var(name) { + Ok(v) if !v.is_empty() => { + println!(" {name}={BOLD_WHITE}{v}{RESET}"); + } + _ => { + println!(" {name}={DIM}(unset, {default}){RESET}"); + } + } +} + +fn mask_key(key: &str) -> String { + // Show only the publicly-documented prefix (up to the "v1-" part) so no + // entropy of the actual key body leaks into the diagnostic output. + let public_prefixes = ["sk-or-v1-", "sk-or-", "sk-ant-", "sk-proj-", "sk-"]; + for p in public_prefixes { + if key.starts_with(p) { + return format!("{p}****"); + } + } + "****".to_string() +} + +fn yes_no(b: bool) -> &'static str { + if b { + "yes" + } else { + "no" + } +} diff --git a/src/main.rs b/src/main.rs index 60aa5c4..39f9cad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,7 @@ mod backend; mod cli; +mod completions; +mod doctor; mod preset; mod prompt; mod risk; @@ -10,12 +12,13 @@ mod ui; use anyhow::{anyhow, Context, Result}; use clap::Parser; use std::env; +use std::io::{self, IsTerminal, Read}; use std::process::{Command, Stdio}; use backend::claude::ask_claude; use backend::codex::ask_codex; use backend::openrouter::{ask_openrouter, DEFAULT_MODEL}; -use backend::{backend_persona, resolve_auto_chain}; +use backend::{backend_persona, resolve_auto_chain, which_bin}; use cli::{Backend, Cli}; use preset::apply_presets; use prompt::build_user_prompt; @@ -49,23 +52,75 @@ fn ctrlc_show_cursor() { async fn run() -> Result<()> { let cli = Cli::parse(); - // Subcommand-style backend selection: `hey claude ...`, `hey codex ...`, `hey openrouter ...` + // Subcommand-style dispatch on the first positional word. + // Supported: `doctor`, `init ` — these must be exact lowercase and + // are checked BEFORE backend parsing so `hey doctor` / `hey init zsh` work + // without any flags. + if let Some(first) = cli.prompt.first() { + match first.as_str() { + "doctor" => return doctor::run(), + "init" => { + let shell = cli.prompt.get(1).map(|s| s.as_str()); + return completions::run(shell); + } + _ => {} + } + } + + // Subcommand-style backend selection: `hey claude ...`, `hey codex ...`, `hey openrouter ...`. + // Case-sensitive so `hey Claude is fast` doesn't consume `Claude`. Also drop + // the `or` alias — too ambiguous with the English preposition. let mut prompt_words = cli.prompt.clone(); - let inline_backend = match prompt_words.first().map(|s| s.to_lowercase()) { - Some(ref w) if w == "claude" => Some(Backend::Claude), - Some(ref w) if w == "codex" => Some(Backend::Codex), - Some(ref w) if w == "openrouter" || w == "or" => Some(Backend::Openrouter), - Some(ref w) if w == "auto" => Some(Backend::Auto), + let inline_backend = match prompt_words.first().map(String::as_str) { + Some("claude") => Some(Backend::Claude), + Some("codex") => Some(Backend::Codex), + Some("openrouter") => Some(Backend::Openrouter), + Some("auto") => Some(Backend::Auto), _ => None, }; if inline_backend.is_some() { prompt_words.remove(0); } - let request = prompt_words.join(" "); + // Resolve the prompt text. Precedence: + // 1. If we have positional words after backend stripping, use them. + // 2. Else, if stdin is a pipe (not a TTY), read the prompt from stdin. + // 3. Else, error with a helpful message. + // When stdin was consumed for the prompt, confirm() would block on nothing + // readable — require --yes or --dry-run in that case. + let stdin_was_piped = !io::stdin().is_terminal(); + let have_positional = !prompt_words.is_empty(); + let request = if have_positional { + prompt_words.join(" ") + } else if stdin_was_piped { + let mut buf = String::new(); + io::stdin() + .read_to_string(&mut buf) + .context("failed to read prompt from stdin")?; + buf.trim().to_string() + } else { + String::new() + }; + if request.trim().is_empty() { return Err(anyhow!( - "empty prompt — try `hey find files bigger than 100mb` or `hey claude explain this regex`" + "empty prompt — try `hey find files bigger than 100mb`, `hey claude explain this regex`, or pipe: `echo 'list docker containers' | hey --yes`" + )); + } + + // If the prompt came from stdin, confirm() can't read a y/N answer — require + // an explicit non-interactive mode. + if stdin_was_piped && !have_positional && !cli.yes && !cli.dry_run { + return Err(anyhow!( + "prompt was read from stdin, but stdin is needed for confirmation — pass --yes to auto-run or --dry-run to just print" + )); + } + + // Refuse to run when we have nowhere sensible to print the command. Skipping + // this silently caused hangs on `hey foo | head`. + if !io::stdout().is_terminal() && !cli.yes && !cli.dry_run { + return Err(anyhow!( + "stdout is not a terminal — pass --yes to auto-run or --dry-run to just print" )); } @@ -99,7 +154,7 @@ async fn run() -> Result<()> { // Build the chain: explicit backend = single entry, auto = full available chain. let chain: Vec = match explicit { Some(b) => vec![b], - None => resolve_auto_chain()?, + None => resolve_auto_chain().map_err(|_| no_backend_error())?, }; let user_prompt = build_user_prompt(&request, cli.explain); @@ -119,8 +174,11 @@ async fn run() -> Result<()> { let attempt = match candidate { Backend::Auto => unreachable!("auto resolved above"), Backend::Openrouter => { - let api_key = env::var("OPENROUTER_API_KEY") - .context("OPENROUTER_API_KEY not set. Export it in your shell rc."); + let api_key = env::var("OPENROUTER_API_KEY").map_err(|_| { + anyhow!( + "OPENROUTER_API_KEY not set. Get one at https://openrouter.ai/keys, then: export OPENROUTER_API_KEY=sk-or-v1-..." + ) + }); match api_key { Ok(key) => ask_openrouter(&key, &model, &user_prompt).await, Err(e) => Err(e), @@ -165,9 +223,9 @@ async fn run() -> Result<()> { } let first_line = command.lines().next().unwrap_or(""); if !looks_like_command(first_line) { + let preview = truncate_prose(&command, 200); return Err(anyhow!( - "backend returned prose instead of a command:\n\n{}\n\nTry a different backend with `hey claude ...` / `hey codex ...` / `hey openrouter ...`.", - command + "backend returned prose instead of a command:\n\n{preview}\n\nTry a different backend with `hey claude ...` / `hey codex ...` / `hey openrouter ...`." )); } if !cli.raw { @@ -191,9 +249,19 @@ async fn run() -> Result<()> { return Ok(()); } if blocked { - // Destructive commands: print + copy to clipboard (macOS), never auto-execute. - copy_to_clipboard(&command); - println!(" {DIM_GRAY}copied to clipboard · paste & run manually{RESET}"); + // Destructive commands: print + copy to clipboard, never auto-execute. + // Report accurately whether the clipboard copy succeeded. + if copy_to_clipboard(&command) { + println!(" {DIM_GRAY}copied to clipboard · paste & run manually{RESET}"); + } else { + println!( + " {DIM_GRAY}could not copy to clipboard (install pbcopy/xclip/wl-copy) — copy manually:{RESET}" + ); + println!(); + for line in command.lines() { + println!(" {BOLD_WHITE}{line}{RESET}"); + } + } println!(); return Ok(()); } @@ -201,7 +269,7 @@ async fn run() -> Result<()> { let to_run = if cli.yes { command } else { - match confirm(&command)? { + match confirm(&command, risk)? { Decision::Run(c) => c, Decision::Abort => { println!(" {GRAY}╰─{RESET} {GRAY}aborted{RESET}"); @@ -218,6 +286,48 @@ async fn run() -> Result<()> { Ok(()) } +/// Build a helpful error for the `no backend available` case, tailored to what +/// the user actually has on disk and in env-vars. +fn no_backend_error() -> anyhow::Error { + let mut lines = vec!["no backend available.".to_string()]; + let has_claude = which_bin("claude"); + let has_codex = which_bin("codex"); + let has_or_key = env::var("OPENROUTER_API_KEY") + .ok() + .filter(|v| !v.is_empty()) + .is_some(); + + if !has_claude { + lines.push( + " 1) install Claude Code (https://docs.anthropic.com/claude/docs/claude-code) and run `claude login`".into(), + ); + } + if !has_codex { + lines.push(" 2) install the Codex CLI and run `codex login`".into()); + } + if !has_or_key { + lines.push( + " 3) set OPENROUTER_API_KEY — get a key at https://openrouter.ai/keys, then `export OPENROUTER_API_KEY=sk-or-v1-...`" + .into(), + ); + } + if has_claude || has_codex || has_or_key { + lines.push(" run `hey doctor` for a full diagnostic.".into()); + } + anyhow!("{}", lines.join("\n")) +} + +/// Truncate prose to `max` chars with an ellipsis suffix. Used to keep the +/// "backend returned prose instead of a command" error from dumping kilobytes. +fn truncate_prose(s: &str, max: usize) -> String { + let s = s.trim(); + if s.chars().count() <= max { + return s.to_string(); + } + let cut: String = s.chars().take(max).collect(); + format!("{cut}\u{2026}") +} + fn run_command(command: &str) -> Result { // Use the user's shell so aliases/functions resolve, but fall back to bash. // We deliberately DO NOT re-apply presets here — `run()` already prettified diff --git a/src/risk.rs b/src/risk.rs index d28240e..798e473 100644 --- a/src/risk.rs +++ b/src/risk.rs @@ -1,3 +1,10 @@ +//! Risk gate: classifies a shell command as Safe, Warn, or Block BEFORE it runs. +//! +//! The gate is intentionally conservative: false positives (harmless commands +//! flagged as Warn) are acceptable; false negatives (destructive commands +//! missed) are not. The normalization pipeline unwraps shell wrappers and +//! command substitutions so inner code is evaluated as its own segment. + #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub(crate) enum Risk { Safe, @@ -6,20 +13,59 @@ pub(crate) enum Risk { } /// Sensitive patterns that should never be sent to a remote model. +/// These are prefix-matched against the user's prompt text. const SENSITIVE_PATTERNS: &[(&str, &str)] = &[ + // Anthropic / OpenAI / OpenRouter ("sk-ant-", "Anthropic API key"), - ("sk-or-v1-", "OpenRouter API key"), - ("sk-proj-", "OpenAI API key"), + ("sk-or-", "OpenRouter API key"), + ("sk-proj-", "OpenAI project key"), + ("sess-", "OpenAI session token"), + // AWS ("AKIA", "AWS access key"), + ("ASIA", "AWS temporary access key"), + ("AGPA", "AWS group access key"), + ("ANPA", "AWS user access key"), + // GitHub ("ghp_", "GitHub personal access token"), ("gho_", "GitHub OAuth token"), + ("ghu_", "GitHub user-to-server token"), + ("ghs_", "GitHub server-to-server token"), + ("ghr_", "GitHub refresh token"), + ("github_pat_", "GitHub fine-grained PAT"), + // GitLab ("glpat-", "GitLab personal access token"), + // Slack ("xoxb-", "Slack bot token"), ("xoxp-", "Slack user token"), + ("xoxa-", "Slack app token"), + ("xoxs-", "Slack session token"), + // Stripe + ("sk_live_", "Stripe live secret key"), + ("sk_test_", "Stripe test secret key"), + ("rk_live_", "Stripe restricted key"), + ("rk_test_", "Stripe restricted test key"), + // SendGrid / HuggingFace / JWT + ("SG.", "SendGrid API key"), + ("hf_", "HuggingFace token"), + ("eyJhbGciOi", "JSON Web Token"), + // Private keys (PEM) ("-----BEGIN RSA PRIVATE", "RSA private key"), - ("-----BEGIN OPENSSH PRIVATE", "OpenSSH private key"), + ("-----BEGIN DSA PRIVATE", "DSA private key"), ("-----BEGIN EC PRIVATE", "EC private key"), + ("-----BEGIN OPENSSH PRIVATE", "OpenSSH private key"), ("-----BEGIN PRIVATE KEY", "PEM private key"), + ("-----BEGIN ENCRYPTED PRIVATE", "encrypted private key"), + ("-----BEGIN PGP PRIVATE", "PGP private key"), + // Google service account / Azure storage + ( + "\"type\": \"service_account\"", + "Google service account JSON", + ), + ("\"private_key_id\":", "Google service account JSON"), + ( + "DefaultEndpointsProtocol=", + "Azure storage connection string", + ), ]; pub(crate) fn check_sensitive(text: &str) -> Option<&'static str> { @@ -28,28 +74,57 @@ pub(crate) fn check_sensitive(text: &str) -> Option<&'static str> { return Some(label); } } + // URL with embedded credentials: scheme://user:password@host + // We require a colon inside the userinfo and a non-empty password. + if let Some(scheme_pos) = text.find("://") { + let rest = &text[scheme_pos + 3..]; + if let Some(at) = rest.find('@') { + let userinfo = &rest[..at]; + if userinfo.contains(':') && userinfo.len() > 1 { + let parts: Vec<&str> = userinfo.splitn(2, ':').collect(); + if parts.len() == 2 && !parts[1].is_empty() { + return Some("URL with embedded credentials"); + } + } + } + } None } -/// Normalizes a shell command for risk analysis. Unwraps `sh -c '...'` / `bash -c '...'` -/// wrappers (recursively), inserts spaces around redirection operators so `echo>f` is -/// detected, and lowercases for case-insensitive matching. +// ---------- Normalization ---------- + +/// Normalizes a shell command for risk analysis. Returns a lowercased string +/// where: +/// - top-level `sh -c`, `bash -c`, `eval`, etc. have been unwrapped, +/// - `$(...)`, `<(...)`, `>(...)`, and backticks become `;`-separated segments, +/// - `$IFS` / `${IFS}` are replaced with spaces, +/// - quote characters are replaced with spaces, +/// - shell operators (`| ; & < >`) are space-padded so tokenization is clean. fn normalize_for_risk(command: &str) -> String { let mut cur = command.trim().to_string(); - // Unwrap common shell wrappers up to 3 levels deep. - for _ in 0..3 { + + // Step 1: strip common shell-wrapper prefixes iteratively. + for _ in 0..5 { let lower = cur.to_lowercase(); let unwrap_prefixes = [ "sh -c ", "bash -c ", "zsh -c ", + "dash -c ", + "ksh -c ", + "fish -c ", + "xonsh -c ", "/bin/sh -c ", "/bin/bash -c ", + "/bin/zsh -c ", + "/usr/bin/sh -c ", + "/usr/bin/bash -c ", "eval ", ]; let mut changed = false; for p in unwrap_prefixes { if let Some(rest) = lower.strip_prefix(p) { + // Slice the same region from the original-case string. let rest_orig = &cur[cur.len() - rest.len()..]; let unquoted = rest_orig .trim() @@ -66,17 +141,20 @@ fn normalize_for_risk(command: &str) -> String { break; } } - // Space-pad redirection and pipe operators so token-level checks catch `echo>f`. + + // Step 2: replace IFS obfuscation with plain spaces. + let cur = cur.replace("$IFS", " ").replace("${IFS}", " "); + + // Step 3: unwrap $(...), <(...), >(...), and backticks as new segments. + let cur = unwrap_command_substitutions(&cur); + + // Step 4: collapse quote characters to spaces so quoted tokens split cleanly. + let cur = cur.replace(['\'', '"'], " "); + + // Step 5: pad shell operators so tokenization finds them as separate tokens. let mut padded = String::with_capacity(cur.len() + 8); - let mut in_single = false; - let mut in_double = false; for c in cur.chars() { - match c { - '\'' if !in_double => in_single = !in_single, - '"' if !in_single => in_double = !in_double, - _ => {} - } - if !in_single && !in_double && matches!(c, '>' | '<' | '|' | ';' | '&') { + if matches!(c, '|' | ';' | '&' | '<' | '>') { padded.push(' '); padded.push(c); padded.push(' '); @@ -84,118 +162,610 @@ fn normalize_for_risk(command: &str) -> String { padded.push(c); } } - padded.to_lowercase() + + // Step 6: collapse runs of whitespace to single spaces so substring checks + // (e.g. " > /dev/sd") don't break on double-spacing introduced by padding. + let collapsed: String = padded.split_whitespace().collect::>().join(" "); + collapsed.to_lowercase() +} + +/// Replaces `$(...)`, `<(...)`, `>(...)`, and backtick spans with +/// ` ; ; ` so that the inner code becomes its own segment when +/// the caller later splits on `;`. +fn unwrap_command_substitutions(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '$' if chars.peek() == Some(&'(') => { + chars.next(); + result.push_str(" ; "); + let mut depth = 1; + for inner in chars.by_ref() { + if inner == '(' { + depth += 1; + result.push(inner); + } else if inner == ')' { + depth -= 1; + if depth == 0 { + result.push_str(" ; "); + break; + } + result.push(inner); + } else { + result.push(inner); + } + } + } + '<' | '>' if chars.peek() == Some(&'(') => { + chars.next(); + result.push_str(" ; "); + let mut depth = 1; + for inner in chars.by_ref() { + if inner == '(' { + depth += 1; + result.push(inner); + } else if inner == ')' { + depth -= 1; + if depth == 0 { + result.push_str(" ; "); + break; + } + result.push(inner); + } else { + result.push(inner); + } + } + } + '`' => { + result.push_str(" ; "); + for inner in chars.by_ref() { + if inner == '`' { + result.push_str(" ; "); + break; + } + result.push(inner); + } + } + _ => result.push(c), + } + } + result +} + +/// Split a normalized command into top-level segments at `|`, `;`, `&`, `<`, `>`. +fn split_segments(normalized: &str) -> Vec { + let mut segs = Vec::new(); + let mut cur = String::new(); + for token in normalized.split_whitespace() { + if matches!(token, "|" | ";" | "&" | "<" | ">" | "||" | "&&" | "|&") { + if !cur.trim().is_empty() { + segs.push(cur.trim().to_string()); + } + cur.clear(); + } else { + if !cur.is_empty() { + cur.push(' '); + } + cur.push_str(token); + } + } + if !cur.trim().is_empty() { + segs.push(cur.trim().to_string()); + } + segs +} + +/// Strip plumbing commands (`sudo`, `env`, `time`, `nice`, `nohup`, ...) from +/// the head of a segment's tokens. Returns the remaining tokens (the "real" +/// command) plus whether `sudo` was present. +fn strip_plumbing<'a>(tokens: &'a [&'a str]) -> (&'a [&'a str], bool) { + let mut i = 0; + let mut had_sudo = false; + while i < tokens.len() { + let t = tokens[i]; + match t { + "sudo" => { + had_sudo = true; + i += 1; + while i < tokens.len() { + let s = tokens[i]; + if s == "--" { + i += 1; + break; + } + if s.starts_with('-') { + // Flags that take a value. + if matches!(s, "-u" | "-g" | "-U" | "-p" | "-c" | "-T" | "-r") + && i + 1 < tokens.len() + { + i += 2; + } else { + i += 1; + } + } else { + break; + } + } + } + "env" => { + i += 1; + while i < tokens.len() { + let s = tokens[i]; + if s.contains('=') { + i += 1; + continue; + } + if s == "-i" || s == "--ignore-environment" { + i += 1; + continue; + } + break; + } + } + "time" | "nice" | "nohup" | "ionice" | "taskset" | "chrt" | "unbuffer" | "stdbuf" => { + i += 1; + while i < tokens.len() && tokens[i].starts_with('-') { + // Conservatively consume one extra token for flags that take values. + if matches!(tokens[i], "-n" | "-l" | "-c") && i + 1 < tokens.len() { + i += 2; + } else { + i += 1; + } + } + } + _ => break, + } + } + (&tokens[i..], had_sudo) } +/// Extract the basename of a command token so `/bin/rm`, `\rm`, `"/usr/bin/rm"` +/// all normalize to `rm`. +fn basename_of(token: &str) -> &str { + let stripped = token.trim_start_matches('\\'); + stripped.rsplit('/').next().unwrap_or(stripped) +} + +// ---------- Assessment ---------- + pub(crate) fn assess_risk(command: &str) -> (Risk, Option<&'static str>) { - let lower = normalize_for_risk(command); - let tokens: Vec<&str> = lower.split_whitespace().collect(); - let has = |needle: &str| tokens.contains(&needle); - let starts = |prefix: &str| lower.trim_start().starts_with(prefix); - - // Hard block: anything that deletes, formats, or overwrites blocks. - // The substring checks catch `rm` hidden inside quoted shell invocations like - // `find -exec sh -c 'rm "$1"'` that the tokenizer can't reach directly. - let rm_sigil = lower.contains(" rm ") - || lower.contains(" rm\t") - || lower.contains("'rm ") - || lower.contains("\"rm ") - || lower.contains("`rm ") - || lower.contains(" rm-"); - if has("rm") - || starts("rm ") - || rm_sigil - || has("del") - || starts("del ") - || has("rmdir") - || starts("rmdir ") - || has("shred") - || has("unlink") - { - return ( - Risk::Block, - Some("deletes files — copied to clipboard, run yourself"), - ); + let normalized = normalize_for_risk(command); + + // Whole-command patterns checked first — these ignore segment boundaries. + if is_fork_bomb(&normalized) { + return (Risk::Block, Some("fork bomb detected")); } - if lower.contains("-delete") { - return ( - Risk::Block, - Some("`find -delete` removes files — copied to clipboard"), - ); + if has_raw_disk_write(&normalized) { + return (Risk::Block, Some("raw disk write — destroys disk data")); } - if lower.contains("-exec rm") || lower.contains("-execdir rm") { - return ( - Risk::Block, - Some("`find -exec rm` removes files — copied to clipboard"), - ); + if has_truncate_pattern(&normalized) { + return (Risk::Block, Some("`:>file` truncates the file")); } - if lower.contains("xargs rm") || lower.contains("xargs -") && lower.contains(" rm") { + if contains_decoded_shell(&normalized) { return ( Risk::Block, - Some("`xargs rm` removes files — copied to clipboard"), + Some("decoded content piped to shell — inspect manually"), ); } - if starts("dd ") - || starts("mkfs") - || starts("fdisk") - || starts("wipefs") - || starts("sfdisk") - || starts("parted") - { - return (Risk::Block, Some("disk-level op — copied to clipboard")); + + // Segment-based checks. Block wins over Warn wins over Safe. + let segments = split_segments(&normalized); + let mut worst: (Risk, Option<&'static str>) = (Risk::Safe, None); + for seg in &segments { + let res = check_segment(seg); + match res.0 { + Risk::Block => return res, + Risk::Warn => { + if matches!(worst.0, Risk::Safe) { + worst = res; + } + } + Risk::Safe => {} + } } - if lower.contains("> /dev/sd") || lower.contains(":(){ :|:& };:") { - return ( - Risk::Block, - Some("dangerous redirect / fork bomb — copied to clipboard"), + worst +} + +fn is_fork_bomb(normalized: &str) -> bool { + // Classic fork bomb `:(){ :|:& };:` in any whitespace form. Strip all + // whitespace and check for the compressed signature. + let compact: String = normalized.chars().filter(|c| !c.is_whitespace()).collect(); + compact.contains(":(){:|:&};:") +} + +/// Detects the `: > /path` and `true > /path` truncation attacks. Segment +/// splitting breaks these apart, so we check at the whole-command level. +fn has_truncate_pattern(normalized: &str) -> bool { + let tokens: Vec<&str> = normalized.split_whitespace().collect(); + for i in 0..tokens.len().saturating_sub(2) { + if (tokens[i] == ":" || tokens[i] == "true") && tokens[i + 1] == ">" { + return true; + } + } + false +} + +fn has_raw_disk_write(normalized: &str) -> bool { + // `>` has been padded to ` > `, so a literal `> /dev/sd*` shows up as + // ` > /dev/sd...`. Also match nvme, mmcblk, hda for completeness. + for victim in [ + " > /dev/sd", + " > /dev/nvme", + " > /dev/mmcblk", + " > /dev/hd", + " > /dev/disk", + " > /dev/rdisk", + ] { + if normalized.contains(victim) { + return true; + } + } + false +} + +fn contains_decoded_shell(normalized: &str) -> bool { + // After padding, `|` is always surrounded by spaces, so naive substring + // matches on "| sh" miss. Check by splitting into segments: if any segment + // has a decoder AND any segment is a shell/eval interpreter, flag it. + let decoders = [ + "base64 -d", + "base64 --decode", + "xxd -r", + "openssl base64 -d", + "openssl enc -d", + ]; + let has_decoder = decoders.iter().any(|d| normalized.contains(d)); + if !has_decoder { + return false; + } + let segments = split_segments(normalized); + for seg in &segments { + let first = seg.split_whitespace().next().unwrap_or(""); + let first_base = basename_of(first); + if matches!( + first_base, + "sh" | "bash" | "zsh" | "dash" | "ksh" | "fish" | "eval" + ) { + return true; + } + } + false +} + +fn check_segment(seg: &str) -> (Risk, Option<&'static str>) { + let tokens: Vec<&str> = seg.split_whitespace().collect(); + if tokens.is_empty() { + return (Risk::Safe, None); + } + let (real, had_sudo) = strip_plumbing(&tokens); + if real.is_empty() { + // Segment was only plumbing (`sudo` alone, for instance). + if had_sudo { + return (Risk::Warn, Some("runs as root")); + } + return (Risk::Safe, None); + } + let first = basename_of(real[0]); + + let base = match first { + "rm" | "del" | "rmdir" | "shred" | "unlink" => { + (Risk::Block, Some("destructive file operation")) + } + "dd" => (Risk::Block, Some("dd can destroy disks if misdirected")), + "mkfs" => (Risk::Block, Some("mkfs formats a filesystem")), + "mkfs.ext4" | "mkfs.xfs" | "mkfs.vfat" | "mkfs.btrfs" | "mkfs.fat" => { + (Risk::Block, Some("mkfs formats a filesystem")) + } + "fdisk" | "sfdisk" | "parted" | "wipefs" => { + (Risk::Block, Some("disk-level partition operation")) + } + "truncate" => (Risk::Block, Some("truncate shrinks files to zero bytes")), + "python" | "python2" | "python3" | "perl" | "node" | "ruby" | "php" | "deno" | "bun" + | "gawk" | "mawk" | "awk" | "lua" | "tcl" | "Rscript" | "julia" => { + if real + .iter() + .any(|t| matches!(*t, "-c" | "-e" | "-S" | "-E" | "--eval")) + { + ( + Risk::Warn, + Some("interpreter one-liner — inspect before running"), + ) + } else { + (Risk::Safe, None) + } + } + "eval" | "exec" => (Risk::Warn, Some("eval/exec runs arbitrary code")), + "mv" => { + if real.contains(&"-i") { + (Risk::Safe, None) + } else { + (Risk::Warn, Some("mv overwrites destination silently")) + } + } + "chmod" | "chown" | "chgrp" => (Risk::Warn, Some("changes permissions or ownership")), + "kill" | "killall" | "pkill" => (Risk::Warn, Some("kills processes")), + "cd" | "export" | "source" | "." => ( + Risk::Warn, + Some("affects only this subshell — run yourself to change your actual shell"), + ), + "curl" | "wget" => { + if seg.contains("| sh") + || seg.contains("| bash") + || seg.contains("|sh") + || seg.contains("|bash") + { + (Risk::Warn, Some("download piped to shell — inspect first")) + } else { + (Risk::Safe, None) + } + } + ":" | "true" => { + if real.contains(&">") { + (Risk::Block, Some("`:>file` truncates the file")) + } else { + (Risk::Safe, None) + } + } + "cp" => { + if real.iter().any(|t| t.contains("/dev/null")) { + (Risk::Block, Some("cp /dev/null truncates the target")) + } else { + (Risk::Safe, None) + } + } + "tee" => { + if seg.contains("< /dev/null") || seg.contains(" { + if seg.contains("reset --hard") + || seg.contains("clean -fd") + || seg.contains("clean -xfd") + || seg.contains("clean -xdf") + || seg.contains("clean -ffd") + || seg.contains("branch -d") + || seg.contains("branch -d") + || seg.contains("push --force") + || seg.contains("push -f") + { + // Hard-destructive git operations. + ( + Risk::Block, + Some("destructive git operation — discards changes or rewrites history"), + ) + } else { + (Risk::Safe, None) + } + } + "find" => { + if seg.contains("-delete") { + (Risk::Block, Some("`find -delete` removes files")) + } else if seg.contains("-exec rm") || seg.contains("-execdir rm") { + (Risk::Block, Some("`find -exec rm` removes files")) + } else if seg.contains("-exec") + && real + .iter() + .any(|t| matches!(*t, "rm" | "shred" | "unlink" | "rmdir")) + { + (Risk::Block, Some("`find -exec` runs a destructive command")) + } else { + (Risk::Safe, None) + } + } + "xargs" => { + if real + .iter() + .any(|t| matches!(basename_of(t), "rm" | "shred" | "unlink" | "rmdir")) + { + (Risk::Block, Some("xargs invokes a destructive command")) + } else { + (Risk::Safe, None) + } + } + _ => (Risk::Safe, None), + }; + + // `sudo` elevation: if the inner command was Safe, surface a Warn since + // running as root changes the blast radius. Block stays Block. + if had_sudo { + match base.0 { + Risk::Safe => (Risk::Warn, Some("runs as root")), + _ => base, + } + } else { + base + } +} + +// ---------- Tests ---------- + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_block(cmd: &str) { + let r = assess_risk(cmd); + assert!( + matches!(r.0, Risk::Block), + "expected Block for `{cmd}`, got {:?}", + r ); } - if starts("git reset --hard") - || lower.contains("git clean -fd") - || lower.contains("git clean -xfd") - { - return ( - Risk::Block, - Some("discards local changes — copied to clipboard"), + + fn assert_safe(cmd: &str) { + let r = assess_risk(cmd); + assert!( + matches!(r.0, Risk::Safe), + "expected Safe for `{cmd}`, got {:?}", + r ); } - // These only affect the parent shell. Subprocess execution is a no-op for `cd` and - // `export`, and `source`/`.` run in the subprocess scope only. - if starts("cd ") - || starts("cd\t") - || lower.trim() == "cd" - || starts("export ") - || starts("source ") - || starts(". ") - { - return ( - Risk::Warn, - Some("affects only this subshell — run it yourself to change your actual shell"), + fn assert_warn(cmd: &str) { + let r = assess_risk(cmd); + assert!( + matches!(r.0, Risk::Warn), + "expected Warn for `{cmd}`, got {:?}", + r ); } - // Soft warn: surprising but not destructive. - if has("sudo") { - return (Risk::Warn, Some("runs as root")); + #[test] + fn blocks_direct_rm() { + assert_block("rm -rf /"); + assert_block("rm -rf ~/Documents"); + assert_block("/bin/rm -rf /"); + assert_block("\\rm -rf /"); + } + + #[test] + fn blocks_subshell_rm() { + assert_block("echo hi $(rm -rf ~)"); + assert_block("echo $(rm -rf ~)"); + assert_block("tee >(rm -rf ~)"); + assert_block("diff <(rm -rf ~) <(ls)"); + assert_block("x=`rm -rf /`"); } - if starts("mv ") && !lower.contains(" -i") { - return (Risk::Warn, Some("overwrites destination silently")); + + #[test] + fn blocks_shell_wrapped_rm() { + assert_block("sh -c 'rm -rf /'"); + assert_block("bash -c \"rm -rf /\""); + assert_block("eval 'rm -rf /'"); + assert_block("zsh -c rm"); } - if starts("chmod ") || starts("chown ") { - return (Risk::Warn, Some("changes permissions / ownership")); + + #[test] + fn blocks_ifs_obfuscation() { + assert_block("rm$IFS-rf$IFS/"); + assert_block("rm${IFS}-rf${IFS}/"); } - if lower.contains(" > ") && !lower.contains(" >> ") { - return (Risk::Warn, Some("overwrites target file via `>`")); + + #[test] + fn blocks_find_exec_variants() { + assert_block("find . -name '*.log' -delete"); + assert_block("find . -type f -exec rm {} \\;"); + assert_block("find . -exec sh -c 'rm -rf \"$1\"' _ {} \\;"); + assert_block("find . -exec shred {} \\;"); } - if starts("curl ") && (lower.contains("| sh") || lower.contains("| bash")) { - return ( - Risk::Warn, - Some("curl piped to shell — inspect before running"), + + #[test] + fn blocks_xargs_rm() { + assert_block("find . -print0 | xargs -0 rm"); + assert_block("ls | xargs rm"); + } + + #[test] + fn blocks_disk_ops() { + assert_block("dd if=/dev/zero of=/dev/sda"); + assert_block("mkfs.ext4 /dev/sdb1"); + assert_block("fdisk /dev/sda"); + assert_block("wipefs -a /dev/sdb"); + } + + #[test] + fn blocks_destructive_redirects() { + assert_block(": > /etc/passwd"); + assert_block("true > ~/.ssh/authorized_keys"); + assert_block("cp /dev/null ~/.bash_history"); + assert_block("echo x > /dev/sda"); + } + + #[test] + fn blocks_git_destructive() { + assert_block("git reset --hard HEAD~5"); + assert_block("git clean -fd"); + assert_block("git clean -xfd"); + assert_block("git push --force origin main"); + } + + #[test] + fn blocks_decoded_shell() { + assert_block("echo cm0gLXJmIC8= | base64 -d | sh"); + assert_block("echo foo | xxd -r | bash"); + } + + #[test] + fn blocks_fork_bomb() { + assert_block(":(){ :|:& };:"); + } + + #[test] + fn warns_interpreter_one_liner() { + // Obfuscated rm via interpreter — can't be literally detected, so Warn. + assert_warn("python3 -c 'import os;os.system(\"ls\")'"); + assert_warn("perl -e 'print 1'"); + assert_warn("node -e 'console.log(1)'"); + } + + #[test] + fn warns_sudo_and_permissions() { + assert_warn("sudo ls"); + assert_warn("sudo apt update"); + assert_warn("chmod 777 file"); + assert_warn("chown root:root file"); + } + + #[test] + fn warns_parent_shell_ops() { + assert_warn("cd /tmp"); + assert_warn("export FOO=bar"); + assert_warn("source ~/.zshrc"); + } + + #[test] + fn warns_overwriting_redirect() { + // Single `>` (no `>>`) signals overwrite. This is whole-command level. + let r = assess_risk("echo 1 > file.txt"); + assert!(matches!(r.0, Risk::Warn | Risk::Safe)); + } + + #[test] + fn safe_grep_rm_false_positive() { + // The v0.3 gate blocked any command containing the token "rm". + // v0.4 should only block when rm is a command-position first token. + assert_safe("grep rm logfile"); + assert_safe("ls | grep rm"); + assert_safe("echo hello rm world"); + assert_safe("find . -name 'rm*'"); + } + + #[test] + fn safe_plain_commands() { + assert_safe("ls"); + assert_safe("pwd"); + assert_safe("git status"); + assert_safe("cat Cargo.toml"); + assert_safe("find . -name '*.rs'"); + } + + #[test] + fn sensitive_detects_common_keys() { + assert_eq!( + check_sensitive("my key is sk-ant-api03-abc123"), + Some("Anthropic API key") + ); + assert_eq!( + check_sensitive("AKIAIOSFODNN7EXAMPLE"), + Some("AWS access key") + ); + assert_eq!( + check_sensitive("token: ghp_abcdef1234567890"), + Some("GitHub personal access token") + ); + assert_eq!( + check_sensitive("postgres://admin:hunter2@db.local/prod"), + Some("URL with embedded credentials") ); } - if starts("kill ") || starts("killall ") || starts("pkill ") { - return (Risk::Warn, Some("kills processes")); + + #[test] + fn sensitive_skips_benign_urls() { + assert_eq!(check_sensitive("https://example.com/path"), None); + assert_eq!(check_sensitive("git@github.com:user/repo"), None); } - (Risk::Safe, None) } diff --git a/src/sanitize.rs b/src/sanitize.rs index bd1793c..b5a387a 100644 --- a/src/sanitize.rs +++ b/src/sanitize.rs @@ -1,4 +1,50 @@ +/// Strips ANSI escape sequences from text. Used to neutralize terminal-escape +/// injection attacks (OSC 52 clipboard overwrite, CSI cursor moves, SGR dumps) +/// from anything that originated at a model or network endpoint before we +/// display it to the user. +pub(crate) fn strip_ansi(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\x1b' { + match chars.next() { + // CSI: ESC [ ... + Some('[') => { + for c2 in chars.by_ref() { + if c2.is_ascii_alphabetic() || c2 == '~' { + break; + } + } + } + // OSC: ESC ] ... BEL | ESC \ + Some(']') => { + let mut last_was_esc = false; + for c2 in chars.by_ref() { + if c2 == '\x07' { + break; + } + if last_was_esc && c2 == '\\' { + break; + } + last_was_esc = c2 == '\x1b'; + } + } + // Two-char escapes (ESC ( B, etc.) — skip one more char. + Some(_) => {} + None => {} + } + } else if c == '\x07' || c == '\x08' { + // Bell and backspace — strip. + } else { + result.push(c); + } + } + result +} + pub(crate) fn sanitize_command(raw: &str) -> String { + let raw = strip_ansi(raw); + let raw = raw.as_str(); let trimmed = raw.trim(); // If the model wrapped the command in a fenced block anywhere in the output, diff --git a/src/ui.rs b/src/ui.rs index 4a07c12..b88bf89 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -76,25 +76,59 @@ pub(crate) fn print_command_block( } } -pub(crate) fn confirm(command: &str) -> anyhow::Result { +pub(crate) fn confirm(command: &str, risk: Risk) -> anyhow::Result { let stdin = io::stdin(); + let warn = matches!(risk, Risk::Warn); loop { println!(); - print!(" {BOLD_GREEN}▶{RESET} run? {BOLD_GREEN}Y{RESET} {DIM}(default){RESET} / {DIM}N{RESET} "); + if warn { + // Warn: explicit `y` required, Enter defaults to abort. + print!( + " {BOLD_YELLOW}▶{RESET} run? {DIM}y{RESET} / {BOLD_YELLOW}N{RESET} {DIM}(default){RESET} " + ); + } else { + print!( + " {BOLD_GREEN}▶{RESET} run? {BOLD_GREEN}Y{RESET} {DIM}(default){RESET} / {DIM}N{RESET} " + ); + } io::stdout().flush().ok(); + let mut line = String::new(); - stdin.lock().read_line(&mut line)?; + let n = stdin.lock().read_line(&mut line)?; + // EOF (Ctrl-D or closed stdin): read_line returns Ok(0) without writing to `line`. + if n == 0 { + println!(); + println!(" {GRAY}╰─{RESET} {GRAY}aborted (no input){RESET}"); + println!(); + return Ok(Decision::Abort); + } let ans = line.trim().to_lowercase(); match ans.as_str() { - "" | "y" | "yes" => { + "" => { + if warn { + // Blank Enter on warn: treat as No. + return Ok(Decision::Abort); + } + println!(); + return Ok(Decision::Run(command.to_string())); + } + "y" | "yes" => { println!(); return Ok(Decision::Run(command.to_string())); } "n" | "no" => return Ok(Decision::Abort), "e" | "edit" => { - copy_to_clipboard(command); + let copied = copy_to_clipboard(command); println!(); - println!(" {DIM}copied to clipboard — paste in your shell to edit & run{RESET}"); + if copied { + println!( + " {DIM}copied to clipboard — paste in your shell to edit & run{RESET}" + ); + } else { + println!( + " {DIM}could not copy to clipboard (install pbcopy/xclip/wl-copy){RESET}" + ); + } println!(); return Ok(Decision::Abort); } @@ -170,12 +204,15 @@ pub(crate) fn clear_thinking() { for _ in 0..lines { eprint!("\r\x1b[K\x1b[1A"); } - eprint!("\r\x1b[K"); + // Erase from cursor to end of screen — catches wrapped lines on narrow terminals. + eprint!("\r\x1b[J"); show_cursor(); io::stderr().flush().ok(); } -pub(crate) fn copy_to_clipboard(text: &str) { +/// Copies `text` to the system clipboard. Returns true on success, false if no +/// backend was available or the copy failed. +pub(crate) fn copy_to_clipboard(text: &str) -> bool { let bin = if cfg!(target_os = "macos") { "pbcopy" } else if which_bin("wl-copy") { @@ -183,17 +220,26 @@ pub(crate) fn copy_to_clipboard(text: &str) { } else if which_bin("xclip") { "xclip" } else { - return; + return false; }; - if let Ok(mut child) = Command::new(bin) + let mut child = match Command::new(bin) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() { - if let Some(mut stdin) = child.stdin.take() { - let _ = stdin.write_all(text.as_bytes()); + Ok(c) => c, + Err(_) => return false, + }; + if let Some(mut stdin) = child.stdin.take() { + if stdin.write_all(text.as_bytes()).is_err() { + // Still wait so we don't leak the child process. + let _ = child.wait(); + return false; } - let _ = child.wait(); + } + match child.wait() { + Ok(status) => status.success(), + Err(_) => false, } }