use std::collections::HashMap;
use apcore::module::ModuleAnnotations;
use crate::models::ScannedCommand;
const READONLY_PATTERNS: &[&str] = &[
"list", "ls", "show", "get", "status", "info", "version", "help", "describe", "view", "cat",
"log", "diff", "search", "find", "check", "inspect", "display", "print", "whoami", "env",
"top", "ps",
];
const DESTRUCTIVE_PATTERNS: &[&str] = &[
"delete", "rm", "remove", "destroy", "purge", "drop", "kill", "prune", "clean", "reset",
"format", "wipe", "erase",
];
const IDEMPOTENT_PATTERNS: &[&str] = &[
"get", "list", "show", "status", "info", "describe", "version", "help", "check",
];
const OPEN_WORLD_TOOLS: &[&str] = &[
"curl", "wget", "ssh", "scp", "sftp", "rsync", "nc", "netcat", "telnet", "ftp", "http",
"httpie", "wscat",
];
const EXEC_WRAPPER_TOOLS: &[&str] = &[
"env", "xargs", "nice", "ionice", "nohup", "timeout", "chroot", "setsid", "stdbuf", "script",
"watch", "time", "sudo", "doas", "su",
];
const OPEN_WORLD_SUBCOMMANDS: &[&str] = &[
"push",
"pull",
"fetch",
"clone",
"publish",
"upload",
"download",
"install",
"uninstall",
"deploy",
"sync",
"login",
"logout",
];
const APPROVAL_FLAGS: &[&str] = &[
"--force",
"-f",
"--hard",
"--recursive",
"-r",
"--all",
"--prune",
"--no-preserve-root",
"--cascade",
"--purge",
"--yes",
"-y",
];
fn infer_open_world(command: &ScannedCommand) -> bool {
let executable = executable_name(command);
if OPEN_WORLD_TOOLS.iter().any(|tool| executable == *tool) {
return true;
}
let name_lower = command.name.to_lowercase();
OPEN_WORLD_SUBCOMMANDS.iter().any(|sub| name_lower == *sub)
}
fn executable_name(command: &ScannedCommand) -> String {
command
.full_command
.split_whitespace()
.next()
.unwrap_or_default()
.rsplit(['/', '\\'])
.next()
.unwrap_or_default()
.to_lowercase()
}
fn is_exec_wrapper(command: &ScannedCommand) -> bool {
let executable = executable_name(command);
EXEC_WRAPPER_TOOLS.iter().any(|tool| executable == *tool)
}
pub fn infer(command: &ScannedCommand) -> ModuleAnnotations {
let name_lower = command.name.to_lowercase();
let exec_wrapper = is_exec_wrapper(command);
let destructive = exec_wrapper || DESTRUCTIVE_PATTERNS.iter().any(|p| name_lower == *p);
let readonly = !destructive && READONLY_PATTERNS.iter().any(|p| name_lower == *p);
let idempotent = IDEMPOTENT_PATTERNS.iter().any(|p| name_lower == *p);
let requires_approval = destructive;
let cacheable = readonly && idempotent;
ModuleAnnotations {
readonly,
destructive,
idempotent,
requires_approval,
open_world: infer_open_world(command),
streaming: false,
cacheable,
cache_ttl: 0,
cache_key_fields: None,
paginated: false,
pagination_style: "cursor".to_string(),
discoverable: true,
extra: HashMap::new(),
}
}
pub const APPROVAL_BASIS_KEY: &str = "x-apexe-approval-basis";
pub const APPROVAL_BASIS_FLAGS: &str = "flags";
pub const ESCALATING_PARAMS_KEY: &str = "x-apexe-escalating-params";
pub fn flag_literal_escalates(literal: &str) -> bool {
APPROVAL_FLAGS.contains(&literal)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{HelpFormat, StructuredOutputInfo};
fn make_root_command(executable: &str) -> ScannedCommand {
let mut command = make_command_named(executable);
command.full_command = executable.to_string();
command
}
fn make_command_named(name: &str) -> ScannedCommand {
ScannedCommand {
name: name.to_string(),
full_command: format!("tool {name}"),
description: String::new(),
flags: vec![],
positional_args: vec![],
subcommands: vec![],
examples: vec![],
help_format: HelpFormat::Gnu,
structured_output: StructuredOutputInfo::default(),
end_of_options: false,
raw_help: String::new(),
}
}
fn make_command_for(full_command: &str) -> ScannedCommand {
let name = full_command
.split_whitespace()
.last()
.unwrap_or(full_command);
ScannedCommand {
full_command: full_command.to_string(),
..make_command_named(name)
}
}
#[test]
fn test_open_world_is_inferred_not_asserted() {
assert!(!infer(&make_command_for("ls")).open_world);
assert!(!infer(&make_command_for("cat")).open_world);
assert!(!infer(&make_command_for("git add")).open_world);
}
#[test]
fn test_open_world_networked_executable() {
for executable in ["curl", "wget", "ssh", "scp", "rsync"] {
assert!(
infer(&make_command_for(executable)).open_world,
"{executable} reaches another host"
);
}
}
#[test]
fn test_open_world_executable_is_matched_by_basename() {
assert!(infer(&make_command_for("/usr/bin/curl")).open_world);
}
#[test]
fn test_open_world_networked_subcommand_of_a_local_tool() {
assert!(infer(&make_command_for("git push")).open_world);
assert!(infer(&make_command_for("git clone")).open_world);
assert!(!infer(&make_command_for("git commit")).open_world);
}
#[test]
fn test_annotations_list_is_readonly() {
let cmd = make_command_named("list");
let ann = infer(&cmd);
assert!(ann.readonly);
assert!(!ann.destructive);
}
#[test]
fn test_annotations_delete_is_destructive() {
let cmd = make_command_named("delete");
let ann = infer(&cmd);
assert!(ann.destructive);
assert!(ann.requires_approval);
assert!(!ann.readonly);
}
#[test]
fn test_annotations_create_is_write() {
let cmd = make_command_named("create");
let ann = infer(&cmd);
assert!(!ann.readonly);
assert!(!ann.destructive);
}
#[test]
fn test_annotations_get_is_idempotent() {
let cmd = make_command_named("get");
let ann = infer(&cmd);
assert!(ann.idempotent);
}
#[test]
fn test_annotations_readonly_is_cacheable() {
let cmd = make_command_named("status");
let ann = infer(&cmd);
assert!(ann.readonly);
assert!(ann.idempotent);
assert!(ann.cacheable);
}
#[test]
fn test_annotations_unknown_defaults() {
let cmd = make_command_named("xyzzy");
let ann = infer(&cmd);
assert!(!ann.readonly);
assert!(!ann.destructive);
assert!(!ann.idempotent);
assert!(!ann.cacheable);
assert!(!ann.requires_approval);
}
fn make_command_with_flags(name: &str, flags: Vec<(&str, &str)>) -> ScannedCommand {
use crate::models::{ScannedFlag, ValueType};
let scanned_flags = flags
.into_iter()
.map(|(long, short)| ScannedFlag {
long_name: if long.is_empty() {
None
} else {
Some(long.to_string())
},
short_name: if short.is_empty() {
None
} else {
Some(short.to_string())
},
description: String::new(),
value_type: ValueType::Boolean,
required: false,
default: None,
enum_values: None,
repeatable: false,
value_name: None,
..Default::default()
})
.collect();
ScannedCommand {
name: name.to_string(),
flags: scanned_flags,
..make_command_named(name)
}
}
#[test]
fn test_infer_does_not_escalate_on_a_merely_accepted_approval_flag() {
let cmd = make_command_with_flags("push", vec![("--force", "-f")]);
let ann = infer(&cmd);
assert!(
!ann.requires_approval,
"an accepted flag is a property of the command, not of a call"
);
}
#[test]
fn test_infer_leaves_a_reader_unescalated_despite_accepting_all() {
let cmd = make_command_with_flags("log", vec![("--all", "-a")]);
let ann = infer(&cmd);
assert!(!ann.requires_approval);
assert!(ann.readonly);
}
#[test]
fn test_infer_does_not_mark_idempotent_from_an_accepted_dry_run_flag() {
let cmd = make_command_with_flags("apply", vec![("--dry-run", "")]);
let ann = infer(&cmd);
assert!(!ann.idempotent);
assert!(!ann.cacheable);
}
#[test]
fn test_infer_still_escalates_a_destructive_name_without_any_flag() {
let cmd = make_command_with_flags("delete", vec![]);
let ann = infer(&cmd);
assert!(ann.destructive);
assert!(ann.requires_approval);
}
#[test]
fn test_flag_literal_escalates_matches_long_and_short_forms() {
assert!(flag_literal_escalates("--force"));
assert!(flag_literal_escalates("-f"));
assert!(flag_literal_escalates("--all"));
assert!(!flag_literal_escalates("--verbose"));
assert!(!flag_literal_escalates(""));
}
#[test]
fn test_infer_classifies_env_as_destructive_not_readonly() {
let annotations = infer(&make_root_command("env"));
assert!(!annotations.readonly, "env executes arbitrary commands");
assert!(annotations.destructive);
assert!(annotations.requires_approval);
assert!(
!annotations.cacheable,
"a command executor's output is not cacheable"
);
}
#[test]
fn test_infer_classifies_every_exec_wrapper_as_destructive() {
for tool in EXEC_WRAPPER_TOOLS {
let annotations = infer(&make_root_command(tool));
assert!(
annotations.destructive && !annotations.readonly,
"{tool} runs whatever it is handed and must not read as safe"
);
}
}
#[test]
fn test_infer_does_not_escalate_a_subcommand_named_like_a_wrapper() {
let mut command = make_command_named("watch");
command.full_command = "kubectl watch".to_string();
assert!(!infer(&command).destructive);
}
#[test]
fn test_infer_still_classifies_an_ordinary_reader_as_readonly() {
assert!(infer(&make_root_command("cat")).readonly);
assert!(infer(&make_root_command("ls")).readonly);
}
}