use serde::{Deserialize, Serialize};
use crate::pattern::GlobPattern;
use crate::policy::PolicyError;
pub const BUILTIN_METADATA_SERVER: &str = "claude-in-chrome";
const METADATA_TOOLS: [&str; 8] = [
"mcp__claude-in-chrome__list_connected_browsers",
"mcp__claude-in-chrome__resize_window",
"mcp__claude-in-chrome__select_browser",
"mcp__claude-in-chrome__shortcuts_list",
"mcp__claude-in-chrome__switch_browser",
"mcp__claude-in-chrome__tabs_close_mcp",
"mcp__claude-in-chrome__tabs_context_mcp",
"mcp__claude-in-chrome__tabs_create_mcp",
];
const MCP_PREFIX: &str = "mcp__";
const PROTECTED_WRITES: [&str; 162] = [
"**/.github/workflows/**",
"**/.gitlab-ci.yml",
"**/.circleci/**",
"**/azure-pipelines.yml",
"**/Jenkinsfile",
"**/.buildkite/**",
"**/.husky/**",
"**/.githooks/**",
"**/.git-hooks/**",
"**/githooks/**",
"**/git-hooks/**",
"**/.githooks",
"**/.git-hooks",
"**/githooks",
"**/git-hooks",
"**/.pre-commit-config.yaml",
"**/lefthook.yml",
"**/.lefthook.yml",
"**/.vscode/**",
"**/.vscode",
"**/*.code-workspace",
"**/.idea/**",
"**/.idea",
"**/*.run.xml",
"**/package.json",
"**/.npmrc",
"**/bunfig.toml",
"**/.yarnrc",
"**/.yarnrc.yml",
"**/pnpm-workspace.yaml",
"**/Makefile",
"**/makefile",
"**/GNUmakefile",
"**/*.mk",
"**/justfile",
"**/Justfile",
"**/.justfile",
"**/Taskfile.yml",
"**/Taskfile.yaml",
"**/build.rs",
"**/Cargo.toml",
"**/.cargo/config",
"**/.cargo/config.toml",
"**/go.mod",
"**/go.work",
"**/CMakeLists.txt",
"**/*.cmake",
"**/CMakePresets.json",
"**/CMakeUserPresets.json",
"**/pom.xml",
"**/.mvn/**",
"**/mvnw",
"**/mvnw.cmd",
"**/*.gradle",
"**/*.gradle.kts",
"**/gradle.properties",
"**/gradlew",
"**/gradlew.bat",
"**/gradle/wrapper/**",
"**/*.csproj",
"**/*.fsproj",
"**/*.vbproj",
"**/*.props",
"**/*.targets",
"**/global.json",
"**/NuGet.config",
"**/nuget.config",
"**/Rakefile",
"**/*.rake",
"**/Gemfile",
"**/*.gemspec",
"**/.rspec",
"**/composer.json",
"**/artisan",
"**/phpunit.xml",
"**/phpunit.xml.dist",
"**/conftest.py",
"**/pytest.ini",
"**/pyproject.toml",
"**/setup.py",
"**/setup.cfg",
"**/tox.ini",
"**/noxfile.py",
"**/site-packages/*.pth",
"**/dist-packages/*.pth",
"**/sitecustomize.py",
"**/usercustomize.py",
"**/.venv/bin/**",
"**/venv/bin/**",
"**/.venv/bin",
"**/venv/bin",
"**/.devcontainer/**",
"**/.devcontainer",
"**/.devcontainer.json",
"**/Dockerfile",
"**/docker-compose*.yml",
"**/docker-compose*.yaml",
"**/compose*.yml",
"**/compose*.yaml",
"**/CLAUDE.md",
"**/AGENTS.md",
"**/.claude/**",
"**/.claude.json",
"**/.codex/**",
"**/.cursor/**",
"**/.cursorrules",
"**/.mcp.json",
"**/.agents/skills",
"**/.agents/skills/**",
"**/.windsurfrules",
"**/.windsurf/rules/**",
"**/.github/instructions/**",
"**/.continue/config.yaml",
"**/.openclaw",
"**/.openclaw/openclaw.json",
"**/.openclaw/openmoat",
"**/.openclaw/openmoat/**",
"**/.openclaw/extensions/**",
"**/.openclaw/state/**",
"**/.copilot",
"**/.copilot/hooks",
"**/.copilot/hooks/**",
"**/.copilot/settings.json",
"**/.copilot/config.json",
"**/.copilot/mcp-config.json",
"**/.copilot/installed-plugins/**",
"**/.copilot/agents/**",
"**/.github/hooks/**",
"**/.github/mcp.json",
"**/.github/agents/**",
"**/.github/copilot/**",
"**/.github/copilot-instructions.md",
"**/.pi",
"**/.pi/**",
"**/GEMINI.md",
"**/.gemini",
"**/.gemini/**",
"**/.config/opencode",
"**/.config/opencode/**",
"**/.opencode",
"**/.opencode/**",
"**/opencode.json",
"**/opencode.jsonc",
"**/.cache/opencode/node_modules/**",
"~/.hermes/skills/**",
"~/.hermes/cron/**",
"~/.hermes/memories/**",
"~/.hermes/SOUL.md",
"**/.hermes.md",
"**/.hermes/**",
"**/.cline",
"**/.cline/**",
"**/Documents/Cline/**",
"**/.clinerules",
"**/.clinerules/**",
"**/.goosehints",
"~/.config/goose",
"~/.config/goose/**",
"**/.config/goose/**",
"**/.agents/plugins",
"**/.agents/plugins/**",
"**/.moat/**",
];
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TaintSettings {
#[serde(default)]
pub protected_writes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub metadata_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub defined_servers: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<bool>,
}
impl TaintSettings {
#[must_use]
pub fn provenance_on(settings: Option<&Self>) -> bool {
settings.and_then(|s| s.provenance).unwrap_or(true)
}
}
pub(crate) fn protected_writes(settings: Option<&TaintSettings>) -> impl Iterator<Item = &str> {
PROTECTED_WRITES.into_iter().chain(
settings
.into_iter()
.flat_map(|s| s.protected_writes.iter().map(String::as_str)),
)
}
pub(crate) fn is_metadata_tool(settings: Option<&TaintSettings>, name: &str) -> bool {
let impostor = settings.is_some_and(|s| {
s.defined_servers
.iter()
.any(|server| server == BUILTIN_METADATA_SERVER)
});
(METADATA_TOOLS.contains(&name) && !impostor)
|| settings.is_some_and(|s| s.metadata_tools.iter().any(|tool| tool == name))
}
pub(crate) fn check(settings: Option<&TaintSettings>) -> Result<(), PolicyError> {
check_protected_writes(settings)?;
check_metadata_tools(settings)
}
fn check_protected_writes(settings: Option<&TaintSettings>) -> Result<(), PolicyError> {
let problem = |problem: String| PolicyError::Rule {
rule: "taint.protected_writes".to_owned(),
problem,
};
for p in settings.iter().flat_map(|s| &s.protected_writes) {
if p.trim_start().starts_with('!') {
return Err(problem(format!(
"`{p}` is an exclusion; the list can only add paths"
)));
}
GlobPattern::compile(p, false).map_err(|e| problem(e.to_string()))?;
}
Ok(())
}
fn check_metadata_tools(settings: Option<&TaintSettings>) -> Result<(), PolicyError> {
let Some(bad) = settings
.iter()
.flat_map(|s| &s.metadata_tools)
.find(|name| !is_tool_name(name))
else {
return Ok(());
};
Err(PolicyError::Rule {
rule: "taint.metadata_tools".to_owned(),
problem: format!(
"`{bad}` is not one MCP tool's full name (`mcp__<server>__<tool>`, no wildcards)"
),
})
}
fn is_tool_name(name: &str) -> bool {
let plain = !name
.chars()
.any(|c| c.is_whitespace() || matches!(c, '*' | '?' | '[' | ']' | '{' | '}' | '!'));
plain
&& name
.strip_prefix(MCP_PREFIX)
.and_then(|rest| rest.split_once("__"))
.is_some_and(|(server, tool)| !server.is_empty() && !tool.is_empty())
}
#[cfg(test)]
mod tests {
use crate::policy::{Policy, PolicyError};
#[test]
fn protected_writes_must_be_plain_globs() {
let ok = "version: 1\ntaint:\n protected_writes: ['**/deploy/**', '${project}/run.sh']\n";
assert_eq!(
Policy::parse(ok)
.unwrap()
.taint
.unwrap()
.protected_writes
.len(),
2
);
assert!(Policy::parse("version: 1\ntaint: {}\n").is_ok());
for bad in ["'**/[x'", "'!**/package.json'", "''"] {
let yaml = format!("version: 1\ntaint:\n protected_writes: [{bad}]\n");
assert!(
matches!(Policy::parse(&yaml), Err(PolicyError::Rule { rule, .. }) if rule == "taint.protected_writes"),
"{bad} must be rejected"
);
}
assert!(Policy::parse("version: 1\ntaint: { bogus: [] }\n").is_err());
}
#[test]
fn metadata_tools_are_full_mcp_tool_names() {
let yaml = |list: &str| format!("version: 1\ntaint:\n metadata_tools: [{list}]\n");
let ok = Policy::parse(&yaml("mcp__tabs__list_tabs")).unwrap();
let settings = ok.taint.as_ref();
assert!(super::is_metadata_tool(settings, "mcp__tabs__list_tabs"));
assert!(super::is_metadata_tool(
settings,
"mcp__claude-in-chrome__tabs_context_mcp"
));
assert!(!super::is_metadata_tool(settings, "mcp__tabs__read_tab"));
assert!(!super::is_metadata_tool(
None,
"mcp__claude-in-chrome__get_page_text"
));
for bad in [
"'mcp__tabs__*'",
"list_tabs",
"'mcp__tabs__'",
"'mcp____x'",
"''",
] {
assert!(
matches!(Policy::parse(&yaml(bad)), Err(PolicyError::Rule { rule, .. }) if rule == "taint.metadata_tools"),
"{bad} must be rejected"
);
}
}
#[test]
fn built_in_metadata_tools_leave_page_content_tools_out() {
for tool in [
"get_page_text",
"read_page",
"find",
"navigate",
"javascript_tool",
"read_console_messages",
"read_network_requests",
"computer",
"gif_creator",
"upload_image",
"form_input",
"file_upload",
"shortcuts_execute",
"browser_batch",
] {
let name = format!("mcp__claude-in-chrome__{tool}");
assert!(!super::is_metadata_tool(None, &name), "{name}");
}
assert!(super::METADATA_TOOLS.iter().all(|t| super::is_tool_name(t)));
}
#[test]
fn provenance_is_on_unless_the_policy_turns_it_off() {
use super::TaintSettings;
let on =
|yaml: &str| TaintSettings::provenance_on(Policy::parse(yaml).unwrap().taint.as_ref());
assert!(on("version: 1\n"));
assert!(on("version: 1\ntaint: {}\n"));
assert!(on("version: 1\ntaint: { provenance: true }\n"));
assert!(!on("version: 1\ntaint: { provenance: false }\n"));
assert!(Policy::parse("version: 1\ntaint: { provenance: maybe }\n").is_err());
}
fn sample(pattern: &str) -> String {
let path = pattern
.replacen('~', "/home/u", 1)
.replacen("**/", "/home/u/work/repo/", 1);
let path = match path.strip_suffix("/**") {
Some(dir) => format!("{dir}/x"),
None => path,
};
path.replace('*', "v1")
}
#[test]
fn protected_writes_cover_runs_later_and_the_kernel_config_files() {
let policy = Policy::parse(crate::DEFAULT_POLICY).unwrap();
let protected: Vec<_> = super::PROTECTED_WRITES
.iter()
.map(|p| crate::pattern::GlobPattern::compile(p, false).unwrap())
.collect();
let rule = |groups: &[crate::RuleGroup], id: &str| {
groups.iter().find(|g| g.id == id).unwrap().fs_write.clone()
};
let mut files = rule(&policy.review, "runs-later");
files.extend(rule(&policy.deny, "kernel-self").into_iter().filter(|p| {
!["/bin/moat", "apps/moat", "/bin/openmoat", "apps/openmoat"]
.iter()
.any(|binary| p.contains(binary))
}));
assert!(files.len() > 30, "{files:?}");
for pattern in &files {
let path = sample(pattern);
assert!(
protected.iter().any(|g| g.is_match(&path)),
"`{pattern}` ({path}) is not in PROTECTED_WRITES"
);
}
}
}