1use serde::{Deserialize, Serialize};
8
9use crate::pattern::GlobPattern;
10use crate::policy::PolicyError;
11
12pub const BUILTIN_METADATA_SERVER: &str = "claude-in-chrome";
19
20const METADATA_TOOLS: [&str; 8] = [
29 "mcp__claude-in-chrome__list_connected_browsers",
30 "mcp__claude-in-chrome__resize_window",
31 "mcp__claude-in-chrome__select_browser",
32 "mcp__claude-in-chrome__shortcuts_list",
33 "mcp__claude-in-chrome__switch_browser",
34 "mcp__claude-in-chrome__tabs_close_mcp",
35 "mcp__claude-in-chrome__tabs_context_mcp",
36 "mcp__claude-in-chrome__tabs_create_mcp",
37];
38
39const MCP_PREFIX: &str = "mcp__";
41
42const PROTECTED_WRITES: [&str; 166] = [
51 "**/.github/workflows/**",
52 "**/.gitlab-ci.yml",
53 "**/.circleci/**",
54 "**/azure-pipelines.yml",
55 "**/Jenkinsfile",
56 "**/.buildkite/**",
57 "**/.husky/**",
58 "**/.githooks/**",
60 "**/.git-hooks/**",
61 "**/githooks/**",
62 "**/git-hooks/**",
63 "**/.githooks",
64 "**/.git-hooks",
65 "**/githooks",
66 "**/git-hooks",
67 "**/.pre-commit-config.yaml",
68 "**/lefthook.yml",
69 "**/.lefthook.yml",
70 "**/.vscode/**",
71 "**/.vscode",
73 "**/*.code-workspace",
74 "**/.idea/**",
75 "**/.idea",
76 "**/*.run.xml",
77 "**/package.json",
79 "**/.npmrc",
80 "**/bunfig.toml",
81 "**/.yarnrc",
82 "**/.yarnrc.yml",
83 "**/pnpm-workspace.yaml",
84 "**/Makefile",
86 "**/makefile",
87 "**/GNUmakefile",
88 "**/*.mk",
89 "**/justfile",
90 "**/Justfile",
91 "**/.justfile",
92 "**/Taskfile.yml",
93 "**/Taskfile.yaml",
94 "**/build.rs",
96 "**/Cargo.toml",
97 "**/.cargo/config",
98 "**/.cargo/config.toml",
99 "**/go.mod",
101 "**/go.work",
102 "**/CMakeLists.txt",
104 "**/*.cmake",
105 "**/CMakePresets.json",
106 "**/CMakeUserPresets.json",
107 "**/pom.xml",
109 "**/.mvn/**",
110 "**/mvnw",
111 "**/mvnw.cmd",
112 "**/*.gradle",
114 "**/*.gradle.kts",
115 "**/gradle.properties",
116 "**/gradlew",
117 "**/gradlew.bat",
118 "**/gradle/wrapper/**",
119 "**/*.csproj",
121 "**/*.fsproj",
122 "**/*.vbproj",
123 "**/*.props",
124 "**/*.targets",
125 "**/global.json",
126 "**/NuGet.config",
127 "**/nuget.config",
128 "**/Rakefile",
130 "**/*.rake",
131 "**/Gemfile",
132 "**/*.gemspec",
133 "**/.rspec",
134 "**/composer.json",
136 "**/artisan",
137 "**/phpunit.xml",
138 "**/phpunit.xml.dist",
139 "**/conftest.py",
141 "**/pytest.ini",
142 "**/pyproject.toml",
143 "**/setup.py",
144 "**/setup.cfg",
145 "**/tox.ini",
146 "**/noxfile.py",
147 "**/site-packages/*.pth",
149 "**/dist-packages/*.pth",
150 "**/sitecustomize.py",
151 "**/usercustomize.py",
152 "**/.venv/bin/**",
153 "**/venv/bin/**",
154 "**/.venv/bin",
155 "**/venv/bin",
156 "**/.devcontainer/**",
157 "**/.devcontainer",
158 "**/.devcontainer.json",
159 "**/Dockerfile",
161 "**/docker-compose*.yml",
162 "**/docker-compose*.yaml",
163 "**/compose*.yml",
164 "**/compose*.yaml",
165 "**/CLAUDE.md",
167 "**/AGENTS.md",
168 "**/.claude/**",
169 "**/.claude.json",
170 "**/.codex/**",
171 "**/.cursor/**",
172 "**/.cursorrules",
173 "**/.mcp.json",
174 "**/.agents/skills",
178 "**/.agents/skills/**",
179 "**/.windsurfrules",
180 "**/.windsurf/rules/**",
181 "**/.github/instructions/**",
182 "**/.continue/config.yaml",
185 "**/.openclaw",
188 "**/.openclaw/openclaw.json",
189 "**/.openclaw/openmoat",
190 "**/.openclaw/openmoat/**",
191 "**/.openclaw/extensions/**",
192 "**/.openclaw/state/**",
194 "**/.copilot",
198 "**/.copilot/hooks",
199 "**/.copilot/hooks/**",
200 "**/.copilot/settings.json",
201 "**/.copilot/config.json",
202 "**/.copilot/mcp-config.json",
203 "**/.copilot/installed-plugins/**",
204 "**/.copilot/agents/**",
205 "**/.github/hooks/**",
206 "**/.github/mcp.json",
207 "**/.github/agents/**",
208 "**/.github/copilot/**",
209 "**/.github/copilot-instructions.md",
210 "**/.pi",
213 "**/.pi/**",
214 "**/GEMINI.md",
217 "**/.gemini",
218 "**/.gemini/**",
219 "**/.config/opencode",
223 "**/.config/opencode/**",
224 "**/.opencode",
225 "**/.opencode/**",
226 "**/opencode.json",
227 "**/opencode.jsonc",
228 "**/.cache/opencode/node_modules/**",
229 "~/.hermes/skills/**",
232 "~/.hermes/cron/**",
233 "~/.hermes/memories/**",
234 "~/.hermes/SOUL.md",
235 "**/.hermes.md",
236 "**/.hermes/**",
237 "**/.cline",
241 "**/.cline/**",
242 "**/Documents/Cline/**",
243 "**/.clinerules",
244 "**/.clinerules/**",
245 "**/.goosehints",
248 "~/.config/goose",
249 "~/.config/goose/**",
250 "**/.config/goose/**",
251 "**/.agents/plugins",
252 "**/.agents/plugins/**",
253 "~/.gemini/config",
255 "~/.gemini/config/**",
256 "**/.agents/hooks.json",
257 "**/.agents/mcp_config.json",
258 "**/.moat/**",
261];
262
263#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(deny_unknown_fields)]
266pub struct TaintSettings {
267 #[serde(default)]
271 pub protected_writes: Vec<String>,
272 #[serde(default, skip_serializing_if = "Vec::is_empty")]
277 pub metadata_tools: Vec<String>,
278 #[serde(default, skip_serializing_if = "Vec::is_empty")]
286 pub defined_servers: Vec<String>,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub provenance: Option<bool>,
292}
293
294impl TaintSettings {
295 #[must_use]
298 pub fn provenance_on(settings: Option<&Self>) -> bool {
299 settings.and_then(|s| s.provenance).unwrap_or(true)
300 }
301}
302
303pub(crate) fn protected_writes(settings: Option<&TaintSettings>) -> impl Iterator<Item = &str> {
305 PROTECTED_WRITES.into_iter().chain(
306 settings
307 .into_iter()
308 .flat_map(|s| s.protected_writes.iter().map(String::as_str)),
309 )
310}
311
312pub(crate) fn is_metadata_tool(settings: Option<&TaintSettings>, name: &str) -> bool {
316 let impostor = settings.is_some_and(|s| {
317 s.defined_servers
318 .iter()
319 .any(|server| server == BUILTIN_METADATA_SERVER)
320 });
321 (METADATA_TOOLS.contains(&name) && !impostor)
322 || settings.is_some_and(|s| s.metadata_tools.iter().any(|tool| tool == name))
323}
324
325pub(crate) fn check(settings: Option<&TaintSettings>) -> Result<(), PolicyError> {
329 check_protected_writes(settings)?;
330 check_metadata_tools(settings)
331}
332
333fn check_protected_writes(settings: Option<&TaintSettings>) -> Result<(), PolicyError> {
334 let problem = |problem: String| PolicyError::Rule {
335 rule: "taint.protected_writes".to_owned(),
336 problem,
337 };
338 for p in settings.iter().flat_map(|s| &s.protected_writes) {
339 if p.trim_start().starts_with('!') {
340 return Err(problem(format!(
341 "`{p}` is an exclusion; the list can only add paths"
342 )));
343 }
344 GlobPattern::compile(p, false).map_err(|e| problem(e.to_string()))?;
345 }
346 Ok(())
347}
348
349fn check_metadata_tools(settings: Option<&TaintSettings>) -> Result<(), PolicyError> {
352 let Some(bad) = settings
353 .iter()
354 .flat_map(|s| &s.metadata_tools)
355 .find(|name| !is_tool_name(name))
356 else {
357 return Ok(());
358 };
359 Err(PolicyError::Rule {
360 rule: "taint.metadata_tools".to_owned(),
361 problem: format!(
362 "`{bad}` is not one MCP tool's full name (`mcp__<server>__<tool>`, no wildcards)"
363 ),
364 })
365}
366
367fn is_tool_name(name: &str) -> bool {
370 let plain = !name
371 .chars()
372 .any(|c| c.is_whitespace() || matches!(c, '*' | '?' | '[' | ']' | '{' | '}' | '!'));
373 plain
374 && name
375 .strip_prefix(MCP_PREFIX)
376 .and_then(|rest| rest.split_once("__"))
377 .is_some_and(|(server, tool)| !server.is_empty() && !tool.is_empty())
378}
379
380#[cfg(test)]
381mod tests {
382 use crate::policy::{Policy, PolicyError};
383
384 #[test]
385 fn protected_writes_must_be_plain_globs() {
386 let ok = "version: 1\ntaint:\n protected_writes: ['**/deploy/**', '${project}/run.sh']\n";
387 assert_eq!(
388 Policy::parse(ok)
389 .unwrap()
390 .taint
391 .unwrap()
392 .protected_writes
393 .len(),
394 2
395 );
396 assert!(Policy::parse("version: 1\ntaint: {}\n").is_ok());
397 for bad in ["'**/[x'", "'!**/package.json'", "''"] {
398 let yaml = format!("version: 1\ntaint:\n protected_writes: [{bad}]\n");
399 assert!(
400 matches!(Policy::parse(&yaml), Err(PolicyError::Rule { rule, .. }) if rule == "taint.protected_writes"),
401 "{bad} must be rejected"
402 );
403 }
404 assert!(Policy::parse("version: 1\ntaint: { bogus: [] }\n").is_err());
405 }
406
407 #[test]
408 fn metadata_tools_are_full_mcp_tool_names() {
409 let yaml = |list: &str| format!("version: 1\ntaint:\n metadata_tools: [{list}]\n");
410 let ok = Policy::parse(&yaml("mcp__tabs__list_tabs")).unwrap();
411 let settings = ok.taint.as_ref();
412 assert!(super::is_metadata_tool(settings, "mcp__tabs__list_tabs"));
413 assert!(super::is_metadata_tool(
414 settings,
415 "mcp__claude-in-chrome__tabs_context_mcp"
416 ));
417 assert!(!super::is_metadata_tool(settings, "mcp__tabs__read_tab"));
418 assert!(!super::is_metadata_tool(
419 None,
420 "mcp__claude-in-chrome__get_page_text"
421 ));
422 for bad in [
423 "'mcp__tabs__*'",
424 "list_tabs",
425 "'mcp__tabs__'",
426 "'mcp____x'",
427 "''",
428 ] {
429 assert!(
430 matches!(Policy::parse(&yaml(bad)), Err(PolicyError::Rule { rule, .. }) if rule == "taint.metadata_tools"),
431 "{bad} must be rejected"
432 );
433 }
434 }
435
436 #[test]
439 fn built_in_metadata_tools_leave_page_content_tools_out() {
440 for tool in [
441 "get_page_text",
442 "read_page",
443 "find",
444 "navigate",
445 "javascript_tool",
446 "read_console_messages",
447 "read_network_requests",
448 "computer",
449 "gif_creator",
450 "upload_image",
451 "form_input",
452 "file_upload",
453 "shortcuts_execute",
454 "browser_batch",
455 ] {
456 let name = format!("mcp__claude-in-chrome__{tool}");
457 assert!(!super::is_metadata_tool(None, &name), "{name}");
458 }
459 assert!(super::METADATA_TOOLS.iter().all(|t| super::is_tool_name(t)));
460 }
461
462 #[test]
463 fn provenance_is_on_unless_the_policy_turns_it_off() {
464 use super::TaintSettings;
465 let on =
466 |yaml: &str| TaintSettings::provenance_on(Policy::parse(yaml).unwrap().taint.as_ref());
467 assert!(on("version: 1\n"));
468 assert!(on("version: 1\ntaint: {}\n"));
469 assert!(on("version: 1\ntaint: { provenance: true }\n"));
470 assert!(!on("version: 1\ntaint: { provenance: false }\n"));
471 assert!(Policy::parse("version: 1\ntaint: { provenance: maybe }\n").is_err());
472 }
473
474 fn sample(pattern: &str) -> String {
477 let path = pattern
478 .replacen('~', "/home/u", 1)
479 .replacen("**/", "/home/u/work/repo/", 1);
480 let path = match path.strip_suffix("/**") {
481 Some(dir) => format!("{dir}/x"),
482 None => path,
483 };
484 path.replace('*', "v1")
485 }
486
487 #[test]
492 fn protected_writes_cover_runs_later_and_the_kernel_config_files() {
493 let policy = Policy::parse(crate::DEFAULT_POLICY).unwrap();
494 let protected: Vec<_> = super::PROTECTED_WRITES
495 .iter()
496 .map(|p| crate::pattern::GlobPattern::compile(p, false).unwrap())
497 .collect();
498 let rule = |groups: &[crate::RuleGroup], id: &str| {
499 groups.iter().find(|g| g.id == id).unwrap().fs_write.clone()
500 };
501 let mut files = rule(&policy.review, "runs-later");
502 files.extend(rule(&policy.deny, "kernel-self").into_iter().filter(|p| {
504 !["/bin/moat", "apps/moat", "/bin/openmoat", "apps/openmoat"]
505 .iter()
506 .any(|binary| p.contains(binary))
507 }));
508 assert!(files.len() > 30, "{files:?}");
509 for pattern in &files {
510 let path = sample(pattern);
511 assert!(
512 protected.iter().any(|g| g.is_match(&path)),
513 "`{pattern}` ({path}) is not in PROTECTED_WRITES"
514 );
515 }
516 }
517}