use serde_json::Value;
use uuid::Uuid;
use super::seen_skills;
#[cfg(test)]
use crate::brain::skills::Skill;
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)"
);
}
}
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()
}
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
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn skill(globs: &[&str]) -> Skill {
let fm = format!(
"---\nname: guard-skill\ndescription: gated\nglobs: {}\n---\nBODY-MARKER\n",
globs.join(", ")
);
Skill::parse(
"guard-skill",
&fm,
crate::brain::skills::SkillSource::Builtin,
)
.unwrap()
}
fn verdict_with_skills(
session: Uuid,
tool: &str,
input: Value,
skills: Vec<Skill>,
enabled: bool,
) -> GateVerdict {
if enabled && !skills.is_empty() && !EXEMPT_TOOLS.contains(&tool) {
let candidates = harvest_candidates(tool, &input, std::path::Path::new("/work"));
for s in &skills {
for g in &s.globs {
let Ok(pattern) = compile_pattern(g, std::path::Path::new("/work")) else {
continue;
};
let options = glob::MatchOptions {
case_sensitive: false,
require_literal_separator: true,
require_literal_leading_dot: false,
};
for c in &candidates {
if pattern.matches_path_with(c, options) {
if seen_skills::seen_since_compaction(session, &s.name) {
return GateVerdict::Pass;
}
return GateVerdict::Block {
skill: s.name.clone(),
matched_path: c.display().to_string(),
body: s.prompt_body(),
globs: s.globs.clone(),
};
}
}
}
}
}
GateVerdict::Pass
}
#[test]
fn match_blocks_with_body_present() {
let s = skill(&["**/skills/guard-skill/**"]);
let v = verdict_with_skills(
Uuid::new_v4(),
"edit_file",
json!({"path": "/root/.opencrabs/skills/guard-skill/SKILL.md", "old_text": "a", "new_text": "b"}),
vec![s],
true,
);
let GateVerdict::Block {
body, matched_path, ..
} = v
else {
panic!("expected Block, got {v:?}");
};
assert!(body.contains("BODY-MARKER"));
assert_eq!(matched_path, "/root/.opencrabs/skills/guard-skill/SKILL.md");
}
#[test]
fn seen_skill_passes() {
let session = Uuid::new_v4();
let s = skill(&["**/guard/**"]);
seen_skills::mark_seen(session, "guard-skill");
let v = verdict_with_skills(
session,
"write_file",
json!({"path": "/x/guard/file.md", "content": "c"}),
vec![s],
true,
);
assert_eq!(v, GateVerdict::Pass);
}
#[test]
fn fresh_session_blocks() {
let s = skill(&["**/guard/**"]);
let v = verdict_with_skills(
Uuid::new_v4(),
"write_file",
json!({"path": "/x/guard/file.md", "content": "c"}),
vec![s],
true,
);
assert!(matches!(v, GateVerdict::Block { .. }));
}
#[test]
fn exempt_tools_pass() {
for tool in EXEMPT_TOOLS {
let s = skill(&["**/**"]);
let v = verdict_with_skills(
Uuid::new_v4(),
tool,
json!({"path": "/x/guard/file.md"}),
vec![s],
true,
);
assert_eq!(v, GateVerdict::Pass, "tool {tool} must be exempt");
}
}
#[test]
fn bash_command_with_matching_path_blocks() {
let s = skill(&["**/secrets/**"]);
let v = verdict_with_skills(
Uuid::new_v4(),
"bash",
json!({"command": "cat /etc/secrets/key.pem"}),
vec![s],
true,
);
assert!(matches!(v, GateVerdict::Block { .. }));
}
#[test]
fn disabled_config_passes() {
let s = skill(&["**/guard/**"]);
let v = verdict_with_skills(
Uuid::new_v4(),
"write_file",
json!({"path": "/x/guard/file.md", "content": "c"}),
vec![s],
false,
);
assert_eq!(v, GateVerdict::Pass);
}
#[test]
fn malformed_glob_fails_open() {
let s = skill(&["[invalid"]);
let v = verdict_with_skills(
Uuid::new_v4(),
"write_file",
json!({"path": "/x/guard/file.md", "content": "c"}),
vec![s],
true,
);
assert_eq!(v, GateVerdict::Pass);
}
#[test]
fn relative_path_resolves_against_cwd() {
let s = skill(&["/work/guard/**"]);
let input = json!({"path": "guard/file.md", "content": "c"});
let candidates = harvest_candidates("write_file", &input, std::path::Path::new("/work"));
assert!(candidates.contains(&std::path::PathBuf::from("/work/guard/file.md")));
let v = verdict_with_skills(Uuid::new_v4(), "write_file", input, vec![s], true);
assert!(matches!(v, GateVerdict::Block { .. }));
}
#[test]
fn star_matches_one_segment_only() {
let s = skill(&["/work/*"]);
let v1 = verdict_with_skills(
Uuid::new_v4(),
"write_file",
json!({"path": "/work/file.md", "content": "c"}),
vec![s.clone()],
true,
);
assert!(matches!(v1, GateVerdict::Block { .. }));
let v2 = verdict_with_skills(
Uuid::new_v4(),
"write_file",
json!({"path": "/work/sub/file.md", "content": "c"}),
vec![s],
true,
);
assert_eq!(v2, GateVerdict::Pass);
}
#[test]
fn grep_pattern_never_harvested() {
let candidates = harvest_candidates(
"grep",
&json!({"pattern": "/etc/secrets/**", "path": "/tmp/x"}),
std::path::Path::new("/work"),
);
assert_eq!(candidates, vec![std::path::PathBuf::from("/tmp/x")]);
}
#[test]
fn tilde_pattern_matches_home_path() {
let s = skill(&["~/guard/**"]);
let v = verdict_with_skills(
Uuid::new_v4(),
"write_file",
json!({"path": "~/guard/file.md", "content": "c"}),
vec![s],
true,
);
let GateVerdict::Block { matched_path, .. } = v else {
panic!("expected Block for a `~/...` glob, got {v:?}");
};
let home = super::super::error::expand_tilde("~");
assert_eq!(
matched_path,
home.join("guard").join("file.md").display().to_string()
);
}
#[test]
fn relative_pattern_resolves_against_cwd() {
let s = skill(&["guard/**"]);
let v = verdict_with_skills(
Uuid::new_v4(),
"write_file",
json!({"path": "guard/file.md", "content": "c"}),
vec![s],
true,
);
assert!(matches!(v, GateVerdict::Block { .. }));
}
}