use kedge_core::{classify, Risk, ToolSafety};
use serde_json::Value;
pub struct Verdict {
pub safety: ToolSafety,
pub arg_reason: Option<String>,
}
pub fn classify_call(name: &str, args: &Value) -> Verdict {
let base = classify(name);
if base.is_mutating() {
return Verdict {
safety: base,
arg_reason: None,
};
}
match args_reveal_mutation(args) {
Some((risk, reason)) => Verdict {
safety: ToolSafety::Mutating { risk },
arg_reason: Some(reason),
},
None => Verdict {
safety: base,
arg_reason: None,
},
}
}
fn args_reveal_mutation(args: &Value) -> Option<(Risk, String)> {
let obj = args.as_object()?;
for key in ["method", "http_method"] {
if let Some(m) = obj.get(key).and_then(Value::as_str) {
if let Some(risk) = http_method_mutation(m) {
return Some((
risk,
format!(
"`{key}: {}` is a writing HTTP method",
m.trim().to_ascii_uppercase()
),
));
}
}
}
for key in ["operation", "action", "verb", "mode", "op"] {
if let Some(v) = obj.get(key).and_then(Value::as_str) {
if let ToolSafety::Mutating { risk } = classify(v) {
return Some((
risk,
format!("`{key}: {}` is a mutating operation", truncate(v, 40)),
));
}
}
}
for key in ["query", "sql", "statement"] {
if let Some(q) = obj.get(key).and_then(Value::as_str) {
if let Some((risk, verb)) = sql_mutation(q) {
return Some((
risk,
format!("SQL `{verb}` in `{key}` mutates the database"),
));
}
}
}
for key in ["command", "cmd", "script"] {
if let Some(c) = obj.get(key).and_then(Value::as_str) {
if let Some(reason) = shell_mutation(c) {
return Some((Risk::High, format!("`{key}`: {reason}")));
}
}
}
None
}
fn http_method_mutation(m: &str) -> Option<Risk> {
match m.trim().to_ascii_uppercase().as_str() {
"GET" | "HEAD" | "OPTIONS" | "TRACE" => None,
"DELETE" => Some(Risk::High),
"POST" | "PUT" | "PATCH" => Some(Risk::Medium),
_ => None,
}
}
const SQL_HIGH: &[&str] = &["DROP", "TRUNCATE", "DELETE", "ALTER", "GRANT", "REVOKE"];
const SQL_MED: &[&str] = &["INSERT", "UPDATE", "CREATE", "REPLACE", "MERGE", "UPSERT"];
fn sql_mutation(q: &str) -> Option<(Risk, &'static str)> {
let first = q
.split(|c: char| !c.is_ascii_alphabetic())
.find(|s| !s.is_empty())?
.to_ascii_uppercase();
if let Some(v) = SQL_HIGH.iter().find(|v| **v == first) {
return Some((Risk::High, v));
}
if let Some(v) = SQL_MED.iter().find(|v| **v == first) {
return Some((Risk::Medium, v));
}
None
}
const DESTRUCTIVE_SHELL: &[&str] = &[
"rm", "rmdir", "dd", "mkfs", "shred", "mv", "chmod", "chown", "truncate", "kill", "killall",
];
fn shell_mutation(cmd: &str) -> Option<String> {
if cmd.contains('>') {
return Some("writes via redirect (`>`)".to_string());
}
for tok in cmd.split(|c: char| c.is_whitespace() || matches!(c, '|' | ';' | '&' | '(')) {
let base = tok.trim().rsplit('/').next().unwrap_or(tok);
if DESTRUCTIVE_SHELL.contains(&base) {
return Some(format!("runs `{base}`"));
}
}
None
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}…", &s[..end])
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn is_mut(name: &str, args: serde_json::Value) -> bool {
classify_call(name, &args).safety.is_mutating()
}
#[test]
fn http_method_upgrades_a_read_looking_fetch() {
assert!(is_mut("fetch", json!({"url": "x", "method": "DELETE"})));
assert!(is_mut("request", json!({"method": "post"})));
assert!(!is_mut("fetch", json!({"url": "x", "method": "GET"})));
assert!(!is_mut("fetch", json!({"url": "x"})));
let v = classify_call("fetch", &json!({"method": "DELETE"}));
assert!(v.arg_reason.unwrap().contains("DELETE"));
}
#[test]
fn sql_verb_upgrades_a_read_looking_query() {
assert!(is_mut("query", json!({"sql": "UPDATE users SET x=1"})));
assert!(is_mut(
"query",
json!({"query": " delete from t where id=1"})
));
assert!(is_mut("run_query", json!({"query": "DROP TABLE users"})));
assert!(!is_mut("query", json!({"sql": "SELECT * FROM users"})));
}
#[test]
fn operation_discriminator_is_classified() {
assert!(is_mut("resource", json!({"operation": "delete"})));
assert!(is_mut("execute", json!({"action": "create_user"})));
assert!(!is_mut("get_thing", json!({"operation": "read"})));
}
#[test]
fn destructive_command_in_a_read_looking_tool() {
assert!(is_mut("check", json!({"command": "rm -rf /tmp/x"})));
assert!(is_mut("analyze", json!({"cmd": "echo hi > out.txt"})));
assert!(!is_mut("check", json!({"command": "cat /etc/hostname"})));
}
#[test]
fn a_mutating_name_stays_mutating_and_args_never_downgrade() {
let v = classify_call("delete_file", &json!({"method": "GET"}));
assert!(v.safety.is_mutating());
assert!(
v.arg_reason.is_none(),
"name caught it; no arg reason needed"
);
}
}