pub mod cmd_parser;
pub mod destructive;
pub mod parser;
pub mod platform;
pub mod readonly;
pub mod semantics;
pub use destructive::destructive_rm_verdict;
pub use parser::{parse_for_security, ParseResult, Redirect, RedirectKind, SimpleCommand};
pub use platform::{detect_shell_type, ShellType};
pub use readonly::is_readonly;
pub use semantics::{check_semantics, SemanticResult};
pub fn analyze_command_safety<F>(command: &str, regex_check: F) -> CommandSafety
where
F: Fn(&str) -> bool,
{
let parsed = parse_for_security(command);
match parsed {
ParseResult::Simple { commands } => {
let sem = check_semantics(&commands);
match sem {
SemanticResult::Ok => {}
SemanticResult::Dangerous(reason) => return CommandSafety::Dangerous(reason),
SemanticResult::TooComplex(reason) => {
return CommandSafety::RequiresApproval(reason)
}
}
for cmd in &commands {
let cmd_str = cmd.argv.join(" ");
if regex_check(&cmd_str) {
return CommandSafety::Dangerous(format!("subcommand blocked: {}", cmd_str));
}
}
CommandSafety::Safe
}
ParseResult::TooComplex { reason, .. } => {
CommandSafety::RequiresApproval(reason)
}
ParseResult::ParseUnavailable => decide_parse_unavailable(command, regex_check),
ParseResult::ParseAborted { reason } => {
CommandSafety::RequiresApproval(format!("parse aborted: {reason}"))
}
}
}
pub(crate) fn decide_parse_unavailable<F>(command: &str, regex_check: F) -> CommandSafety
where
F: Fn(&str) -> bool,
{
if regex_check(command) {
CommandSafety::Dangerous("regex matched (AST unavailable)".into())
} else {
CommandSafety::RequiresApproval(
"AST parse unavailable and regex found no known-dangerous \
pattern; requiring approval as a fail-closed fallback"
.into(),
)
}
}
#[derive(Debug, Clone)]
pub enum CommandSafety {
Safe,
Dangerous(String),
RequiresApproval(String),
}
#[cfg(test)]
mod stage3_tests {
use super::*;
fn regex_matches_rm(cmd: &str) -> bool {
cmd.contains("rm -rf")
}
#[test]
fn stage3_simple_safe_command_is_safe() {
let result = analyze_command_safety("ls -la", regex_matches_rm);
assert!(matches!(result, CommandSafety::Safe));
}
#[test]
fn stage3_dangerous_subcommand_is_caught() {
let result = analyze_command_safety("rm -rf /", regex_matches_rm);
assert!(matches!(result, CommandSafety::Dangerous(_)));
}
#[test]
fn stage3_compound_command_dangerous_subcommand_caught() {
let result = analyze_command_safety("ls && rm -rf /tmp", regex_matches_rm);
assert!(matches!(result, CommandSafety::Dangerous(_)));
}
#[test]
fn stage3_quoted_dangerous_pattern_is_safe() {
let result = analyze_command_safety("echo \"rm -rf /\"", regex_matches_rm);
assert!(matches!(result, CommandSafety::Dangerous(_)));
}
#[test]
fn stage3_too_complex_always_requires_approval() {
let result = analyze_command_safety("echo $(whoami)", regex_matches_rm);
assert!(
matches!(result, CommandSafety::RequiresApproval(_)),
"TooComplex should be RequiresApproval in Stage 3, got: {:?}",
result
);
}
#[test]
fn stage3_too_complex_with_regex_match_still_requires_approval() {
let result = analyze_command_safety("rm -rf $(echo /tmp)", regex_matches_rm);
assert!(
matches!(result, CommandSafety::RequiresApproval(_)),
"TooComplex should be RequiresApproval even if regex matches, got: {:?}",
result
);
}
#[test]
fn stage3_semantic_danger_overrides_everything() {
let result = analyze_command_safety("eval 'evil code'", |_| false);
assert!(matches!(result, CommandSafety::Dangerous(_)));
}
#[test]
fn stage3_safe_compound_command() {
let result = analyze_command_safety("ls && echo done", regex_matches_rm);
assert!(matches!(result, CommandSafety::Safe));
}
#[test]
fn stage3_parse_unavailable_falls_back_to_regex() {
let result = analyze_command_safety("dir", regex_matches_rm);
assert!(
!matches!(result, CommandSafety::Dangerous(_)),
"clean simple command must not be Dangerous, got {:?}",
result
);
}
#[test]
fn d06_parse_unavailable_clean_regex_requires_approval() {
let result = decide_parse_unavailable("some unparsable command", regex_matches_rm);
assert!(
matches!(result, CommandSafety::RequiresApproval(_)),
"fail-closed violated: got {:?}",
result
);
let permissive = decide_parse_unavailable("anything", |_| false);
assert!(
matches!(permissive, CommandSafety::RequiresApproval(_)),
"fail-closed violated with a permissive regex, got {:?}",
permissive
);
let blocked = decide_parse_unavailable("rm -rf /", regex_matches_rm);
assert!(
matches!(blocked, CommandSafety::Dangerous(_)),
"blocked path violated, got {:?}",
blocked
);
}
#[test]
fn stage3_parse_unavailable_with_regex_match_is_dangerous() {
let result = analyze_command_safety("rm -rf /", regex_matches_rm);
assert!(matches!(result, CommandSafety::Dangerous(_)));
}
#[test]
fn stage3_per_subcommand_check_catches_danger_in_second_segment() {
let result = analyze_command_safety("git status && rm -rf /", regex_matches_rm);
assert!(matches!(result, CommandSafety::Dangerous(_)));
}
#[test]
fn stage3_env_prefix_does_not_bypass_check() {
let result = analyze_command_safety("FOO=bar rm -rf /", regex_matches_rm);
assert!(matches!(result, CommandSafety::Dangerous(_)));
}
}