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, EffectOccurrence, EffectTarget, 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_occurrence(
cls: &mut Classification,
verb: &str,
target_class: Option<&str>,
attrs: serde_json::Map<String, serde_json::Value>,
target: Option<EffectTarget>,
) {
let occurrence = EffectOccurrence {
verb: EffectVerb(verb.to_string()),
target_class: target_class.map(|value| TargetClass(value.to_string())),
attrs,
target,
};
let tag = cls.provenance.tag_now();
catalogue::record_occurrence_tagged(cls, occurrence, tag);
}
pub fn observe_target(
cls: &mut Classification,
raw: &str,
env: &path::PathEnv<'_>,
) -> Option<EffectTarget> {
let target = EffectTarget {
raw: raw.to_string(),
normalized: path::normalize_target(raw, env)?,
};
let tag = cls.provenance.tag_now();
catalogue::record_observed_target_tagged(cls, target.clone(), tag);
Some(target)
}
pub fn observe_shell_target(
cls: &mut Classification,
cooked: &str,
raw: &str,
env: &path::PathEnv<'_>,
) -> Option<EffectTarget> {
let target = EffectTarget {
raw: cooked.to_string(),
normalized: path::normalize_shell_target(cooked, raw, env)?,
};
let tag = cls.provenance.tag_now();
catalogue::record_observed_target_tagged(cls, target.clone(), tag);
Some(target)
}
pub fn add_unknown(cls: &mut Classification, shape: i64, reason: String, command: &str) {
add_unknown_with_targets(cls, shape, reason, command, &[]);
}
pub fn add_unknown_with_targets(
cls: &mut Classification,
shape: i64,
reason: String,
command: &str,
targets: &[EffectTarget],
) {
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(&[]));
if targets.is_empty() {
add_occurrence(cls, "unknown", None, attrs_of(&[]), None);
} else {
for target in targets {
add_occurrence(cls, "unknown", None, attrs_of(&[]), Some(target.clone()));
}
}
}
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)>,
paths: Vec<(String, Origin)>,
path_gaps: Vec<Origin>,
canonical_path_malformed: bool,
}
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(),
paths: Vec::new(),
path_gaps: Vec::new(),
canonical_path_malformed: false,
};
for entry in entries.iter().filter(|e| !e.narrow) {
for (pointer, role) in &entry.input_map {
let Some(value) = input.pointer(pointer) else {
if role == "file_path" && !mapped.path_gaps.contains(&entry.origin) {
mapped.path_gaps.push(entry.origin.clone());
}
continue;
};
let key = match role.as_str() {
"file_path" => {
match value {
serde_json::Value::String(text) => {
mapped.paths.push((text.clone(), entry.origin.clone()));
}
serde_json::Value::Array(items) => {
if items.iter().any(|item| item.as_str().is_none())
&& !mapped.path_gaps.contains(&entry.origin)
{
mapped.path_gaps.push(entry.origin.clone());
}
mapped.paths.extend(items.iter().filter_map(|item| {
item.as_str()
.map(|text| (text.to_string(), entry.origin.clone()))
}));
}
_ => {}
}
if !matches!(
value,
serde_json::Value::String(_) | serde_json::Value::Array(_)
) && !mapped.path_gaps.contains(&entry.origin)
{
mapped.path_gaps.push(entry.origin.clone());
}
"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 mut mapped = map_input(&event.tool_input, &entries);
let (raw_paths, malformed_path) = collect_paths(&event.tool_input);
mapped.canonical_path_malformed = malformed_path;
let input = mapped.input(&event.tool_input);
for raw in raw_paths {
record_path_input(cls, &raw, facts, &env);
}
for (raw, origin) in &mapped.paths {
let needs = catalogue::one_of(std::slice::from_ref(origin));
catalogue::within(cls, &needs, &[], |cls| {
record_path_input(cls, raw, facts, &env);
});
}
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)) {
let targets = cls.observed_targets.clone();
add_unknown_with_targets(
cls,
13,
format!("{tool} is not yet classified (Z19)"),
tool,
&targets,
);
}
}
fn record_path_input(
cls: &mut Classification,
raw: &str,
facts: &FactSet,
env: &path::PathEnv<'_>,
) {
observe_target(cls, raw, env);
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);
});
}
}
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 && spec.verb == "write" {
if mapped.canonical_path_malformed
|| (cls.observed_targets.is_empty() && mapped.path_gaps.is_empty())
{
add_occurrence(cls, "unknown", None, attrs_of(&[]), None);
}
for origin in &mapped.path_gaps {
let needs = catalogue::one_of(std::slice::from_ref(origin));
catalogue::within(cls, &needs, &[], |cls| {
add_occurrence(cls, "unknown", None, attrs_of(&[]), None);
});
}
}
if paths && !cls.observed_targets.is_empty() {
add_path_occurrences(cls, &spec.verb);
}
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_class_gap(cls, 10, format!("{tool}: no resolvable path operand"), tool);
}
pub fn add_class_gap(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(&[]));
}
fn add_path_occurrences(cls: &mut Classification, verb: &str) {
let targets: Vec<(EffectTarget, catalogue::Derivations)> = cls
.observed_targets
.iter()
.enumerate()
.map(|(index, target)| {
let derivations = cls
.provenance
.observed_targets
.get(index)
.map(|tag| tag.derivations.clone())
.unwrap_or_else(catalogue::always);
(target.clone(), derivations)
})
.collect();
for (target, target_derivations) in targets {
let classes: Vec<(String, catalogue::Derivations)> = cls
.paths
.iter()
.enumerate()
.filter(|(_, path)| path.value == target.raw)
.map(|(index, path)| {
let derivations = cls
.provenance
.paths
.get(index)
.map(|tag| tag.derivations.clone())
.unwrap_or_else(catalogue::always);
(path.class.0.clone(), derivations)
})
.collect();
if classes.is_empty() {
catalogue::within(cls, &target_derivations, &[], |cls| {
add_occurrence(cls, verb, None, attrs_of(&[]), Some(target));
});
} else {
for (class, derivations) in classes {
let needs = catalogue::compose(&target_derivations, &derivations);
catalogue::within(cls, &needs, &[], |cls| {
add_occurrence(cls, verb, Some(&class), attrs_of(&[]), Some(target.clone()));
});
}
}
}
}
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_paths(value: &serde_json::Value) -> (Vec<String>, bool) {
let mut found = Vec::new();
let mut malformed = false;
collect_paths_into(value, &mut found, &mut malformed);
(found, malformed)
}
fn collect_paths_into(value: &serde_json::Value, out: &mut Vec<String>, malformed: &mut bool) {
match value {
serde_json::Value::Object(map) => {
for (key, child) in map {
if PATH_KEYS.contains(&key.as_str()) {
match child {
serde_json::Value::String(text) if !text.is_empty() => {
out.push(text.clone())
}
serde_json::Value::Array(items) => {
for item in items {
match item.as_str() {
Some(text) if !text.is_empty() => out.push(text.to_string()),
_ => *malformed = true,
}
}
}
_ => *malformed = true,
}
}
collect_paths_into(child, out, malformed);
}
}
serde_json::Value::Array(items) => {
for item in items {
collect_paths_into(item, out, malformed);
}
}
_ => {}
}
}
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 event_with_env(tool: &str, input: serde_json::Value) -> Event {
Event {
env: Some(super::super::types::EventEnv {
home: Some("/home/dev".to_string()),
cwd: Some("/home/dev/proj".to_string()),
path_dirs: vec!["/usr/bin".to_string()],
additional_dirs: Vec::new(),
}),
..event(tool, input)
}
}
#[test]
fn known_file_verbs_keep_unclassified_targets_without_guessing_a_class() {
let write = classify(
&event_with_env("Write", serde_json::json!({"file_path": "/srv/out"})),
&FactSet::default(),
Some(seed_r1()),
);
assert!(write.occurrences.iter().any(|occurrence| {
occurrence.verb.0 == "write"
&& occurrence.target_class.is_none()
&& occurrence
.target
.as_ref()
.map(|target| target.normalized.as_str())
== Some("/srv/out")
}));
assert!(!write
.occurrences
.iter()
.any(|occurrence| occurrence.verb.0 == "unknown"));
let read = classify(
&event_with_env("Read", serde_json::json!({"file_path": "/srv/in"})),
&FactSet::default(),
Some(seed_r1()),
);
assert!(read
.occurrences
.iter()
.any(|occurrence| occurrence.verb.0 == "read"));
assert!(!read
.occurrences
.iter()
.any(|occurrence| occurrence.verb.0 == "write"));
assert!(!read
.occurrences
.iter()
.any(|occurrence| occurrence.verb.0 == "unknown"));
}
#[test]
fn multi_edit_keeps_each_write_target_and_malformed_targets_are_unknown() {
let class = classify(
&event_with_env(
"MultiEdit",
serde_json::json!({"files": [
{"file_path": "/tmp/inside", "content": "ok"},
{"file_path": "/srv/outside", "content": "bad"},
{"file_path": 7, "content": "unknown"}
]}),
),
&FactSet::default(),
Some(seed_r1()),
);
let write_targets: Vec<&str> = class
.occurrences
.iter()
.filter(|occurrence| occurrence.verb.0 == "write")
.filter_map(|occurrence| {
occurrence
.target
.as_ref()
.map(|target| target.normalized.as_str())
})
.collect();
assert!(write_targets.contains(&"/tmp/inside") && write_targets.contains(&"/srv/outside"));
assert!(class
.occurrences
.iter()
.any(|occurrence| occurrence.verb.0 == "unknown" && occurrence.target.is_none()));
for input in [
serde_json::json!({}),
serde_json::json!({"file_path": null}),
serde_json::json!({"file_path": 7}),
serde_json::json!({"file_path": {"nested": true}}),
] {
let malformed = classify(
&event_with_env("Write", input),
&FactSet::default(),
Some(seed_r1()),
);
assert!(malformed
.occurrences
.iter()
.any(|occurrence| occurrence.verb.0 == "unknown" && occurrence.target.is_none()));
assert!(!malformed
.occurrences
.iter()
.any(|occurrence| occurrence.verb.0 == "write"));
}
}
#[test]
fn a_shared_catalogue_file_role_does_not_guess_which_operand_is_written() {
let catalogue = seed_r1_plus(serde_json::json!({
"programs": [{
"id": "seed:prog:dd", "origin": "seed", "since_rev": 2, "key": "dd",
"effects": [
{"verb": "read", "target_class": "@file"},
{"verb": "write", "target_class": "@file"}
],
"operands": {"file": ["kv:if", "kv:of"]}
}]
}));
let class = classify(
&event_with_env(
"Bash",
serde_json::json!({"command": "dd if=/home/dev/proj/in of=/home/dev/proj/out"}),
),
&FactSet::default(),
Some(catalogue),
);
assert!(class.effects.iter().any(|effect| effect.verb.0 == "read"));
assert!(class.effects.iter().any(|effect| effect.verb.0 == "write"));
assert!(class
.occurrences
.iter()
.any(|occurrence| occurrence.verb.0 == "unknown" && occurrence.target.is_some()));
assert!(!class
.occurrences
.iter()
.any(|occurrence| matches!(occurrence.verb.0.as_str(), "read" | "write")));
}
#[test]
fn dynamic_shell_targets_are_unknown_but_direct_tool_paths_are_literal() {
for command in ["cp /tmp/in \"$DEST\"", "echo x | tee \"$DEST\""] {
let class = classify(
&event_with_env("Bash", serde_json::json!({"command": command})),
&FactSet::default(),
Some(seed_r1()),
);
assert!(!class
.occurrences
.iter()
.any(|occurrence| occurrence.verb.0 == "write"));
assert!(class.effects.iter().any(|effect| effect.verb.0 == "write"));
assert!(class
.occurrences
.iter()
.any(|occurrence| occurrence.verb.0 == "unknown" && occurrence.target.is_none()));
}
let direct = classify(
&event_with_env("Write", serde_json::json!({"file_path": "$DEST"})),
&FactSet::default(),
Some(seed_r1()),
);
assert!(direct.occurrences.iter().any(|occurrence| {
occurrence.verb.0 == "write"
&& occurrence
.target
.as_ref()
.is_some_and(|target| target.normalized.ends_with("/$DEST"))
}));
let cross_drive = classify(
&Event {
tool_name: "Write".to_string(),
tool_input: serde_json::json!({"file_path": "C:tmp\\out"}),
env: Some(super::super::types::EventEnv {
cwd: Some("D:\\work".to_string()),
..super::super::types::EventEnv::default()
}),
..Event::default()
},
&FactSet::default(),
Some(seed_r1()),
);
assert!(cross_drive
.occurrences
.iter()
.any(|occurrence| occurrence.verb.0 == "unknown" && occurrence.target.is_none()));
assert!(!cross_drive
.occurrences
.iter()
.any(|occurrence| occurrence.verb.0 == "write"));
}
#[test]
fn a_native_windows_path_classifies_like_its_forward_slash_form() {
let catalogue = super::catalogue::fixtures::seed_r1_plus(serde_json::json!({
"targets": [{
"id": "org:tgt:vault", "origin": "org", "since_rev": 1,
"pattern_kind": "path", "key": "**\\vault\\**",
"target_class": "secret_material"
}]
}));
let classify_at = |cwd: &str, tool: &str, path: &str| {
let cls = classify(
&Event {
env: Some(super::super::types::EventEnv {
home: Some("C:\\Users\\dev".to_string()),
cwd: Some(cwd.to_string()),
..super::super::types::EventEnv::default()
}),
..event(tool, serde_json::json!({"file_path": path}))
},
&FactSet::default(),
Some(catalogue),
);
tuples(&cls)
};
let windows = |tool: &str, path: &str| classify_at("C:\\Users\\dev\\project", tool, path);
for (tool, path, tuple) in [
(
"Read",
"C:\\Users\\dev\\.aws\\credentials",
"readxsecret_material",
),
(
"Read",
"C:/Users/dev/.aws/credentials",
"readxsecret_material",
),
(
"Read",
"C:\\Users\\dev\\project\\.env",
"readxsecret_material",
),
(
"Write",
"C:\\Users\\dev\\project\\.env",
"writexsecret_material",
),
(
"Edit",
"C:\\Users\\dev\\project\\.env",
"writexsecret_material",
),
("Read", "C:\\Users\\dev\\vault\\key", "readxsecret_material"),
("Write", "C:\\Windows\\System32\\hosts", "writexsystem_path"),
("Write", "C:/Windows/System32/hosts", "writexsystem_path"),
(
"Write",
"C:\\Users\\dev\\project\\src\\main.rs",
"writexworkspace_file",
),
("Write", "src\\main.rs", "writexworkspace_file"),
] {
let got = windows(tool, path);
assert!(got.contains(&tuple.to_string()), "{tool} {path}: {got:?}");
}
for (tool, path, tuple) in [
(
"Read",
"C:\\Users\\dev\\project\\.ENV",
"readxsecret_material",
),
(
"Read",
"c:\\users\\DEV\\.AWS\\credentials",
"readxsecret_material",
),
("Read", "C:\\Users\\dev\\VAULT\\key", "readxsecret_material"),
("Write", "c:\\windows\\system32\\hosts", "writexsystem_path"),
(
"Read",
"c:\\users\\dev\\project\\src\\main.rs",
"readxworkspace_file",
),
] {
let got = windows(tool, path);
assert!(got.contains(&tuple.to_string()), "{tool} {path}: {got:?}");
}
for cwd in ["\\\\server\\share\\project", "//server/share/project"] {
let got = classify_at(cwd, "Read", ".aws\\credentials");
assert!(
got.contains(&"readxsecret_material".to_string()),
"{cwd}: {got:?}"
);
}
assert!(!windows("Write", "C:\\srv\\blob.bin")
.iter()
.any(|tuple| tuple.ends_with("workspace_file")));
}
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]
);
assert!(!view
.occurrences
.iter()
.any(|occurrence| occurrence.verb.0 == "read"));
let explicit = Scope {
seed_rev: SeedRev::Rev(2),
entries: Entries::Ids(vec!["5be0417f".to_string()]),
};
let explicit_view = cls.view(&explicit);
assert_eq!(tuples(&explicit_view), vec!["readxsecret_material"]);
assert!(explicit_view
.occurrences
.iter()
.any(|occurrence| occurrence.verb.0 == "read"));
assert_eq!(tuples(&cls.view(&Scope::all())), tuples(&cls));
}
#[test]
fn unknown_occurrences_use_only_their_own_simple_commands_targets() {
for command in ["cat /tmp/in && mystery", "mystery /tmp/in && mystery"] {
let cls = bash(command);
assert!(cls
.occurrences
.iter()
.any(|occurrence| occurrence.verb.0 == "unknown" && occurrence.target.is_none()),
"the targetless sibling must remain visible for {command:?}"
);
}
}
#[test]
fn mapped_targets_and_effects_both_belong_to_an_occurrences_scope() {
let catalogue = seed_r1_plus(serde_json::json!({
"tools": [
{"id": "map-a", "origin": "org", "key": "mcp__fs__write", "effects": [],
"input_map": {"/p": "file_path"}},
{"id": "write-b", "origin": "org", "key": "mcp__fs__write",
"effects": [{"verb": "write", "target_class": "@target"}]}
]
}));
let cls = classify(
&event("mcp__fs__write", serde_json::json!({"p": "/srv/out"})),
&FactSet::default(),
Some(catalogue),
);
let write_count = |view: &Classification| {
view.occurrences
.iter()
.filter(|occurrence| occurrence.verb.0 == "write")
.count()
};
assert_eq!(write_count(&cls), 1);
let effect_only = Scope {
seed_rev: SeedRev::Latest,
entries: Entries::Ids(vec!["write-b".to_string()]),
};
assert_eq!(write_count(&cls.view(&effect_only)), 0);
let both = Scope {
seed_rev: SeedRev::Latest,
entries: Entries::Ids(vec!["map-a".to_string(), "write-b".to_string()]),
};
assert_eq!(write_count(&cls.view(&both)), 1);
let missing = classify(
&event("mcp__fs__write", serde_json::json!({})),
&FactSet::default(),
Some(catalogue),
);
let targetless_unknown = |view: &Classification| {
view.occurrences
.iter()
.any(|occurrence| occurrence.verb.0 == "unknown" && occurrence.target.is_none())
};
assert!(targetless_unknown(&missing));
assert!(!targetless_unknown(&missing.view(&effect_only)));
assert!(targetless_unknown(&missing.view(&both)));
}
#[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());
}
fn occurrences(cls: &Classification) -> Vec<(String, Option<String>, Option<String>)> {
cls.occurrences
.iter()
.map(|o| {
(
o.verb.0.clone(),
o.target_class.as_ref().map(|c| c.0.clone()),
o.target.as_ref().map(|t| t.normalized.clone()),
)
})
.collect()
}
fn programs(cls: &Classification) -> Vec<String> {
cls.effects
.iter()
.filter_map(|e| e.attrs.get("program").and_then(|p| p.as_str()))
.map(str::to_string)
.collect()
}
fn qa(tool: &str, input: serde_json::Value) -> Classification {
classify(
&event_with_env(tool, input),
&FactSet::default(),
Some(seed_r1()),
)
}
fn write_to(path: &str) -> (String, Option<String>, Option<String>) {
("write".to_string(), None, Some(path.to_string()))
}
#[test]
fn an_inline_python_write_is_a_located_write_beside_its_gap() {
let cls = qa(
"Bash",
serde_json::json!({"command": "python -c \"open('/home/dev/temp/openlatch-nearmiss.txt','w').write('t\\n')\"; echo \"exit=$?\"; ls -la /home/dev/temp/openlatch-nearmiss.txt 2>&1"}),
);
let found = occurrences(&cls);
assert!(found.contains(&write_to("/home/dev/temp/openlatch-nearmiss.txt")));
assert!(found.contains(&("unknown".to_string(), None, None)));
assert_eq!(programs(&cls), vec!["python", "echo", "ls"]);
}
#[test]
fn a_redirect_write_keeps_its_target_and_fd_duplication_names_no_program() {
let cls = qa(
"Bash",
serde_json::json!({"command": "echo \"x\" > /home/dev/temp/openlatch-test-echo.txt; echo \"exit=$?\"; ls -la /home/dev/temp/openlatch-test-echo.txt 2>&1"}),
);
assert!(occurrences(&cls).contains(&write_to("/home/dev/temp/openlatch-test-echo.txt")));
assert_eq!(programs(&cls), vec!["echo", "ls"]);
assert!(cls
.simple
.iter()
.all(|sc| !sc.argv.iter().any(|a| a == "2>")));
}
#[test]
fn a_write_tool_outside_every_class_is_a_located_write_and_a_class_gap() {
let cls = qa(
"Write",
serde_json::json!({"file_path": "/home/dev/temp/openlatch-test.txt", "content": "x"}),
);
assert_eq!(
occurrences(&cls),
vec![write_to("/home/dev/temp/openlatch-test.txt")]
);
assert_eq!(tuples(&cls), vec!["unknownxshell"]);
assert_eq!(cls.unknown[0].shape, 10);
}
#[test]
fn fd_duplications_and_closes_are_not_commands() {
for command in [
"ls -la /tmp/x 2>&1",
"ls -la /tmp/x >&2",
"ls -la /tmp/x 1>&2 2>&-",
"ls -la /tmp/x 0<&3",
] {
let cls = qa("Bash", serde_json::json!({"command": command}));
assert_eq!(programs(&cls), vec!["ls"], "{command}");
assert_eq!(cls.simple.len(), 1, "{command}");
assert_eq!(cls.simple[0].argv, vec!["-la", "/tmp/x"], "{command}");
}
for command in ["make &> /srv/build.log", "make >& /srv/build.log"] {
let cls = qa("Bash", serde_json::json!({"command": command}));
assert_eq!(programs(&cls), vec!["make"], "{command}");
assert!(
occurrences(&cls).contains(&write_to("/srv/build.log")),
"{command}"
);
}
}
#[test]
fn inline_write_idioms_locate_only_plain_literal_paths() {
let written = |command: &str| -> Vec<String> {
occurrences(&qa("Bash", serde_json::json!({"command": command})))
.into_iter()
.filter(|(verb, _, _)| verb == "write")
.filter_map(|(_, _, target)| target)
.collect()
};
assert_eq!(
written("python3 -c \"open('/srv/a', mode='a')\""),
vec!["/srv/a"]
);
assert_eq!(
written("python3 -c \"from pathlib import Path; Path('/srv/b').write_text('x')\""),
vec!["/srv/b"]
);
assert_eq!(
written("python3 -c \"Path('/srv/c').open('w')\""),
vec!["/srv/c"]
);
assert_eq!(
written("node -e \"require('fs').writeFileSync('/srv/d', 'x')\""),
vec!["/srv/d"]
);
assert_eq!(
written("python3 -c \"open('out.txt','w')\""),
vec!["/home/dev/proj/out.txt"]
);
for command in [
"python3 -c \"print(open('/srv/r').read())\"",
"python3 -c \"open('/srv/' + name, 'w')\"",
"python3 -c \"open(f'/srv/{name}', 'w')\"",
"python3 -c \"open('/srv/a\\\\b', 'w')\"",
"python3 -c \"f.write('/srv/not-a-path')\"",
"python3 -c \"open('$HOME/x', 'w')\"",
"node -e 'fs.writeFileSync(`/srv/${x}`)'",
] {
assert!(written(command).is_empty(), "{command}");
}
}
}