1use serde::{Deserialize, Serialize};
7
8use crate::pattern::GlobPattern;
9use crate::policy::PolicyError;
10
11const 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 "**/package.json",
34 "**/.npmrc",
35 "**/.yarnrc",
36 "**/.yarnrc.yml",
37 "**/pnpm-workspace.yaml",
38 "**/Makefile",
40 "**/makefile",
41 "**/GNUmakefile",
42 "**/*.mk",
43 "**/justfile",
44 "**/Justfile",
45 "**/.justfile",
46 "**/Taskfile.yml",
47 "**/Taskfile.yaml",
48 "**/build.rs",
50 "**/Cargo.toml",
51 "**/.cargo/config",
52 "**/.cargo/config.toml",
53 "**/go.mod",
55 "**/go.work",
56 "**/CMakeLists.txt",
58 "**/*.cmake",
59 "**/CMakePresets.json",
60 "**/CMakeUserPresets.json",
61 "**/pom.xml",
63 "**/.mvn/**",
64 "**/mvnw",
65 "**/mvnw.cmd",
66 "**/*.gradle",
68 "**/*.gradle.kts",
69 "**/gradle.properties",
70 "**/gradlew",
71 "**/gradlew.bat",
72 "**/gradle/wrapper/**",
73 "**/*.csproj",
75 "**/*.fsproj",
76 "**/*.vbproj",
77 "**/*.props",
78 "**/*.targets",
79 "**/global.json",
80 "**/NuGet.config",
81 "**/nuget.config",
82 "**/Rakefile",
84 "**/*.rake",
85 "**/Gemfile",
86 "**/*.gemspec",
87 "**/.rspec",
88 "**/composer.json",
90 "**/artisan",
91 "**/phpunit.xml",
92 "**/phpunit.xml.dist",
93 "**/conftest.py",
95 "**/pytest.ini",
96 "**/pyproject.toml",
97 "**/setup.py",
98 "**/setup.cfg",
99 "**/tox.ini",
100 "**/noxfile.py",
101 "**/site-packages/*.pth",
103 "**/dist-packages/*.pth",
104 "**/sitecustomize.py",
105 "**/usercustomize.py",
106 "**/Dockerfile",
108 "**/docker-compose*.yml",
109 "**/docker-compose*.yaml",
110 "**/compose*.yml",
111 "**/compose*.yaml",
112 "**/CLAUDE.md",
114 "**/AGENTS.md",
115 "**/.claude/**",
116 "**/.claude.json",
117 "**/.codex/**",
118 "**/.cursor/**",
119 "**/.cursorrules",
120 "**/.mcp.json",
121 "**/.continue/config.yaml",
124 "**/.openclaw",
127 "**/.openclaw/openclaw.json",
128 "**/.openclaw/openmoat",
129 "**/.openclaw/openmoat/**",
130 "**/.openclaw/extensions/**",
131 "**/.openclaw/state/**",
133 "**/.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",
152 "**/.pi/**",
153 "**/GEMINI.md",
156 "**/.gemini",
157 "**/.gemini/**",
158 "**/.config/opencode",
162 "**/.config/opencode/**",
163 "**/.opencode",
164 "**/.opencode/**",
165 "**/opencode.json",
166 "**/opencode.jsonc",
167 "**/.cache/opencode/node_modules/**",
168 "~/.hermes/skills/**",
171 "~/.hermes/cron/**",
172 "~/.hermes/memories/**",
173 "~/.hermes/SOUL.md",
174 "**/.hermes.md",
175 "**/.hermes/**",
176 "**/.moat/**",
179];
180
181#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(deny_unknown_fields)]
184pub struct TaintSettings {
185 #[serde(default)]
189 pub protected_writes: Vec<String>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub provenance: Option<bool>,
195}
196
197impl TaintSettings {
198 #[must_use]
201 pub fn provenance_on(settings: Option<&Self>) -> bool {
202 settings.and_then(|s| s.provenance).unwrap_or(true)
203 }
204}
205
206pub(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
215pub(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 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 #[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 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}