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. The engine applies it in
3//! `engine/taint.rs`.
4
5use serde::{Deserialize, Serialize};
6
7use crate::pattern::GlobPattern;
8use crate::policy::PolicyError;
9
10/// Files whose change runs code or steers the agent later, beyond the edit
11/// itself: CI, git hooks, editor tasks, the build and test definitions of every
12/// ecosystem whose commands the default policy runs, and agent instructions and
13/// settings. Writing them is ordinary work, until the session has read content an
14/// attacker may control. Source and test files, including test-runner configs
15/// written in JavaScript (`jest.config.js`), are not listed: they are code the run
16/// executes like any other file, and the command that runs them is named in a
17/// listed file (`package.json`).
18const PROTECTED_WRITES: [&str; 84] = [
19    "**/.github/workflows/**",
20    "**/.gitlab-ci.yml",
21    "**/.circleci/**",
22    "**/azure-pipelines.yml",
23    "**/Jenkinsfile",
24    "**/.buildkite/**",
25    "**/.husky/**",
26    "**/.githooks/**",
27    "**/.pre-commit-config.yaml",
28    "**/lefthook.yml",
29    "**/.lefthook.yml",
30    "**/.vscode/**",
31    // JavaScript: scripts, and package-manager settings that pick the script shell
32    "**/package.json",
33    "**/.npmrc",
34    "**/.yarnrc",
35    "**/.yarnrc.yml",
36    "**/pnpm-workspace.yaml",
37    // make, just, task
38    "**/Makefile",
39    "**/makefile",
40    "**/GNUmakefile",
41    "**/*.mk",
42    "**/justfile",
43    "**/Justfile",
44    "**/.justfile",
45    "**/Taskfile.yml",
46    "**/Taskfile.yaml",
47    // Rust: build scripts, `[build]`/`runner` settings
48    "**/build.rs",
49    "**/Cargo.toml",
50    "**/.cargo/config",
51    "**/.cargo/config.toml",
52    // Go: a `toolchain` line downloads and runs another Go
53    "**/go.mod",
54    "**/go.work",
55    // CMake
56    "**/CMakeLists.txt",
57    "**/*.cmake",
58    "**/CMakePresets.json",
59    "**/CMakeUserPresets.json",
60    // Maven
61    "**/pom.xml",
62    "**/.mvn/**",
63    "**/mvnw",
64    "**/mvnw.cmd",
65    // Gradle
66    "**/*.gradle",
67    "**/*.gradle.kts",
68    "**/gradle.properties",
69    "**/gradlew",
70    "**/gradlew.bat",
71    "**/gradle/wrapper/**",
72    // .NET: projects and the MSBuild files they import
73    "**/*.csproj",
74    "**/*.fsproj",
75    "**/*.vbproj",
76    "**/*.props",
77    "**/*.targets",
78    "**/global.json",
79    "**/NuGet.config",
80    "**/nuget.config",
81    // Ruby
82    "**/Rakefile",
83    "**/*.rake",
84    "**/Gemfile",
85    "**/*.gemspec",
86    "**/.rspec",
87    // PHP
88    "**/composer.json",
89    "**/artisan",
90    "**/phpunit.xml",
91    "**/phpunit.xml.dist",
92    // Python: pytest loads `conftest.py` and its config before any test
93    "**/conftest.py",
94    "**/pytest.ini",
95    "**/pyproject.toml",
96    "**/setup.py",
97    "**/setup.cfg",
98    "**/tox.ini",
99    "**/noxfile.py",
100    // containers
101    "**/Dockerfile",
102    "**/docker-compose*.yml",
103    "**/docker-compose*.yaml",
104    "**/compose*.yml",
105    "**/compose*.yaml",
106    // agents
107    "**/CLAUDE.md",
108    "**/AGENTS.md",
109    "**/.claude/**",
110    "**/.claude.json",
111    "**/.codex/**",
112    "**/.cursor/**",
113    "**/.cursorrules",
114    "**/.mcp.json",
115    // OpenMoat's own state (`kernel-self` denies it outright)
116    "**/.moat/**",
117];
118
119/// The `taint:` section. Absent in policies written before it existed.
120#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(deny_unknown_fields)]
122pub struct TaintSettings {
123    /// Path globs a write to asks once the session read untrusted content, in
124    /// addition to the built-in list, which a policy cannot shorten. No `!`
125    /// exclusions, for the same reason.
126    #[serde(default)]
127    pub protected_writes: Vec<String>,
128}
129
130/// The built-in protected paths followed by the ones `settings` adds.
131pub(crate) fn protected_writes(settings: Option<&TaintSettings>) -> impl Iterator<Item = &str> {
132    PROTECTED_WRITES.into_iter().chain(
133        settings
134            .into_iter()
135            .flat_map(|s| s.protected_writes.iter().map(String::as_str)),
136    )
137}
138
139/// Each added path is a glob that compiles and is not an exclusion, which
140/// would take a built-in path out of protection.
141pub(crate) fn check(settings: Option<&TaintSettings>) -> Result<(), PolicyError> {
142    let problem = |problem: String| PolicyError::Rule {
143        rule: "taint.protected_writes".to_owned(),
144        problem,
145    };
146    for p in settings.iter().flat_map(|s| &s.protected_writes) {
147        if p.trim_start().starts_with('!') {
148            return Err(problem(format!(
149                "`{p}` is an exclusion; the list can only add paths"
150            )));
151        }
152        GlobPattern::compile(p, false).map_err(|e| problem(e.to_string()))?;
153    }
154    Ok(())
155}
156
157#[cfg(test)]
158mod tests {
159    use crate::policy::{Policy, PolicyError};
160
161    #[test]
162    fn protected_writes_must_be_plain_globs() {
163        let ok = "version: 1\ntaint:\n  protected_writes: ['**/deploy/**', '${project}/run.sh']\n";
164        assert_eq!(
165            Policy::parse(ok)
166                .unwrap()
167                .taint
168                .unwrap()
169                .protected_writes
170                .len(),
171            2
172        );
173        assert!(Policy::parse("version: 1\ntaint: {}\n").is_ok());
174        for bad in ["'**/[x'", "'!**/package.json'", "''"] {
175            let yaml = format!("version: 1\ntaint:\n  protected_writes: [{bad}]\n");
176            assert!(
177                matches!(Policy::parse(&yaml), Err(PolicyError::Rule { rule, .. }) if rule == "taint.protected_writes"),
178                "{bad} must be rejected"
179            );
180        }
181        assert!(Policy::parse("version: 1\ntaint: { bogus: [] }\n").is_err());
182    }
183
184    /// A path a `fs.write` pattern of the default policy names: `~` as a home,
185    /// `**/` as a project, a trailing `/**` as a file below, `*` as a word.
186    fn sample(pattern: &str) -> String {
187        let path = pattern
188            .replacen('~', "/home/u", 1)
189            .replacen("**/", "/home/u/work/repo/", 1);
190        let path = match path.strip_suffix("/**") {
191            Some(dir) => format!("{dir}/x"),
192            None => path,
193        };
194        path.replace('*', "v1")
195    }
196
197    /// One list of "files that run later": every file `runs-later` asks for and
198    /// every OpenMoat or host config file `kernel-self` denies is also a write
199    /// session taint protects. (`sandbox/patterns.rs` `RUNS_LATER` is checked
200    /// against `runs-later` in the CLI.)
201    #[test]
202    fn protected_writes_cover_runs_later_and_the_kernel_config_files() {
203        let policy = Policy::parse(crate::DEFAULT_POLICY).unwrap();
204        let protected: Vec<_> = super::PROTECTED_WRITES
205            .iter()
206            .map(|p| crate::pattern::GlobPattern::compile(p, false).unwrap())
207            .collect();
208        let rule = |groups: &[crate::RuleGroup], id: &str| {
209            groups.iter().find(|g| g.id == id).unwrap().fs_write.clone()
210        };
211        let mut files = rule(&policy.ask, "runs-later");
212        // the binary is not a file that runs later as configuration does
213        files.extend(
214            rule(&policy.deny, "kernel-self")
215                .into_iter()
216                .filter(|p| !p.contains("/bin/moat") && !p.contains("apps/moat")),
217        );
218        assert!(files.len() > 30, "{files:?}");
219        for pattern in &files {
220            let path = sample(pattern);
221            assert!(
222                protected.iter().any(|g| g.is_match(&path)),
223                "`{pattern}` ({path}) is not in PROTECTED_WRITES"
224            );
225        }
226    }
227}