Skip to main content

leviath_cli/commands/
approvals.rs

1//! `lev approvals` - what a run may do without asking, and why.
2//!
3//! The question this answers is "why did it not ask me", which is the one a
4//! person asks the first time a run does something unprompted. Every key is
5//! listed with the layer that put it there, so the answer is always a file the
6//! user can edit rather than a rule they have to infer.
7//!
8//! There is no `list` or `clear`: nothing is persisted. A grant made at a prompt
9//! dies with the run that made it, so the only durable state is the config this
10//! command reports.
11
12use clap::{Args, Subcommand};
13
14use crate::approvals::SafeSource;
15use crate::config::Config;
16
17/// Arguments for `lev approvals`.
18#[derive(Args)]
19pub struct ApprovalsArgs {
20    /// Which approvals subcommand to run.
21    #[command(subcommand)]
22    pub command: ApprovalsCommand,
23}
24
25/// The `lev approvals` subcommands.
26#[derive(Subcommand)]
27pub enum ApprovalsCommand {
28    /// Show what runs without an approval prompt, and where each entry came from
29    Safe(SafeArgs),
30}
31
32/// Arguments for `lev approvals safe`.
33#[derive(Args)]
34pub struct SafeArgs {
35    /// Include the per-agent entries for this agent. Without it, only the
36    /// entries every agent gets are shown.
37    #[arg(long)]
38    pub agent: Option<String>,
39    /// Emit the inventory as JSON.
40    #[arg(long)]
41    pub json: bool,
42}
43
44/// The label for a source, matching the config key that sets it.
45fn source_label(source: SafeSource) -> &'static str {
46    match source {
47        SafeSource::Default => "built-in",
48        SafeSource::Config => "[safe_commands]",
49        SafeSource::Agent => "[agent_safe_commands]",
50        SafeSource::Blueprint => "blueprint",
51    }
52}
53
54/// Render the report, returning the text to print.
55///
56/// Split from the IO so the whole output is testable without a config file or a
57/// terminal, which is the same shape `lev tools` uses.
58fn render(keys: &std::collections::BTreeMap<String, SafeSource>, json: bool) -> String {
59    if json {
60        let rows: Vec<_> = keys
61            .iter()
62            .map(|(key, source)| serde_json::json!({ "key": key, "source": source }))
63            .collect();
64        // A `Vec<Value>` always serializes, so a failure here would be a bug in
65        // serde_json rather than a case to handle.
66        return serde_json::to_string_pretty(&rows).expect("a key listing serializes");
67    }
68    if keys.is_empty() {
69        return "nothing runs without a prompt: `[safe_commands] defaults` is off and \
70                nothing else is listed\n"
71            .to_string();
72    }
73    let width = keys.keys().map(String::len).max().unwrap_or(0);
74    let mut out = String::from("These run without an approval prompt:\n\n");
75    for (key, source) in keys {
76        let shown = key.strip_prefix("shell:").unwrap_or(key);
77        let kind = if key.starts_with("shell:") {
78            "shell"
79        } else {
80            "tool"
81        };
82        out.push_str(&format!(
83            "  {kind:<6} {shown:<width$}  {}\n",
84            source_label(*source)
85        ));
86    }
87    out.push_str(
88        "\nA shell entry covers the program it names with any arguments, so `cat` covers \
89         `cat notes.md`.\nIt does not cover a line that also runs something else: \
90         `cat x && curl evil` still asks.\n",
91    );
92    out
93}
94
95/// The agent whose per-agent entries to include. No `--agent` reports only what
96/// every agent gets, and no agent is named `""`, so the empty name matches
97/// nothing in `[agent_safe_commands]`.
98fn agent_name(args: &SafeArgs) -> &str {
99    args.agent.as_deref().unwrap_or("")
100}
101
102/// Run `lev approvals`.
103pub async fn execute(args: ApprovalsArgs) -> anyhow::Result<()> {
104    let ApprovalsCommand::Safe(safe) = args.command;
105    let config = Config::load()?;
106    // The blueprint layer is deliberately absent: it depends on which manifest
107    // is being run, and `lev validate <agent>` is where a blueprint's own
108    // declarations are reported.
109    let keys = config.safe_keys_for_agent(agent_name(&safe), None);
110    print!("{}", render(&keys, safe.json));
111    Ok(())
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use std::collections::BTreeMap;
118
119    fn keys(entries: &[(&str, SafeSource)]) -> BTreeMap<String, SafeSource> {
120        entries.iter().map(|(k, s)| (k.to_string(), *s)).collect()
121    }
122
123    /// The report has to name the file that put each key there, or it does not
124    /// answer the question it exists for.
125    #[test]
126    fn the_text_report_names_every_source() {
127        let out = render(
128            &keys(&[
129                ("shell:ls", SafeSource::Default),
130                ("shell:rg", SafeSource::Config),
131                ("shell:./gradlew", SafeSource::Agent),
132                ("web_fetch", SafeSource::Blueprint),
133            ]),
134            false,
135        );
136        assert!(out.contains("shell  ls"), "{out}");
137        assert!(out.contains("built-in"), "{out}");
138        assert!(out.contains("[safe_commands]"), "{out}");
139        assert!(out.contains("[agent_safe_commands]"), "{out}");
140        assert!(out.contains("tool   web_fetch"), "{out}");
141        assert!(out.contains("blueprint"), "{out}");
142        assert!(
143            out.contains("still asks"),
144            "the caveat is part of the answer"
145        );
146    }
147
148    #[test]
149    fn the_agent_name_defaults_to_one_that_matches_nothing() {
150        let args = |agent: Option<&str>| SafeArgs {
151            agent: agent.map(str::to_string),
152            json: false,
153        };
154        assert_eq!(agent_name(&args(Some("coder"))), "coder");
155        assert_eq!(agent_name(&args(None)), "");
156    }
157
158    /// An empty report says why it is empty rather than printing a bare header.
159    #[test]
160    fn an_empty_report_explains_itself() {
161        let out = render(&BTreeMap::new(), false);
162        assert!(out.contains("defaults` is off"), "{out}");
163    }
164
165    #[test]
166    fn the_json_report_is_machine_readable() {
167        let out = render(&keys(&[("shell:ls", SafeSource::Default)]), true);
168        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
169        assert_eq!(parsed[0]["key"], "shell:ls");
170        assert_eq!(parsed[0]["source"], "default");
171    }
172
173    #[test]
174    fn an_empty_json_report_is_an_empty_array() {
175        let parsed: serde_json::Value =
176            serde_json::from_str(&render(&BTreeMap::new(), true)).unwrap();
177        assert_eq!(parsed, serde_json::json!([]));
178    }
179}