pub mod catalogue;
pub mod mcp;
pub mod path;
pub mod shell;
use crate::generated::types::{EffectVerb, TargetClass};
use super::facts::FactSet;
use super::types::{Classification, ClassifiedUrl, Effect, Event, UnknownCommand, UrlBoundary};
use catalogue::{Catalogue, Fallback, Origin, ToolEntry};
pub const PATH_KEYS: &[&str] = &["file_path", "path", "notebook_path"];
pub const URL_KEYS: &[&str] = &["url", "uri"];
pub const SHELL_TOOLS: &[&str] = &["Bash", "BashOutput", "PowerShell"];
const BUILTIN_TOOLS: &[&str] = &[
"Read",
"Glob",
"Grep",
"Write",
"Edit",
"MultiEdit",
"NotebookEdit",
"WebFetch",
"WebSearch",
"Task",
"Agent",
"AskUserQuestion",
"TodoWrite",
"ExitPlanMode",
];
const PATH_CLASSES: &[&str] = &[
"secret_material",
"agent_config",
"system_path",
"classified_source",
"data_store",
"workspace_file",
];
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,
};
let tag = cls.provenance.tag_now();
catalogue::record_effect(cls, effect, tag);
}
pub fn add_unknown(cls: &mut Classification, shape: i64, reason: String, command: &str) {
let entry = UnknownCommand {
shape,
reason,
command: command.to_string(),
};
let tag = cls.provenance.tag_now();
catalogue::record_unknown(cls, entry, tag);
add_effect(cls, "unknown", "shell", attrs_of(&[]));
}
pub fn add_located_unknown(cls: &mut Classification) {
let carries_unknown_shell = cls
.effects
.iter()
.any(|e| e.verb.0 == "unknown" && e.target_class.0 == "shell");
if !carries_unknown_shell {
return;
}
let mut located: Vec<(String, catalogue::Derivations)> = Vec::new();
for (i, path) in cls.paths.iter().enumerate() {
let derivations = cls
.provenance
.paths
.get(i)
.map(|tag| tag.derivations.clone())
.unwrap_or_else(catalogue::always);
match located.iter_mut().find(|(class, _)| *class == path.class.0) {
Some((_, seen)) => {
for derivation in derivations {
if !seen.contains(&derivation) {
seen.push(derivation);
}
}
}
None => located.push((path.class.0.clone(), derivations)),
}
}
if !cls.urls.is_empty() {
located.push(("network_host".to_string(), catalogue::always()));
}
for (class, derivations) in located {
let effect = Effect {
verb: EffectVerb("unknown".to_string()),
target_class: TargetClass(class),
attrs: serde_json::Map::new(),
};
let tag = catalogue::Tag {
derivations,
extra: Vec::new(),
located: true,
};
catalogue::record_effect(cls, effect, tag);
}
}
struct MappedInput {
changed: Option<serde_json::Value>,
statements: Vec<(String, Origin)>,
commands: Vec<(String, Origin)>,
}
impl MappedInput {
fn input<'a>(&'a self, original: &'a serde_json::Value) -> &'a serde_json::Value {
self.changed.as_ref().unwrap_or(original)
}
}
fn map_input(input: &serde_json::Value, entries: &[&ToolEntry]) -> MappedInput {
let mut mapped = MappedInput {
changed: None,
statements: Vec::new(),
commands: Vec::new(),
};
for entry in entries.iter().filter(|e| !e.narrow) {
for (pointer, role) in &entry.input_map {
let Some(value) = input.pointer(pointer) else {
continue;
};
let key = match role.as_str() {
"file_path" => "file_path",
"url" => "url",
"statement" | "command" => {
if let Some(text) = value.as_str() {
let target = if role == "statement" {
&mut mapped.statements
} else {
&mut mapped.commands
};
target.push((text.to_string(), entry.origin.clone()));
}
continue;
}
_ => continue,
};
if !input.is_object() {
continue;
}
let working = mapped.changed.get_or_insert_with(|| input.clone());
let Some(object) = working.as_object_mut() else {
continue;
};
match object.get_mut(key) {
None => {
object.insert(key.to_string(), value.clone());
}
Some(existing) => {
let mut all: Vec<serde_json::Value> = match &*existing {
serde_json::Value::Array(items) => items.clone(),
other => vec![other.clone()],
};
match value {
serde_json::Value::Array(items) => all.extend(items.iter().cloned()),
other => all.push(other.clone()),
}
*existing = serde_json::Value::Array(all);
}
}
}
}
mapped
}
pub fn classify(event: &Event, facts: &FactSet, catalogue: Option<&Catalogue>) -> Classification {
let mut cls = Classification::default();
classify_into(event, facts, catalogue, &mut cls);
add_located_unknown(&mut cls);
cls
}
fn classify_into(
event: &Event,
facts: &FactSet,
catalogue: Option<&Catalogue>,
cls: &mut Classification,
) {
let env = path::PathEnv::from_event(event.env.as_ref()).with_catalogue(catalogue);
let tool = event.tool_name.as_str();
let entries: Vec<&ToolEntry> = catalogue.map(|c| c.tool_entries(tool)).unwrap_or_default();
let mapped = map_input(&event.tool_input, &entries);
let input = mapped.input(&event.tool_input);
for raw in collect(input, PATH_KEYS) {
for found in path::resolve_all(&raw, &env, facts) {
let needs = catalogue::one_of(&found.origins);
catalogue::within(cls, &needs, &[], |cls| {
shell::record_path(cls, &raw, &found.class);
});
}
}
for raw in collect(input, URL_KEYS) {
if let Some(url) = parse_url(&raw) {
cls.urls.push(url);
}
}
let mut matched: Vec<&ToolEntry> = entries
.iter()
.copied()
.filter(|entry| mcp::claims(entry, event, cls))
.collect();
if matched.iter().all(|entry| entry.narrow) {
matched.clear();
}
let classifies_shell = SHELL_TOOLS.contains(&tool);
let claimers: Vec<&ToolEntry> = matched.iter().copied().filter(|e| e.claims).collect();
if !claimers.is_empty() {
apply_tool_entries(&claimers, &mapped, tool, facts, &env, cls);
return;
}
if !matched.is_empty() {
apply_tool_entries(&matched, &mapped, tool, facts, &env, cls);
if !BUILTIN_TOOLS.contains(&tool) && !classifies_shell {
let mut fallback = Classification::default();
add_unknown(
&mut fallback,
13,
format!("{tool} is not yet classified (Z19)"),
tool,
);
cls.provenance.fallbacks.push(Fallback {
stands_for: matched.iter().map(|e| e.origin.clone()).collect(),
items: fallback,
});
}
}
if classifies_shell {
let command = input
.get("command")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
shell::classify_into(command, cls, facts, &env);
return;
}
if !matched.is_empty() || BUILTIN_TOOLS.contains(&tool) {
return;
}
if !tool.is_empty() && (tool.starts_with("mcp__") || is_tool_event(event)) {
add_unknown(cls, 13, format!("{tool} is not yet classified (Z19)"), tool);
}
}
const NON_TOOL_EVENTS: &[&str] = &[
"session_start",
"message_display",
"stop",
"post_tool_batch",
];
fn is_tool_event(event: &Event) -> bool {
!NON_TOOL_EVENTS.contains(&event.event_type.as_str())
}
fn apply_tool_entries(
matched: &[&ToolEntry],
mapped: &MappedInput,
tool: &str,
facts: &FactSet,
env: &path::PathEnv,
cls: &mut Classification,
) {
let (effects, narrowed_by) = catalogue::merge_tool_effects(matched);
let has_file_role = matched
.iter()
.any(|e| e.input_map.iter().any(|(_, role)| role == "file_path"));
for merged in &effects {
let needs = catalogue::one_of(&merged.origins);
catalogue::within(cls, &needs, &narrowed_by, |cls| {
apply_tool_effect(&merged.spec, mapped, tool, has_file_role, env, cls);
});
}
for (command, origin) in &mapped.commands {
let needs = catalogue::one_of(std::slice::from_ref(origin));
catalogue::within(cls, &needs, &narrowed_by, |cls| {
shell::classify_into(command, cls, facts, env);
});
}
}
fn apply_tool_effect(
spec: &catalogue::EffectSpec,
mapped: &MappedInput,
tool: &str,
has_file_role: bool,
env: &path::PathEnv,
cls: &mut Classification,
) {
if spec.verb == catalogue::VERB_STATEMENT {
for (statement, _) in &mapped.statements {
match shell::classify_sql(statement) {
Some(verb) => add_effect(cls, verb, &spec.target_class, attrs_of(&[])),
None => add_unknown(cls, 9, format!("{tool}: unparsed statement"), tool),
}
}
return;
}
let Some(role) = spec.target_class.strip_prefix('@') else {
if has_file_role
&& PATH_CLASSES.contains(&spec.target_class.as_str())
&& !cls.paths.is_empty()
{
add_path_tuples(cls, &spec.verb);
return;
}
add_effect(cls, &spec.verb, &spec.target_class, attrs_of(&[]));
return;
};
let (paths, urls) = match role {
"url" | "host" => (false, true),
"file" => (true, false),
_ => (true, true),
};
if (paths && !cls.paths.is_empty()) || (urls && !cls.urls.is_empty()) {
if paths {
add_path_tuples(cls, &spec.verb);
}
if urls && !cls.urls.is_empty() {
add_effect(cls, &spec.verb, "network_host", attrs_of(&[]));
let hosts: Vec<String> = cls.urls.iter().map(|u| u.host.clone()).collect();
for host in hosts {
add_host_classes(cls, &spec.verb, &host, env, &attrs_of(&[]));
}
}
return;
}
add_unknown(cls, 10, format!("{tool}: no resolvable path operand"), tool);
}
fn add_path_tuples(cls: &mut Classification, verb: &str) {
let named: Vec<(String, catalogue::Derivations)> = cls
.paths
.iter()
.enumerate()
.map(|(i, p)| {
let derivations = cls
.provenance
.paths
.get(i)
.map(|t| t.derivations.clone())
.unwrap_or_else(catalogue::always);
(p.class.0.clone(), derivations)
})
.collect();
for (class, derivations) in named {
catalogue::within(cls, &derivations, &[], |cls| {
add_effect(cls, verb, &class, attrs_of(&[]));
});
}
}
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()) {
match child {
serde_json::Value::String(text) => out.push(text.clone()),
serde_json::Value::Array(items) => {
out.extend(items.iter().filter_map(|v| v.as_str().map(str::to_string)));
}
_ => {}
}
}
collect_into(child, keys, out);
}
}
serde_json::Value::Array(items) => {
for child in items {
collect_into(child, keys, out);
}
}
_ => {}
}
}
pub(super) fn host_url(raw: &str) -> Option<ClassifiedUrl> {
let text = raw.trim_start_matches('@');
let text = text.rsplit_once('@').map_or(text, |(_, host)| host);
let host = text.split(['/', ':']).next().unwrap_or_default();
if host.is_empty()
|| host.starts_with(['+', '-'])
|| host.chars().all(|c| c.is_ascii_digit())
|| host.contains(['$', '*', '?', '`'])
{
return None;
}
let mut url = parse_url(&format!("x://{host}"))?;
url.value = raw.to_string();
url.scheme = String::new();
Some(url)
}
pub(super) fn add_host_classes(
cls: &mut Classification,
verb: &str,
host: &str,
env: &path::PathEnv,
attrs: &serde_json::Map<String, serde_json::Value>,
) {
for found in path::resolve_host(host, env) {
if found.class.0 == "network_host" {
continue;
}
let needs = catalogue::one_of(&found.origins);
catalogue::within(cls, &needs, &[], |cls| {
add_effect(cls, verb, &found.class.0, attrs.clone());
});
}
}
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::catalogue::fixtures::seed_r1;
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(),
Some(seed_r1()),
);
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(),
Some(seed_r1()),
);
assert_eq!(tuples(&cls), vec!["unknownxshell"]);
assert_eq!(cls.unknown.first().map(|u| u.shape), Some(10));
}
#[test]
fn an_unclaimed_mcp_tool_produces_the_z19_shape() {
let cls = classify(
&event(
"mcp__jira__create_issue",
serde_json::json!({"summary": "x"}),
),
&FactSet::default(),
Some(seed_r1()),
);
assert_eq!(
cls.effects,
vec![Effect {
verb: EffectVerb("unknown".to_string()),
target_class: TargetClass("shell".to_string()),
attrs: serde_json::Map::new(),
}]
);
assert_eq!(cls.unknown.len(), 1);
let unknown = &cls.unknown[0];
assert_eq!(unknown.shape, 13);
assert_eq!(unknown.command, "mcp__jira__create_issue");
assert_eq!(
unknown.reason,
"mcp__jira__create_issue is not yet classified (Z19)"
);
}
#[test]
fn a_tool_nobody_has_classified_is_unknown_whatever_the_agent() {
for tool in ["KillShell", "cline_hook:browser_action"] {
let cls = classify(
&Event {
event_type: "pre_tool_use".to_string(),
..event(tool, serde_json::json!({"shell_id": "bash-1"}))
},
&FactSet::default(),
Some(seed_r1()),
);
assert_eq!(tuples(&cls), vec!["unknownxshell"], "{tool}");
assert_eq!(cls.unknown.first().map(|u| u.shape), Some(13), "{tool}");
}
}
#[test]
fn an_event_that_is_not_a_tool_call_has_nothing_to_classify() {
let cls = classify(
&Event {
event_type: "message_display".to_string(),
..event("message_display", serde_json::json!({"message": "hi"}))
},
&FactSet::default(),
Some(seed_r1()),
);
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(),
Some(seed_r1()),
);
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(),
Some(seed_r1()),
);
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(),
Some(seed_r1()),
);
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"
);
}
use super::catalogue::fixtures::seed_r1_plus;
use super::catalogue::{Entries, Scope, SeedRev};
fn worked() -> &'static Catalogue {
seed_r1_plus(serde_json::json!({
"programs": [
{"id": "seed:prog:cat", "origin": "seed", "since_rev": 2, "key": "cat",
"effects": [{"verb": "read", "target_class": "@file"}],
"operands": {"file": "positional_all"}},
{"id": "5be0417f", "origin": "org", "key": "tar",
"effects": [{"verb": "read", "target_class": "@file"}],
"operands": {"file": "positional_all"}},
{"id": "org-rm-read", "origin": "org", "key": "rm",
"effects": [{"verb": "read", "target_class": "@file"}],
"operands": {"file": "positional_all"}},
{"id": "admin-rm-narrow", "origin": "org", "narrow": true, "key": "rm",
"effects": [{"verb": "delete", "target_class": "@file"}]},
],
"targets": [
{"id": "seed:tgt:pgpass", "origin": "seed", "since_rev": 3,
"pattern_kind": "path", "key": "~/.pgpass", "target_class": "secret_material"},
],
}))
}
fn bash(command: &str) -> Classification {
classify(
&event("Bash", serde_json::json!({"command": command})),
&FactSet::default(),
Some(worked()),
)
}
fn origins_of(cls: &Classification, verb: &str, class: &str) -> Vec<String> {
cls.tuples_with_origins()
.into_iter()
.find(|(e, _)| e.verb.0 == verb && e.target_class.0 == class)
.map(|(_, origins)| origins.iter().map(Origin::label).collect())
.unwrap_or_default()
}
#[test]
fn a_tuple_reports_every_entry_that_produced_it() {
let cls = bash("cat ~/.pgpass");
assert_eq!(
origins_of(&cls, "read", "secret_material"),
vec!["seed@2", "seed@3"],
"`cat` (seed r2) and the pattern (seed r3), nothing else"
);
let cls = bash("cat ~/.ssh/id_rsa");
assert_eq!(
origins_of(&cls, "read", "secret_material"),
vec!["seed@1", "seed@2"]
);
}
#[test]
fn narrow_subtracts_and_records_its_entry_id() {
let cls = bash("rm -rf /data/warehouse/x");
assert_eq!(
tuples(&cls),
vec!["readxdata_store"],
"the seed delete is gone"
);
assert!(
origins_of(&cls, "read", "data_store").contains(&"admin-rm-narrow".to_string()),
"{:?}",
cls.tuples_with_origins()
);
}
#[test]
fn a_view_drops_a_tuple_whose_entry_is_out_of_scope_and_falls_back_to_unknown() {
let cls = bash("tar -cf - ~/.ssh/id_rsa");
assert_eq!(tuples(&cls), vec!["readxsecret_material"]);
let strict = Scope {
seed_rev: SeedRev::Rev(2),
entries: Entries::Ids(Vec::new()),
};
let view = cls.view(&strict);
assert_eq!(
tuples(&view),
vec!["unknownxshell", "executexshell", "unknownxsecret_material"],
"the fallback of `tar`, with its target located"
);
assert_eq!(
view.unknown.iter().map(|u| u.shape).collect::<Vec<_>>(),
vec![9]
);
let explicit = Scope {
seed_rev: SeedRev::Rev(2),
entries: Entries::Ids(vec!["5be0417f".to_string()]),
};
assert_eq!(tuples(&cls.view(&explicit)), vec!["readxsecret_material"]);
assert_eq!(tuples(&cls.view(&Scope::all())), tuples(&cls));
}
#[test]
fn a_pin_before_a_pattern_does_not_see_it_but_the_classification_does() {
let cls = bash("cat ~/.pgpass");
let strict = Scope {
seed_rev: SeedRev::Rev(2),
entries: Entries::Ids(Vec::new()),
};
assert!(
!tuples(&cls.view(&strict)).contains(&"readxsecret_material".to_string()),
"{:?}",
tuples(&cls.view(&strict))
);
assert!(tuples(&cls).contains(&"readxsecret_material".to_string()));
}
#[test]
fn a_narrow_entry_applies_in_every_scope() {
let cls = bash("rm -rf /data/warehouse/x");
let pinned = Scope {
seed_rev: SeedRev::Rev(1),
entries: Entries::Ids(Vec::new()),
};
assert!(
!tuples(&cls.view(&pinned)).contains(&"deletexdata_store".to_string()),
"the correction reaches a scope that does not list the narrow entry"
);
}
#[test]
fn an_empty_classified_tool_entry_suppresses_unknown() {
let catalogue = seed_r1_plus(serde_json::json!({"tools": [
{"id": "org-noop", "origin": "org", "key": "mcp__jira__ping", "effects": []}
]}));
let cls = classify(
&event("mcp__jira__ping", serde_json::json!({})),
&FactSet::default(),
Some(catalogue),
);
assert!(cls.effects.is_empty() && cls.unknown.is_empty());
}
#[test]
fn input_map_roles_feed_the_extraction_the_sql_classifier_and_the_shell() {
let catalogue = seed_r1_plus(serde_json::json!({"tools": [
{"id": "fs", "origin": "org", "key": "mcp__fs__read_file",
"effects": [{"verb": "read", "target_class": "workspace_file"}],
"input_map": {"/path": "file_path"}},
{"id": "db", "origin": "org", "key": "mcp__db__query",
"effects": [{"verb": "@statement", "target_class": "data_store"}],
"input_map": {"/sql": "statement"}},
{"id": "sh", "origin": "org", "key": "mcp__sh__run", "effects": [],
"input_map": {"/cmd": "command"}},
{"id": "web", "origin": "org", "key": "mcp__web__get",
"effects": [{"verb": "network_egress", "target_class": "@target"}],
"input_map": {"/link": "url"}},
]}));
let run = |tool: &str, input: serde_json::Value| {
classify(&event(tool, input), &FactSet::default(), Some(catalogue))
};
let read = run(
"mcp__fs__read_file",
serde_json::json!({"path": "~/.ssh/id_rsa"}),
);
assert_eq!(tuples(&read), vec!["readxsecret_material"]);
let drop = run("mcp__db__query", serde_json::json!({"sql": "DROP TABLE t"}));
assert_eq!(tuples(&drop), vec!["deletexdata_store"]);
let cmd = run(
"mcp__sh__run",
serde_json::json!({"cmd": "rm -rf /data/warehouse/x"}),
);
assert_eq!(tuples(&cmd), vec!["deletexdata_store"]);
let web = run(
"mcp__web__get",
serde_json::json!({"link": "https://www.google.fr"}),
);
assert_eq!(tuples(&web), vec!["network_egressxnetwork_host"]);
assert_eq!(web.urls[0].host, "www.google.fr");
}
#[test]
fn an_unreadable_action_carries_a_located_tuple_per_class_and_per_url() {
let cls = bash("stat ~/.ssh/id_rsa /data/warehouse/x.db https://example.org/x");
assert_eq!(
tuples(&cls),
vec![
"unknownxshell",
"executexshell",
"unknownxsecret_material",
"unknownxdata_store",
"unknownxnetwork_host"
]
);
assert_eq!(
tuples(&bash("weirdtool --flag")),
vec!["unknownxshell", "executexshell"]
);
}
#[test]
fn global_flags_are_skipped_before_the_subcommand_and_an_unlisted_one_stops_the_match() {
let catalogue = seed_r1_plus(serde_json::json!({"programs": [
{"id": "ssm", "origin": "org", "key": "aws secretsmanager get-secret-value",
"effects": [{"verb": "read", "target_class": "secret_material"}],
"global_flags": ["--profile", "--region"]},
]}));
let run = |command: &str| {
classify(
&event("Bash", serde_json::json!({"command": command})),
&FactSet::default(),
Some(catalogue),
)
};
let listed =
run("aws --profile p --region=eu-west-1 secretsmanager get-secret-value --secret-id x");
assert_eq!(tuples(&listed), vec!["readxsecret_material"]);
let unlisted = run("aws --debug secretsmanager get-secret-value --secret-id x");
assert!(!tuples(&unlisted).contains(&"readxsecret_material".to_string()));
assert!(
tuples(&unlisted).contains(&"unknownxshell".to_string()),
"{:?}",
tuples(&unlisted)
);
}
#[test]
fn a_flags_only_entry_does_not_silence_the_unknown() {
let catalogue = seed_r1_plus(serde_json::json!({"programs": [
{"id": "git-flags", "origin": "seed", "since_rev": 2, "key": "git", "effects": [],
"global_flags": ["-C"]},
]}));
let cls = classify(
&event("Bash", serde_json::json!({"command": "git status"})),
&FactSet::default(),
Some(catalogue),
);
assert_eq!(tuples(&cls), vec!["unknownxshell", "executexshell"]);
}
#[test]
fn the_code_rows_of_d_i16_30() {
let cat = |command: &str| tuples(&bash(command));
assert_eq!(
cat("mail ops < ~/.aws/credentials"),
vec![
"readxsecret_material",
"unknownxshell",
"executexshell",
"unknownxsecret_material"
]
);
assert_eq!(cat("cat <<< hello"), Vec::<String>::new());
assert_eq!(
cat("bash -c \"cat ~/.ssh/id_rsa\""),
vec!["readxsecret_material"]
);
assert_eq!(
cat("python3 -c \"print(open('/home/dev/.ssh/id_rsa').read())\""),
vec!["executexshell", "unknownxshell", "unknownxsecret_material"]
);
assert!(cat("bash -c \"$CMD\"").contains(&"unknownxshell".to_string()));
}
#[test]
fn a_network_tuple_names_its_program() {
let cls = bash("curl https://www.google.fr");
let egress = cls
.effects
.iter()
.find(|e| e.verb.0 == "network_egress")
.expect("egress");
assert_eq!(
egress.attrs.get("program"),
Some(&serde_json::json!("curl"))
);
assert_eq!(cls.urls[0].host, "www.google.fr");
}
#[test]
fn a_bare_host_operand_is_a_host_and_a_number_or_flag_is_not() {
let host = host_url("deploy@Build.Example.com:22/x").expect("a host");
assert_eq!(host.host, "build.example.com");
assert!(host_url("1").is_none());
assert!(host_url("+short").is_none());
assert!(host_url("").is_none());
}
}