Skip to main content

openmoat_core/
taint.rs

1//! The policy's `taint:` section (ADR-020, docs/POLICY.md ยง4.1): what session
2//! taint protects beyond the built-in list, which MCP tools return metadata
3//! rather than content, and whether file provenance (ADR-029) is on. The
4//! engine applies the lists in `engine/taint.rs`; the CLI keeps provenance in
5//! the audit log.
6
7use serde::{Deserialize, Serialize};
8
9use crate::pattern::GlobPattern;
10use crate::policy::PolicyError;
11
12/// The MCP server whose tab and window tools are the built-in metadata tools:
13/// Claude in Chrome, which Claude Code provides itself (`claude --chrome`, the
14/// browser extension), not a server any MCP configuration file defines. A server a configuration or a
15/// pin defines under this name is another program that took the name, and the
16/// built-in list does not apply to it ([`TaintSettings::defined_servers`],
17/// ADR-069).
18pub const BUILTIN_METADATA_SERVER: &str = "claude-in-chrome";
19
20/// MCP tools whose result is the browser's own bookkeeping, not the content of
21/// a page: Claude in Chrome's tab, window, browser and shortcut lists (tab ids,
22/// titles, URLs). Their results do not taint the session (ADR-057 amendment of
23/// 2026-11-07), and are still read for injected input: a host, path or command
24/// in a tab's title or URL is kept with the tool as its source. Every tool that
25/// returns what a page says (`get_page_text`, `read_page`, `find`, `navigate`,
26/// `javascript_tool`, the console and network logs, screenshots) is not listed.
27/// They apply only while no configured server takes [`BUILTIN_METADATA_SERVER`].
28const 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
39/// How every MCP tool's name starts (`mcp__<server>__<tool>`).
40const MCP_PREFIX: &str = "mcp__";
41
42/// Files whose change runs code or steers the agent later, beyond the edit
43/// itself: CI, git hooks, editor tasks, the build and test definitions of every
44/// ecosystem whose commands the default policy runs, and agent instructions and
45/// settings. Writing them is ordinary work, until the session has read content an
46/// attacker may control. Source and test files, including test-runner configs
47/// written in JavaScript (`jest.config.js`), are not listed: they are code the run
48/// executes like any other file, and the command that runs them is named in a
49/// listed file (`package.json`).
50const PROTECTED_WRITES: [&str; 166] = [
51    "**/.github/workflows/**",
52    "**/.gitlab-ci.yml",
53    "**/.circleci/**",
54    "**/azure-pipelines.yml",
55    "**/Jenkinsfile",
56    "**/.buildkite/**",
57    "**/.husky/**",
58    // the folders a repository's `core.hooksPath` usually names (`runs-later`)
59    "**/.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    // the editors' project configuration, which the editor runs (ADR-060)
72    "**/.vscode",
73    "**/*.code-workspace",
74    "**/.idea/**",
75    "**/.idea",
76    "**/*.run.xml",
77    // JavaScript: scripts, and package-manager settings that pick the script shell
78    "**/package.json",
79    "**/.npmrc",
80    "**/bunfig.toml",
81    "**/.yarnrc",
82    "**/.yarnrc.yml",
83    "**/pnpm-workspace.yaml",
84    // make, just, task
85    "**/Makefile",
86    "**/makefile",
87    "**/GNUmakefile",
88    "**/*.mk",
89    "**/justfile",
90    "**/Justfile",
91    "**/.justfile",
92    "**/Taskfile.yml",
93    "**/Taskfile.yaml",
94    // Rust: build scripts, `[build]`/`runner` settings
95    "**/build.rs",
96    "**/Cargo.toml",
97    "**/.cargo/config",
98    "**/.cargo/config.toml",
99    // Go: a `toolchain` line downloads and runs another Go
100    "**/go.mod",
101    "**/go.work",
102    // CMake
103    "**/CMakeLists.txt",
104    "**/*.cmake",
105    "**/CMakePresets.json",
106    "**/CMakeUserPresets.json",
107    // Maven
108    "**/pom.xml",
109    "**/.mvn/**",
110    "**/mvnw",
111    "**/mvnw.cmd",
112    // Gradle
113    "**/*.gradle",
114    "**/*.gradle.kts",
115    "**/gradle.properties",
116    "**/gradlew",
117    "**/gradlew.bat",
118    "**/gradle/wrapper/**",
119    // .NET: projects and the MSBuild files they import
120    "**/*.csproj",
121    "**/*.fsproj",
122    "**/*.vbproj",
123    "**/*.props",
124    "**/*.targets",
125    "**/global.json",
126    "**/NuGet.config",
127    "**/nuget.config",
128    // Ruby
129    "**/Rakefile",
130    "**/*.rake",
131    "**/Gemfile",
132    "**/*.gemspec",
133    "**/.rspec",
134    // PHP
135    "**/composer.json",
136    "**/artisan",
137    "**/phpunit.xml",
138    "**/phpunit.xml.dist",
139    // Python: pytest loads `conftest.py` and its config before any test
140    "**/conftest.py",
141    "**/pytest.ini",
142    "**/pyproject.toml",
143    "**/setup.py",
144    "**/setup.cfg",
145    "**/tox.ini",
146    "**/noxfile.py",
147    // what every Python start runs (`runs-later`)
148    "**/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    // containers
160    "**/Dockerfile",
161    "**/docker-compose*.yml",
162    "**/docker-compose*.yaml",
163    "**/compose*.yml",
164    "**/compose*.yaml",
165    // agents
166    "**/CLAUDE.md",
167    "**/AGENTS.md",
168    "**/.claude/**",
169    "**/.claude.json",
170    "**/.codex/**",
171    "**/.cursor/**",
172    "**/.cursorrules",
173    "**/.mcp.json",
174    // instruction files and skills the next session of an agent obeys
175    // (ADR-057): the shared `.agents/skills` folder several agents load,
176    // Windsurf's rules, and GitHub Copilot's path-scoped instructions
177    "**/.agents/skills",
178    "**/.agents/skills/**",
179    "**/.windsurfrules",
180    "**/.windsurf/rules/**",
181    "**/.github/instructions/**",
182    // generated:taint from each host spec's `protect.taint` (openmoat-hosts `tests/protect.rs`)
183    // Continue CLI: its configuration, whose MCP servers it starts
184    "**/.continue/config.yaml",
185    // OpenClaw: its configuration, OpenMoat's plugin and the plugin directories the
186    // Gateway loads; not the whole state directory, whose workspace is ordinary work
187    "**/.openclaw",
188    "**/.openclaw/openclaw.json",
189    "**/.openclaw/openmoat",
190    "**/.openclaw/openmoat/**",
191    "**/.openclaw/extensions/**",
192    // the database where the Gateway keeps cron jobs and exec approvals
193    "**/.openclaw/state/**",
194    // GitHub Copilot CLI: its hooks, settings, MCP servers, plugins and custom
195    // agents, not its session folders; a repository's hooks, settings and
196    // instructions
197    "**/.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: its agent directory (extensions, settings, trust decisions) and a
211    // project's `.pi` (extensions, settings, MCP servers, system prompt)
212    "**/.pi",
213    "**/.pi/**",
214    // Gemini CLI: its settings, extensions and the memory file it loads into
215    // every session
216    "**/GEMINI.md",
217    "**/.gemini",
218    "**/.gemini/**",
219    // OpenCode: its configuration directory, a project's `.opencode` and
220    // `opencode.json[c]` (plugins, custom tools, commands, MCP servers, permission
221    // rules) and the npm plugins it caches
222    "**/.config/opencode",
223    "**/.config/opencode/**",
224    "**/.opencode",
225    "**/.opencode/**",
226    "**/opencode.json",
227    "**/opencode.jsonc",
228    "**/.cache/opencode/node_modules/**",
229    // Hermes Agent: the skills, cron jobs, memories and persona it loads into
230    // later sessions, and a project's `.hermes.md` and plugins
231    "~/.hermes/skills/**",
232    "~/.hermes/cron/**",
233    "~/.hermes/memories/**",
234    "~/.hermes/SOUL.md",
235    "**/.hermes.md",
236    "**/.hermes/**",
237    // Cline: its directory (settings, MCP servers, hooks, plugins), the
238    // rules, workflows and hooks in `Documents/Cline`, and a project's `.cline`
239    // and `.clinerules` (hooks, plugins, rules)
240    "**/.cline",
241    "**/.cline/**",
242    "**/Documents/Cline/**",
243    "**/.clinerules",
244    "**/.clinerules/**",
245    // goose: the hints it loads into every session, its configuration, saved
246    // recipes and plugin settings, and the plugins whose hooks it runs
247    "**/.goosehints",
248    "~/.config/goose",
249    "~/.config/goose/**",
250    "**/.config/goose/**",
251    "**/.agents/plugins",
252    "**/.agents/plugins/**",
253    // Antigravity CLI: its hook and MCP files, and the plugins it loads
254    "~/.gemini/config",
255    "~/.gemini/config/**",
256    "**/.agents/hooks.json",
257    "**/.agents/mcp_config.json",
258    // /generated
259    // OpenMoat's own state (`kernel-self` denies it outright)
260    "**/.moat/**",
261];
262
263/// The `taint:` section. Absent in policies written before it existed.
264#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(deny_unknown_fields)]
266pub struct TaintSettings {
267    /// Path globs whose writes need review once the session read untrusted content, in
268    /// addition to the built-in list, which a policy cannot shorten. No `!`
269    /// exclusions, for the same reason.
270    #[serde(default)]
271    pub protected_writes: Vec<String>,
272    /// MCP tools, by full name (`mcp__<server>__<tool>`), whose result is the
273    /// server's own metadata rather than content an attacker writes, in
274    /// addition to the built-in list: their results do not taint the session,
275    /// and their output still feeds injected input (ADR-057).
276    #[serde(default, skip_serializing_if = "Vec::is_empty")]
277    pub metadata_tools: Vec<String>,
278    /// MCP servers, by name, that an agent's MCP configuration or OpenMoat's
279    /// pins define. The built-in metadata tools are those of a server the
280    /// host provides itself ([`BUILTIN_METADATA_SERVER`]); one defined here
281    /// under that name is not it, and every tool of it taints the session.
282    /// The CLI adds the names it finds pinned; a policy may list more, which
283    /// can only make more results taint. `metadata_tools` still counts: a
284    /// person who lists a tool there trusts it by its full name.
285    #[serde(default, skip_serializing_if = "Vec::is_empty")]
286    pub defined_servers: Vec<String>,
287    /// File provenance (ADR-029): a file a session wrote after reading
288    /// untrusted content taints the sessions that later read or run it.
289    /// Absent means on; `false` turns it off.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub provenance: Option<bool>,
292}
293
294impl TaintSettings {
295    /// Whether file provenance is on under `settings` (the policy's `taint:`
296    /// section, if any). On unless the section says `provenance: false`.
297    #[must_use]
298    pub fn provenance_on(settings: Option<&Self>) -> bool {
299        settings.and_then(|s| s.provenance).unwrap_or(true)
300    }
301}
302
303/// The built-in protected paths followed by the ones `settings` adds.
304pub(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
312/// Whether the MCP tool `name` returns metadata (the built-in list, unless a
313/// configured server took its server's name, and the ones `settings` adds),
314/// whose result does not taint the session.
315pub(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
325/// Each added path is a glob that compiles and is not an exclusion, which
326/// would take a built-in path out of protection; each added metadata tool is
327/// one MCP tool's full name.
328pub(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
349/// Names are compared exactly, so a glob would match no tool: each entry is
350/// one full `mcp__<server>__<tool>` name.
351fn 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
367/// Whether `name` is `mcp__<server>__<tool>` with both parts present and no
368/// space or glob character.
369fn 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    /// The built-in list names only tools that list tabs, windows, browsers or
437    /// shortcuts: none of Claude in Chrome's page-content tools.
438    #[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    /// A path a `fs.write` pattern of the default policy names: `~` as a home,
475    /// `**/` as a project, a trailing `/**` as a file below, `*` as a word.
476    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    /// One list of "files that run later": every file `runs-later` holds for review and
488    /// every OpenMoat or host config file `kernel-self` denies is also a write
489    /// session taint protects. (`sandbox/patterns.rs` `RUNS_LATER` is checked
490    /// against `runs-later` in the CLI.)
491    #[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        // the binary is not a file that runs later as configuration does
503        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}