pub mod mcp;
pub mod path;
pub mod shell;
use crate::generated::types::{EffectVerb, PolicyBundleEffectClasses, TargetClass};
use super::facts::FactSet;
use super::types::{
Classification, ClassifiedPath, ClassifiedUrl, Effect, Event, UnknownCommand, UrlBoundary,
};
pub const PATH_KEYS: &[&str] = &[
"file_path",
"filePath",
"path",
"notebook_path",
"notebookPath",
"file",
];
pub const URL_KEYS: &[&str] = &["url", "uri"];
pub const SHELL_TOOLS: &[&str] = &["Bash", "BashOutput", "PowerShell"];
const BUILTIN_TOOLS: &[(&str, &[(&str, &str)])] = &[
("Read", &[("read", "PATH")]),
("Glob", &[("read", "PATH")]),
("Grep", &[("read", "PATH")]),
("Write", &[("write", "PATH")]),
("Edit", &[("write", "PATH")]),
("MultiEdit", &[("write", "PATH")]),
("NotebookEdit", &[("write", "PATH")]),
(
"WebFetch",
&[("network_egress", "network_host"), ("read", "network_host")],
),
(
"WebSearch",
&[("network_egress", "network_host"), ("read", "network_host")],
),
(
"Task",
&[("delegate", "model_call"), ("spend", "model_call")],
),
(
"Agent",
&[("delegate", "model_call"), ("spend", "model_call")],
),
("AskUserQuestion", &[]),
("TodoWrite", &[]),
("ExitPlanMode", &[]),
];
pub fn attrs_of(pairs: &[(&str, serde_json::Value)]) -> serde_json::Map<String, serde_json::Value> {
pairs
.iter()
.map(|(key, value)| ((*key).to_string(), value.clone()))
.collect()
}
pub fn add_effect(
cls: &mut Classification,
verb: &str,
target_class: &str,
attrs: serde_json::Map<String, serde_json::Value>,
) {
let effect = Effect {
verb: EffectVerb(verb.to_string()),
target_class: TargetClass(target_class.to_string()),
attrs,
};
if !cls.effects.contains(&effect) {
cls.effects.push(effect);
}
}
pub fn add_unknown(cls: &mut Classification, shape: i64, reason: String, command: &str) {
let entry = UnknownCommand {
shape,
reason,
command: command.to_string(),
};
if !cls.unknown.contains(&entry) {
cls.unknown.push(entry);
}
add_effect(cls, "unknown", "shell", attrs_of(&[]));
}
pub fn classify(
event: &Event,
facts: &FactSet,
feed: Option<&PolicyBundleEffectClasses>,
) -> Classification {
let mut cls = Classification::default();
let env = path::PathEnv::from_event(event.env.as_ref());
for raw in collect(&event.tool_input, PATH_KEYS) {
if let Some(class) = path::resolve_with(&raw, &env, facts) {
cls.paths.push(ClassifiedPath { class, value: raw });
}
}
for raw in collect(&event.tool_input, URL_KEYS) {
if let Some(url) = parse_url(&raw) {
cls.urls.push(url);
}
}
let entries = mcp::entries_for(feed, &event.tool_name);
if !entries.is_empty() && mcp::apply(&entries, event, &mut cls, facts) {
return cls;
}
if SHELL_TOOLS.contains(&event.tool_name.as_str()) {
let command = event
.tool_input
.get("command")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
shell::classify_into(command, &mut cls, facts, &env);
return cls;
}
let Some(row) = builtin_row(&event.tool_name) else {
return cls;
};
for (verb, target_class) in row {
if *target_class != "PATH" {
add_effect(&mut cls, verb, target_class, attrs_of(&[]));
continue;
}
let classes: Vec<String> = cls.paths.iter().map(|p| p.class.0.clone()).collect();
if classes.is_empty() {
add_unknown(
&mut cls,
10,
format!("{}: no resolvable path operand", event.tool_name),
&event.tool_name,
);
continue;
}
for class in classes {
add_effect(&mut cls, verb, &class, attrs_of(&[]));
}
}
cls
}
fn builtin_row(tool_name: &str) -> Option<&'static [(&'static str, &'static str)]> {
BUILTIN_TOOLS
.iter()
.find(|(name, _)| *name == tool_name)
.map(|(_, row)| *row)
}
pub fn builtin_tool_effects(tool_name: &str) -> Option<Vec<Effect>> {
builtin_row(tool_name).map(|row| {
row.iter()
.map(|(verb, target_class)| Effect {
verb: EffectVerb((*verb).to_string()),
target_class: TargetClass((*target_class).to_string()),
attrs: serde_json::Map::new(),
})
.collect()
})
}
fn collect(value: &serde_json::Value, keys: &[&str]) -> Vec<String> {
let mut out = Vec::new();
collect_into(value, keys, &mut out);
out
}
fn collect_into(value: &serde_json::Value, keys: &[&str], out: &mut Vec<String>) {
match value {
serde_json::Value::Object(map) => {
for (key, child) in map {
if keys.contains(&key.as_str()) {
if let serde_json::Value::String(text) = child {
out.push(text.clone());
}
}
collect_into(child, keys, out);
}
}
serde_json::Value::Array(items) => {
for child in items {
collect_into(child, keys, out);
}
}
_ => {}
}
}
const PRIVATE_HOST_SUFFIXES: &[&str] =
&[".local", ".internal", ".lan", ".home.arpa", ".localdomain"];
const PRIVATE_HOST_PREFIXES: &[&str] = &["10.", "127.", "192.168.", "169.254."];
fn is_private_172(host: &str) -> bool {
let octets: Vec<&str> = host.split('.').collect();
if octets.len() != 4 || !octets.iter().all(|o| o.chars().all(|c| c.is_ascii_digit())) {
return false;
}
octets[0] == "172"
&& octets[1]
.parse::<u32>()
.is_ok_and(|n| (16..=31).contains(&n))
}
pub fn parse_url(raw: &str) -> Option<ClassifiedUrl> {
if !raw.contains("://") {
return None;
}
let parsed = url::Url::parse(raw).ok()?;
let host = parsed.host_str().unwrap_or_default().to_lowercase();
let tld = psl::suffix_str(&host)
.map(str::to_string)
.unwrap_or_else(|| {
host.rsplit_once('.')
.map(|(_, last)| last.to_string())
.unwrap_or_default()
});
let internal = matches!(host.as_str(), "localhost" | "::1" | "[::1]")
|| !host.contains('.')
|| PRIVATE_HOST_SUFFIXES.iter().any(|s| host.ends_with(s))
|| PRIVATE_HOST_PREFIXES.iter().any(|p| host.starts_with(p))
|| is_private_172(&host);
Some(ClassifiedUrl {
value: raw.to_string(),
host,
tld,
scheme: parsed.scheme().to_lowercase(),
boundary: if internal {
UrlBoundary::Internal
} else {
UrlBoundary::External
},
})
}
#[cfg(test)]
mod tests {
use super::*;
fn event(tool: &str, input: serde_json::Value) -> Event {
Event {
tool_name: tool.to_string(),
tool_input: input,
..Event::default()
}
}
fn tuples(cls: &Classification) -> Vec<String> {
cls.effects
.iter()
.map(|e| format!("{}x{}", e.verb.0, e.target_class.0))
.collect()
}
#[test]
fn the_builtin_table_resolves_path_rows_from_the_calls_own_operands() {
let cls = classify(
&event(
"Read",
serde_json::json!({"file_path": "/data/warehouse/x.db"}),
),
&FactSet::default(),
None,
);
assert_eq!(tuples(&cls), vec!["readxdata_store"]);
}
#[test]
fn a_path_row_with_no_resolvable_operand_is_a_coverage_gap_not_a_guess() {
let cls = classify(
&event(
"Read",
serde_json::json!({"file_path": "/srv/blob/opaque.bin"}),
),
&FactSet::default(),
None,
);
assert_eq!(tuples(&cls), vec!["unknownxshell"]);
assert_eq!(cls.unknown.first().map(|u| u.shape), Some(10));
}
#[test]
fn a_tool_nobody_has_classified_produces_no_tuple_and_no_shape() {
let cls = classify(
&event(
"mcp__jira__create_issue",
serde_json::json!({"summary": "x"}),
),
&FactSet::default(),
None,
);
assert!(cls.effects.is_empty() && cls.unknown.is_empty());
}
#[test]
fn the_no_effect_tools_really_produce_none() {
for tool in ["AskUserQuestion", "TodoWrite", "ExitPlanMode"] {
let cls = classify(
&event(tool, serde_json::json!({})),
&FactSet::default(),
None,
);
assert!(cls.effects.is_empty(), "{tool} should carry no effect");
}
}
#[test]
fn a_builtin_tool_call_records_the_path_and_the_url_it_named() {
let cls = classify(
&event(
"Read",
serde_json::json!({"file_path": "/home/dev/.aws/credentials"}),
),
&FactSet::default(),
None,
);
assert_eq!(cls.paths.len(), 1);
assert_eq!(cls.paths[0].value, "/home/dev/.aws/credentials");
assert_eq!(cls.paths[0].class.0, "secret_material");
let cls = classify(
&event(
"WebFetch",
serde_json::json!({"url": "https://API.Example.com/v1"}),
),
&FactSet::default(),
None,
);
assert_eq!(cls.urls.len(), 1);
assert_eq!(cls.urls[0].host, "api.example.com", "lowercased");
assert_eq!(cls.urls[0].scheme, "https");
assert_eq!(cls.urls[0].boundary, UrlBoundary::External);
}
#[test]
fn the_url_boundary_is_arithmetic_and_not_a_string_prefix() {
for host in ["172.16.0.1", "172.20.5.5", "172.31.255.254"] {
let url = parse_url(&format!("https://{host}/x")).expect("parses");
assert_eq!(url.boundary, UrlBoundary::Internal, "{host} is RFC 1918");
}
for host in ["172.15.0.1", "172.32.0.1"] {
let url = parse_url(&format!("https://{host}/x")).expect("parses");
assert_eq!(url.boundary, UrlBoundary::External, "{host} is not");
}
}
#[test]
fn the_tld_is_the_public_suffix_and_not_the_last_label() {
assert_eq!(
parse_url("https://a.example.co.uk/x").expect("parses").tld,
"co.uk"
);
}
}