leviath_cli/commands/
approvals.rs1use clap::{Args, Subcommand};
13
14use crate::approvals::SafeSource;
15use crate::config::Config;
16
17#[derive(Args)]
19pub struct ApprovalsArgs {
20 #[command(subcommand)]
22 pub command: ApprovalsCommand,
23}
24
25#[derive(Subcommand)]
27pub enum ApprovalsCommand {
28 Safe(SafeArgs),
30}
31
32#[derive(Args)]
34pub struct SafeArgs {
35 #[arg(long)]
38 pub agent: Option<String>,
39 #[arg(long)]
41 pub json: bool,
42}
43
44fn 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
54fn 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 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
95fn agent_name(args: &SafeArgs) -> &str {
99 args.agent.as_deref().unwrap_or("")
100}
101
102pub async fn execute(args: ApprovalsArgs) -> anyhow::Result<()> {
104 let ApprovalsCommand::Safe(safe) = args.command;
105 let config = Config::load()?;
106 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 #[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 #[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}