Skip to main content

tuff_core/
policy.rs

1//! Policy capabilities: rules that narrow what an agent may do.
2//!
3//! A policy is a list of rules, each with an effect, `deny` or `ask`, and
4//! exactly one subject: a command prefix, file paths an agent may not read,
5//! file paths it may not edit, or an MCP tool. There is no `allow` effect. A
6//! policy can come from anyone's repository or pack, and one that could grant
7//! permissions could quietly widen what an agent may do in every project that
8//! installs it; a policy that can only take permissions away can at worst be
9//! too strict, and too strict is visible.
10//!
11//! The subjects are deliberately the intersection of what harnesses can
12//! match: commands by prefix and paths by glob. A richer rule would compile
13//! into something that means less than it says.
14//!
15//! Every harness declares, per effect and subject, how it enforces such a
16//! rule, in the same `full` / `partial` / `unsupported` terms the hooks
17//! specification uses. Until a harness compiles policies, every row is
18//! `unsupported`, and installing a policy for it is refused rather than
19//! reported as installed.
20
21use serde::{Deserialize, Serialize};
22use tuff_hooks_spec::CoverageLevel;
23
24use crate::error::{Result, TuffError};
25use crate::lockfile::{ManagedPermission, UnenforcedRule};
26
27/// The `[policy]` section of a `type = "policy"` manifest.
28#[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/// What a matching rule does to the call.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum PolicyEffect {
39    /// Refuse the call.
40    Deny,
41    /// Ask a human before the call runs.
42    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/// The kind of thing a rule matches.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum PolicySubjectKind {
68    /// A shell command, matched by a prefix of its arguments.
69    Command,
70    /// Reading a file, matched by path pattern.
71    Read,
72    /// Editing or writing a file, matched by path pattern.
73    Edit,
74    /// Calling an MCP tool, matched by `server:tool` pattern.
75    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/// One `[[policy.rules]]` entry, as written.
92///
93/// `effect` stays a string here so that `effect = "allow"` can be refused
94/// with the reason rather than a parser's list of variants.
95#[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/// A rule's subject, once validated.
112#[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    /// The rule's effect. `allow` is refused with the reason there is none.
133    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    /// The rule's single subject.
151    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    /// A short description for messages, such as `deny command "git push --force"`.
198    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
291/// Refuse a policy that could not be enforced as written anywhere: no
292/// rules, or a rule with no subject, two subjects, an `allow` effect, or a
293/// malformed pattern.
294pub 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/// How one harness enforces one kind of rule.
326#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
327pub struct PolicyCoverageEntry {
328    pub effect: PolicyEffect,
329    pub subject: PolicySubjectKind,
330    pub coverage: CoverageLevel,
331    /// What the rule compiles to in the harness, when it compiles at all.
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub mechanism: Option<String>,
334    /// Why coverage is partial or unsupported.
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub caveat: Option<String>,
337    /// Where the claim can be checked.
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub source: Option<String>,
340}
341
342/// The matrix of a harness Tuff does not compile policies for: every effect
343/// and subject `unsupported`, said plainly.
344pub 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/// One rule's verdict on one harness.
365#[derive(Debug, Clone)]
366pub struct RuleVerdict<'a> {
367    /// Zero-based position of the rule in the policy.
368    pub index: usize,
369    pub rule: &'a PolicyRule,
370    pub entry: PolicyCoverageEntry,
371}
372
373/// Look up every rule in a harness's matrix. A matrix missing a row for a
374/// rule's effect and subject is treated as `unsupported`, never as enforced.
375pub 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
403/// Split a policy's rules by whether a harness enforces them: the zero-based
404/// positions of the rules its matrix covers `full` or `partial`, and a
405/// record of each rule it covers `unsupported`, for the lockfile when the
406/// policy is installed with `--accept-unenforced` (RFC-107 D6).
407pub 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
430/// Whether a native permissions file is a rules file, one compiled rule per
431/// line, such as Codex's `.codex/rules/tuff.rules`, rather than a JSON
432/// settings file.
433pub fn is_rules_file(relpath: &str) -> bool {
434    relpath.ends_with(".rules")
435}
436
437/// The first line of a rules file Tuff writes.
438pub 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
440/// `merge_permissions` for a rules file. Tuff owns the file, but lines it
441/// did not write are kept. The result is empty when no rule is left, so the
442/// caller can remove the file.
443fn 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
479/// Whether a native permissions file is an OpenCode config file, whose
480/// `permission` object maps permission names to actions, or to patterns and
481/// actions, and whose order OpenCode reads as precedence.
482pub 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
488/// Where `tuff check` reports a compiled rule that is missing: the file for
489/// a rules file, the permission for an OpenCode config, and the list for a
490/// JSON settings file.
491pub 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
505/// An OpenCode rule as Tuff records it: the permission name, then a space
506/// and a pattern when the rule sits in that permission's object. A rule with
507/// no pattern is a top-level `"<name>": "<action>"` entry, as for MCP tools.
508fn 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/// A JSON value that keeps object keys in file order. OpenCode applies the
516/// last matching permission rule, so reordering its config changes what it
517/// enforces, and serde_json in this workspace sorts keys.
518#[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    /// OpenCode's shorthand `"read": "allow"` means `{"*": "allow"}`.
536    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
548/// `merge_permissions` for an OpenCode config file.
549///
550/// Every key, rule, and position already in the file is kept. Tuff's rules
551/// are appended, `ask` before `deny`, so that a `deny` wins where both
552/// match. A rule already in the file with the same pattern and a different
553/// action is refused rather than overwritten. The result is empty when the
554/// file holds nothing but `$schema` after a removal, so the caller can
555/// remove it.
556fn 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
683/// Add and remove native permission rules in a harness settings file, given
684/// the bytes it holds now, and return the bytes it should hold next.
685///
686/// The file belongs to the user, as with hook registrations: every other key
687/// and every rule Tuff did not write is kept. A rule already present is not
688/// added twice. A `deny` or `ask` list that this call empties is removed, and
689/// so is a `permissions` object this call leaves empty. A file that is not
690/// JSON, or whose `permissions` or a touched list has the wrong type, is
691/// refused as corrupt, so a caller can run this before writing anything.
692///
693/// A rules file (`is_rules_file`) holds one rule per line instead, and comes
694/// back empty when no rule is left in it.
695pub 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
770/// Take recorded permission rules back out of their settings files.
771///
772/// A settings file that no longer exists holds nothing to remove. One that
773/// is not valid JSON stops the removal, before the caller deletes anything.
774pub 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        // A rules file and an OpenCode config both come back empty once
796        // nothing Tuff or the user wrote is left in them.
797        if is_rules_file(relpath) || is_opencode_config(relpath) {
798            // The rules file exists only to hold compiled rules.
799            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
814/// Whether a recorded rule is still in its list: `clean` when it is,
815/// `missing` when the rule or the file is gone, `modified` when the file is
816/// no longer valid JSON.
817pub 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, &not_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}