use crate::hooks::ToolCtx;
pub fn matches(pattern: &str, ctx: &ToolCtx<'_>) -> bool {
matches_with_workspace(pattern, ctx, &workspace_root())
}
pub fn workspace_root() -> std::path::PathBuf {
if let Ok(out) = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
&& out.status.success()
{
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !s.is_empty() {
return std::path::PathBuf::from(s);
}
}
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
}
fn split_pattern(pattern: &str) -> (&str, Option<&str>) {
pattern
.split_once(':')
.map_or((pattern, None), |(name, spec)| (name, Some(spec)))
}
fn is_file_edit_tool(name: &str) -> bool {
matches!(
name,
"Read" | "Write" | "Edit" | "MultiEdit" | "NotebookEdit"
)
}
fn glob_match(pat: &str, hay: &str) -> bool {
let g = globset::GlobBuilder::new(pat)
.literal_separator(false)
.build();
match g {
Ok(g) => g.compile_matcher().is_match(hay),
Err(_) => false, }
}
fn glob_match_path(pat: &str, hay: &std::path::Path) -> bool {
let g = globset::GlobBuilder::new(pat)
.literal_separator(true) .build();
match g {
Ok(g) => g.compile_matcher().is_match(hay),
Err(_) => false,
}
}
pub fn matches_with_workspace(
pattern: &str,
ctx: &ToolCtx<'_>,
workspace: &std::path::Path,
) -> bool {
let (tool_pat, arg_pat) = split_pattern(pattern);
if tool_pat != "*" && !glob_match(tool_pat, ctx.tool_name) {
return false;
}
let Some(spec) = arg_pat else {
return true;
};
if let Some(rest) = spec.strip_prefix('~') {
if ctx.tool_name != "Bash" {
return false;
}
let cmd = ctx
.input
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("");
return contains_glob(rest, cmd);
}
if spec.contains('=') {
return spec.split(',').all(|kv| kv_match(kv, ctx.input));
}
if is_file_edit_tool(ctx.tool_name) {
let raw = first_arg(ctx).unwrap_or_default();
let target = workspace_normalize(&raw, workspace);
let spec_path = std::path::Path::new(spec);
if spec_path.is_absolute() {
return glob_match_path(spec, &target);
}
if !target.starts_with(workspace) {
return false;
}
let stripped = spec.strip_prefix("./").unwrap_or(spec);
let ws = globset::escape(&workspace.to_string_lossy());
let glob_pat = format!("{ws}/**/{stripped}");
return glob_match_path(&glob_pat, &target);
}
let first = first_arg(ctx).unwrap_or_default();
glob_match(spec, &first)
}
fn first_arg(ctx: &ToolCtx<'_>) -> Option<String> {
caliban_common::glob_match::first_arg(ctx.tool_name, ctx.input)
}
fn contains_glob(pat: &str, hay: &str) -> bool {
for i in 0..=hay.len() {
for j in i..=hay.len() {
if !hay.is_char_boundary(i) || !hay.is_char_boundary(j) {
continue;
}
if glob_match(pat, &hay[i..j]) {
return true;
}
}
}
false
}
fn kv_match(kv: &str, input: &serde_json::Value) -> bool {
let Some((key, glob)) = kv.split_once('=') else {
return false;
};
let mut cursor = input;
for part in key.split('.') {
match cursor.get(part) {
Some(next) => cursor = next,
None => return glob_match(glob, ""), }
}
let val = cursor.as_str().unwrap_or("");
glob_match(glob, val)
}
fn workspace_normalize(p: &str, workspace: &std::path::Path) -> std::path::PathBuf {
let path = std::path::Path::new(p);
let joined = if path.is_absolute() {
path.to_path_buf()
} else {
let stripped: &std::path::Path = path.strip_prefix("./").unwrap_or(path);
workspace.join(stripped)
};
lexical_normalize(&joined)
}
fn lexical_normalize(p: &std::path::Path) -> std::path::PathBuf {
use std::path::Component;
let mut out = std::path::PathBuf::new();
for comp in p.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => match out.components().next_back() {
Some(Component::Normal(_)) => {
out.pop();
}
_ => out.push(".."),
},
other => out.push(other.as_os_str()),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn ctx<'a>(name: &'a str, input: &'a serde_json::Value) -> ToolCtx<'a> {
ToolCtx {
session_id: "test-session",
turn_index: 0,
tool_use_id: "t",
tool_name: name,
input,
is_read_only: false,
}
}
#[test]
fn globstar_path_matches_nested_rs_file() {
let ws = std::path::Path::new("/repo");
let i = json!({"path": "/repo/crates/x/src/y.rs"});
assert!(
matches_with_workspace("Edit:src/**/*.rs", &ctx("Edit", &i), ws),
"globstar should match nested .rs under the workspace src tree"
);
}
#[test]
fn relative_pattern_does_not_escape_workspace() {
let ws = std::path::Path::new("/repo");
let outside = json!({"path": "/etc/src/evil.rs"});
assert!(
!matches_with_workspace("Edit:src/**/*.rs", &ctx("Edit", &outside), ws),
"relative pattern must be workspace-scoped, must not match /etc/src/..."
);
let home = json!({"path": "/home/attacker/src/evil.rs"});
assert!(
!matches_with_workspace("Edit:src/**/*.rs", &ctx("Edit", &home), ws),
"relative pattern must not match /home/attacker/src/..."
);
let inside = json!({"path": "/repo/crates/x/src/y.rs"});
assert!(
matches_with_workspace("Edit:src/**/*.rs", &ctx("Edit", &inside), ws),
"relative pattern must still match src/** anywhere under the workspace"
);
}
#[test]
fn dotdot_traversal_does_not_escape_workspace() {
let ws = std::path::Path::new("/repo");
let escape = json!({"path": "../../../../etc/passwd"});
assert!(
!matches_with_workspace("Edit:**", &ctx("Edit", &escape), ws),
"workspace-scoped Edit:** must not match a ../ traversal outside the workspace"
);
let escape_abs = json!({"path": "/repo/../../etc/passwd"});
assert!(
!matches_with_workspace("Edit:**", &ctx("Edit", &escape_abs), ws),
"a path that lexically escapes the workspace must not match a workspace-scoped rule"
);
let inside = json!({"path": "src/main.rs"});
assert!(
matches_with_workspace("Edit:**", &ctx("Edit", &inside), ws),
"in-workspace relative path still matches Edit:**"
);
let inside_dotdot = json!({"path": "crates/x/../y/z.rs"});
assert!(
matches_with_workspace("Edit:**", &ctx("Edit", &inside_dotdot), ws),
"a `..` that resolves to a path still inside the workspace matches"
);
}
#[test]
fn path_normalization_handles_relative_pattern() {
let ws = std::path::Path::new("/repo");
let i = json!({"path": "/repo/foo.rs"});
assert!(matches_with_workspace(
"Edit:./foo.rs",
&ctx("Edit", &i),
ws
));
assert!(matches_with_workspace("Edit:foo.rs", &ctx("Edit", &i), ws));
}
#[test]
fn multi_edit_path_matches_workspace_glob() {
let ws = std::path::Path::new("/repo");
let i = json!({"path": "/repo/src/foo.rs", "edits": []});
assert!(
matches_with_workspace("MultiEdit:src/**/*.rs", &ctx("MultiEdit", &i), ws),
"MultiEdit rule must match against the tool's `path` field"
);
}
#[test]
fn notebook_edit_path_matches_workspace_glob() {
let ws = std::path::Path::new("/repo");
let i = json!({"path": "/repo/nb.ipynb", "cell_id": "x", "new_source": ""});
assert!(matches_with_workspace(
"NotebookEdit:**/*.ipynb",
&ctx("NotebookEdit", &i),
ws
));
}
#[test]
fn bash_anywhere_catches_sudo() {
let i = json!({"command": "sudo rm -rf /"});
assert!(matches_with_workspace(
"Bash:~rm *",
&ctx("Bash", &i),
std::path::Path::new("/")
));
}
#[test]
fn bash_anywhere_only_for_bash() {
let i = json!({"path": "rm"});
assert!(!matches_with_workspace(
"Read:~rm",
&ctx("Read", &i),
std::path::Path::new("/")
));
}
#[test]
fn mcp_dotted_key_matches() {
let i = json!({"repo": "anthropic/caliban", "title": "feat"});
assert!(matches_with_workspace(
"mcp__github__create_issue:repo=anthropic/*",
&ctx("mcp__github__create_issue", &i),
std::path::Path::new("/")
));
}
#[test]
fn mcp_multi_kv_all_must_match() {
let i = json!({"repo": "anthropic/caliban", "title": "feat"});
assert!(matches_with_workspace(
"mcp__github__create_issue:repo=anthropic/*,title=feat*",
&ctx("mcp__github__create_issue", &i),
std::path::Path::new("/")
));
assert!(!matches_with_workspace(
"mcp__github__create_issue:repo=anthropic/*,title=docs*",
&ctx("mcp__github__create_issue", &i),
std::path::Path::new("/")
));
}
#[test]
fn first_arg_fallback_preserved() {
let i = json!({"command": "git push"});
assert!(matches_with_workspace(
"Bash:git *",
&ctx("Bash", &i),
std::path::Path::new("/")
));
assert!(!matches_with_workspace(
"Bash:git *",
&ctx("Bash", &json!({"command": "gitk"})),
std::path::Path::new("/")
));
}
#[test]
fn star_matches_unknown_mcp_tool() {
let i = json!({});
assert!(matches_with_workspace(
"*",
&ctx("mcp__weird__tool", &i),
std::path::Path::new("/")
));
}
}