Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<pid>.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/<pid>/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 <shell>`** — 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
Expand Down Expand Up @@ -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
Expand Down
51 changes: 50 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand Down
68 changes: 61 additions & 7 deletions src/backend/claude.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -42,13 +43,36 @@ async fn ask_anthropic_direct(api_key: &str, user_prompt: &str) -> Result<String
.await
.context("failed to reach Anthropic API")?;

if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow!("Anthropic API error {status}: {text}"));
let status = resp.status();
if let Some(len) = resp.content_length() {
if len > 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"))
Expand Down Expand Up @@ -82,5 +106,35 @@ fn ask_claude_code(user_prompt: &str) -> Result<String> {
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())
}
57 changes: 47 additions & 10 deletions src/backend/codex.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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/<pid>/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")
Expand All @@ -35,7 +59,7 @@ pub(crate) fn ask_codex(user_prompt: &str) -> Result<String> {
"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"));
}

Expand All @@ -59,3 +83,16 @@ pub(crate) fn ask_codex(user_prompt: &str) -> Result<String> {

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(),
}
}
1 change: 1 addition & 0 deletions src/backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ pub(crate) fn resolve_auto_chain() -> Result<Vec<Backend>> {
Ok(chain)
}

#[allow(dead_code)]
pub(crate) fn post_cli_backend_output(name: &str, output: std::process::Output) -> Result<String> {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
Expand Down
Loading
Loading