use serde_json::Value;
use uuid::Uuid;
use super::seen_skills;
pub(crate) const EXEMPT_TOOLS: &[&str] = &[
"load_brain_file",
"read_file",
"slash_command",
"session_search",
"tool_search",
"write_opencrabs_file",
"execute_code",
];
const PATH_KEYS: &[&str] = &["path", "file_path", "filePath"];
const PATTERN_ONLY_TOOLS: &[&str] = &["grep", "glob"];
#[derive(Debug, Clone, PartialEq)]
pub enum GateVerdict {
Pass,
Block {
skill: String,
matched_path: String,
body: String,
globs: Vec<String>,
},
}
pub fn check(
session_id: Uuid,
tool_name: &str,
input: &Value,
cwd: &std::path::Path,
enabled: bool,
) -> GateVerdict {
if !enabled || EXEMPT_TOOLS.contains(&tool_name) {
return GateVerdict::Pass;
}
let skills = crate::brain::skills::skills_with_globs();
if skills.is_empty() {
return GateVerdict::Pass;
}
let candidates = harvest_candidates(tool_name, input, cwd);
if candidates.is_empty() {
return GateVerdict::Pass;
}
for skill in &skills {
for glob_str in &skill.globs {
let pattern = match compile_pattern(glob_str, cwd) {
Ok(p) => p,
Err(e) => {
warn_malformed_glob(&skill.name, glob_str, &e.to_string());
continue;
}
};
let options = glob::MatchOptions {
case_sensitive: false,
require_literal_separator: true, require_literal_leading_dot: false,
};
for candidate in &candidates {
if pattern.matches_path_with(candidate, options) {
if seen_skills::seen_since_compaction(session_id, &skill.name) {
return GateVerdict::Pass;
}
return GateVerdict::Block {
skill: skill.name.clone(),
matched_path: candidate.display().to_string(),
body: skill.prompt_body(),
globs: skill.globs.clone(),
};
}
}
}
}
GateVerdict::Pass
}
fn warn_malformed_glob(slug: &str, glob_str: &str, err: &str) {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNT: AtomicU64 = AtomicU64::new(0);
if COUNT.fetch_add(1, Ordering::Relaxed) < 8 {
tracing::warn!(
"skill_gate: skill '{slug}' has malformed glob '{glob_str}' ({err}) — skipping (fail-open)"
);
}
}
pub(crate) fn harvest_candidates(
tool_name: &str,
input: &Value,
cwd: &std::path::Path,
) -> Vec<std::path::PathBuf> {
let mut out: Vec<String> = Vec::new();
let obj = match input.as_object() {
Some(o) => o,
None => return Vec::new(),
};
if !PATTERN_ONLY_TOOLS.contains(&tool_name) {
for key in PATH_KEYS {
if let Some(Value::String(s)) = obj.get(*key) {
out.push(s.clone());
}
}
} else {
if let Some(Value::String(p)) = obj.get("path") {
out.push(p.clone());
}
}
if tool_name == "bash"
&& let Some(Value::String(cmd)) = obj.get("command")
{
for token in cmd.split_whitespace() {
let tok = token.trim_matches(|c| c == '"' || c == '\'' || c == ';' || c == ',');
if tok.contains('/') || tok.starts_with('~') {
out.push(tok.to_string());
}
}
}
out.into_iter()
.map(|raw| {
let expanded = super::error::expand_tilde(&raw);
let joined = if expanded.is_absolute() {
expanded
} else {
cwd.join(expanded)
};
normalize(&joined)
})
.collect()
}
pub(crate) fn compile_pattern(
glob_str: &str,
cwd: &std::path::Path,
) -> Result<glob::Pattern, glob::PatternError> {
let expanded = super::error::expand_tilde(glob_str);
let anchored = if expanded.is_absolute() || is_wildcard_led(glob_str) {
expanded
} else {
cwd.join(expanded)
};
glob::Pattern::new(&normalize(&anchored).to_string_lossy())
}
fn is_wildcard_led(glob_str: &str) -> bool {
matches!(glob_str.as_bytes().first(), Some(b'*' | b'?' | b'['))
}
fn normalize(path: &std::path::Path) -> std::path::PathBuf {
let mut out = std::path::PathBuf::new();
for comp in path.components() {
match comp {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}