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, build scripts the allowed dev commands
12/// run, and agent instructions and settings. Writing them is ordinary work,
13/// until the session has read content an attacker may control.
14const PROTECTED_WRITES: [&str; 16] = [
15    "**/.github/workflows/**",
16    "**/.gitlab-ci.yml",
17    "**/.circleci/**",
18    "**/.husky/**",
19    "**/.githooks/**",
20    "**/.vscode/**",
21    "**/package.json",
22    "**/Makefile",
23    "**/build.rs",
24    "**/CLAUDE.md",
25    "**/AGENTS.md",
26    "**/.claude/**",
27    "**/.codex/**",
28    "**/.cursor/**",
29    "**/.cursorrules",
30    "**/.mcp.json",
31];
32
33/// The `taint:` section. Absent in policies written before it existed.
34#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct TaintSettings {
37    /// Path globs a write to asks once the session read untrusted content, in
38    /// addition to the built-in list, which a policy cannot shorten. No `!`
39    /// exclusions, for the same reason.
40    #[serde(default)]
41    pub protected_writes: Vec<String>,
42}
43
44/// The built-in protected paths followed by the ones `settings` adds.
45pub(crate) fn protected_writes(settings: Option<&TaintSettings>) -> impl Iterator<Item = &str> {
46    PROTECTED_WRITES.into_iter().chain(
47        settings
48            .into_iter()
49            .flat_map(|s| s.protected_writes.iter().map(String::as_str)),
50    )
51}
52
53/// Each added path is a glob that compiles and is not an exclusion, which
54/// would take a built-in path out of protection.
55pub(crate) fn check(settings: Option<&TaintSettings>) -> Result<(), PolicyError> {
56    let problem = |problem: String| PolicyError::Rule {
57        rule: "taint.protected_writes".to_owned(),
58        problem,
59    };
60    for p in settings.iter().flat_map(|s| &s.protected_writes) {
61        if p.trim_start().starts_with('!') {
62            return Err(problem(format!(
63                "`{p}` is an exclusion; the list can only add paths"
64            )));
65        }
66        GlobPattern::compile(p, false).map_err(|e| problem(e.to_string()))?;
67    }
68    Ok(())
69}
70
71#[cfg(test)]
72mod tests {
73    use crate::policy::{Policy, PolicyError};
74
75    #[test]
76    fn protected_writes_must_be_plain_globs() {
77        let ok = "version: 1\ntaint:\n  protected_writes: ['**/deploy/**', '${project}/run.sh']\n";
78        assert_eq!(
79            Policy::parse(ok)
80                .unwrap()
81                .taint
82                .unwrap()
83                .protected_writes
84                .len(),
85            2
86        );
87        assert!(Policy::parse("version: 1\ntaint: {}\n").is_ok());
88        for bad in ["'**/[x'", "'!**/package.json'", "''"] {
89            let yaml = format!("version: 1\ntaint:\n  protected_writes: [{bad}]\n");
90            assert!(
91                matches!(Policy::parse(&yaml), Err(PolicyError::Rule { rule, .. }) if rule == "taint.protected_writes"),
92                "{bad} must be rejected"
93            );
94        }
95        assert!(Policy::parse("version: 1\ntaint: { bogus: [] }\n").is_err());
96    }
97}