1use serde::{Deserialize, Serialize};
22use tuff_hooks_spec::CoverageLevel;
23
24use crate::error::{Result, TuffError};
25use crate::lockfile::{ManagedPermission, UnenforcedRule};
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 type RuleGap<'g> = &'g dyn Fn(&PolicyRule) -> Result<Option<String>>;
377
378pub fn no_gaps(_rule: &PolicyRule) -> Result<Option<String>> {
380 Ok(None)
381}
382
383pub fn verdicts<'a>(
387 policy: &'a PolicyConfig,
388 matrix: &[PolicyCoverageEntry],
389 gap: RuleGap<'_>,
390) -> Result<Vec<RuleVerdict<'a>>> {
391 policy
392 .rules
393 .iter()
394 .enumerate()
395 .map(|(index, rule)| {
396 let effect = rule.effect()?;
397 let subject = rule.subject()?.kind();
398 let mut entry = matrix
399 .iter()
400 .find(|entry| entry.effect == effect && entry.subject == subject)
401 .cloned()
402 .unwrap_or_else(|| PolicyCoverageEntry {
403 effect,
404 subject,
405 coverage: CoverageLevel::Unsupported,
406 mechanism: None,
407 caveat: Some("this agent declares nothing for this kind of rule".to_string()),
408 source: None,
409 });
410 if entry.coverage != CoverageLevel::Unsupported
411 && let Some(reason) = gap(rule)?
412 {
413 entry.coverage = CoverageLevel::Unsupported;
414 entry.mechanism = None;
415 entry.caveat = Some(reason);
416 }
417 Ok(RuleVerdict { index, rule, entry })
418 })
419 .collect()
420}
421
422pub fn enforcement(
427 policy: &PolicyConfig,
428 matrix: &[PolicyCoverageEntry],
429 gap: RuleGap<'_>,
430) -> Result<(Vec<usize>, Vec<UnenforcedRule>)> {
431 let mut enforced = Vec::new();
432 let mut unenforced = Vec::new();
433 for verdict in verdicts(policy, matrix, gap)? {
434 if verdict.entry.coverage == CoverageLevel::Unsupported {
435 unenforced.push(UnenforcedRule {
436 rule: verdict.index + 1,
437 description: verdict.rule.describe(),
438 reason: verdict
439 .entry
440 .caveat
441 .unwrap_or_else(|| "this agent does not enforce this kind of rule".to_string()),
442 });
443 } else {
444 enforced.push(verdict.index);
445 }
446 }
447 Ok((enforced, unenforced))
448}
449
450pub fn is_rules_file(relpath: &str) -> bool {
454 relpath.ends_with(".rules")
455}
456
457pub const RULES_FILE_HEADER: &str = "# Managed by Tuff: rules compiled from policy capabilities. Change the policy and run tuff update rather than editing this file.";
459
460fn merge_rules_file(
464 relpath: &str,
465 existing: Option<&[u8]>,
466 remove: &[(PolicyEffect, String)],
467 add: &[(PolicyEffect, String)],
468) -> Result<Vec<u8>> {
469 let text = match existing {
470 Some(bytes) => std::str::from_utf8(bytes)
471 .map_err(|_| TuffError::corrupt(format!("{relpath} is not valid UTF-8")))?,
472 None => "",
473 };
474 let mut lines: Vec<String> = text
475 .lines()
476 .filter(|line| !remove.iter().any(|(_, rule)| rule == line))
477 .map(str::to_string)
478 .collect();
479 if !lines.iter().any(|line| line == RULES_FILE_HEADER) {
480 lines.insert(0, RULES_FILE_HEADER.to_string());
481 }
482 for (_, rule) in add {
483 if !lines.contains(rule) {
484 lines.push(rule.clone());
485 }
486 }
487 let has_rules = lines.iter().any(|line| {
488 let line = line.trim();
489 !line.is_empty() && !line.starts_with('#')
490 });
491 if !has_rules {
492 return Ok(Vec::new());
493 }
494 let mut merged = lines.join("\n");
495 merged.push('\n');
496 Ok(merged.into_bytes())
497}
498
499pub fn is_opencode_config(relpath: &str) -> bool {
503 std::path::Path::new(relpath)
504 .file_name()
505 .is_some_and(|name| name == "opencode.json")
506}
507
508pub fn is_codex_config(relpath: &str) -> bool {
511 relpath.ends_with(".toml")
512}
513
514fn codex_mcp_rule<'r>(relpath: &str, rule: &'r str) -> Result<(&'r str, &'r str)> {
516 rule.split_once(':').ok_or_else(|| {
517 TuffError::corrupt(format!(
518 "recorded rule '{}' for {relpath} is not <server>:<tool>",
519 rule.escape_debug()
520 ))
521 })
522}
523
524pub fn permission_location(permission: &ManagedPermission) -> String {
528 if is_rules_file(&permission.settings_path) {
529 permission.settings_path.clone()
530 } else if is_codex_config(&permission.settings_path) {
531 let (server, tool) = permission
532 .rule
533 .split_once(':')
534 .unwrap_or((permission.rule.as_str(), ""));
535 match PolicyEffect::parse(&permission.list) {
536 Some(PolicyEffect::Ask) => format!(
537 "{}#mcp_servers.{server}.tools.{tool}.approval_mode",
538 permission.settings_path
539 ),
540 _ => format!(
541 "{}#mcp_servers.{server}.disabled_tools",
542 permission.settings_path
543 ),
544 }
545 } else if is_opencode_config(&permission.settings_path) {
546 let (name, _) = opencode_rule(&permission.rule);
547 format!("{}#permission.{name}", permission.settings_path)
548 } else {
549 format!(
550 "{}#permissions.{}",
551 permission.settings_path, permission.list
552 )
553 }
554}
555
556fn opencode_rule(rule: &str) -> (&str, Option<&str>) {
560 match rule.split_once(' ') {
561 Some((name, pattern)) => (name, Some(pattern)),
562 None => (rule, None),
563 }
564}
565
566#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
570#[serde(untagged)]
571pub(crate) enum OrderedJson {
572 Object(indexmap::IndexMap<String, OrderedJson>),
573 Array(Vec<OrderedJson>),
574 Scalar(serde_json::Value),
575}
576
577impl OrderedJson {
578 fn action(effect: PolicyEffect) -> Self {
579 Self::Scalar(serde_json::Value::String(effect.as_str().to_string()))
580 }
581
582 fn is_action(&self, effect: PolicyEffect) -> bool {
583 matches!(self, Self::Scalar(serde_json::Value::String(action)) if action == effect.as_str())
584 }
585
586 fn expand_shorthand(&mut self) {
588 if let Self::Scalar(serde_json::Value::String(_)) = self {
589 let action = std::mem::replace(self, Self::Object(indexmap::IndexMap::new()));
590 if let Self::Object(patterns) = self {
591 patterns.insert("*".to_string(), action);
592 }
593 }
594 }
595}
596
597pub(crate) const OPENCODE_SCHEMA: &str = "https://opencode.ai/config.json";
598
599fn merge_opencode_config(
608 relpath: &str,
609 existing: Option<&[u8]>,
610 remove: &[(PolicyEffect, String)],
611 add: &[(PolicyEffect, String)],
612) -> Result<Vec<u8>> {
613 let corrupt = |detail: &str| TuffError::corrupt(format!("{relpath} {detail}"));
614 let mut root = match existing {
615 Some(bytes) if !bytes.iter().all(u8::is_ascii_whitespace) => serde_json::from_slice::<
616 OrderedJson,
617 >(bytes)
618 .map_err(|error| TuffError::corrupt(format!("{relpath} is not valid JSON: {error}")))?,
619 _ => OrderedJson::Object(indexmap::IndexMap::from([(
620 "$schema".to_string(),
621 OrderedJson::Scalar(serde_json::Value::String(OPENCODE_SCHEMA.to_string())),
622 )])),
623 };
624 let OrderedJson::Object(root_map) = &mut root else {
625 return Err(corrupt("must be a JSON object"));
626 };
627 if add.is_empty() && !root_map.contains_key("permission") {
628 return render_opencode_config(&root, false);
629 }
630 let permission = root_map
631 .entry("permission".to_string())
632 .or_insert_with(|| OrderedJson::Object(indexmap::IndexMap::new()));
633 permission.expand_shorthand();
634 let OrderedJson::Object(permission) = permission else {
635 return Err(corrupt("field 'permission' must be an object"));
636 };
637
638 for (effect, rule) in remove {
639 let (name, pattern) = opencode_rule(rule);
640 let Some(pattern) = pattern else {
641 if permission
642 .get(name)
643 .is_some_and(|value| value.is_action(*effect))
644 {
645 permission.shift_remove(name);
646 }
647 continue;
648 };
649 let emptied = match permission.get_mut(name) {
650 Some(OrderedJson::Object(patterns))
651 if patterns
652 .get(pattern)
653 .is_some_and(|value| value.is_action(*effect)) =>
654 {
655 patterns.shift_remove(pattern);
656 patterns.is_empty()
657 }
658 _ => false,
659 };
660 if emptied {
661 permission.shift_remove(name);
662 }
663 }
664
665 let conflict = |name: &str, pattern: Option<&str>| {
666 let rule = match pattern {
667 Some(pattern) => format!("permission.{name} \"{pattern}\""),
668 None => format!("permission \"{name}\""),
669 };
670 TuffError::refused(format!(
671 "{relpath} already has its own {rule} with a different action, so the policy was not installed"
672 ))
673 .with_hint("remove or change that rule in the file, or change the policy")
674 };
675 let ordered = add
676 .iter()
677 .filter(|(effect, _)| *effect == PolicyEffect::Ask)
678 .chain(
679 add.iter()
680 .filter(|(effect, _)| *effect == PolicyEffect::Deny),
681 );
682 for (effect, rule) in ordered {
683 let (name, pattern) = opencode_rule(rule);
684 let action = OrderedJson::action(*effect);
685 match pattern {
686 None => {
687 if permission
688 .get(name)
689 .is_some_and(|value| !value.is_action(*effect))
690 {
691 return Err(conflict(name, None));
692 }
693 permission.shift_remove(name);
694 permission.insert(name.to_string(), action);
695 }
696 Some(pattern) => {
697 let entry = permission
698 .entry(name.to_string())
699 .or_insert_with(|| OrderedJson::Object(indexmap::IndexMap::new()));
700 entry.expand_shorthand();
701 let OrderedJson::Object(patterns) = entry else {
702 return Err(corrupt(&format!(
703 "field 'permission.{name}' must be an object or an action"
704 )));
705 };
706 if patterns
707 .get(pattern)
708 .is_some_and(|value| !value.is_action(*effect))
709 {
710 return Err(conflict(name, Some(pattern)));
711 }
712 patterns.shift_remove(pattern);
713 patterns.insert(pattern.to_string(), action);
714 }
715 }
716 }
717
718 if !remove.is_empty() && permission.is_empty() {
719 root_map.shift_remove("permission");
720 }
721 let only_schema = root_map.keys().all(|key| key == "$schema");
722 render_opencode_config(&root, !remove.is_empty() && only_schema)
723}
724
725fn render_opencode_config(root: &OrderedJson, empty: bool) -> Result<Vec<u8>> {
726 if empty {
727 return Ok(Vec::new());
728 }
729 let mut text = serde_json::to_string_pretty(root)?;
730 text.push('\n');
731 Ok(text.into_bytes())
732}
733
734fn merge_codex_config(
744 relpath: &str,
745 existing: Option<&[u8]>,
746 remove: &[(PolicyEffect, String)],
747 add: &[(PolicyEffect, String)],
748) -> Result<Vec<u8>> {
749 use toml_edit::{Array, InlineTable, Item, Table, TableLike, Value};
750
751 let corrupt = |detail: &str| TuffError::corrupt(format!("{relpath} {detail}"));
752 let text = match existing {
753 Some(bytes) => std::str::from_utf8(bytes).map_err(|_| corrupt("is not valid UTF-8"))?,
754 None => "",
755 };
756 let mut document: toml_edit::DocumentMut = text
757 .parse()
758 .map_err(|error| corrupt(&format!("is not valid TOML: {error}")))?;
759 if document
760 .get("mcp_servers")
761 .is_some_and(|servers| !servers.is_table_like())
762 {
763 return Err(corrupt("field 'mcp_servers' must be a table"));
764 }
765
766 fn server<'d>(
767 document: &'d mut toml_edit::DocumentMut,
768 name: &str,
769 ) -> Option<&'d mut dyn TableLike> {
770 document
771 .get_mut("mcp_servers")?
772 .as_table_like_mut()?
773 .get_mut(name)?
774 .as_table_like_mut()
775 }
776 fn child<'t>(
779 parent: &'t mut dyn TableLike,
780 key: &str,
781 inline: bool,
782 corrupt: &dyn Fn(&str) -> TuffError,
783 path: &str,
784 ) -> Result<&'t mut dyn TableLike> {
785 if !parent.contains_key(key) {
786 let item = if inline {
787 Item::Value(Value::InlineTable(InlineTable::new()))
788 } else {
789 let mut table = Table::new();
790 table.set_implicit(true);
791 Item::Table(table)
792 };
793 parent.insert(key, item);
794 }
795 parent
796 .get_mut(key)
797 .and_then(Item::as_table_like_mut)
798 .ok_or_else(|| corrupt(&format!("field '{path}' must be a table")))
799 }
800
801 for (effect, rule) in remove {
802 let (name, tool) = codex_mcp_rule(relpath, rule)?;
803 let Some(table) = server(&mut document, name) else {
804 continue;
805 };
806 match effect {
807 PolicyEffect::Deny => {
808 let emptied = match table.get_mut("disabled_tools").and_then(Item::as_array_mut) {
809 Some(list) => {
810 list.retain(|entry| entry.as_str() != Some(tool));
811 list.is_empty()
812 }
813 None => false,
814 };
815 if emptied {
816 table.remove("disabled_tools");
817 }
818 }
819 PolicyEffect::Ask => {
820 let Some(tools) = table.get_mut("tools").and_then(Item::as_table_like_mut) else {
821 continue;
822 };
823 let emptied = match tools.get_mut(tool).and_then(Item::as_table_like_mut) {
824 Some(settings)
825 if settings.get("approval_mode").and_then(Item::as_str)
826 == Some("prompt") =>
827 {
828 settings.remove("approval_mode");
829 settings.is_empty()
830 }
831 _ => false,
832 };
833 if emptied {
834 tools.remove(tool);
835 }
836 if tools.is_empty() {
837 table.remove("tools");
838 }
839 }
840 }
841 }
842
843 for (effect, rule) in add {
844 let (name, tool) = codex_mcp_rule(relpath, rule)?;
845 let inline = document
846 .get("mcp_servers")
847 .and_then(|servers| servers.get(name))
848 .is_some_and(Item::is_inline_table);
849 let Some(table) = server(&mut document, name) else {
850 return Err(TuffError::refused(format!(
851 "policy rule mcp \"{rule}\" needs the MCP server '{name}' in {relpath}, where Codex reads the tools it disables and asks about, so the policy was not installed"
852 ))
853 .with_hint(format!(
854 "install the server for Codex first ('tuff add <source> -a codex'), or add [mcp_servers.{name}] to {relpath}"
855 )));
856 };
857 match effect {
858 PolicyEffect::Deny => {
859 if !table.contains_key("disabled_tools") {
860 table.insert("disabled_tools", Item::Value(Value::Array(Array::new())));
861 }
862 let list = table
863 .get_mut("disabled_tools")
864 .and_then(Item::as_array_mut)
865 .ok_or_else(|| {
866 corrupt(&format!(
867 "field 'mcp_servers.{name}.disabled_tools' must be an array"
868 ))
869 })?;
870 if !list.iter().any(|entry| entry.as_str() == Some(tool)) {
871 list.push(tool);
872 }
873 }
874 PolicyEffect::Ask => {
875 let tools_path = format!("mcp_servers.{name}.tools");
876 let tools = child(table, "tools", inline, &corrupt, &tools_path)?;
877 let settings = child(
878 tools,
879 tool,
880 inline,
881 &corrupt,
882 &format!("{tools_path}.{tool}"),
883 )?;
884 match settings.get("approval_mode").map(Item::as_str) {
885 None => {
886 settings.insert("approval_mode", toml_edit::value("prompt"));
887 }
888 Some(Some("prompt")) => {}
889 Some(_) => {
890 return Err(TuffError::refused(format!(
891 "{relpath} already sets its own approval_mode for {tools_path}.{tool}, so the policy was not installed"
892 ))
893 .with_hint("remove or change that setting in the file, or change the policy"));
894 }
895 }
896 }
897 }
898 }
899 Ok(document.to_string().into_bytes())
900}
901
902pub fn merge_permissions(
915 settings_relpath: &str,
916 existing: Option<&[u8]>,
917 remove: &[(PolicyEffect, String)],
918 add: &[(PolicyEffect, String)],
919) -> Result<Vec<u8>> {
920 if is_rules_file(settings_relpath) {
921 return merge_rules_file(settings_relpath, existing, remove, add);
922 }
923 if is_opencode_config(settings_relpath) {
924 return merge_opencode_config(settings_relpath, existing, remove, add);
925 }
926 if is_codex_config(settings_relpath) {
927 return merge_codex_config(settings_relpath, existing, remove, add);
928 }
929 let mut settings: serde_json::Value = match existing {
930 Some(bytes) if !bytes.is_empty() => serde_json::from_slice(bytes).map_err(|error| {
931 TuffError::corrupt(format!("{settings_relpath} is not valid JSON: {error}"))
932 })?,
933 _ => serde_json::json!({}),
934 };
935 let object = settings
936 .as_object_mut()
937 .ok_or_else(|| TuffError::corrupt(format!("{settings_relpath} must be a JSON object")))?;
938 if add.is_empty() && !object.contains_key("permissions") {
939 return Ok(serde_json::to_string_pretty(&settings)?.into_bytes());
940 }
941 let permissions = object
942 .entry("permissions")
943 .or_insert_with(|| serde_json::json!({}))
944 .as_object_mut()
945 .ok_or_else(|| {
946 TuffError::corrupt(format!(
947 "{settings_relpath} field 'permissions' must be an object"
948 ))
949 })?;
950 let not_a_list = |effect: PolicyEffect| {
951 TuffError::corrupt(format!(
952 "{settings_relpath} field 'permissions.{}' must be an array",
953 effect.as_str()
954 ))
955 };
956 for (effect, rule) in remove {
957 if let Some(list) = permissions.get_mut(effect.as_str()) {
958 let list = list.as_array_mut().ok_or_else(|| not_a_list(*effect))?;
959 list.retain(|entry| entry.as_str() != Some(rule.as_str()));
960 }
961 }
962 for (effect, rule) in add {
963 let list = permissions
964 .entry(effect.as_str())
965 .or_insert_with(|| serde_json::json!([]))
966 .as_array_mut()
967 .ok_or_else(|| not_a_list(*effect))?;
968 if !list
969 .iter()
970 .any(|entry| entry.as_str() == Some(rule.as_str()))
971 {
972 list.push(serde_json::Value::String(rule.clone()));
973 }
974 }
975 for effect in PolicyEffect::ALL {
976 let emptied_here = remove.iter().any(|(removed, _)| *removed == effect)
977 && permissions
978 .get(effect.as_str())
979 .and_then(serde_json::Value::as_array)
980 .is_some_and(Vec::is_empty);
981 if emptied_here {
982 permissions.remove(effect.as_str());
983 }
984 }
985 let now_empty = !remove.is_empty() && permissions.is_empty();
986 if now_empty {
987 object.remove("permissions");
988 }
989 Ok(serde_json::to_string_pretty(&settings)?.into_bytes())
990}
991
992pub fn remove_permissions(
997 repo_root: &std::path::Path,
998 managed: &[ManagedPermission],
999) -> Result<()> {
1000 let mut by_file: std::collections::BTreeMap<&str, Vec<(PolicyEffect, String)>> =
1001 std::collections::BTreeMap::new();
1002 for permission in managed {
1003 if let Some(effect) = PolicyEffect::parse(&permission.list) {
1004 by_file
1005 .entry(permission.settings_path.as_str())
1006 .or_default()
1007 .push((effect, permission.rule.clone()));
1008 }
1009 }
1010 for (relpath, removals) in by_file {
1011 let path = repo_root.join(relpath);
1012 if !path.is_file() {
1013 continue;
1014 }
1015 let bytes = std::fs::read(&path)?;
1016 let mut merged = merge_permissions(relpath, Some(&bytes), &removals, &[])?;
1017 if is_codex_config(relpath) {
1020 if merged != bytes {
1022 std::fs::write(&path, merged)?;
1023 }
1024 continue;
1025 }
1026 if is_rules_file(relpath) || is_opencode_config(relpath) {
1027 if merged.is_empty() {
1029 std::fs::remove_file(&path)?;
1030 } else if merged != bytes {
1031 std::fs::write(&path, merged)?;
1032 }
1033 continue;
1034 }
1035 if merged != bytes {
1036 merged.push(b'\n');
1037 std::fs::write(&path, merged)?;
1038 }
1039 }
1040 Ok(())
1041}
1042
1043pub fn managed_permission_status(
1047 repo_root: &std::path::Path,
1048 permission: &ManagedPermission,
1049) -> &'static str {
1050 let Ok(raw) = std::fs::read_to_string(repo_root.join(&permission.settings_path)) else {
1051 return "missing";
1052 };
1053 if is_opencode_config(&permission.settings_path) {
1054 let Ok(settings) = serde_json::from_str::<serde_json::Value>(&raw) else {
1055 return "modified";
1056 };
1057 let (name, pattern) = opencode_rule(&permission.rule);
1058 let entry = settings
1059 .get("permission")
1060 .and_then(|permissions| permissions.get(name));
1061 let action = match pattern {
1062 Some(pattern) => entry.and_then(|patterns| patterns.get(pattern)),
1063 None => entry,
1064 };
1065 return if action.and_then(serde_json::Value::as_str) == Some(permission.list.as_str()) {
1066 "clean"
1067 } else {
1068 "missing"
1069 };
1070 }
1071 if is_codex_config(&permission.settings_path) {
1072 let Ok(document) = toml::from_str::<toml::Value>(&raw) else {
1073 return "modified";
1074 };
1075 let Some((name, tool)) = permission.rule.split_once(':') else {
1076 return "missing";
1077 };
1078 let server = document
1079 .get("mcp_servers")
1080 .and_then(|servers| servers.get(name));
1081 let present = match PolicyEffect::parse(&permission.list) {
1082 Some(PolicyEffect::Deny) => server
1083 .and_then(|server| server.get("disabled_tools"))
1084 .and_then(toml::Value::as_array)
1085 .is_some_and(|list| list.iter().any(|entry| entry.as_str() == Some(tool))),
1086 Some(PolicyEffect::Ask) => {
1087 server
1088 .and_then(|server| server.get("tools"))
1089 .and_then(|tools| tools.get(tool))
1090 .and_then(|settings| settings.get("approval_mode"))
1091 .and_then(toml::Value::as_str)
1092 == Some("prompt")
1093 }
1094 None => false,
1095 };
1096 return if present { "clean" } else { "missing" };
1097 }
1098 if is_rules_file(&permission.settings_path) {
1099 return if raw.lines().any(|line| line == permission.rule) {
1100 "clean"
1101 } else {
1102 "missing"
1103 };
1104 }
1105 let Ok(settings) = serde_json::from_str::<serde_json::Value>(&raw) else {
1106 return "modified";
1107 };
1108 let present = settings
1109 .get("permissions")
1110 .and_then(|permissions| permissions.get(&permission.list))
1111 .and_then(serde_json::Value::as_array)
1112 .is_some_and(|list| {
1113 list.iter()
1114 .any(|entry| entry.as_str() == Some(permission.rule.as_str()))
1115 });
1116 if present { "clean" } else { "missing" }
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121 use super::*;
1122 use crate::error::ErrorKind;
1123
1124 fn deny(rule: &str) -> (PolicyEffect, String) {
1125 (PolicyEffect::Deny, rule.to_string())
1126 }
1127
1128 fn ask(rule: &str) -> (PolicyEffect, String) {
1129 (PolicyEffect::Ask, rule.to_string())
1130 }
1131
1132 #[test]
1133 fn merging_permissions_keeps_the_users_rules_and_adds_each_rule_once() {
1134 let existing = br#"{"model": "opus", "permissions": {"deny": ["Bash(curl *)"], "allow": ["Bash(npm test *)"]}}"#;
1135 let add = [
1136 deny("Bash(git push --force *)"),
1137 ask("Bash(terraform apply *)"),
1138 ];
1139 let once = merge_permissions(".claude/settings.json", Some(existing), &[], &add).unwrap();
1140 let twice = merge_permissions(".claude/settings.json", Some(&once), &[], &add).unwrap();
1141 assert_eq!(once, twice, "a redundant merge leaves the file unchanged");
1142 let settings: serde_json::Value = serde_json::from_slice(&once).unwrap();
1143 assert_eq!(settings["model"], "opus");
1144 assert_eq!(
1145 settings["permissions"]["deny"],
1146 serde_json::json!(["Bash(curl *)", "Bash(git push --force *)"])
1147 );
1148 assert_eq!(
1149 settings["permissions"]["ask"],
1150 serde_json::json!(["Bash(terraform apply *)"])
1151 );
1152 assert_eq!(
1153 settings["permissions"]["allow"],
1154 serde_json::json!(["Bash(npm test *)"])
1155 );
1156 }
1157
1158 #[test]
1159 fn removing_permissions_prunes_only_what_it_emptied() {
1160 let existing = br#"{"permissions": {"deny": ["Bash(curl *)", "Bash(git push --force *)"], "ask": ["Bash(terraform apply *)"]}}"#;
1161 let merged = merge_permissions(
1162 "s.json",
1163 Some(existing),
1164 &[
1165 deny("Bash(git push --force *)"),
1166 ask("Bash(terraform apply *)"),
1167 ],
1168 &[],
1169 )
1170 .unwrap();
1171 let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
1172 assert_eq!(
1173 settings,
1174 serde_json::json!({"permissions": {"deny": ["Bash(curl *)"]}})
1175 );
1176
1177 let only_ours =
1178 br#"{"model": "opus", "permissions": {"ask": ["Bash(terraform apply *)"]}}"#;
1179 let merged = merge_permissions(
1180 "s.json",
1181 Some(only_ours),
1182 &[ask("Bash(terraform apply *)")],
1183 &[],
1184 )
1185 .unwrap();
1186 let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
1187 assert_eq!(settings, serde_json::json!({"model": "opus"}));
1188
1189 let untouched = br#"{"permissions": {}}"#;
1190 let merged = merge_permissions("s.json", Some(untouched), &[], &[]).unwrap();
1191 let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
1192 assert_eq!(
1193 settings,
1194 serde_json::json!({"permissions": {}}),
1195 "nothing removed, nothing pruned"
1196 );
1197 }
1198
1199 #[test]
1200 fn a_corrupt_settings_file_is_refused() {
1201 for (bytes, expected) in [
1202 (&b"{ not json"[..], "is not valid JSON"),
1203 (&b"[]"[..], "must be a JSON object"),
1204 (
1205 &br#"{"permissions": []}"#[..],
1206 "'permissions' must be an object",
1207 ),
1208 (
1209 &br#"{"permissions": {"deny": "x"}}"#[..],
1210 "'permissions.deny' must be an array",
1211 ),
1212 ] {
1213 let error = merge_permissions(
1214 ".claude/settings.json",
1215 Some(bytes),
1216 &[],
1217 &[deny("Bash(rm *)")],
1218 )
1219 .unwrap_err();
1220 assert_eq!(error.kind(), ErrorKind::Corrupt, "{error}");
1221 assert!(error.to_string().contains(expected), "{error}");
1222 }
1223 }
1224
1225 #[test]
1226 fn recorded_permission_status_and_removal_from_disk() {
1227 let temp = tempfile::tempdir().unwrap();
1228 std::fs::create_dir_all(temp.path().join(".claude")).unwrap();
1229 let path = temp.path().join(".claude/settings.json");
1230 std::fs::write(
1231 &path,
1232 r#"{"permissions": {"deny": ["Bash(curl *)", "Bash(rm *)"]}}"#,
1233 )
1234 .unwrap();
1235 let ours = ManagedPermission {
1236 settings_path: ".claude/settings.json".to_string(),
1237 list: "deny".to_string(),
1238 rule: "Bash(rm *)".to_string(),
1239 };
1240 assert_eq!(managed_permission_status(temp.path(), &ours), "clean");
1241 remove_permissions(temp.path(), std::slice::from_ref(&ours)).unwrap();
1242 assert_eq!(managed_permission_status(temp.path(), &ours), "missing");
1243 let settings: serde_json::Value =
1244 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1245 assert_eq!(
1246 settings,
1247 serde_json::json!({"permissions": {"deny": ["Bash(curl *)"]}})
1248 );
1249
1250 std::fs::write(&path, "{ not json").unwrap();
1251 assert_eq!(managed_permission_status(temp.path(), &ours), "modified");
1252 assert!(remove_permissions(temp.path(), &[ours]).is_err());
1253 }
1254
1255 #[test]
1256 fn a_rules_file_holds_one_rule_per_line_and_is_removed_when_emptied() {
1257 const RELPATH: &str = ".codex/rules/tuff.rules";
1258 let forbid =
1259 deny(r#"prefix_rule(pattern = ["git", "push", "--force"], decision = "forbidden")"#);
1260 let prompt = ask(r#"prefix_rule(pattern = ["terraform", "apply"], decision = "prompt")"#);
1261 let once =
1262 merge_permissions(RELPATH, None, &[], &[forbid.clone(), prompt.clone()]).unwrap();
1263 let twice =
1264 merge_permissions(RELPATH, Some(&once), &[], std::slice::from_ref(&forbid)).unwrap();
1265 assert_eq!(once, twice, "a redundant merge leaves the file unchanged");
1266 assert_eq!(
1267 String::from_utf8(once.clone()).unwrap(),
1268 format!("{RULES_FILE_HEADER}\n{}\n{}\n", forbid.1, prompt.1)
1269 );
1270
1271 let temp = tempfile::tempdir().unwrap();
1272 std::fs::create_dir_all(temp.path().join(".codex/rules")).unwrap();
1273 let path = temp.path().join(RELPATH);
1274 std::fs::write(&path, &once).unwrap();
1275 let recorded = |(effect, rule): &(PolicyEffect, String)| ManagedPermission {
1276 settings_path: RELPATH.to_string(),
1277 list: effect.as_str().to_string(),
1278 rule: rule.clone(),
1279 };
1280 assert_eq!(
1281 managed_permission_status(temp.path(), &recorded(&forbid)),
1282 "clean"
1283 );
1284 remove_permissions(temp.path(), &[recorded(&forbid)]).unwrap();
1285 assert_eq!(
1286 managed_permission_status(temp.path(), &recorded(&forbid)),
1287 "missing"
1288 );
1289 assert_eq!(
1290 managed_permission_status(temp.path(), &recorded(&prompt)),
1291 "clean"
1292 );
1293 remove_permissions(temp.path(), &[recorded(&prompt)]).unwrap();
1294 assert!(!path.exists(), "a rules file with no rules left is removed");
1295 }
1296
1297 #[test]
1298 fn an_opencode_config_keeps_the_users_order_and_puts_policy_rules_last() {
1299 const RELPATH: &str = ".opencode/opencode.json";
1300 let existing = br#"{"$schema": "https://opencode.ai/config.json", "permission": {"bash": {"*": "allow", "git push *": "allow"}, "read": "allow"}, "model": "x"}"#;
1301 let add = [
1302 deny("bash git push --force *"),
1303 ask("bash terraform apply *"),
1304 deny("read .env"),
1305 deny("read */.env"),
1306 deny("github_delete_*"),
1307 ];
1308 let once = merge_permissions(RELPATH, Some(existing), &[], &add).unwrap();
1309 let twice = merge_permissions(RELPATH, Some(&once), &[], &add).unwrap();
1310 assert_eq!(once, twice, "a redundant merge leaves the file unchanged");
1311 let OrderedJson::Object(root) = serde_json::from_slice::<OrderedJson>(&once).unwrap()
1312 else {
1313 panic!("an object")
1314 };
1315 assert_eq!(
1316 root.keys().collect::<Vec<_>>(),
1317 ["$schema", "permission", "model"]
1318 );
1319 let OrderedJson::Object(permission) = &root["permission"] else {
1320 panic!("an object")
1321 };
1322 assert_eq!(
1323 permission.keys().collect::<Vec<_>>(),
1324 ["bash", "read", "github_delete_*"]
1325 );
1326 let OrderedJson::Object(bash) = &permission["bash"] else {
1327 panic!("an object")
1328 };
1329 assert_eq!(
1330 bash.keys().collect::<Vec<_>>(),
1331 ["*", "git push *", "terraform apply *", "git push --force *"],
1332 "ask rules come before deny rules, both after the user's"
1333 );
1334 let OrderedJson::Object(read) = &permission["read"] else {
1335 panic!("an object")
1336 };
1337 assert_eq!(read.keys().collect::<Vec<_>>(), ["*", ".env", "*/.env"]);
1338
1339 let temp = tempfile::tempdir().unwrap();
1340 std::fs::create_dir_all(temp.path().join(".opencode")).unwrap();
1341 std::fs::write(temp.path().join(RELPATH), &once).unwrap();
1342 let recorded: Vec<ManagedPermission> = add
1343 .iter()
1344 .map(|(effect, rule)| ManagedPermission {
1345 settings_path: RELPATH.to_string(),
1346 list: effect.as_str().to_string(),
1347 rule: rule.clone(),
1348 })
1349 .collect();
1350 for permission in &recorded {
1351 assert_eq!(
1352 managed_permission_status(temp.path(), permission),
1353 "clean",
1354 "{permission:?}"
1355 );
1356 }
1357 assert_eq!(
1358 permission_location(&recorded[0]),
1359 ".opencode/opencode.json#permission.bash"
1360 );
1361 remove_permissions(temp.path(), &recorded).unwrap();
1362 let left: serde_json::Value =
1363 serde_json::from_str(&std::fs::read_to_string(temp.path().join(RELPATH)).unwrap())
1364 .unwrap();
1365 assert_eq!(
1366 left,
1367 serde_json::json!({
1368 "$schema": "https://opencode.ai/config.json",
1369 "permission": {"bash": {"*": "allow", "git push *": "allow"}, "read": {"*": "allow"}},
1370 "model": "x"
1371 })
1372 );
1373 }
1374
1375 #[test]
1376 fn an_opencode_config_refuses_a_conflicting_rule_and_empties_when_only_tuff_wrote_it() {
1377 const RELPATH: &str = ".opencode/opencode.json";
1378 let conflicting = br#"{"permission": {"bash": {"git push --force *": "allow"}}}"#;
1379 let error = merge_permissions(
1380 RELPATH,
1381 Some(conflicting),
1382 &[],
1383 &[deny("bash git push --force *")],
1384 )
1385 .unwrap_err();
1386 assert_eq!(error.kind(), ErrorKind::Refused, "{error}");
1387
1388 let rule = deny("bash git push --force *");
1389 let created = merge_permissions(RELPATH, None, &[], std::slice::from_ref(&rule)).unwrap();
1390 let removed =
1391 merge_permissions(RELPATH, Some(&created), std::slice::from_ref(&rule), &[]).unwrap();
1392 assert!(
1393 removed.is_empty(),
1394 "a file with only $schema left is removed"
1395 );
1396
1397 let error = merge_permissions(RELPATH, Some(b"[]"), &[], &[rule]).unwrap_err();
1398 assert_eq!(error.kind(), ErrorKind::Corrupt, "{error}");
1399 }
1400
1401 #[test]
1402 fn a_codex_config_takes_mcp_tool_rules_on_the_servers_table_and_gives_them_back() {
1403 const RELPATH: &str = ".codex/config.toml";
1404 let original = "# team settings\nmodel = \"gpt-5-codex\"\n\n[mcp_servers.github]\ncommand = \"github-mcp\"\ndisabled_tools = [\"fork_repo\"]\n\n[mcp_servers.docs]\nurl = \"https://docs.example.test/mcp\"\n";
1405 let add = [
1406 deny("github:delete_repo"),
1407 ask("github:merge_pull_request"),
1408 deny("docs:purge"),
1409 ];
1410 let once = merge_permissions(RELPATH, Some(original.as_bytes()), &[], &add).unwrap();
1411 let twice = merge_permissions(RELPATH, Some(&once), &[], &add).unwrap();
1412 assert_eq!(once, twice, "a redundant merge leaves the file unchanged");
1413 let text = String::from_utf8(once.clone()).unwrap();
1414 assert!(
1415 text.starts_with("# team settings\nmodel = \"gpt-5-codex\"\n"),
1416 "{text}"
1417 );
1418 assert!(
1419 text.contains("disabled_tools = [\"fork_repo\", \"delete_repo\"]"),
1420 "the user's entry stays first: {text}"
1421 );
1422 assert!(
1423 text.contains(
1424 "[mcp_servers.github.tools.merge_pull_request]\napproval_mode = \"prompt\"\n"
1425 ),
1426 "{text}"
1427 );
1428 assert!(!text.contains("[mcp_servers.github.tools]\n"), "{text}");
1429 let parsed: toml::Value = toml::from_str(&text).unwrap();
1430 assert_eq!(
1431 parsed["mcp_servers"]["docs"]["disabled_tools"],
1432 toml::Value::Array(vec![toml::Value::String("purge".to_string())])
1433 );
1434
1435 let removed = merge_permissions(RELPATH, Some(&once), &add, &[]).unwrap();
1436 let removed: toml::Value = toml::from_str(std::str::from_utf8(&removed).unwrap()).unwrap();
1437 let original: toml::Value = toml::from_str(original).unwrap();
1438 assert_eq!(removed, original, "only what Tuff added is taken out");
1439 }
1440
1441 #[test]
1442 fn a_codex_config_writes_inline_server_tables_inline() {
1443 let original = "mcp_servers.github = { command = \"github-mcp\" }\n";
1444 let merged = merge_permissions(
1445 ".codex/config.toml",
1446 Some(original.as_bytes()),
1447 &[],
1448 &[deny("github:delete_repo"), ask("github:merge_pull_request")],
1449 )
1450 .unwrap();
1451 let text = String::from_utf8(merged).unwrap();
1452 let parsed: toml::Value = toml::from_str(&text).unwrap();
1453 let github = &parsed["mcp_servers"]["github"];
1454 assert_eq!(github["command"].as_str(), Some("github-mcp"), "{text}");
1455 assert_eq!(
1456 github["tools"]["merge_pull_request"]["approval_mode"].as_str(),
1457 Some("prompt"),
1458 "{text}"
1459 );
1460 assert_eq!(text.lines().count(), 1, "still one inline table: {text}");
1461 }
1462
1463 #[test]
1464 fn a_codex_config_refuses_a_rule_for_an_undeclared_server_or_a_conflicting_approval_mode() {
1465 const RELPATH: &str = ".codex/config.toml";
1466 let error = merge_permissions(
1467 RELPATH,
1468 Some(b"model = \"o3\"\n"),
1469 &[],
1470 &[deny("github:delete_repo")],
1471 )
1472 .unwrap_err();
1473 assert_eq!(error.kind(), ErrorKind::Refused, "{error}");
1474 assert!(
1475 error.to_string().contains("needs the MCP server 'github'"),
1476 "{error}"
1477 );
1478 let error =
1479 merge_permissions(RELPATH, None, &[], &[deny("github:delete_repo")]).unwrap_err();
1480 assert_eq!(error.kind(), ErrorKind::Refused, "{error}");
1481
1482 let approved = b"[mcp_servers.github]\ncommand = \"github-mcp\"\n\n[mcp_servers.github.tools.merge_pull_request]\napproval_mode = \"approve\"\n";
1483 let error = merge_permissions(
1484 RELPATH,
1485 Some(approved),
1486 &[],
1487 &[ask("github:merge_pull_request")],
1488 )
1489 .unwrap_err();
1490 assert_eq!(error.kind(), ErrorKind::Refused, "{error}");
1491
1492 let error = merge_permissions(RELPATH, Some(b"mcp_servers = 3\n"), &[], &[deny("a:b")])
1493 .unwrap_err();
1494 assert_eq!(error.kind(), ErrorKind::Corrupt, "{error}");
1495 }
1496
1497 #[test]
1498 fn a_gap_makes_a_covered_rule_unenforced() {
1499 let policy = parse(INFRA);
1500 let matrix: Vec<_> = PolicyEffect::ALL
1501 .into_iter()
1502 .flat_map(|effect| {
1503 PolicySubjectKind::ALL.map(|subject| PolicyCoverageEntry {
1504 effect,
1505 subject,
1506 coverage: CoverageLevel::Full,
1507 mechanism: Some("native".to_string()),
1508 caveat: None,
1509 source: None,
1510 })
1511 })
1512 .collect();
1513 let gap = |rule: &PolicyRule| -> Result<Option<String>> {
1514 Ok(rule.mcp.as_ref().map(|_| "no patterns".to_string()))
1515 };
1516 let (enforced, unenforced) = enforcement(&policy, &matrix, &gap).unwrap();
1517 assert_eq!(enforced, vec![0, 1, 2]);
1518 assert_eq!(unenforced.len(), 1);
1519 assert_eq!(unenforced[0].rule, 4);
1520 assert_eq!(unenforced[0].reason, "no patterns");
1521 }
1522
1523 #[test]
1524 fn a_command_argument_cannot_be_a_pattern() {
1525 let policy =
1526 parse("[[policy.rules]]\neffect = \"deny\"\ncommand = [\"git\", \"push\", \"*\"]\n");
1527 let error = validate_policy(&policy).unwrap_err();
1528 assert!(
1529 error.to_string().contains("'*' is not a pattern here"),
1530 "{error}"
1531 );
1532 }
1533
1534 fn parse(toml_body: &str) -> PolicyConfig {
1535 #[derive(Deserialize)]
1536 struct Wrapper {
1537 policy: PolicyConfig,
1538 }
1539 toml::from_str::<Wrapper>(toml_body)
1540 .expect("valid TOML")
1541 .policy
1542 }
1543
1544 const INFRA: &str = r#"
1545[[policy.rules]]
1546effect = "deny"
1547command = ["git", "push", "--force"]
1548reason = "Force pushes rewrite shared history."
1549
1550[[policy.rules]]
1551effect = "deny"
1552read = [".env", "secrets/**"]
1553
1554[[policy.rules]]
1555effect = "ask"
1556command = ["terraform", "apply"]
1557
1558[[policy.rules]]
1559effect = "deny"
1560mcp = "github:delete_*"
1561"#;
1562
1563 #[test]
1564 fn the_infrastructure_example_is_a_valid_policy() {
1565 let policy = parse(INFRA);
1566 validate_policy(&policy).unwrap();
1567 let kinds: Vec<_> = policy
1568 .rules
1569 .iter()
1570 .map(|rule| (rule.effect().unwrap(), rule.subject().unwrap().kind()))
1571 .collect();
1572 assert_eq!(
1573 kinds,
1574 vec![
1575 (PolicyEffect::Deny, PolicySubjectKind::Command),
1576 (PolicyEffect::Deny, PolicySubjectKind::Read),
1577 (PolicyEffect::Ask, PolicySubjectKind::Command),
1578 (PolicyEffect::Deny, PolicySubjectKind::Mcp),
1579 ]
1580 );
1581 assert_eq!(
1582 policy.rules[0].describe(),
1583 "deny command \"git push --force\""
1584 );
1585 assert_eq!(
1586 policy.rules[1].describe(),
1587 "deny read \".env\", \"secrets/**\""
1588 );
1589 }
1590
1591 #[test]
1592 fn a_policy_cannot_allow_anything() {
1593 let policy = parse("[[policy.rules]]\neffect = \"allow\"\ncommand = [\"rm\"]\n");
1594 let error = validate_policy(&policy).unwrap_err();
1595 assert_eq!(error.kind(), ErrorKind::Refused);
1596 assert!(error.to_string().contains("policy rule 1"), "{error}");
1597 assert!(error.to_string().contains("only narrow"), "{error}");
1598 }
1599
1600 #[test]
1601 fn each_rule_has_exactly_one_subject() {
1602 let none = parse("[[policy.rules]]\neffect = \"deny\"\n");
1603 assert!(
1604 validate_policy(&none)
1605 .unwrap_err()
1606 .to_string()
1607 .contains("needs a subject")
1608 );
1609 let two =
1610 parse("[[policy.rules]]\neffect = \"deny\"\ncommand = [\"rm\"]\nread = [\".env\"]\n");
1611 assert!(
1612 validate_policy(&two)
1613 .unwrap_err()
1614 .to_string()
1615 .contains("more than one subject (command, read)")
1616 );
1617 }
1618
1619 #[test]
1620 fn malformed_rules_are_refused_with_the_rule_number() {
1621 for (body, expected) in [
1622 (
1623 "effect = \"block\"\ncommand = [\"rm\"]",
1624 "must be \"deny\" or \"ask\"",
1625 ),
1626 ("effect = \"deny\"\ncommand = []", "at least the program"),
1627 (
1628 "effect = \"deny\"\ncommand = [\"git push\"]",
1629 "without spaces",
1630 ),
1631 ("effect = \"deny\"\nread = []", "at least one path pattern"),
1632 (
1633 "effect = \"deny\"\nread = [\"../outside\"]",
1634 "relative to the project root",
1635 ),
1636 (
1637 "effect = \"deny\"\nedit = [\"/etc/passwd\"]",
1638 "relative to the project root",
1639 ),
1640 (
1641 "effect = \"deny\"\nread = [\"~/.ssh/id_rsa\"]",
1642 "relative to the project root",
1643 ),
1644 ("effect = \"deny\"\nmcp = \"github\"", "\"server:tool\""),
1645 ("effect = \"deny\"\nmcp = \"git hub:x\"", "\"server:tool\""),
1646 ("effect = \"deny\"\nmcp = \"github:\"", "\"server:tool\""),
1647 (
1648 "effect = \"deny\"\ncommand = [\"rm\"]\nreason = \" \"",
1649 "must not be empty",
1650 ),
1651 ] {
1652 let policy = parse(&format!(
1653 "[[policy.rules]]\neffect = \"deny\"\ncommand = [\"ok\"]\n\n[[policy.rules]]\n{body}\n"
1654 ));
1655 let error = validate_policy(&policy).unwrap_err();
1656 let text = error.to_string();
1657 assert!(text.contains("policy rule 2"), "{body}: {text}");
1658 assert!(text.contains(expected), "{body}: {text}");
1659 }
1660 }
1661
1662 #[test]
1663 fn an_empty_policy_is_refused() {
1664 let error = validate_policy(&PolicyConfig { rules: Vec::new() }).unwrap_err();
1665 assert!(error.to_string().contains("at least one"), "{error}");
1666 }
1667
1668 #[test]
1669 fn unknown_keys_in_a_rule_are_a_parse_error() {
1670 #[derive(Deserialize)]
1671 #[allow(dead_code)]
1672 struct Wrapper {
1673 policy: PolicyConfig,
1674 }
1675 let result =
1676 toml::from_str::<Wrapper>("[[policy.rules]]\neffect = \"deny\"\npath = [\".env\"]\n");
1677 assert!(result.is_err(), "a misspelt subject must not be ignored");
1678 }
1679
1680 #[test]
1681 fn the_not_implemented_matrix_covers_every_effect_and_subject_as_unsupported() {
1682 let matrix = not_implemented_matrix();
1683 assert_eq!(
1684 matrix.len(),
1685 PolicyEffect::ALL.len() * PolicySubjectKind::ALL.len()
1686 );
1687 assert!(
1688 matrix
1689 .iter()
1690 .all(|entry| entry.coverage == CoverageLevel::Unsupported && entry.caveat.is_some())
1691 );
1692 }
1693
1694 #[test]
1695 fn enforcement_separates_the_rules_a_harness_enforces_from_the_ones_it_does_not() {
1696 let policy = parse(INFRA);
1697 let mut matrix = Vec::new();
1698 for effect in PolicyEffect::ALL {
1699 for subject in [PolicySubjectKind::Command, PolicySubjectKind::Mcp] {
1700 matrix.push(PolicyCoverageEntry {
1701 effect,
1702 subject,
1703 coverage: CoverageLevel::Partial,
1704 mechanism: Some("native".to_string()),
1705 caveat: None,
1706 source: None,
1707 });
1708 }
1709 }
1710 let (enforced, unenforced) = enforcement(&policy, &matrix, &no_gaps).unwrap();
1711 assert_eq!(enforced, vec![0, 2, 3]);
1712 assert_eq!(
1713 unenforced,
1714 vec![UnenforcedRule {
1715 rule: 2,
1716 description: "deny read \".env\", \"secrets/**\"".to_string(),
1717 reason: "this agent declares nothing for this kind of rule".to_string(),
1718 }]
1719 );
1720
1721 let (enforced, unenforced) =
1722 enforcement(&policy, ¬_implemented_matrix(), &no_gaps).unwrap();
1723 assert!(enforced.is_empty());
1724 assert_eq!(unenforced.len(), 4);
1725 assert_eq!(
1726 unenforced[0].reason,
1727 "Tuff does not compile policy rules for this agent yet"
1728 );
1729 }
1730
1731 #[test]
1732 fn a_rule_the_matrix_does_not_mention_is_never_treated_as_enforced() {
1733 let policy = parse(INFRA);
1734 let matrix = vec![PolicyCoverageEntry {
1735 effect: PolicyEffect::Deny,
1736 subject: PolicySubjectKind::Command,
1737 coverage: CoverageLevel::Partial,
1738 mechanism: Some("native".to_string()),
1739 caveat: None,
1740 source: None,
1741 }];
1742 let verdicts = verdicts(&policy, &matrix, &no_gaps).unwrap();
1743 let coverage: Vec<_> = verdicts
1744 .iter()
1745 .map(|verdict| verdict.entry.coverage)
1746 .collect();
1747 assert_eq!(
1748 coverage,
1749 vec![
1750 CoverageLevel::Partial,
1751 CoverageLevel::Unsupported,
1752 CoverageLevel::Unsupported,
1753 CoverageLevel::Unsupported,
1754 ]
1755 );
1756 }
1757}