use awaken_runtime_contract::registry_spec::AgentSpec;
use awaken_tool_pattern::tool_id_match;
#[must_use]
pub(crate) fn tool_allowed(spec: &AgentSpec, id: &str) -> bool {
spec.tool_allowed(id)
}
#[must_use]
pub(crate) fn unmatched_patterns(
spec: &AgentSpec,
registered: &[&str],
) -> Vec<(&'static str, String)> {
let mut out = Vec::new();
for (field, list) in [
(
"allowed_tool_patterns",
spec.allowed_tool_patterns.as_deref(),
),
(
"excluded_tool_patterns",
spec.excluded_tool_patterns.as_deref(),
),
] {
if let Some(entries) = list {
for p in entries {
if !registered.iter().any(|id| tool_id_match(p, id)) {
out.push((field, p.clone()));
}
}
}
}
out
}
#[must_use]
pub(crate) fn argument_pattern_misuse(spec: &AgentSpec) -> Vec<(&'static str, String)> {
let mut out = Vec::new();
for (field, list) in [
("allowed_tools", spec.allowed_tools.as_deref()),
(
"allowed_tool_patterns",
spec.allowed_tool_patterns.as_deref(),
),
("excluded_tools", spec.excluded_tools.as_deref()),
(
"excluded_tool_patterns",
spec.excluded_tool_patterns.as_deref(),
),
] {
if let Some(entries) = list {
for entry in entries {
if looks_like_argument_pattern(entry) {
out.push((field, entry.clone()));
}
}
}
}
out
}
#[must_use]
pub(crate) fn permission_rules_without_catalog_match(
spec: &AgentSpec,
surviving: &[&str],
) -> Vec<String> {
let surviving_set: std::collections::HashSet<&str> = surviving.iter().copied().collect();
let Some(rules) = spec
.sections
.get("permission")
.and_then(|p| p.get("rules"))
.and_then(|r| r.as_array())
else {
return Vec::new();
};
let mut out = Vec::new();
for rule in rules {
let Some(tool_field) = rule.get("tool").and_then(|t| t.as_str()) else {
continue;
};
let bare = tool_field
.split('(')
.next()
.unwrap_or("")
.trim()
.to_string();
if bare.is_empty() || !is_literal_tool_id(&bare) {
continue;
}
if !surviving_set.contains(bare.as_str()) {
out.push(bare);
}
}
out.sort();
out.dedup();
out
}
fn is_literal_tool_id(id: &str) -> bool {
if id.contains('*') || id.contains('?') || id.contains('[') {
return false;
}
if id.starts_with('/') && id.ends_with('/') && id.len() >= 2 {
return false;
}
true
}
fn looks_like_argument_pattern(entry: &str) -> bool {
let Some(open) = entry.find('(') else {
return false;
};
if !entry.ends_with(')') {
return false;
}
let inner = &entry[open + 1..entry.len() - 1];
!inner.is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_spec() -> AgentSpec {
serde_json::from_str(r#"{"id":"a","model_id":"m","system_prompt":""}"#).unwrap()
}
#[test]
fn unmatched_patterns_lists_dead_glob_entries() {
let mut spec = empty_spec();
spec.allowed_tool_patterns = Some(vec!["mcp:*".into(), "old-*".into()]);
spec.excluded_tool_patterns = Some(vec!["never-*".into()]);
let registered = ["mcp:weather", "Bash"];
let out = unmatched_patterns(&spec, ®istered);
let mut got: Vec<_> = out
.iter()
.map(|(field, pat)| (*field, pat.clone()))
.collect();
got.sort();
assert_eq!(
got,
vec![
("allowed_tool_patterns", "old-*".into()),
("excluded_tool_patterns", "never-*".into()),
]
);
}
#[test]
fn unmatched_patterns_ignores_literal_fields() {
let mut spec = empty_spec();
spec.allowed_tools = Some(vec!["nonexistent".into()]);
spec.allowed_tool_patterns = Some(vec![]);
let registered = ["Bash"];
assert!(
unmatched_patterns(&spec, ®istered).is_empty(),
"literal-only unmatched entries are intentional and not reported"
);
}
#[test]
fn argument_pattern_misuse_flags_paren_entries() {
let mut spec = empty_spec();
spec.allowed_tools = Some(vec!["Bash".into(), "Bash(npm)".into()]);
spec.allowed_tool_patterns = Some(vec!["mcp:weather/forecast(token=X)".into()]);
let out = argument_pattern_misuse(&spec);
let mut got: Vec<_> = out.iter().map(|(f, e)| (*f, e.clone())).collect();
got.sort();
assert_eq!(
got,
vec![
(
"allowed_tool_patterns",
"mcp:weather/forecast(token=X)".into()
),
("allowed_tools", "Bash(npm)".into()),
]
);
}
#[test]
fn argument_pattern_misuse_ignores_well_formed_entries() {
let mut spec = empty_spec();
spec.allowed_tools = Some(vec!["Bash".into(), "Read".into()]);
spec.allowed_tool_patterns = Some(vec!["mcp:*".into()]);
spec.excluded_tool_patterns = Some(vec!["dangerous-*".into()]);
let out = argument_pattern_misuse(&spec);
assert!(out.is_empty(), "no entries should be flagged: {out:?}");
}
#[test]
fn argument_pattern_misuse_ignores_paren_edge_cases() {
let mut spec = empty_spec();
spec.allowed_tools = Some(vec![
"Bash()".into(),
"Bash(".into(),
"Bash)".into(),
"foo(x)bar".into(),
]);
spec.allowed_tool_patterns = Some(vec![]);
let out = argument_pattern_misuse(&spec);
assert!(
out.is_empty(),
"paren edge cases should not be flagged: {out:?}"
);
}
#[test]
fn permission_rule_for_removed_tool_is_flagged() {
use serde_json::json;
let mut spec = empty_spec();
spec.allowed_tools = Some(vec!["Read".into()]);
spec.allowed_tool_patterns = Some(vec![]);
spec.sections.insert(
"permission".into(),
json!({
"rules": [
{ "tool": "Bash(npm)", "action": "deny" },
{ "tool": "Read", "action": "allow" }
]
}),
);
let surviving = ["Read"];
let out = permission_rules_without_catalog_match(&spec, &surviving);
assert_eq!(
out,
vec!["Bash".to_string()],
"Bash isn't in the post-catalog tool set, so the rule referencing it is stale"
);
}
#[test]
fn permission_rule_for_kept_tool_is_not_flagged() {
use serde_json::json;
let mut spec = empty_spec();
spec.sections.insert(
"permission".into(),
json!({
"rules": [
{ "tool": "Read", "action": "allow" }
]
}),
);
let surviving = ["Read", "Bash"];
let out = permission_rules_without_catalog_match(&spec, &surviving);
assert!(out.is_empty());
}
#[test]
fn no_permission_section_yields_no_warnings() {
let spec = empty_spec();
let surviving = ["Bash"];
assert!(permission_rules_without_catalog_match(&spec, &surviving).is_empty());
}
#[test]
fn permission_glob_rule_is_not_falsely_flagged_as_orphan() {
use serde_json::json;
let mut spec = empty_spec();
spec.sections.insert(
"permission".into(),
json!({
"rules": [
{ "tool": "mcp:*", "action": "deny" },
{ "tool": "/B.*/", "action": "ask" },
{ "tool": "file_?", "action": "deny" },
{ "tool": "read[12]", "action": "ask" }
]
}),
);
let surviving = ["mcp:weather", "Bash"];
let out = permission_rules_without_catalog_match(&spec, &surviving);
assert!(
out.is_empty(),
"glob/regex rules must not be flagged by the literal-orphan diagnostic, got {out:?}"
);
}
}