use super::PROJECT;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Spot<'a> {
Project(&'a str),
Absolute(&'a str),
Anywhere(&'a str),
}
pub fn spot(pattern: &str) -> Spot<'_> {
if pattern == PROJECT {
return Spot::Project("");
}
if let Some(rest) = pattern
.strip_prefix(PROJECT)
.and_then(|r| r.strip_prefix('/'))
{
return Spot::Project(rest);
}
let bytes = pattern.as_bytes();
let drive = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
if pattern.starts_with('/') || drive {
Spot::Absolute(pattern)
} else {
Spot::Anywhere(pattern.trim_start_matches("**/"))
}
}
pub const ANY_GIT: &str = "**/.git/**";
pub const RUNS_LATER: &[&str] = &[
"**/.github/workflows/**",
"**/.gitlab-ci.yml",
"**/.circleci/**",
"**/azure-pipelines.yml",
"**/Jenkinsfile",
"**/.buildkite/**",
"**/.husky/**",
"**/.pre-commit-config.yaml",
"**/lefthook.yml",
"**/.lefthook.yml",
"**/.vscode/tasks.json",
"**/.claude/hooks/**",
"**/.mcp.json",
"**/.cursor/mcp.json",
"**/.codex/config.toml",
"**/.openclaw/extensions/**",
"**/.github/hooks/**",
"**/.github/mcp.json",
"**/.pi/extensions/**",
"**/.pi/mcp.json",
"**/.pi/settings.json",
"**/.github/hooks",
"**/.pi",
"**/.pi/extensions",
"**/.gemini/policies/**",
"**/.gemini/commands/**",
"**/.gemini/agents/**",
"**/.github/agents/**",
"**/.claude/agents/**",
"**/.claude/skills/**",
"**/.claude/commands/**",
"**/.opencode/plugins/**",
"**/.opencode/plugin/**",
"**/.opencode/tools/**",
"**/.opencode/tool/**",
"**/.opencode/commands/**",
"**/.opencode/command/**",
"**/.opencode/opencode.json",
"**/.opencode/opencode.jsonc",
"**/.opencode/package.json",
"**/opencode.json",
"**/opencode.jsonc",
"**/.opencode",
"**/.opencode/plugins",
"**/.opencode/plugin",
"**/.hermes/plugins/**",
"**/.hermes",
"**/.hermes/plugins",
"**/site-packages/*.pth",
"**/dist-packages/*.pth",
"**/sitecustomize.py",
"**/usercustomize.py",
];
pub const AGENT_CONFIG: &[&str] = &[
"**/.claude/hooks/**",
"**/.mcp.json",
"**/.cursor/mcp.json",
"**/.codex/config.toml",
"**/.openclaw/extensions/**",
"**/.github/hooks/**",
"**/.github/mcp.json",
"**/.pi/extensions/**",
"**/.pi/mcp.json",
"**/.pi/settings.json",
"**/.opencode/plugins/**",
"**/.opencode/plugin/**",
"**/.opencode/tools/**",
"**/.opencode/tool/**",
"**/.opencode/opencode.json",
"**/.opencode/opencode.jsonc",
"**/opencode.json",
"**/opencode.jsonc",
"**/.hermes/plugins/**",
];
pub fn runs_later_exclusion(pattern: &str) -> bool {
exclusion_of(pattern, RUNS_LATER)
}
pub fn agent_config_exclusion(pattern: &str) -> bool {
exclusion_of(pattern, AGENT_CONFIG)
}
pub fn folders_above(entry: &str) -> Vec<String> {
let name = entry.strip_prefix("**/").unwrap_or(entry);
let name = name.strip_suffix("/**").unwrap_or(name);
let mut folders = Vec::new();
let mut at = 0;
while let Some(slash) = name[at..].find('/') {
at += slash;
folders.push(name[..at].to_owned());
at += 1;
}
folders
}
pub fn agent_config_folders(pattern: &str) -> Vec<String> {
for file in AGENT_CONFIG {
let Some(head) = pattern.strip_suffix(file) else {
continue;
};
if head.ends_with('/') {
return folders_above(file)
.into_iter()
.map(|folder| format!("{head}{folder}"))
.collect();
}
}
Vec::new()
}
fn exclusion_of(pattern: &str, files: &[&str]) -> bool {
let rest = match spot(pattern) {
Spot::Project(rest) => rest,
Spot::Absolute(path) => path,
Spot::Anywhere(_) => return false,
};
files
.iter()
.any(|file| rest == *file || rest.ends_with(&format!("/{file}")))
}
pub fn git_exclusion(pattern: &str) -> bool {
match spot(pattern) {
Spot::Project(rest) => rest == ".git/**" || rest == ANY_GIT,
Spot::Absolute(path) => path.ends_with("/.git/**"),
Spot::Anywhere(_) => false,
}
}
pub fn has_glob(pattern: &str) -> bool {
pattern.contains(['*', '?', '[', ']', '{', '}'])
}
pub fn literal_tree(pattern: &str) -> Option<&str> {
let base = pattern.strip_suffix("/**").unwrap_or(pattern);
(!base.is_empty() && !has_glob(base)).then_some(base)
}
pub fn is_below(entry: &str, node: &str) -> bool {
let prefix = if node.ends_with('/') {
node.to_owned()
} else {
format!("{node}/")
};
entry.len() > prefix.len() && entry.starts_with(&prefix)
}
pub fn split(patterns: &[String]) -> (Vec<&str>, Vec<&str>) {
let (excluded, positive): (Vec<&str>, Vec<&str>) = patterns
.iter()
.map(String::as_str)
.partition(|p| p.starts_with('!'));
let excluded = excluded.into_iter().map(|p| &p[1..]).collect();
(positive, excluded)
}
pub fn domain(pattern: &str) -> bool {
let name = pattern.strip_prefix("*.").unwrap_or(pattern);
let label = |l: &str| !l.is_empty() && l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
name.split('.').all(label)
&& name
.rsplit('.')
.next()
.is_some_and(|tld| tld.chars().any(|c| c.is_ascii_alphabetic()))
}
pub fn push_unique(list: &mut Vec<String>, value: String) {
if !list.contains(&value) {
list.push(value);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spots_and_shapes() {
assert_eq!(spot("/__moat_project__"), Spot::Project(""));
assert_eq!(spot("/__moat_project__/.git/**"), Spot::Project(".git/**"));
assert_eq!(
spot("/__moat_project__x"),
Spot::Absolute("/__moat_project__x")
);
assert_eq!(spot("C:/x"), Spot::Absolute("C:/x"));
assert_eq!(spot("**/.env"), Spot::Anywhere(".env"));
assert_eq!(literal_tree("/h/.ssh/**"), Some("/h/.ssh"));
assert_eq!(literal_tree("/h/**/.env"), None);
assert_eq!(split(&["a".into(), "!b".into()]), (vec!["a"], vec!["b"]));
assert!(is_below("/h/.ssh", "/h") && is_below("/h", "/") && is_below("C:/x", "C:/"));
assert!(!is_below("/hx", "/h") && !is_below("/h", "/h") && !is_below("/", "/"));
for ok in ["github.com", "*.crates.io", "localhost"] {
assert!(domain(ok), "{ok}");
}
for bad in [
"169.254.*",
"fe80:*",
"100.100.100.200",
"*",
"a*.b.com",
"::1",
] {
assert!(!domain(bad), "{bad}");
}
}
#[test]
fn runs_later_matches_the_default_policy() {
let policy = openmoat_core::Policy::parse(openmoat_core::DEFAULT_POLICY).unwrap();
let group = |groups: &[openmoat_core::RuleGroup], id: &str| {
groups.iter().find(|g| g.id == id).unwrap().fs_write.clone()
};
let reviewed = group(&policy.review, "runs-later");
let allowed = group(&policy.allow, "project-fs");
for file in RUNS_LATER {
assert!(
reviewed.iter().any(|p| p == file),
"runs-later lacks {file}"
);
let left_out = format!("!${{project}}/{file}");
assert!(allowed.contains(&left_out), "project-fs lacks {left_out}");
}
let in_projects = reviewed.iter().filter(|p| p.starts_with("**/")).count();
assert_eq!(in_projects, RUNS_LATER.len(), "{reviewed:?}");
}
#[test]
fn agent_configuration_is_part_of_runs_later() {
for file in AGENT_CONFIG {
assert!(RUNS_LATER.contains(file), "{file}");
}
for file in [
"**/.github/workflows/**",
"**/.husky/**",
"**/.vscode/tasks.json",
] {
assert!(!AGENT_CONFIG.contains(&file), "{file}");
}
assert!(agent_config_exclusion("/__moat_project__/**/.mcp.json"));
assert!(agent_config_exclusion("/w/lib/**/.claude/hooks/**"));
assert!(!agent_config_exclusion("/__moat_project__/**/.husky/**"));
assert!(runs_later_exclusion("/__moat_project__/**/.husky/**"));
assert!(!agent_config_exclusion("**/.mcp.json"));
}
#[test]
fn the_folders_above_agent_configuration_are_named() {
assert_eq!(folders_above("**/.github/hooks/**"), [".github"]);
assert_eq!(folders_above("**/.cursor/mcp.json"), [".cursor"]);
assert_eq!(
folders_above("**/.github/copilot/settings.json"),
[".github", ".github/copilot"]
);
assert_eq!(folders_above("**/.mcp.json"), Vec::<String>::new());
assert_eq!(
agent_config_folders("/__moat_project__/**/.pi/extensions/**"),
["/__moat_project__/.pi"]
);
assert_eq!(
agent_config_folders("/w/lib/**/.openclaw/extensions/**"),
["/w/lib/.openclaw"]
);
assert_eq!(
agent_config_folders("/__moat_project__/**/.mcp.json"),
Vec::<String>::new()
);
assert_eq!(
agent_config_folders("/__moat_project__/**/.husky/**"),
Vec::<String>::new()
);
}
}