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, and whether file provenance
3//! (ADR-029) is on. The engine applies the list in `engine/taint.rs`; the CLI
4//! keeps provenance in the audit log.
5
6use serde::{Deserialize, Serialize};
7
8use crate::pattern::GlobPattern;
9use crate::policy::PolicyError;
10
11/// Files whose change runs code or steers the agent later, beyond the edit
12/// itself: CI, git hooks, editor tasks, the build and test definitions of every
13/// ecosystem whose commands the default policy runs, and agent instructions and
14/// settings. Writing them is ordinary work, until the session has read content an
15/// attacker may control. Source and test files, including test-runner configs
16/// written in JavaScript (`jest.config.js`), are not listed: they are code the run
17/// executes like any other file, and the command that runs them is named in a
18/// listed file (`package.json`).
19const PROTECTED_WRITES: [&str; 126] = [
20    "**/.github/workflows/**",
21    "**/.gitlab-ci.yml",
22    "**/.circleci/**",
23    "**/azure-pipelines.yml",
24    "**/Jenkinsfile",
25    "**/.buildkite/**",
26    "**/.husky/**",
27    "**/.githooks/**",
28    "**/.pre-commit-config.yaml",
29    "**/lefthook.yml",
30    "**/.lefthook.yml",
31    "**/.vscode/**",
32    // JavaScript: scripts, and package-manager settings that pick the script shell
33    "**/package.json",
34    "**/.npmrc",
35    "**/.yarnrc",
36    "**/.yarnrc.yml",
37    "**/pnpm-workspace.yaml",
38    // make, just, task
39    "**/Makefile",
40    "**/makefile",
41    "**/GNUmakefile",
42    "**/*.mk",
43    "**/justfile",
44    "**/Justfile",
45    "**/.justfile",
46    "**/Taskfile.yml",
47    "**/Taskfile.yaml",
48    // Rust: build scripts, `[build]`/`runner` settings
49    "**/build.rs",
50    "**/Cargo.toml",
51    "**/.cargo/config",
52    "**/.cargo/config.toml",
53    // Go: a `toolchain` line downloads and runs another Go
54    "**/go.mod",
55    "**/go.work",
56    // CMake
57    "**/CMakeLists.txt",
58    "**/*.cmake",
59    "**/CMakePresets.json",
60    "**/CMakeUserPresets.json",
61    // Maven
62    "**/pom.xml",
63    "**/.mvn/**",
64    "**/mvnw",
65    "**/mvnw.cmd",
66    // Gradle
67    "**/*.gradle",
68    "**/*.gradle.kts",
69    "**/gradle.properties",
70    "**/gradlew",
71    "**/gradlew.bat",
72    "**/gradle/wrapper/**",
73    // .NET: projects and the MSBuild files they import
74    "**/*.csproj",
75    "**/*.fsproj",
76    "**/*.vbproj",
77    "**/*.props",
78    "**/*.targets",
79    "**/global.json",
80    "**/NuGet.config",
81    "**/nuget.config",
82    // Ruby
83    "**/Rakefile",
84    "**/*.rake",
85    "**/Gemfile",
86    "**/*.gemspec",
87    "**/.rspec",
88    // PHP
89    "**/composer.json",
90    "**/artisan",
91    "**/phpunit.xml",
92    "**/phpunit.xml.dist",
93    // Python: pytest loads `conftest.py` and its config before any test
94    "**/conftest.py",
95    "**/pytest.ini",
96    "**/pyproject.toml",
97    "**/setup.py",
98    "**/setup.cfg",
99    "**/tox.ini",
100    "**/noxfile.py",
101    // what every Python start runs (`runs-later`)
102    "**/site-packages/*.pth",
103    "**/dist-packages/*.pth",
104    "**/sitecustomize.py",
105    "**/usercustomize.py",
106    // containers
107    "**/Dockerfile",
108    "**/docker-compose*.yml",
109    "**/docker-compose*.yaml",
110    "**/compose*.yml",
111    "**/compose*.yaml",
112    // agents
113    "**/CLAUDE.md",
114    "**/AGENTS.md",
115    "**/.claude/**",
116    "**/.claude.json",
117    "**/.codex/**",
118    "**/.cursor/**",
119    "**/.cursorrules",
120    "**/.mcp.json",
121    // generated:taint from each host spec's `protect.taint` (openmoat-hosts `tests/protect.rs`)
122    // Continue CLI: its configuration, whose MCP servers it starts
123    "**/.continue/config.yaml",
124    // OpenClaw: its configuration, OpenMoat's plugin and the plugin directories the
125    // Gateway loads; not the whole state directory, whose workspace is ordinary work
126    "**/.openclaw",
127    "**/.openclaw/openclaw.json",
128    "**/.openclaw/openmoat",
129    "**/.openclaw/openmoat/**",
130    "**/.openclaw/extensions/**",
131    // the database where the Gateway keeps cron jobs and exec approvals
132    "**/.openclaw/state/**",
133    // GitHub Copilot CLI: its hooks, settings, MCP servers, plugins and custom
134    // agents, not its session folders; a repository's hooks, settings and
135    // instructions
136    "**/.copilot",
137    "**/.copilot/hooks",
138    "**/.copilot/hooks/**",
139    "**/.copilot/settings.json",
140    "**/.copilot/config.json",
141    "**/.copilot/mcp-config.json",
142    "**/.copilot/installed-plugins/**",
143    "**/.copilot/agents/**",
144    "**/.github/hooks/**",
145    "**/.github/mcp.json",
146    "**/.github/agents/**",
147    "**/.github/copilot/**",
148    "**/.github/copilot-instructions.md",
149    // pi: its agent directory (extensions, settings, trust decisions) and a
150    // project's `.pi` (extensions, settings, MCP servers, system prompt)
151    "**/.pi",
152    "**/.pi/**",
153    // Gemini CLI: its settings, extensions and the memory file it loads into
154    // every session
155    "**/GEMINI.md",
156    "**/.gemini",
157    "**/.gemini/**",
158    // OpenCode: its configuration directory, a project's `.opencode` and
159    // `opencode.json[c]` (plugins, custom tools, commands, MCP servers, permission
160    // rules) and the npm plugins it caches
161    "**/.config/opencode",
162    "**/.config/opencode/**",
163    "**/.opencode",
164    "**/.opencode/**",
165    "**/opencode.json",
166    "**/opencode.jsonc",
167    "**/.cache/opencode/node_modules/**",
168    // Hermes Agent: the skills, cron jobs, memories and persona it loads into
169    // later sessions, and a project's `.hermes.md` and plugins
170    "~/.hermes/skills/**",
171    "~/.hermes/cron/**",
172    "~/.hermes/memories/**",
173    "~/.hermes/SOUL.md",
174    "**/.hermes.md",
175    "**/.hermes/**",
176    // /generated
177    // OpenMoat's own state (`kernel-self` denies it outright)
178    "**/.moat/**",
179];
180
181/// The `taint:` section. Absent in policies written before it existed.
182#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(deny_unknown_fields)]
184pub struct TaintSettings {
185    /// Path globs a write to asks once the session read untrusted content, in
186    /// addition to the built-in list, which a policy cannot shorten. No `!`
187    /// exclusions, for the same reason.
188    #[serde(default)]
189    pub protected_writes: Vec<String>,
190    /// File provenance (ADR-029): a file a session wrote after reading
191    /// untrusted content taints the sessions that later read or run it.
192    /// Absent means on; `false` turns it off.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub provenance: Option<bool>,
195}
196
197impl TaintSettings {
198    /// Whether file provenance is on under `settings` (the policy's `taint:`
199    /// section, if any). On unless the section says `provenance: false`.
200    #[must_use]
201    pub fn provenance_on(settings: Option<&Self>) -> bool {
202        settings.and_then(|s| s.provenance).unwrap_or(true)
203    }
204}
205
206/// The built-in protected paths followed by the ones `settings` adds.
207pub(crate) fn protected_writes(settings: Option<&TaintSettings>) -> impl Iterator<Item = &str> {
208    PROTECTED_WRITES.into_iter().chain(
209        settings
210            .into_iter()
211            .flat_map(|s| s.protected_writes.iter().map(String::as_str)),
212    )
213}
214
215/// Each added path is a glob that compiles and is not an exclusion, which
216/// would take a built-in path out of protection.
217pub(crate) fn check(settings: Option<&TaintSettings>) -> Result<(), PolicyError> {
218    let problem = |problem: String| PolicyError::Rule {
219        rule: "taint.protected_writes".to_owned(),
220        problem,
221    };
222    for p in settings.iter().flat_map(|s| &s.protected_writes) {
223        if p.trim_start().starts_with('!') {
224            return Err(problem(format!(
225                "`{p}` is an exclusion; the list can only add paths"
226            )));
227        }
228        GlobPattern::compile(p, false).map_err(|e| problem(e.to_string()))?;
229    }
230    Ok(())
231}
232
233#[cfg(test)]
234mod tests {
235    use crate::policy::{Policy, PolicyError};
236
237    #[test]
238    fn protected_writes_must_be_plain_globs() {
239        let ok = "version: 1\ntaint:\n  protected_writes: ['**/deploy/**', '${project}/run.sh']\n";
240        assert_eq!(
241            Policy::parse(ok)
242                .unwrap()
243                .taint
244                .unwrap()
245                .protected_writes
246                .len(),
247            2
248        );
249        assert!(Policy::parse("version: 1\ntaint: {}\n").is_ok());
250        for bad in ["'**/[x'", "'!**/package.json'", "''"] {
251            let yaml = format!("version: 1\ntaint:\n  protected_writes: [{bad}]\n");
252            assert!(
253                matches!(Policy::parse(&yaml), Err(PolicyError::Rule { rule, .. }) if rule == "taint.protected_writes"),
254                "{bad} must be rejected"
255            );
256        }
257        assert!(Policy::parse("version: 1\ntaint: { bogus: [] }\n").is_err());
258    }
259
260    #[test]
261    fn provenance_is_on_unless_the_policy_turns_it_off() {
262        use super::TaintSettings;
263        let on =
264            |yaml: &str| TaintSettings::provenance_on(Policy::parse(yaml).unwrap().taint.as_ref());
265        assert!(on("version: 1\n"));
266        assert!(on("version: 1\ntaint: {}\n"));
267        assert!(on("version: 1\ntaint: { provenance: true }\n"));
268        assert!(!on("version: 1\ntaint: { provenance: false }\n"));
269        assert!(Policy::parse("version: 1\ntaint: { provenance: maybe }\n").is_err());
270    }
271
272    /// A path a `fs.write` pattern of the default policy names: `~` as a home,
273    /// `**/` as a project, a trailing `/**` as a file below, `*` as a word.
274    fn sample(pattern: &str) -> String {
275        let path = pattern
276            .replacen('~', "/home/u", 1)
277            .replacen("**/", "/home/u/work/repo/", 1);
278        let path = match path.strip_suffix("/**") {
279            Some(dir) => format!("{dir}/x"),
280            None => path,
281        };
282        path.replace('*', "v1")
283    }
284
285    /// One list of "files that run later": every file `runs-later` asks for and
286    /// every OpenMoat or host config file `kernel-self` denies is also a write
287    /// session taint protects. (`sandbox/patterns.rs` `RUNS_LATER` is checked
288    /// against `runs-later` in the CLI.)
289    #[test]
290    fn protected_writes_cover_runs_later_and_the_kernel_config_files() {
291        let policy = Policy::parse(crate::DEFAULT_POLICY).unwrap();
292        let protected: Vec<_> = super::PROTECTED_WRITES
293            .iter()
294            .map(|p| crate::pattern::GlobPattern::compile(p, false).unwrap())
295            .collect();
296        let rule = |groups: &[crate::RuleGroup], id: &str| {
297            groups.iter().find(|g| g.id == id).unwrap().fs_write.clone()
298        };
299        let mut files = rule(&policy.ask, "runs-later");
300        // the binary is not a file that runs later as configuration does
301        files.extend(rule(&policy.deny, "kernel-self").into_iter().filter(|p| {
302            !["/bin/moat", "apps/moat", "/bin/openmoat", "apps/openmoat"]
303                .iter()
304                .any(|binary| p.contains(binary))
305        }));
306        assert!(files.len() > 30, "{files:?}");
307        for pattern in &files {
308            let path = sample(pattern);
309            assert!(
310                protected.iter().any(|g| g.is_match(&path)),
311                "`{pattern}` ({path}) is not in PROTECTED_WRITES"
312            );
313        }
314    }
315}