1use serde::{Deserialize, Serialize};
22use tuff_hooks_spec::CoverageLevel;
23
24use crate::error::{Result, TuffError};
25use crate::lockfile::ManagedPermission;
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(deny_unknown_fields)]
30pub struct PolicyConfig {
31 #[serde(default)]
32 pub rules: Vec<PolicyRule>,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum PolicyEffect {
39 Deny,
41 Ask,
43}
44
45impl PolicyEffect {
46 pub const ALL: [Self; 2] = [Self::Deny, Self::Ask];
47
48 pub fn parse(text: &str) -> Option<Self> {
49 match text {
50 "deny" => Some(Self::Deny),
51 "ask" => Some(Self::Ask),
52 _ => None,
53 }
54 }
55
56 pub const fn as_str(self) -> &'static str {
57 match self {
58 Self::Deny => "deny",
59 Self::Ask => "ask",
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum PolicySubjectKind {
68 Command,
70 Read,
72 Edit,
74 Mcp,
76}
77
78impl PolicySubjectKind {
79 pub const ALL: [Self; 4] = [Self::Command, Self::Read, Self::Edit, Self::Mcp];
80
81 pub const fn as_str(self) -> &'static str {
82 match self {
83 Self::Command => "command",
84 Self::Read => "read",
85 Self::Edit => "edit",
86 Self::Mcp => "mcp",
87 }
88 }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(deny_unknown_fields)]
97pub struct PolicyRule {
98 pub effect: String,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub command: Option<Vec<String>>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub read: Option<Vec<String>>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub edit: Option<Vec<String>>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub mcp: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub reason: Option<String>,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum PolicySubject<'a> {
114 Command(&'a [String]),
115 Read(&'a [String]),
116 Edit(&'a [String]),
117 Mcp { server: &'a str, tool: &'a str },
118}
119
120impl PolicySubject<'_> {
121 pub const fn kind(&self) -> PolicySubjectKind {
122 match self {
123 Self::Command(_) => PolicySubjectKind::Command,
124 Self::Read(_) => PolicySubjectKind::Read,
125 Self::Edit(_) => PolicySubjectKind::Edit,
126 Self::Mcp { .. } => PolicySubjectKind::Mcp,
127 }
128 }
129}
130
131impl PolicyRule {
132 pub fn effect(&self) -> Result<PolicyEffect> {
134 match self.effect.as_str() {
135 "deny" => Ok(PolicyEffect::Deny),
136 "ask" => Ok(PolicyEffect::Ask),
137 "allow" => Err(TuffError::refused(
138 "a policy rule cannot allow anything: policies only narrow what an agent may do",
139 )
140 .with_hint(
141 "use effect = \"deny\" or \"ask\"; permissions an agent should have belong in the harness's own settings, not in a shareable policy",
142 )),
143 other => Err(TuffError::usage(format!(
144 "policy rule effect must be \"deny\" or \"ask\", not '{}'",
145 other.escape_debug()
146 ))),
147 }
148 }
149
150 pub fn subject(&self) -> Result<PolicySubject<'_>> {
152 let mut present = Vec::new();
153 if self.command.is_some() {
154 present.push("command");
155 }
156 if self.read.is_some() {
157 present.push("read");
158 }
159 if self.edit.is_some() {
160 present.push("edit");
161 }
162 if self.mcp.is_some() {
163 present.push("mcp");
164 }
165 match present.as_slice() {
166 [] => {
167 return Err(TuffError::usage(
168 "policy rule needs a subject: one of command, read, edit, or mcp",
169 ));
170 }
171 [_] => {}
172 several => {
173 return Err(TuffError::usage(format!(
174 "policy rule has more than one subject ({}); write one rule per subject",
175 several.join(", ")
176 )));
177 }
178 }
179
180 if let Some(command) = &self.command {
181 validate_command(command)?;
182 return Ok(PolicySubject::Command(command));
183 }
184 if let Some(read) = &self.read {
185 validate_paths("read", read)?;
186 return Ok(PolicySubject::Read(read));
187 }
188 if let Some(edit) = &self.edit {
189 validate_paths("edit", edit)?;
190 return Ok(PolicySubject::Edit(edit));
191 }
192 let mcp = self.mcp.as_deref().expect("one subject is present");
193 let (server, tool) = parse_mcp_pattern(mcp)?;
194 Ok(PolicySubject::Mcp { server, tool })
195 }
196
197 pub fn describe(&self) -> String {
199 let subject = if let Some(command) = &self.command {
200 format!("command \"{}\"", command.join(" "))
201 } else if let Some(read) = &self.read {
202 format!("read {}", quoted_list(read))
203 } else if let Some(edit) = &self.edit {
204 format!("edit {}", quoted_list(edit))
205 } else if let Some(mcp) = &self.mcp {
206 format!("mcp \"{mcp}\"")
207 } else {
208 "no subject".to_string()
209 };
210 format!("{} {subject}", self.effect)
211 }
212}
213
214fn quoted_list(items: &[String]) -> String {
215 items
216 .iter()
217 .map(|item| format!("\"{item}\""))
218 .collect::<Vec<_>>()
219 .join(", ")
220}
221
222fn validate_command(command: &[String]) -> Result<()> {
223 if command.is_empty() {
224 return Err(TuffError::usage(
225 "policy rule command must name at least the program, such as [\"git\", \"push\"]",
226 ));
227 }
228 for token in command {
229 if token.contains('*') {
230 return Err(TuffError::usage(format!(
231 "policy rule command arguments are literal words, and '*' is not a pattern here: '{}'",
232 token.escape_debug()
233 ))
234 .with_hint("a command rule already matches every command that starts with its arguments"));
235 }
236 if token.is_empty() || token.chars().any(char::is_whitespace) || token.contains('\0') {
237 return Err(TuffError::usage(format!(
238 "policy rule command arguments must be single words without spaces: '{}'",
239 token.escape_debug()
240 ))
241 .with_hint("write each argument as its own string: [\"git\", \"push\", \"--force\"]"));
242 }
243 }
244 Ok(())
245}
246
247fn validate_paths(subject: &str, patterns: &[String]) -> Result<()> {
248 if patterns.is_empty() {
249 return Err(TuffError::usage(format!(
250 "policy rule {subject} must list at least one path pattern"
251 )));
252 }
253 for pattern in patterns {
254 let escapes = pattern.split('/').any(|segment| segment == "..");
255 let invalid = pattern.is_empty()
256 || pattern.trim() != pattern
257 || pattern.starts_with('/')
258 || pattern.starts_with('~')
259 || pattern.contains(['\\', '\0']);
260 if escapes || invalid {
261 return Err(TuffError::usage(format!(
262 "policy rule {subject} patterns are paths relative to the project root, such as \".env\" or \"secrets/**\": '{}'",
263 pattern.escape_debug()
264 ))
265 .with_hint("a policy governs its project, so patterns cannot start with '/' or '~' or climb out with '..'"));
266 }
267 }
268 Ok(())
269}
270
271fn parse_mcp_pattern(pattern: &str) -> Result<(&str, &str)> {
272 let invalid = || {
273 TuffError::usage(format!(
274 "policy rule mcp must be \"server:tool\", where either side may use '*', such as \"github:delete_*\": '{}'",
275 pattern.escape_debug()
276 ))
277 };
278 let (server, tool) = pattern.split_once(':').ok_or_else(invalid)?;
279 let allowed = |part: &str| {
280 !part.is_empty()
281 && part
282 .chars()
283 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '*'))
284 };
285 if !allowed(server) || !allowed(tool) {
286 return Err(invalid());
287 }
288 Ok((server, tool))
289}
290
291pub fn validate_policy(policy: &PolicyConfig) -> Result<()> {
295 if policy.rules.is_empty() {
296 return Err(TuffError::usage(
297 "a policy needs at least one [[policy.rules]] entry",
298 ));
299 }
300 for (index, rule) in policy.rules.iter().enumerate() {
301 let context = |error: TuffError| {
302 let hint = error.hint().map(str::to_string);
303 let rewritten = TuffError::of(
304 error.kind(),
305 format!("policy rule {}: {}", index + 1, error.message()),
306 );
307 match hint {
308 Some(hint) => rewritten.with_hint(hint),
309 None => rewritten,
310 }
311 };
312 rule.effect().map_err(context)?;
313 rule.subject().map_err(context)?;
314 if let Some(reason) = &rule.reason
315 && reason.trim().is_empty()
316 {
317 return Err(context(TuffError::usage(
318 "reason, when given, must not be empty",
319 )));
320 }
321 }
322 Ok(())
323}
324
325#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
327pub struct PolicyCoverageEntry {
328 pub effect: PolicyEffect,
329 pub subject: PolicySubjectKind,
330 pub coverage: CoverageLevel,
331 #[serde(skip_serializing_if = "Option::is_none")]
333 pub mechanism: Option<String>,
334 #[serde(skip_serializing_if = "Option::is_none")]
336 pub caveat: Option<String>,
337 #[serde(skip_serializing_if = "Option::is_none")]
339 pub source: Option<String>,
340}
341
342pub fn not_implemented_matrix() -> Vec<PolicyCoverageEntry> {
345 PolicyEffect::ALL
346 .into_iter()
347 .flat_map(|effect| {
348 PolicySubjectKind::ALL
349 .into_iter()
350 .map(move |subject| PolicyCoverageEntry {
351 effect,
352 subject,
353 coverage: CoverageLevel::Unsupported,
354 mechanism: None,
355 caveat: Some(
356 "Tuff does not compile policy rules for this agent yet".to_string(),
357 ),
358 source: None,
359 })
360 })
361 .collect()
362}
363
364#[derive(Debug, Clone)]
366pub struct RuleVerdict<'a> {
367 pub index: usize,
369 pub rule: &'a PolicyRule,
370 pub entry: PolicyCoverageEntry,
371}
372
373pub fn verdicts<'a>(
376 policy: &'a PolicyConfig,
377 matrix: &[PolicyCoverageEntry],
378) -> Result<Vec<RuleVerdict<'a>>> {
379 policy
380 .rules
381 .iter()
382 .enumerate()
383 .map(|(index, rule)| {
384 let effect = rule.effect()?;
385 let subject = rule.subject()?.kind();
386 let entry = matrix
387 .iter()
388 .find(|entry| entry.effect == effect && entry.subject == subject)
389 .cloned()
390 .unwrap_or_else(|| PolicyCoverageEntry {
391 effect,
392 subject,
393 coverage: CoverageLevel::Unsupported,
394 mechanism: None,
395 caveat: Some("this agent declares nothing for this kind of rule".to_string()),
396 source: None,
397 });
398 Ok(RuleVerdict { index, rule, entry })
399 })
400 .collect()
401}
402
403pub fn merge_permissions(
413 settings_relpath: &str,
414 existing: Option<&[u8]>,
415 remove: &[(PolicyEffect, String)],
416 add: &[(PolicyEffect, String)],
417) -> Result<Vec<u8>> {
418 let mut settings: serde_json::Value = match existing {
419 Some(bytes) if !bytes.is_empty() => serde_json::from_slice(bytes).map_err(|error| {
420 TuffError::corrupt(format!("{settings_relpath} is not valid JSON: {error}"))
421 })?,
422 _ => serde_json::json!({}),
423 };
424 let object = settings
425 .as_object_mut()
426 .ok_or_else(|| TuffError::corrupt(format!("{settings_relpath} must be a JSON object")))?;
427 if add.is_empty() && !object.contains_key("permissions") {
428 return Ok(serde_json::to_string_pretty(&settings)?.into_bytes());
429 }
430 let permissions = object
431 .entry("permissions")
432 .or_insert_with(|| serde_json::json!({}))
433 .as_object_mut()
434 .ok_or_else(|| {
435 TuffError::corrupt(format!(
436 "{settings_relpath} field 'permissions' must be an object"
437 ))
438 })?;
439 let not_a_list = |effect: PolicyEffect| {
440 TuffError::corrupt(format!(
441 "{settings_relpath} field 'permissions.{}' must be an array",
442 effect.as_str()
443 ))
444 };
445 for (effect, rule) in remove {
446 if let Some(list) = permissions.get_mut(effect.as_str()) {
447 let list = list.as_array_mut().ok_or_else(|| not_a_list(*effect))?;
448 list.retain(|entry| entry.as_str() != Some(rule.as_str()));
449 }
450 }
451 for (effect, rule) in add {
452 let list = permissions
453 .entry(effect.as_str())
454 .or_insert_with(|| serde_json::json!([]))
455 .as_array_mut()
456 .ok_or_else(|| not_a_list(*effect))?;
457 if !list
458 .iter()
459 .any(|entry| entry.as_str() == Some(rule.as_str()))
460 {
461 list.push(serde_json::Value::String(rule.clone()));
462 }
463 }
464 for effect in PolicyEffect::ALL {
465 let emptied_here = remove.iter().any(|(removed, _)| *removed == effect)
466 && permissions
467 .get(effect.as_str())
468 .and_then(serde_json::Value::as_array)
469 .is_some_and(Vec::is_empty);
470 if emptied_here {
471 permissions.remove(effect.as_str());
472 }
473 }
474 let now_empty = !remove.is_empty() && permissions.is_empty();
475 if now_empty {
476 object.remove("permissions");
477 }
478 Ok(serde_json::to_string_pretty(&settings)?.into_bytes())
479}
480
481pub fn remove_permissions(
486 repo_root: &std::path::Path,
487 managed: &[ManagedPermission],
488) -> Result<()> {
489 let mut by_file: std::collections::BTreeMap<&str, Vec<(PolicyEffect, String)>> =
490 std::collections::BTreeMap::new();
491 for permission in managed {
492 if let Some(effect) = PolicyEffect::parse(&permission.list) {
493 by_file
494 .entry(permission.settings_path.as_str())
495 .or_default()
496 .push((effect, permission.rule.clone()));
497 }
498 }
499 for (relpath, removals) in by_file {
500 let path = repo_root.join(relpath);
501 if !path.is_file() {
502 continue;
503 }
504 let bytes = std::fs::read(&path)?;
505 let mut merged = merge_permissions(relpath, Some(&bytes), &removals, &[])?;
506 if merged != bytes {
507 merged.push(b'\n');
508 std::fs::write(&path, merged)?;
509 }
510 }
511 Ok(())
512}
513
514pub fn managed_permission_status(
518 repo_root: &std::path::Path,
519 permission: &ManagedPermission,
520) -> &'static str {
521 let Ok(raw) = std::fs::read_to_string(repo_root.join(&permission.settings_path)) else {
522 return "missing";
523 };
524 let Ok(settings) = serde_json::from_str::<serde_json::Value>(&raw) else {
525 return "modified";
526 };
527 let present = settings
528 .get("permissions")
529 .and_then(|permissions| permissions.get(&permission.list))
530 .and_then(serde_json::Value::as_array)
531 .is_some_and(|list| {
532 list.iter()
533 .any(|entry| entry.as_str() == Some(permission.rule.as_str()))
534 });
535 if present { "clean" } else { "missing" }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541 use crate::error::ErrorKind;
542
543 fn deny(rule: &str) -> (PolicyEffect, String) {
544 (PolicyEffect::Deny, rule.to_string())
545 }
546
547 fn ask(rule: &str) -> (PolicyEffect, String) {
548 (PolicyEffect::Ask, rule.to_string())
549 }
550
551 #[test]
552 fn merging_permissions_keeps_the_users_rules_and_adds_each_rule_once() {
553 let existing = br#"{"model": "opus", "permissions": {"deny": ["Bash(curl *)"], "allow": ["Bash(npm test *)"]}}"#;
554 let add = [
555 deny("Bash(git push --force *)"),
556 ask("Bash(terraform apply *)"),
557 ];
558 let once = merge_permissions(".claude/settings.json", Some(existing), &[], &add).unwrap();
559 let twice = merge_permissions(".claude/settings.json", Some(&once), &[], &add).unwrap();
560 assert_eq!(once, twice, "a redundant merge leaves the file unchanged");
561 let settings: serde_json::Value = serde_json::from_slice(&once).unwrap();
562 assert_eq!(settings["model"], "opus");
563 assert_eq!(
564 settings["permissions"]["deny"],
565 serde_json::json!(["Bash(curl *)", "Bash(git push --force *)"])
566 );
567 assert_eq!(
568 settings["permissions"]["ask"],
569 serde_json::json!(["Bash(terraform apply *)"])
570 );
571 assert_eq!(
572 settings["permissions"]["allow"],
573 serde_json::json!(["Bash(npm test *)"])
574 );
575 }
576
577 #[test]
578 fn removing_permissions_prunes_only_what_it_emptied() {
579 let existing = br#"{"permissions": {"deny": ["Bash(curl *)", "Bash(git push --force *)"], "ask": ["Bash(terraform apply *)"]}}"#;
580 let merged = merge_permissions(
581 "s.json",
582 Some(existing),
583 &[
584 deny("Bash(git push --force *)"),
585 ask("Bash(terraform apply *)"),
586 ],
587 &[],
588 )
589 .unwrap();
590 let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
591 assert_eq!(
592 settings,
593 serde_json::json!({"permissions": {"deny": ["Bash(curl *)"]}})
594 );
595
596 let only_ours =
597 br#"{"model": "opus", "permissions": {"ask": ["Bash(terraform apply *)"]}}"#;
598 let merged = merge_permissions(
599 "s.json",
600 Some(only_ours),
601 &[ask("Bash(terraform apply *)")],
602 &[],
603 )
604 .unwrap();
605 let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
606 assert_eq!(settings, serde_json::json!({"model": "opus"}));
607
608 let untouched = br#"{"permissions": {}}"#;
609 let merged = merge_permissions("s.json", Some(untouched), &[], &[]).unwrap();
610 let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
611 assert_eq!(
612 settings,
613 serde_json::json!({"permissions": {}}),
614 "nothing removed, nothing pruned"
615 );
616 }
617
618 #[test]
619 fn a_corrupt_settings_file_is_refused() {
620 for (bytes, expected) in [
621 (&b"{ not json"[..], "is not valid JSON"),
622 (&b"[]"[..], "must be a JSON object"),
623 (
624 &br#"{"permissions": []}"#[..],
625 "'permissions' must be an object",
626 ),
627 (
628 &br#"{"permissions": {"deny": "x"}}"#[..],
629 "'permissions.deny' must be an array",
630 ),
631 ] {
632 let error = merge_permissions(
633 ".claude/settings.json",
634 Some(bytes),
635 &[],
636 &[deny("Bash(rm *)")],
637 )
638 .unwrap_err();
639 assert_eq!(error.kind(), ErrorKind::Corrupt, "{error}");
640 assert!(error.to_string().contains(expected), "{error}");
641 }
642 }
643
644 #[test]
645 fn recorded_permission_status_and_removal_from_disk() {
646 let temp = tempfile::tempdir().unwrap();
647 std::fs::create_dir_all(temp.path().join(".claude")).unwrap();
648 let path = temp.path().join(".claude/settings.json");
649 std::fs::write(
650 &path,
651 r#"{"permissions": {"deny": ["Bash(curl *)", "Bash(rm *)"]}}"#,
652 )
653 .unwrap();
654 let ours = ManagedPermission {
655 settings_path: ".claude/settings.json".to_string(),
656 list: "deny".to_string(),
657 rule: "Bash(rm *)".to_string(),
658 };
659 assert_eq!(managed_permission_status(temp.path(), &ours), "clean");
660 remove_permissions(temp.path(), std::slice::from_ref(&ours)).unwrap();
661 assert_eq!(managed_permission_status(temp.path(), &ours), "missing");
662 let settings: serde_json::Value =
663 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
664 assert_eq!(
665 settings,
666 serde_json::json!({"permissions": {"deny": ["Bash(curl *)"]}})
667 );
668
669 std::fs::write(&path, "{ not json").unwrap();
670 assert_eq!(managed_permission_status(temp.path(), &ours), "modified");
671 assert!(remove_permissions(temp.path(), &[ours]).is_err());
672 }
673
674 #[test]
675 fn a_command_argument_cannot_be_a_pattern() {
676 let policy =
677 parse("[[policy.rules]]\neffect = \"deny\"\ncommand = [\"git\", \"push\", \"*\"]\n");
678 let error = validate_policy(&policy).unwrap_err();
679 assert!(
680 error.to_string().contains("'*' is not a pattern here"),
681 "{error}"
682 );
683 }
684
685 fn parse(toml_body: &str) -> PolicyConfig {
686 #[derive(Deserialize)]
687 struct Wrapper {
688 policy: PolicyConfig,
689 }
690 toml::from_str::<Wrapper>(toml_body)
691 .expect("valid TOML")
692 .policy
693 }
694
695 const INFRA: &str = r#"
696[[policy.rules]]
697effect = "deny"
698command = ["git", "push", "--force"]
699reason = "Force pushes rewrite shared history."
700
701[[policy.rules]]
702effect = "deny"
703read = [".env", "secrets/**"]
704
705[[policy.rules]]
706effect = "ask"
707command = ["terraform", "apply"]
708
709[[policy.rules]]
710effect = "deny"
711mcp = "github:delete_*"
712"#;
713
714 #[test]
715 fn the_infrastructure_example_is_a_valid_policy() {
716 let policy = parse(INFRA);
717 validate_policy(&policy).unwrap();
718 let kinds: Vec<_> = policy
719 .rules
720 .iter()
721 .map(|rule| (rule.effect().unwrap(), rule.subject().unwrap().kind()))
722 .collect();
723 assert_eq!(
724 kinds,
725 vec![
726 (PolicyEffect::Deny, PolicySubjectKind::Command),
727 (PolicyEffect::Deny, PolicySubjectKind::Read),
728 (PolicyEffect::Ask, PolicySubjectKind::Command),
729 (PolicyEffect::Deny, PolicySubjectKind::Mcp),
730 ]
731 );
732 assert_eq!(
733 policy.rules[0].describe(),
734 "deny command \"git push --force\""
735 );
736 assert_eq!(
737 policy.rules[1].describe(),
738 "deny read \".env\", \"secrets/**\""
739 );
740 }
741
742 #[test]
743 fn a_policy_cannot_allow_anything() {
744 let policy = parse("[[policy.rules]]\neffect = \"allow\"\ncommand = [\"rm\"]\n");
745 let error = validate_policy(&policy).unwrap_err();
746 assert_eq!(error.kind(), ErrorKind::Refused);
747 assert!(error.to_string().contains("policy rule 1"), "{error}");
748 assert!(error.to_string().contains("only narrow"), "{error}");
749 }
750
751 #[test]
752 fn each_rule_has_exactly_one_subject() {
753 let none = parse("[[policy.rules]]\neffect = \"deny\"\n");
754 assert!(
755 validate_policy(&none)
756 .unwrap_err()
757 .to_string()
758 .contains("needs a subject")
759 );
760 let two =
761 parse("[[policy.rules]]\neffect = \"deny\"\ncommand = [\"rm\"]\nread = [\".env\"]\n");
762 assert!(
763 validate_policy(&two)
764 .unwrap_err()
765 .to_string()
766 .contains("more than one subject (command, read)")
767 );
768 }
769
770 #[test]
771 fn malformed_rules_are_refused_with_the_rule_number() {
772 for (body, expected) in [
773 (
774 "effect = \"block\"\ncommand = [\"rm\"]",
775 "must be \"deny\" or \"ask\"",
776 ),
777 ("effect = \"deny\"\ncommand = []", "at least the program"),
778 (
779 "effect = \"deny\"\ncommand = [\"git push\"]",
780 "without spaces",
781 ),
782 ("effect = \"deny\"\nread = []", "at least one path pattern"),
783 (
784 "effect = \"deny\"\nread = [\"../outside\"]",
785 "relative to the project root",
786 ),
787 (
788 "effect = \"deny\"\nedit = [\"/etc/passwd\"]",
789 "relative to the project root",
790 ),
791 (
792 "effect = \"deny\"\nread = [\"~/.ssh/id_rsa\"]",
793 "relative to the project root",
794 ),
795 ("effect = \"deny\"\nmcp = \"github\"", "\"server:tool\""),
796 ("effect = \"deny\"\nmcp = \"git hub:x\"", "\"server:tool\""),
797 ("effect = \"deny\"\nmcp = \"github:\"", "\"server:tool\""),
798 (
799 "effect = \"deny\"\ncommand = [\"rm\"]\nreason = \" \"",
800 "must not be empty",
801 ),
802 ] {
803 let policy = parse(&format!(
804 "[[policy.rules]]\neffect = \"deny\"\ncommand = [\"ok\"]\n\n[[policy.rules]]\n{body}\n"
805 ));
806 let error = validate_policy(&policy).unwrap_err();
807 let text = error.to_string();
808 assert!(text.contains("policy rule 2"), "{body}: {text}");
809 assert!(text.contains(expected), "{body}: {text}");
810 }
811 }
812
813 #[test]
814 fn an_empty_policy_is_refused() {
815 let error = validate_policy(&PolicyConfig { rules: Vec::new() }).unwrap_err();
816 assert!(error.to_string().contains("at least one"), "{error}");
817 }
818
819 #[test]
820 fn unknown_keys_in_a_rule_are_a_parse_error() {
821 #[derive(Deserialize)]
822 #[allow(dead_code)]
823 struct Wrapper {
824 policy: PolicyConfig,
825 }
826 let result =
827 toml::from_str::<Wrapper>("[[policy.rules]]\neffect = \"deny\"\npath = [\".env\"]\n");
828 assert!(result.is_err(), "a misspelt subject must not be ignored");
829 }
830
831 #[test]
832 fn the_not_implemented_matrix_covers_every_effect_and_subject_as_unsupported() {
833 let matrix = not_implemented_matrix();
834 assert_eq!(
835 matrix.len(),
836 PolicyEffect::ALL.len() * PolicySubjectKind::ALL.len()
837 );
838 assert!(
839 matrix
840 .iter()
841 .all(|entry| entry.coverage == CoverageLevel::Unsupported && entry.caveat.is_some())
842 );
843 }
844
845 #[test]
846 fn a_rule_the_matrix_does_not_mention_is_never_treated_as_enforced() {
847 let policy = parse(INFRA);
848 let matrix = vec![PolicyCoverageEntry {
849 effect: PolicyEffect::Deny,
850 subject: PolicySubjectKind::Command,
851 coverage: CoverageLevel::Partial,
852 mechanism: Some("native".to_string()),
853 caveat: None,
854 source: None,
855 }];
856 let verdicts = verdicts(&policy, &matrix).unwrap();
857 let coverage: Vec<_> = verdicts
858 .iter()
859 .map(|verdict| verdict.entry.coverage)
860 .collect();
861 assert_eq!(
862 coverage,
863 vec![
864 CoverageLevel::Partial,
865 CoverageLevel::Unsupported,
866 CoverageLevel::Unsupported,
867 CoverageLevel::Unsupported,
868 ]
869 );
870 }
871}