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; 76] = [
19    "**/.github/workflows/**",
20    "**/.gitlab-ci.yml",
21    "**/.circleci/**",
22    "**/.husky/**",
23    "**/.githooks/**",
24    "**/.vscode/**",
25    // JavaScript: scripts, and package-manager settings that pick the script shell
26    "**/package.json",
27    "**/.npmrc",
28    "**/.yarnrc",
29    "**/.yarnrc.yml",
30    "**/pnpm-workspace.yaml",
31    // make, just, task
32    "**/Makefile",
33    "**/makefile",
34    "**/GNUmakefile",
35    "**/*.mk",
36    "**/justfile",
37    "**/Justfile",
38    "**/.justfile",
39    "**/Taskfile.yml",
40    "**/Taskfile.yaml",
41    // Rust: build scripts, `[build]`/`runner` settings
42    "**/build.rs",
43    "**/Cargo.toml",
44    "**/.cargo/config",
45    "**/.cargo/config.toml",
46    // Go: a `toolchain` line downloads and runs another Go
47    "**/go.mod",
48    "**/go.work",
49    // CMake
50    "**/CMakeLists.txt",
51    "**/*.cmake",
52    "**/CMakePresets.json",
53    "**/CMakeUserPresets.json",
54    // Maven
55    "**/pom.xml",
56    "**/.mvn/**",
57    "**/mvnw",
58    "**/mvnw.cmd",
59    // Gradle
60    "**/*.gradle",
61    "**/*.gradle.kts",
62    "**/gradle.properties",
63    "**/gradlew",
64    "**/gradlew.bat",
65    "**/gradle/wrapper/**",
66    // .NET: projects and the MSBuild files they import
67    "**/*.csproj",
68    "**/*.fsproj",
69    "**/*.vbproj",
70    "**/*.props",
71    "**/*.targets",
72    "**/global.json",
73    "**/NuGet.config",
74    "**/nuget.config",
75    // Ruby
76    "**/Rakefile",
77    "**/*.rake",
78    "**/Gemfile",
79    "**/*.gemspec",
80    "**/.rspec",
81    // PHP
82    "**/composer.json",
83    "**/artisan",
84    "**/phpunit.xml",
85    "**/phpunit.xml.dist",
86    // Python: pytest loads `conftest.py` and its config before any test
87    "**/conftest.py",
88    "**/pytest.ini",
89    "**/pyproject.toml",
90    "**/setup.py",
91    "**/setup.cfg",
92    "**/tox.ini",
93    "**/noxfile.py",
94    // containers
95    "**/Dockerfile",
96    "**/docker-compose*.yml",
97    "**/docker-compose*.yaml",
98    "**/compose*.yml",
99    "**/compose*.yaml",
100    // agents
101    "**/CLAUDE.md",
102    "**/AGENTS.md",
103    "**/.claude/**",
104    "**/.codex/**",
105    "**/.cursor/**",
106    "**/.cursorrules",
107    "**/.mcp.json",
108];
109
110/// The `taint:` section. Absent in policies written before it existed.
111#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct TaintSettings {
114    /// Path globs a write to asks once the session read untrusted content, in
115    /// addition to the built-in list, which a policy cannot shorten. No `!`
116    /// exclusions, for the same reason.
117    #[serde(default)]
118    pub protected_writes: Vec<String>,
119}
120
121/// The built-in protected paths followed by the ones `settings` adds.
122pub(crate) fn protected_writes(settings: Option<&TaintSettings>) -> impl Iterator<Item = &str> {
123    PROTECTED_WRITES.into_iter().chain(
124        settings
125            .into_iter()
126            .flat_map(|s| s.protected_writes.iter().map(String::as_str)),
127    )
128}
129
130/// Each added path is a glob that compiles and is not an exclusion, which
131/// would take a built-in path out of protection.
132pub(crate) fn check(settings: Option<&TaintSettings>) -> Result<(), PolicyError> {
133    let problem = |problem: String| PolicyError::Rule {
134        rule: "taint.protected_writes".to_owned(),
135        problem,
136    };
137    for p in settings.iter().flat_map(|s| &s.protected_writes) {
138        if p.trim_start().starts_with('!') {
139            return Err(problem(format!(
140                "`{p}` is an exclusion; the list can only add paths"
141            )));
142        }
143        GlobPattern::compile(p, false).map_err(|e| problem(e.to_string()))?;
144    }
145    Ok(())
146}
147
148#[cfg(test)]
149mod tests {
150    use crate::policy::{Policy, PolicyError};
151
152    #[test]
153    fn protected_writes_must_be_plain_globs() {
154        let ok = "version: 1\ntaint:\n  protected_writes: ['**/deploy/**', '${project}/run.sh']\n";
155        assert_eq!(
156            Policy::parse(ok)
157                .unwrap()
158                .taint
159                .unwrap()
160                .protected_writes
161                .len(),
162            2
163        );
164        assert!(Policy::parse("version: 1\ntaint: {}\n").is_ok());
165        for bad in ["'**/[x'", "'!**/package.json'", "''"] {
166            let yaml = format!("version: 1\ntaint:\n  protected_writes: [{bad}]\n");
167            assert!(
168                matches!(Policy::parse(&yaml), Err(PolicyError::Rule { rule, .. }) if rule == "taint.protected_writes"),
169                "{bad} must be rejected"
170            );
171        }
172        assert!(Policy::parse("version: 1\ntaint: { bogus: [] }\n").is_err());
173    }
174}