use clap::{Args, Subcommand};
use crate::approvals::SafeSource;
use crate::config::Config;
#[derive(Args)]
pub struct ApprovalsArgs {
#[command(subcommand)]
pub command: ApprovalsCommand,
}
#[derive(Subcommand)]
pub enum ApprovalsCommand {
Safe(SafeArgs),
}
#[derive(Args)]
pub struct SafeArgs {
#[arg(long)]
pub agent: Option<String>,
#[arg(long)]
pub json: bool,
}
fn source_label(source: SafeSource) -> &'static str {
match source {
SafeSource::Default => "built-in",
SafeSource::Config => "[safe_commands]",
SafeSource::Agent => "[agent_safe_commands]",
SafeSource::Blueprint => "blueprint",
}
}
fn render(keys: &std::collections::BTreeMap<String, SafeSource>, json: bool) -> String {
if json {
let rows: Vec<_> = keys
.iter()
.map(|(key, source)| serde_json::json!({ "key": key, "source": source }))
.collect();
return serde_json::to_string_pretty(&rows).expect("a key listing serializes");
}
if keys.is_empty() {
return "nothing runs without a prompt: `[safe_commands] defaults` is off and \
nothing else is listed\n"
.to_string();
}
let width = keys.keys().map(String::len).max().unwrap_or(0);
let mut out = String::from("These run without an approval prompt:\n\n");
for (key, source) in keys {
let shown = key.strip_prefix("shell:").unwrap_or(key);
let kind = if key.starts_with("shell:") {
"shell"
} else {
"tool"
};
out.push_str(&format!(
" {kind:<6} {shown:<width$} {}\n",
source_label(*source)
));
}
out.push_str(
"\nA shell entry covers the program it names with any arguments, so `cat` covers \
`cat notes.md`.\nIt does not cover a line that also runs something else: \
`cat x && curl evil` still asks.\n",
);
out
}
fn agent_name(args: &SafeArgs) -> &str {
args.agent.as_deref().unwrap_or("")
}
pub async fn execute(args: ApprovalsArgs) -> anyhow::Result<()> {
let ApprovalsCommand::Safe(safe) = args.command;
let config = Config::load()?;
let keys = config.safe_keys_for_agent(agent_name(&safe), None);
print!("{}", render(&keys, safe.json));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn keys(entries: &[(&str, SafeSource)]) -> BTreeMap<String, SafeSource> {
entries.iter().map(|(k, s)| (k.to_string(), *s)).collect()
}
#[test]
fn the_text_report_names_every_source() {
let out = render(
&keys(&[
("shell:ls", SafeSource::Default),
("shell:rg", SafeSource::Config),
("shell:./gradlew", SafeSource::Agent),
("web_fetch", SafeSource::Blueprint),
]),
false,
);
assert!(out.contains("shell ls"), "{out}");
assert!(out.contains("built-in"), "{out}");
assert!(out.contains("[safe_commands]"), "{out}");
assert!(out.contains("[agent_safe_commands]"), "{out}");
assert!(out.contains("tool web_fetch"), "{out}");
assert!(out.contains("blueprint"), "{out}");
assert!(
out.contains("still asks"),
"the caveat is part of the answer"
);
}
#[test]
fn the_agent_name_defaults_to_one_that_matches_nothing() {
let args = |agent: Option<&str>| SafeArgs {
agent: agent.map(str::to_string),
json: false,
};
assert_eq!(agent_name(&args(Some("coder"))), "coder");
assert_eq!(agent_name(&args(None)), "");
}
#[test]
fn an_empty_report_explains_itself() {
let out = render(&BTreeMap::new(), false);
assert!(out.contains("defaults` is off"), "{out}");
}
#[test]
fn the_json_report_is_machine_readable() {
let out = render(&keys(&[("shell:ls", SafeSource::Default)]), true);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed[0]["key"], "shell:ls");
assert_eq!(parsed[0]["source"], "default");
}
#[test]
fn an_empty_json_report_is_an_empty_array() {
let parsed: serde_json::Value =
serde_json::from_str(&render(&BTreeMap::new(), true)).unwrap();
assert_eq!(parsed, serde_json::json!([]));
}
}