//! Auto permission mode: LLM transcript classifier with safe fast-paths. //! //! Port of common agent auto-permission classifier semantics adapted to Kigi's //! `AccessKind` permission gate (classifier blocks prompt the user; upstream //! denial-limit tracking is deliberately not ported). use std::future::Future; use std::pin::Pin; use std::sync::Arc; use tree_sitter::Node; use super::bash_command_splitting::{ PlainCommand, is_wrapper_command, strip_wrapper_command, try_parse_shell, try_parse_word_only_commands_sequence, unwrap_wrappers, }; use super::shell_access::{command_words_write_paths, command_write_paths_in_tree}; use super::types::AccessKind; /// Classifier outcome for a single tool authorization. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ClassifierVerdict { /// Safe to run without user prompt. Allow, /// Blocked by classifier; the user is prompted to decide. Block, /// Classifier unavailable (API error / no client); treated as a block (prompt). Unavailable, } /// Role of a single classifier request message (transport-agnostic; the shell /// crate maps these onto sampling-types so this crate stays decoupled). #[derive(Debug, Clone, PartialEq, Eq)] pub enum ClassifierMessageRole { System, User, } /// How much context [`build_classifier_messages`] includes (decreasing order). /// Also the type of the `[auto_mode] prompt_type` config field — the shell reads /// it straight off the resolved config (serde wire values are the snake_case /// variant names). Operator-facing meaning of each variant: /// - `full`: system + AGENTS.md + transcript + proposed action + JSON instruction. /// - `no_user_tool_prefix`: drops the conversation transcript (the `User:` / /// tool-call turns); keeps AGENTS.md. /// - `bare_instructions`: system + proposed action + JSON instruction (no /// AGENTS.md, no transcript). /// - `just_command`: system + the command to judge only (json_schema still /// enforces the output shape). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum ClassifierPromptType { #[default] Full, NoUserToolPrefix, BareInstructions, JustCommand, } /// One message in the classifier request array (role + rendered text). #[derive(Debug, Clone, PartialEq, Eq)] pub struct ClassifierMessage { pub role: ClassifierMessageRole, pub text: String, } /// One recent transcript turn the classifier sees. Includes user text + #[derive(Debug, Clone, PartialEq, Eq)] pub enum ClassifierTurn { /// A user text turn. UserText(String), /// An assistant tool_use block: tool name + compact JSON args (or raw detail). AssistantToolUse { tool: String, args: String }, PermissionDecision { tool: String, args: String, approved: bool, }, } impl ClassifierTurn { /// Render one turn chronologically for the classifier transcript. fn render(&self) -> String { match self { ClassifierTurn::UserText(text) => format!("User: {text}"), ClassifierTurn::AssistantToolUse { tool, args } => format!("{tool} {args}"), ClassifierTurn::PermissionDecision { tool, args, approved, } => { if *approved { format!( "The user was asked before running {tool} {args} and approved it; it has run once." ) } else { format!("The user was asked about running {tool} {args} and declined it.") } } } } } /// Owned conversation/transcript context for the classifier. The shell crate /// populates `turns` (compacted) and `project_instructions` (AGENTS.md). #[derive(Debug, Clone, Default)] pub struct ClassifierContext { /// Recent turns, chronological: user text + assistant tool_use only. pub turns: Vec, /// Project AGENTS.md ("what the main agent sees"); None when absent. pub project_instructions: Option, } impl ClassifierContext { /// Flat transcript text feeding the heuristic substring pre-check. Renders all /// turns including assistant tool_use args (`{tool} {args}`), so the /// dangerous-pattern / hostile-intent blob now also scans tool-call args — a /// conservative broadening (only expands matches), not a strict-parity claim. fn transcript_text(&self) -> String { self.turns .iter() .map(ClassifierTurn::render) .collect::>() .join("\n") } } /// Injectable seam for the permission auto-mode classifier. /// /// Production implementations call a side inference path; tests inject a /// fixed verdict without mocking the permission gate itself. pub trait PermissionClassifier: Send + Sync { fn classify<'a>( &'a self, tool_name: &'a str, access: &'a AccessKind, access_detail: Option<&'a str>, context: ClassifierContext, ) -> Pin + Send + 'a>>; } /// Fixed-verdict classifier for tests and headless fallbacks. #[derive(Debug, Clone, Copy)] pub struct FixedClassifier(pub ClassifierVerdict); impl PermissionClassifier for FixedClassifier { fn classify<'a>( &'a self, _tool_name: &'a str, _access: &'a AccessKind, _access_detail: Option<&'a str>, _context: ClassifierContext, ) -> Pin + Send + 'a>> { let v = self.0; Box::pin(async move { v }) } } /// Production default classifier: rule-based transcript-style risk assessment /// without a network call. Blocks known-dangerous patterns; allows routine /// dev commands; **unknown bash defaults to Block** (which prompts the user) /// so auto is not silent always-approve. A live LLM can /// replace this via `set_classifier` and use full transcript context. #[derive(Debug, Default, Clone, Copy)] pub struct HeuristicPermissionClassifier; impl HeuristicPermissionClassifier { pub fn classify_sync( tool_name: &str, access: &AccessKind, access_detail: Option<&str>, context: &ClassifierContext, ) -> ClassifierVerdict { let detail = access_detail.unwrap_or("").to_ascii_lowercase(); let tool = tool_name.to_ascii_lowercase(); // Flatten the structured turns (user text + assistant tool_use args) into // the substring-checkable blob the dangerous-pattern + hostile-intent // pre-checks scan, so tool-call args feed the scan too (conservative). let transcript = context.transcript_text().to_ascii_lowercase(); let blob = format!("{tool} {detail} {transcript}"); // Interactive / user-facing tools must not be silently approved here // (caller also checks requires_user_interaction). if tool.contains("ask_user") || tool.contains("askuserquestion") { return ClassifierVerdict::Block; } // High-risk shell / network patterns (dangerous-patterns spirit). let dangerous = [ "rm -rf /", "rm -rf /*", "mkfs", "dd if=", ":(){ :|:& };:", "curl | sh", "curl|sh", "curl | bash", "curl|bash", "wget | sh", "wget|sh", "wget | bash", "wget|bash", "chmod 777", "chmod -r 777", "chmod +x /tmp", "base64 -d", "base64 --decode", "nc -e", "ncat -e", "/dev/tcp/", "shutdown", "reboot", "useradd", "userdel", "passwd ", "chown -r /", "iptables -f", "kill -9 1", "sudo rm", "sudo dd", "sudo mkfs", "exfiltrat", "steal credential", "send secrets", ]; for pat in dangerous { if blob.contains(pat) { return ClassifierVerdict::Block; } } // Pipe-to-shell without space variants if (blob.contains("curl") || blob.contains("wget") || blob.contains("fetch")) && (blob.contains("| sh") || blob.contains("|sh") || blob.contains("| bash") || blob.contains("|bash") || blob.contains("| zsh") || blob.contains("|zsh")) { return ClassifierVerdict::Block; } // Transcript asks for destructive / exfil actions → block even routine cmds. let hostile_intent = [ "delete all files", "wipe the disk", "exfiltrate", "steal secrets", "send my credentials", "ignore safety", "bypass permission", ]; if hostile_intent.iter().any(|p| transcript.contains(p)) { return ClassifierVerdict::Block; } match access { AccessKind::Bash(cmd) => classify_bash(cmd), AccessKind::WebFetch(url) => { let u = url.to_ascii_lowercase(); if u.contains("localhost") || u.contains("127.0.0.1") || u.starts_with("file:") { ClassifierVerdict::Block } else { // Non-local fetch still needs explicit allow; conservative. ClassifierVerdict::Block } } // Edits never reach here in practice: the fast path Allows ALL edits // before classify (the accept-all-edits product decision). If one // ever does (fast-path bypass), Block is the fail-closed // defense-in-depth fallback so the user is prompted rather than // silently auto-approving; non-allowlisted MCP tools land // here too. AccessKind::Edit(_) | AccessKind::MCPTool { .. } => ClassifierVerdict::Block, AccessKind::Read(_) | AccessKind::Grep { .. } | AccessKind::WebSearch(_) => { ClassifierVerdict::Allow } } } } /// Routine local-dev command prefixes (word-boundary matched). `env`/`find` are /// handled separately (wrapper unwrapping / read-only predicate). The package /// managers `uv`/`npm`/`pnpm`/`yarn`/`rustup` are ABSENT: a blanket prefix is /// denylist-shaped whack-a-mole, so they go through the fail-closed /// SAFE-subcommand allowlist in [`package_manager_subcommand_is_routine`]. /// `cp`/`mv`/`mkdir`/`touch` are also ABSENT: they write/create arbitrary /// destinations the write model already Blocks. `cd`/`pushd`/`popd` only move /// the spawned shell's cwd; git entries are the local workflow plus read-only /// queries. const ROUTINE_PREFIXES: &[&str] = &[ "cargo ", "git status", "git diff", "git log", "git branch", "git add", "git commit", "git checkout", "git switch", "git stash", "git pull", "git fetch", "git show", "git blame", "git grep", "git ls-files", "git rev-parse", "git describe", "git merge-base", "git worktree list", "pytest", "python ", "python3 ", "node ", "rustc ", "rustfmt", "clippy", "make ", "cmake ", "cd", "pushd", "popd", "ls", "pwd", "echo ", "printf ", "cat ", "head ", "tail ", "wc ", "rg ", "grep ", "which ", "type ", "true", "false", "test ", "sort ", "uniq ", "tr ", "cut ", "diff ", "jq ", "date", "whoami", "hostname", "uname", "nproc", "printenv", "stat ", "file ", "tree", "basename ", "dirname ", "realpath ", "readlink ", "strings ", "sleep ", "df ", "du ", "ps ", "top", "htop", "bazel ", "just ", "go ", "kubectl get", "kubectl logs", "kubectl describe", // shell options affect only the spawned shell "set", ]; /// Env var KEYs safe to set for a routine command: cosmetic / logging only, with /// no effect on which binary runs or how it resolves code. Anything else /// (LD_PRELOAD, DYLD_*, PATH, NODE_OPTIONS, PYTHONPATH, GIT_SSH_COMMAND, FOO, ...) /// is treated as exec-affecting and blocks. Case-sensitive exact match. const SAFE_ENV_KEYS: &[&str] = &[ "CARGO_TERM_COLOR", "CARGO_TERM_PROGRESS_WHEN", "RUST_LOG", "RUST_LOG_STYLE", "RUST_BACKTRACE", "RUST_TEST_THREADS", "RUST_MIN_STACK", "NO_COLOR", "CLICOLOR", "CLICOLOR_FORCE", "FORCE_COLOR", "COLORTERM", ]; /// Heuristic classification of a bash command (fail-closed). Parses ONCE with /// the canonical tree-sitter splitter and Blocks anything it can't prove is a /// chain of routine, side-effect-free dev commands. fn classify_bash(cmd: &str) -> ClassifierVerdict { // Fail closed (Block) for anything the splitter can't decompose into plain // word-only commands: `&` background, `$'...'` ANSI-C quoting, // `$(...)`/backticks/`<()`/`>()` substitutions, `${...}`/`$VAR` expansions, // parens, control flow, and complex strings. let Some(tree) = try_parse_shell(cmd) else { return ClassifierVerdict::Block; }; let Some(cmds) = try_parse_word_only_commands_sequence(&tree, cmd) else { return ClassifierVerdict::Block; }; // Default-deny env: an assigned env KEY outside the cosmetic-safe allowlist // (or any `env` option) can differ which binary runs / how code resolves. // Read from the PARSED, quote-stripped tree so `env "LD_PRELOAD=..."` can't // hide the key. if sets_unsafe_env(tree.root_node(), cmd, &cmds) { return ClassifierVerdict::Block; } // A routine command can still write an arbitrary destination via a redirect // OR a command-internal flag/operand (`sort -o`, `git --output`, `go -o`, // `dd of=`, `tee`, `truncate`, `uniq out`, in-place `sed`/`rustfmt`). Reuse // the canonical shell write model (sharing the already-parsed tree) and Block // any write to a non-sink path. for path in command_write_paths_in_tree(tree.root_node(), cmd) { if !is_safe_write_sink(&path) { return ClassifierVerdict::Block; } } // Every parsed command must be routine (sudo/doas/run0 stay as a non-wrapper // head and fail the check), else Block. BY DESIGN, project code-runners // (`cargo`/`make`/`pytest`/`python`/`node`, `npm test`/`run`, `uv run // `) execute project-controlled code; this heuristic is a fail-closed // FALLBACK and the real safety boundary is the LLM side-query + managed policy. if !cmds.is_empty() && cmds.iter().all(|c| bash_command_is_routine(c.words())) { return ClassifierVerdict::Allow; } ClassifierVerdict::Block } /// One parsed command is routine if, after peeling canonical wrappers, its inner /// command matches [`ROUTINE_PREFIXES`] on a word boundary (equal, or prefix then /// a space — plain `starts_with` over-matches `top`→`topgrade`, `ls`→`lsof`). /// /// Package managers (`uv`/`npm`/`pnpm`/`yarn`/`rustup`, plus `npx`/`uvx`) are /// classified by [`package_manager_subcommand_is_routine`]: a fail-closed /// SAFE-subcommand allowlist (build/test/dep-management Allow; explicit launchers /// re-classified; remote / arbitrary-exec / unknown → Block). fn bash_command_is_routine(words: &[String]) -> bool { // Peel canonical (quote-aware) wrappers: env [NAME=VALUE], timeout, nice, // stdbuf, ionice, chrt (incl. path-qualified). let inner = unwrap_wrappers(words); // A bare wrapper (e.g. `env` printing the environment) or a command that was // only env assignments → routine. if inner.is_empty() || is_lone_wrapper(inner) { return true; } let head = inner[0] .rsplit(['/', '\\']) .next() .unwrap_or(inner[0].as_str()) .to_ascii_lowercase(); // Package managers: fail-closed safe-subcommand allowlist (None = not a // package manager → fall through to the generic find/prefix checks). if let Some(routine) = package_manager_subcommand_is_routine(&head, inner) { return routine; } // `find` is routine only without a filesystem-mutating primary. if head == "find" { return find_is_read_only(inner); } // `git grep -O`/`--open-files-in-pager` executes ; the write // model treats `-O` as a read-only order-file (true for diff/log only). // Git accepts uniquely-abbreviated long options, so any `--o*` word whose // pre-`=` part prefixes the full option (`--op`, `--open`, ...) blocks too; // `--or`/`--only-matching` diverge at the 4th char and stay routine. if head == "git" && inner.get(1).is_some_and(|s| s.eq_ignore_ascii_case("grep")) && inner.iter().any(|w| { let flag = w.split('=').next().unwrap_or(w); w.starts_with("-O") || (flag.starts_with("--o") && "--open-files-in-pager".starts_with(flag)) }) { return false; } // `tree -o ` writes an arbitrary path outside the write model; short // flags group (`-ao`), so reject any short-flag word containing `o`. if head == "tree" && inner.iter().any(|w| { (w.starts_with('-') && !w.starts_with("--") && w.contains('o')) || w.starts_with("--output") }) { return false; } // Fail-closed read-only matchers (mutating siblings must not ride a prefix). if head == "gh" { return gh_subcommand_is_read_only(inner); } let joined = inner.join(" ").to_ascii_lowercase(); ROUTINE_PREFIXES.iter().any(|p| { let base = p.trim(); joined == base || (joined.starts_with(base) && joined[base.len()..].starts_with(' ')) }) } /// First `n` non-flag tokens after the head. Space-separated flag values are /// not modeled; one landing here can only make a match fail, never allow more. fn nonflag_tokens(inner: &[String], n: usize) -> Vec<&str> { inner[1..] .iter() .filter(|w| !w.starts_with('-')) .take(n) .map(String::as_str) .collect() } /// Read-only `gh` invocations, exact-matched; anything else (`pr merge`, /// `api`, aliases) fails closed to the model. fn gh_subcommand_is_read_only(inner: &[String]) -> bool { let toks = nonflag_tokens(inner, 2); match toks.as_slice() { [group, sub] => matches!( (*group, *sub), ( "pr" | "issue" | "release" | "run" | "workflow" | "repo" | "gist", "view" | "list" | "status" | "checks" | "diff" ) | ("auth", "status") ), ["status"] => true, _ => false, } } /// Per-tool subcommand classification for package managers (replaces the old /// blanket `uv `/`npm `/... prefixes with a fail-closed allowlist). `None` = /// `prog` is not a package manager (caller falls through to the generic /// find/prefix checks). `Some(true)` = a safe build/test/dep-management /// subcommand; `Some(false)` = remote / arbitrary-exec / unknown / missing → Block. /// /// Reuses the existing helpers so there is ONE place per concept: remote /// fetch-and-run via [`is_remote_launcher`], explicit launchers via /// [`explicit_launch_target`] (which re-classifies the inner command after /// re-checking its writes/env), and everything else against a per-tool allowlist. fn package_manager_subcommand_is_routine(prog: &str, inner: &[String]) -> Option { if !matches!( prog, "uv" | "uvx" | "npm" | "npx" | "pnpm" | "yarn" | "rustup" ) { return None; } // Remote / arbitrary-exec (npx, uvx, uv tool run, dlx, create, init , // explore) → Block. if is_remote_launcher(prog, inner) { return Some(false); } // Explicit launchers (`*exec`/`x`, `uv run`, `rustup run TOOLCHAIN`) → strip // and re-classify the inner command (its writes/env are invisible to the // outer tree-level guards), failing closed on any launcher option we won't // model. match explicit_launch_target(prog, inner) { LaunchTarget::Unresolved => return Some(false), LaunchTarget::Inner(launched) => { return Some( !command_env_is_unsafe(launched) && !launched_writes_nonsink(launched) && bash_command_is_routine(launched), ); } LaunchTarget::NotLauncher => {} } // A remaining non-launcher subcommand must be on the per-tool safe allowlist; // anything else (incl. a missing subcommand) fails closed. let sub = launcher_subcommand(prog, inner); Some(match prog { "npm" | "pnpm" | "yarn" => sub.is_some_and(|s| NPM_SAFE_SUBCOMMANDS.contains(&s)), "uv" => sub.is_some_and(|s| UV_SAFE_SUBCOMMANDS.contains(&s)), "rustup" => sub.is_some_and(|s| RUSTUP_SAFE_SUBCOMMANDS.contains(&s)), // `npx`/`uvx` are remote (handled above); anything reaching here → Block. _ => false, }) } /// Safe non-launcher subcommands of `npm`/`pnpm`/`yarn` (dependency / build / test /// management). `run