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 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 enforcement(
408 policy: &PolicyConfig,
409 matrix: &[PolicyCoverageEntry],
410) -> Result<(Vec<usize>, Vec<UnenforcedRule>)> {
411 let mut enforced = Vec::new();
412 let mut unenforced = Vec::new();
413 for verdict in verdicts(policy, matrix)? {
414 if verdict.entry.coverage == CoverageLevel::Unsupported {
415 unenforced.push(UnenforcedRule {
416 rule: verdict.index + 1,
417 description: verdict.rule.describe(),
418 reason: verdict
419 .entry
420 .caveat
421 .unwrap_or_else(|| "this agent does not enforce this kind of rule".to_string()),
422 });
423 } else {
424 enforced.push(verdict.index);
425 }
426 }
427 Ok((enforced, unenforced))
428}
429
430pub fn is_rules_file(relpath: &str) -> bool {
434 relpath.ends_with(".rules")
435}
436
437pub 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.";
439
440fn merge_rules_file(
444 relpath: &str,
445 existing: Option<&[u8]>,
446 remove: &[(PolicyEffect, String)],
447 add: &[(PolicyEffect, String)],
448) -> Result<Vec<u8>> {
449 let text = match existing {
450 Some(bytes) => std::str::from_utf8(bytes)
451 .map_err(|_| TuffError::corrupt(format!("{relpath} is not valid UTF-8")))?,
452 None => "",
453 };
454 let mut lines: Vec<String> = text
455 .lines()
456 .filter(|line| !remove.iter().any(|(_, rule)| rule == line))
457 .map(str::to_string)
458 .collect();
459 if !lines.iter().any(|line| line == RULES_FILE_HEADER) {
460 lines.insert(0, RULES_FILE_HEADER.to_string());
461 }
462 for (_, rule) in add {
463 if !lines.contains(rule) {
464 lines.push(rule.clone());
465 }
466 }
467 let has_rules = lines.iter().any(|line| {
468 let line = line.trim();
469 !line.is_empty() && !line.starts_with('#')
470 });
471 if !has_rules {
472 return Ok(Vec::new());
473 }
474 let mut merged = lines.join("\n");
475 merged.push('\n');
476 Ok(merged.into_bytes())
477}
478
479pub fn is_opencode_config(relpath: &str) -> bool {
483 std::path::Path::new(relpath)
484 .file_name()
485 .is_some_and(|name| name == "opencode.json")
486}
487
488pub fn permission_location(permission: &ManagedPermission) -> String {
492 if is_rules_file(&permission.settings_path) {
493 permission.settings_path.clone()
494 } else if is_opencode_config(&permission.settings_path) {
495 let (name, _) = opencode_rule(&permission.rule);
496 format!("{}#permission.{name}", permission.settings_path)
497 } else {
498 format!(
499 "{}#permissions.{}",
500 permission.settings_path, permission.list
501 )
502 }
503}
504
505fn opencode_rule(rule: &str) -> (&str, Option<&str>) {
509 match rule.split_once(' ') {
510 Some((name, pattern)) => (name, Some(pattern)),
511 None => (rule, None),
512 }
513}
514
515#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
519#[serde(untagged)]
520enum OrderedJson {
521 Object(indexmap::IndexMap<String, OrderedJson>),
522 Array(Vec<OrderedJson>),
523 Scalar(serde_json::Value),
524}
525
526impl OrderedJson {
527 fn action(effect: PolicyEffect) -> Self {
528 Self::Scalar(serde_json::Value::String(effect.as_str().to_string()))
529 }
530
531 fn is_action(&self, effect: PolicyEffect) -> bool {
532 matches!(self, Self::Scalar(serde_json::Value::String(action)) if action == effect.as_str())
533 }
534
535 fn expand_shorthand(&mut self) {
537 if let Self::Scalar(serde_json::Value::String(_)) = self {
538 let action = std::mem::replace(self, Self::Object(indexmap::IndexMap::new()));
539 if let Self::Object(patterns) = self {
540 patterns.insert("*".to_string(), action);
541 }
542 }
543 }
544}
545
546const OPENCODE_SCHEMA: &str = "https://opencode.ai/config.json";
547
548fn merge_opencode_config(
557 relpath: &str,
558 existing: Option<&[u8]>,
559 remove: &[(PolicyEffect, String)],
560 add: &[(PolicyEffect, String)],
561) -> Result<Vec<u8>> {
562 let corrupt = |detail: &str| TuffError::corrupt(format!("{relpath} {detail}"));
563 let mut root = match existing {
564 Some(bytes) if !bytes.iter().all(u8::is_ascii_whitespace) => serde_json::from_slice::<
565 OrderedJson,
566 >(bytes)
567 .map_err(|error| TuffError::corrupt(format!("{relpath} is not valid JSON: {error}")))?,
568 _ => OrderedJson::Object(indexmap::IndexMap::from([(
569 "$schema".to_string(),
570 OrderedJson::Scalar(serde_json::Value::String(OPENCODE_SCHEMA.to_string())),
571 )])),
572 };
573 let OrderedJson::Object(root_map) = &mut root else {
574 return Err(corrupt("must be a JSON object"));
575 };
576 if add.is_empty() && !root_map.contains_key("permission") {
577 return render_opencode_config(&root, false);
578 }
579 let permission = root_map
580 .entry("permission".to_string())
581 .or_insert_with(|| OrderedJson::Object(indexmap::IndexMap::new()));
582 permission.expand_shorthand();
583 let OrderedJson::Object(permission) = permission else {
584 return Err(corrupt("field 'permission' must be an object"));
585 };
586
587 for (effect, rule) in remove {
588 let (name, pattern) = opencode_rule(rule);
589 let Some(pattern) = pattern else {
590 if permission
591 .get(name)
592 .is_some_and(|value| value.is_action(*effect))
593 {
594 permission.shift_remove(name);
595 }
596 continue;
597 };
598 let emptied = match permission.get_mut(name) {
599 Some(OrderedJson::Object(patterns))
600 if patterns
601 .get(pattern)
602 .is_some_and(|value| value.is_action(*effect)) =>
603 {
604 patterns.shift_remove(pattern);
605 patterns.is_empty()
606 }
607 _ => false,
608 };
609 if emptied {
610 permission.shift_remove(name);
611 }
612 }
613
614 let conflict = |name: &str, pattern: Option<&str>| {
615 let rule = match pattern {
616 Some(pattern) => format!("permission.{name} \"{pattern}\""),
617 None => format!("permission \"{name}\""),
618 };
619 TuffError::refused(format!(
620 "{relpath} already has its own {rule} with a different action, so the policy was not installed"
621 ))
622 .with_hint("remove or change that rule in the file, or change the policy")
623 };
624 let ordered = add
625 .iter()
626 .filter(|(effect, _)| *effect == PolicyEffect::Ask)
627 .chain(
628 add.iter()
629 .filter(|(effect, _)| *effect == PolicyEffect::Deny),
630 );
631 for (effect, rule) in ordered {
632 let (name, pattern) = opencode_rule(rule);
633 let action = OrderedJson::action(*effect);
634 match pattern {
635 None => {
636 if permission
637 .get(name)
638 .is_some_and(|value| !value.is_action(*effect))
639 {
640 return Err(conflict(name, None));
641 }
642 permission.shift_remove(name);
643 permission.insert(name.to_string(), action);
644 }
645 Some(pattern) => {
646 let entry = permission
647 .entry(name.to_string())
648 .or_insert_with(|| OrderedJson::Object(indexmap::IndexMap::new()));
649 entry.expand_shorthand();
650 let OrderedJson::Object(patterns) = entry else {
651 return Err(corrupt(&format!(
652 "field 'permission.{name}' must be an object or an action"
653 )));
654 };
655 if patterns
656 .get(pattern)
657 .is_some_and(|value| !value.is_action(*effect))
658 {
659 return Err(conflict(name, Some(pattern)));
660 }
661 patterns.shift_remove(pattern);
662 patterns.insert(pattern.to_string(), action);
663 }
664 }
665 }
666
667 if !remove.is_empty() && permission.is_empty() {
668 root_map.shift_remove("permission");
669 }
670 let only_schema = root_map.keys().all(|key| key == "$schema");
671 render_opencode_config(&root, !remove.is_empty() && only_schema)
672}
673
674fn render_opencode_config(root: &OrderedJson, empty: bool) -> Result<Vec<u8>> {
675 if empty {
676 return Ok(Vec::new());
677 }
678 let mut text = serde_json::to_string_pretty(root)?;
679 text.push('\n');
680 Ok(text.into_bytes())
681}
682
683pub fn merge_permissions(
696 settings_relpath: &str,
697 existing: Option<&[u8]>,
698 remove: &[(PolicyEffect, String)],
699 add: &[(PolicyEffect, String)],
700) -> Result<Vec<u8>> {
701 if is_rules_file(settings_relpath) {
702 return merge_rules_file(settings_relpath, existing, remove, add);
703 }
704 if is_opencode_config(settings_relpath) {
705 return merge_opencode_config(settings_relpath, existing, remove, add);
706 }
707 let mut settings: serde_json::Value = match existing {
708 Some(bytes) if !bytes.is_empty() => serde_json::from_slice(bytes).map_err(|error| {
709 TuffError::corrupt(format!("{settings_relpath} is not valid JSON: {error}"))
710 })?,
711 _ => serde_json::json!({}),
712 };
713 let object = settings
714 .as_object_mut()
715 .ok_or_else(|| TuffError::corrupt(format!("{settings_relpath} must be a JSON object")))?;
716 if add.is_empty() && !object.contains_key("permissions") {
717 return Ok(serde_json::to_string_pretty(&settings)?.into_bytes());
718 }
719 let permissions = object
720 .entry("permissions")
721 .or_insert_with(|| serde_json::json!({}))
722 .as_object_mut()
723 .ok_or_else(|| {
724 TuffError::corrupt(format!(
725 "{settings_relpath} field 'permissions' must be an object"
726 ))
727 })?;
728 let not_a_list = |effect: PolicyEffect| {
729 TuffError::corrupt(format!(
730 "{settings_relpath} field 'permissions.{}' must be an array",
731 effect.as_str()
732 ))
733 };
734 for (effect, rule) in remove {
735 if let Some(list) = permissions.get_mut(effect.as_str()) {
736 let list = list.as_array_mut().ok_or_else(|| not_a_list(*effect))?;
737 list.retain(|entry| entry.as_str() != Some(rule.as_str()));
738 }
739 }
740 for (effect, rule) in add {
741 let list = permissions
742 .entry(effect.as_str())
743 .or_insert_with(|| serde_json::json!([]))
744 .as_array_mut()
745 .ok_or_else(|| not_a_list(*effect))?;
746 if !list
747 .iter()
748 .any(|entry| entry.as_str() == Some(rule.as_str()))
749 {
750 list.push(serde_json::Value::String(rule.clone()));
751 }
752 }
753 for effect in PolicyEffect::ALL {
754 let emptied_here = remove.iter().any(|(removed, _)| *removed == effect)
755 && permissions
756 .get(effect.as_str())
757 .and_then(serde_json::Value::as_array)
758 .is_some_and(Vec::is_empty);
759 if emptied_here {
760 permissions.remove(effect.as_str());
761 }
762 }
763 let now_empty = !remove.is_empty() && permissions.is_empty();
764 if now_empty {
765 object.remove("permissions");
766 }
767 Ok(serde_json::to_string_pretty(&settings)?.into_bytes())
768}
769
770pub fn remove_permissions(
775 repo_root: &std::path::Path,
776 managed: &[ManagedPermission],
777) -> Result<()> {
778 let mut by_file: std::collections::BTreeMap<&str, Vec<(PolicyEffect, String)>> =
779 std::collections::BTreeMap::new();
780 for permission in managed {
781 if let Some(effect) = PolicyEffect::parse(&permission.list) {
782 by_file
783 .entry(permission.settings_path.as_str())
784 .or_default()
785 .push((effect, permission.rule.clone()));
786 }
787 }
788 for (relpath, removals) in by_file {
789 let path = repo_root.join(relpath);
790 if !path.is_file() {
791 continue;
792 }
793 let bytes = std::fs::read(&path)?;
794 let mut merged = merge_permissions(relpath, Some(&bytes), &removals, &[])?;
795 if is_rules_file(relpath) || is_opencode_config(relpath) {
798 if merged.is_empty() {
800 std::fs::remove_file(&path)?;
801 } else if merged != bytes {
802 std::fs::write(&path, merged)?;
803 }
804 continue;
805 }
806 if merged != bytes {
807 merged.push(b'\n');
808 std::fs::write(&path, merged)?;
809 }
810 }
811 Ok(())
812}
813
814pub fn managed_permission_status(
818 repo_root: &std::path::Path,
819 permission: &ManagedPermission,
820) -> &'static str {
821 let Ok(raw) = std::fs::read_to_string(repo_root.join(&permission.settings_path)) else {
822 return "missing";
823 };
824 if is_opencode_config(&permission.settings_path) {
825 let Ok(settings) = serde_json::from_str::<serde_json::Value>(&raw) else {
826 return "modified";
827 };
828 let (name, pattern) = opencode_rule(&permission.rule);
829 let entry = settings
830 .get("permission")
831 .and_then(|permissions| permissions.get(name));
832 let action = match pattern {
833 Some(pattern) => entry.and_then(|patterns| patterns.get(pattern)),
834 None => entry,
835 };
836 return if action.and_then(serde_json::Value::as_str) == Some(permission.list.as_str()) {
837 "clean"
838 } else {
839 "missing"
840 };
841 }
842 if is_rules_file(&permission.settings_path) {
843 return if raw.lines().any(|line| line == permission.rule) {
844 "clean"
845 } else {
846 "missing"
847 };
848 }
849 let Ok(settings) = serde_json::from_str::<serde_json::Value>(&raw) else {
850 return "modified";
851 };
852 let present = settings
853 .get("permissions")
854 .and_then(|permissions| permissions.get(&permission.list))
855 .and_then(serde_json::Value::as_array)
856 .is_some_and(|list| {
857 list.iter()
858 .any(|entry| entry.as_str() == Some(permission.rule.as_str()))
859 });
860 if present { "clean" } else { "missing" }
861}
862
863#[cfg(test)]
864mod tests {
865 use super::*;
866 use crate::error::ErrorKind;
867
868 fn deny(rule: &str) -> (PolicyEffect, String) {
869 (PolicyEffect::Deny, rule.to_string())
870 }
871
872 fn ask(rule: &str) -> (PolicyEffect, String) {
873 (PolicyEffect::Ask, rule.to_string())
874 }
875
876 #[test]
877 fn merging_permissions_keeps_the_users_rules_and_adds_each_rule_once() {
878 let existing = br#"{"model": "opus", "permissions": {"deny": ["Bash(curl *)"], "allow": ["Bash(npm test *)"]}}"#;
879 let add = [
880 deny("Bash(git push --force *)"),
881 ask("Bash(terraform apply *)"),
882 ];
883 let once = merge_permissions(".claude/settings.json", Some(existing), &[], &add).unwrap();
884 let twice = merge_permissions(".claude/settings.json", Some(&once), &[], &add).unwrap();
885 assert_eq!(once, twice, "a redundant merge leaves the file unchanged");
886 let settings: serde_json::Value = serde_json::from_slice(&once).unwrap();
887 assert_eq!(settings["model"], "opus");
888 assert_eq!(
889 settings["permissions"]["deny"],
890 serde_json::json!(["Bash(curl *)", "Bash(git push --force *)"])
891 );
892 assert_eq!(
893 settings["permissions"]["ask"],
894 serde_json::json!(["Bash(terraform apply *)"])
895 );
896 assert_eq!(
897 settings["permissions"]["allow"],
898 serde_json::json!(["Bash(npm test *)"])
899 );
900 }
901
902 #[test]
903 fn removing_permissions_prunes_only_what_it_emptied() {
904 let existing = br#"{"permissions": {"deny": ["Bash(curl *)", "Bash(git push --force *)"], "ask": ["Bash(terraform apply *)"]}}"#;
905 let merged = merge_permissions(
906 "s.json",
907 Some(existing),
908 &[
909 deny("Bash(git push --force *)"),
910 ask("Bash(terraform apply *)"),
911 ],
912 &[],
913 )
914 .unwrap();
915 let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
916 assert_eq!(
917 settings,
918 serde_json::json!({"permissions": {"deny": ["Bash(curl *)"]}})
919 );
920
921 let only_ours =
922 br#"{"model": "opus", "permissions": {"ask": ["Bash(terraform apply *)"]}}"#;
923 let merged = merge_permissions(
924 "s.json",
925 Some(only_ours),
926 &[ask("Bash(terraform apply *)")],
927 &[],
928 )
929 .unwrap();
930 let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
931 assert_eq!(settings, serde_json::json!({"model": "opus"}));
932
933 let untouched = br#"{"permissions": {}}"#;
934 let merged = merge_permissions("s.json", Some(untouched), &[], &[]).unwrap();
935 let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
936 assert_eq!(
937 settings,
938 serde_json::json!({"permissions": {}}),
939 "nothing removed, nothing pruned"
940 );
941 }
942
943 #[test]
944 fn a_corrupt_settings_file_is_refused() {
945 for (bytes, expected) in [
946 (&b"{ not json"[..], "is not valid JSON"),
947 (&b"[]"[..], "must be a JSON object"),
948 (
949 &br#"{"permissions": []}"#[..],
950 "'permissions' must be an object",
951 ),
952 (
953 &br#"{"permissions": {"deny": "x"}}"#[..],
954 "'permissions.deny' must be an array",
955 ),
956 ] {
957 let error = merge_permissions(
958 ".claude/settings.json",
959 Some(bytes),
960 &[],
961 &[deny("Bash(rm *)")],
962 )
963 .unwrap_err();
964 assert_eq!(error.kind(), ErrorKind::Corrupt, "{error}");
965 assert!(error.to_string().contains(expected), "{error}");
966 }
967 }
968
969 #[test]
970 fn recorded_permission_status_and_removal_from_disk() {
971 let temp = tempfile::tempdir().unwrap();
972 std::fs::create_dir_all(temp.path().join(".claude")).unwrap();
973 let path = temp.path().join(".claude/settings.json");
974 std::fs::write(
975 &path,
976 r#"{"permissions": {"deny": ["Bash(curl *)", "Bash(rm *)"]}}"#,
977 )
978 .unwrap();
979 let ours = ManagedPermission {
980 settings_path: ".claude/settings.json".to_string(),
981 list: "deny".to_string(),
982 rule: "Bash(rm *)".to_string(),
983 };
984 assert_eq!(managed_permission_status(temp.path(), &ours), "clean");
985 remove_permissions(temp.path(), std::slice::from_ref(&ours)).unwrap();
986 assert_eq!(managed_permission_status(temp.path(), &ours), "missing");
987 let settings: serde_json::Value =
988 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
989 assert_eq!(
990 settings,
991 serde_json::json!({"permissions": {"deny": ["Bash(curl *)"]}})
992 );
993
994 std::fs::write(&path, "{ not json").unwrap();
995 assert_eq!(managed_permission_status(temp.path(), &ours), "modified");
996 assert!(remove_permissions(temp.path(), &[ours]).is_err());
997 }
998
999 #[test]
1000 fn a_rules_file_holds_one_rule_per_line_and_is_removed_when_emptied() {
1001 const RELPATH: &str = ".codex/rules/tuff.rules";
1002 let forbid =
1003 deny(r#"prefix_rule(pattern = ["git", "push", "--force"], decision = "forbidden")"#);
1004 let prompt = ask(r#"prefix_rule(pattern = ["terraform", "apply"], decision = "prompt")"#);
1005 let once =
1006 merge_permissions(RELPATH, None, &[], &[forbid.clone(), prompt.clone()]).unwrap();
1007 let twice =
1008 merge_permissions(RELPATH, Some(&once), &[], std::slice::from_ref(&forbid)).unwrap();
1009 assert_eq!(once, twice, "a redundant merge leaves the file unchanged");
1010 assert_eq!(
1011 String::from_utf8(once.clone()).unwrap(),
1012 format!("{RULES_FILE_HEADER}\n{}\n{}\n", forbid.1, prompt.1)
1013 );
1014
1015 let temp = tempfile::tempdir().unwrap();
1016 std::fs::create_dir_all(temp.path().join(".codex/rules")).unwrap();
1017 let path = temp.path().join(RELPATH);
1018 std::fs::write(&path, &once).unwrap();
1019 let recorded = |(effect, rule): &(PolicyEffect, String)| ManagedPermission {
1020 settings_path: RELPATH.to_string(),
1021 list: effect.as_str().to_string(),
1022 rule: rule.clone(),
1023 };
1024 assert_eq!(
1025 managed_permission_status(temp.path(), &recorded(&forbid)),
1026 "clean"
1027 );
1028 remove_permissions(temp.path(), &[recorded(&forbid)]).unwrap();
1029 assert_eq!(
1030 managed_permission_status(temp.path(), &recorded(&forbid)),
1031 "missing"
1032 );
1033 assert_eq!(
1034 managed_permission_status(temp.path(), &recorded(&prompt)),
1035 "clean"
1036 );
1037 remove_permissions(temp.path(), &[recorded(&prompt)]).unwrap();
1038 assert!(!path.exists(), "a rules file with no rules left is removed");
1039 }
1040
1041 #[test]
1042 fn an_opencode_config_keeps_the_users_order_and_puts_policy_rules_last() {
1043 const RELPATH: &str = ".opencode/opencode.json";
1044 let existing = br#"{"$schema": "https://opencode.ai/config.json", "permission": {"bash": {"*": "allow", "git push *": "allow"}, "read": "allow"}, "model": "x"}"#;
1045 let add = [
1046 deny("bash git push --force *"),
1047 ask("bash terraform apply *"),
1048 deny("read .env"),
1049 deny("read */.env"),
1050 deny("github_delete_*"),
1051 ];
1052 let once = merge_permissions(RELPATH, Some(existing), &[], &add).unwrap();
1053 let twice = merge_permissions(RELPATH, Some(&once), &[], &add).unwrap();
1054 assert_eq!(once, twice, "a redundant merge leaves the file unchanged");
1055 let OrderedJson::Object(root) = serde_json::from_slice::<OrderedJson>(&once).unwrap()
1056 else {
1057 panic!("an object")
1058 };
1059 assert_eq!(
1060 root.keys().collect::<Vec<_>>(),
1061 ["$schema", "permission", "model"]
1062 );
1063 let OrderedJson::Object(permission) = &root["permission"] else {
1064 panic!("an object")
1065 };
1066 assert_eq!(
1067 permission.keys().collect::<Vec<_>>(),
1068 ["bash", "read", "github_delete_*"]
1069 );
1070 let OrderedJson::Object(bash) = &permission["bash"] else {
1071 panic!("an object")
1072 };
1073 assert_eq!(
1074 bash.keys().collect::<Vec<_>>(),
1075 ["*", "git push *", "terraform apply *", "git push --force *"],
1076 "ask rules come before deny rules, both after the user's"
1077 );
1078 let OrderedJson::Object(read) = &permission["read"] else {
1079 panic!("an object")
1080 };
1081 assert_eq!(read.keys().collect::<Vec<_>>(), ["*", ".env", "*/.env"]);
1082
1083 let temp = tempfile::tempdir().unwrap();
1084 std::fs::create_dir_all(temp.path().join(".opencode")).unwrap();
1085 std::fs::write(temp.path().join(RELPATH), &once).unwrap();
1086 let recorded: Vec<ManagedPermission> = add
1087 .iter()
1088 .map(|(effect, rule)| ManagedPermission {
1089 settings_path: RELPATH.to_string(),
1090 list: effect.as_str().to_string(),
1091 rule: rule.clone(),
1092 })
1093 .collect();
1094 for permission in &recorded {
1095 assert_eq!(
1096 managed_permission_status(temp.path(), permission),
1097 "clean",
1098 "{permission:?}"
1099 );
1100 }
1101 assert_eq!(
1102 permission_location(&recorded[0]),
1103 ".opencode/opencode.json#permission.bash"
1104 );
1105 remove_permissions(temp.path(), &recorded).unwrap();
1106 let left: serde_json::Value =
1107 serde_json::from_str(&std::fs::read_to_string(temp.path().join(RELPATH)).unwrap())
1108 .unwrap();
1109 assert_eq!(
1110 left,
1111 serde_json::json!({
1112 "$schema": "https://opencode.ai/config.json",
1113 "permission": {"bash": {"*": "allow", "git push *": "allow"}, "read": {"*": "allow"}},
1114 "model": "x"
1115 })
1116 );
1117 }
1118
1119 #[test]
1120 fn an_opencode_config_refuses_a_conflicting_rule_and_empties_when_only_tuff_wrote_it() {
1121 const RELPATH: &str = ".opencode/opencode.json";
1122 let conflicting = br#"{"permission": {"bash": {"git push --force *": "allow"}}}"#;
1123 let error = merge_permissions(
1124 RELPATH,
1125 Some(conflicting),
1126 &[],
1127 &[deny("bash git push --force *")],
1128 )
1129 .unwrap_err();
1130 assert_eq!(error.kind(), ErrorKind::Refused, "{error}");
1131
1132 let rule = deny("bash git push --force *");
1133 let created = merge_permissions(RELPATH, None, &[], std::slice::from_ref(&rule)).unwrap();
1134 let removed =
1135 merge_permissions(RELPATH, Some(&created), std::slice::from_ref(&rule), &[]).unwrap();
1136 assert!(
1137 removed.is_empty(),
1138 "a file with only $schema left is removed"
1139 );
1140
1141 let error = merge_permissions(RELPATH, Some(b"[]"), &[], &[rule]).unwrap_err();
1142 assert_eq!(error.kind(), ErrorKind::Corrupt, "{error}");
1143 }
1144
1145 #[test]
1146 fn a_command_argument_cannot_be_a_pattern() {
1147 let policy =
1148 parse("[[policy.rules]]\neffect = \"deny\"\ncommand = [\"git\", \"push\", \"*\"]\n");
1149 let error = validate_policy(&policy).unwrap_err();
1150 assert!(
1151 error.to_string().contains("'*' is not a pattern here"),
1152 "{error}"
1153 );
1154 }
1155
1156 fn parse(toml_body: &str) -> PolicyConfig {
1157 #[derive(Deserialize)]
1158 struct Wrapper {
1159 policy: PolicyConfig,
1160 }
1161 toml::from_str::<Wrapper>(toml_body)
1162 .expect("valid TOML")
1163 .policy
1164 }
1165
1166 const INFRA: &str = r#"
1167[[policy.rules]]
1168effect = "deny"
1169command = ["git", "push", "--force"]
1170reason = "Force pushes rewrite shared history."
1171
1172[[policy.rules]]
1173effect = "deny"
1174read = [".env", "secrets/**"]
1175
1176[[policy.rules]]
1177effect = "ask"
1178command = ["terraform", "apply"]
1179
1180[[policy.rules]]
1181effect = "deny"
1182mcp = "github:delete_*"
1183"#;
1184
1185 #[test]
1186 fn the_infrastructure_example_is_a_valid_policy() {
1187 let policy = parse(INFRA);
1188 validate_policy(&policy).unwrap();
1189 let kinds: Vec<_> = policy
1190 .rules
1191 .iter()
1192 .map(|rule| (rule.effect().unwrap(), rule.subject().unwrap().kind()))
1193 .collect();
1194 assert_eq!(
1195 kinds,
1196 vec![
1197 (PolicyEffect::Deny, PolicySubjectKind::Command),
1198 (PolicyEffect::Deny, PolicySubjectKind::Read),
1199 (PolicyEffect::Ask, PolicySubjectKind::Command),
1200 (PolicyEffect::Deny, PolicySubjectKind::Mcp),
1201 ]
1202 );
1203 assert_eq!(
1204 policy.rules[0].describe(),
1205 "deny command \"git push --force\""
1206 );
1207 assert_eq!(
1208 policy.rules[1].describe(),
1209 "deny read \".env\", \"secrets/**\""
1210 );
1211 }
1212
1213 #[test]
1214 fn a_policy_cannot_allow_anything() {
1215 let policy = parse("[[policy.rules]]\neffect = \"allow\"\ncommand = [\"rm\"]\n");
1216 let error = validate_policy(&policy).unwrap_err();
1217 assert_eq!(error.kind(), ErrorKind::Refused);
1218 assert!(error.to_string().contains("policy rule 1"), "{error}");
1219 assert!(error.to_string().contains("only narrow"), "{error}");
1220 }
1221
1222 #[test]
1223 fn each_rule_has_exactly_one_subject() {
1224 let none = parse("[[policy.rules]]\neffect = \"deny\"\n");
1225 assert!(
1226 validate_policy(&none)
1227 .unwrap_err()
1228 .to_string()
1229 .contains("needs a subject")
1230 );
1231 let two =
1232 parse("[[policy.rules]]\neffect = \"deny\"\ncommand = [\"rm\"]\nread = [\".env\"]\n");
1233 assert!(
1234 validate_policy(&two)
1235 .unwrap_err()
1236 .to_string()
1237 .contains("more than one subject (command, read)")
1238 );
1239 }
1240
1241 #[test]
1242 fn malformed_rules_are_refused_with_the_rule_number() {
1243 for (body, expected) in [
1244 (
1245 "effect = \"block\"\ncommand = [\"rm\"]",
1246 "must be \"deny\" or \"ask\"",
1247 ),
1248 ("effect = \"deny\"\ncommand = []", "at least the program"),
1249 (
1250 "effect = \"deny\"\ncommand = [\"git push\"]",
1251 "without spaces",
1252 ),
1253 ("effect = \"deny\"\nread = []", "at least one path pattern"),
1254 (
1255 "effect = \"deny\"\nread = [\"../outside\"]",
1256 "relative to the project root",
1257 ),
1258 (
1259 "effect = \"deny\"\nedit = [\"/etc/passwd\"]",
1260 "relative to the project root",
1261 ),
1262 (
1263 "effect = \"deny\"\nread = [\"~/.ssh/id_rsa\"]",
1264 "relative to the project root",
1265 ),
1266 ("effect = \"deny\"\nmcp = \"github\"", "\"server:tool\""),
1267 ("effect = \"deny\"\nmcp = \"git hub:x\"", "\"server:tool\""),
1268 ("effect = \"deny\"\nmcp = \"github:\"", "\"server:tool\""),
1269 (
1270 "effect = \"deny\"\ncommand = [\"rm\"]\nreason = \" \"",
1271 "must not be empty",
1272 ),
1273 ] {
1274 let policy = parse(&format!(
1275 "[[policy.rules]]\neffect = \"deny\"\ncommand = [\"ok\"]\n\n[[policy.rules]]\n{body}\n"
1276 ));
1277 let error = validate_policy(&policy).unwrap_err();
1278 let text = error.to_string();
1279 assert!(text.contains("policy rule 2"), "{body}: {text}");
1280 assert!(text.contains(expected), "{body}: {text}");
1281 }
1282 }
1283
1284 #[test]
1285 fn an_empty_policy_is_refused() {
1286 let error = validate_policy(&PolicyConfig { rules: Vec::new() }).unwrap_err();
1287 assert!(error.to_string().contains("at least one"), "{error}");
1288 }
1289
1290 #[test]
1291 fn unknown_keys_in_a_rule_are_a_parse_error() {
1292 #[derive(Deserialize)]
1293 #[allow(dead_code)]
1294 struct Wrapper {
1295 policy: PolicyConfig,
1296 }
1297 let result =
1298 toml::from_str::<Wrapper>("[[policy.rules]]\neffect = \"deny\"\npath = [\".env\"]\n");
1299 assert!(result.is_err(), "a misspelt subject must not be ignored");
1300 }
1301
1302 #[test]
1303 fn the_not_implemented_matrix_covers_every_effect_and_subject_as_unsupported() {
1304 let matrix = not_implemented_matrix();
1305 assert_eq!(
1306 matrix.len(),
1307 PolicyEffect::ALL.len() * PolicySubjectKind::ALL.len()
1308 );
1309 assert!(
1310 matrix
1311 .iter()
1312 .all(|entry| entry.coverage == CoverageLevel::Unsupported && entry.caveat.is_some())
1313 );
1314 }
1315
1316 #[test]
1317 fn enforcement_separates_the_rules_a_harness_enforces_from_the_ones_it_does_not() {
1318 let policy = parse(INFRA);
1319 let mut matrix = Vec::new();
1320 for effect in PolicyEffect::ALL {
1321 for subject in [PolicySubjectKind::Command, PolicySubjectKind::Mcp] {
1322 matrix.push(PolicyCoverageEntry {
1323 effect,
1324 subject,
1325 coverage: CoverageLevel::Partial,
1326 mechanism: Some("native".to_string()),
1327 caveat: None,
1328 source: None,
1329 });
1330 }
1331 }
1332 let (enforced, unenforced) = enforcement(&policy, &matrix).unwrap();
1333 assert_eq!(enforced, vec![0, 2, 3]);
1334 assert_eq!(
1335 unenforced,
1336 vec![UnenforcedRule {
1337 rule: 2,
1338 description: "deny read \".env\", \"secrets/**\"".to_string(),
1339 reason: "this agent declares nothing for this kind of rule".to_string(),
1340 }]
1341 );
1342
1343 let (enforced, unenforced) = enforcement(&policy, ¬_implemented_matrix()).unwrap();
1344 assert!(enforced.is_empty());
1345 assert_eq!(unenforced.len(), 4);
1346 assert_eq!(
1347 unenforced[0].reason,
1348 "Tuff does not compile policy rules for this agent yet"
1349 );
1350 }
1351
1352 #[test]
1353 fn a_rule_the_matrix_does_not_mention_is_never_treated_as_enforced() {
1354 let policy = parse(INFRA);
1355 let matrix = vec![PolicyCoverageEntry {
1356 effect: PolicyEffect::Deny,
1357 subject: PolicySubjectKind::Command,
1358 coverage: CoverageLevel::Partial,
1359 mechanism: Some("native".to_string()),
1360 caveat: None,
1361 source: None,
1362 }];
1363 let verdicts = verdicts(&policy, &matrix).unwrap();
1364 let coverage: Vec<_> = verdicts
1365 .iter()
1366 .map(|verdict| verdict.entry.coverage)
1367 .collect();
1368 assert_eq!(
1369 coverage,
1370 vec![
1371 CoverageLevel::Partial,
1372 CoverageLevel::Unsupported,
1373 CoverageLevel::Unsupported,
1374 CoverageLevel::Unsupported,
1375 ]
1376 );
1377 }
1378}