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