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;
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/// Add and remove native permission rules in a harness settings file, given
404/// the bytes it holds now, and return the bytes it should hold next.
405///
406/// The file belongs to the user, as with hook registrations: every other key
407/// and every rule Tuff did not write is kept. A rule already present is not
408/// added twice. A `deny` or `ask` list that this call empties is removed, and
409/// so is a `permissions` object this call leaves empty. A file that is not
410/// JSON, or whose `permissions` or a touched list has the wrong type, is
411/// refused as corrupt, so a caller can run this before writing anything.
412pub fn merge_permissions(
413    settings_relpath: &str,
414    existing: Option<&[u8]>,
415    remove: &[(PolicyEffect, String)],
416    add: &[(PolicyEffect, String)],
417) -> Result<Vec<u8>> {
418    let mut settings: serde_json::Value = match existing {
419        Some(bytes) if !bytes.is_empty() => serde_json::from_slice(bytes).map_err(|error| {
420            TuffError::corrupt(format!("{settings_relpath} is not valid JSON: {error}"))
421        })?,
422        _ => serde_json::json!({}),
423    };
424    let object = settings
425        .as_object_mut()
426        .ok_or_else(|| TuffError::corrupt(format!("{settings_relpath} must be a JSON object")))?;
427    if add.is_empty() && !object.contains_key("permissions") {
428        return Ok(serde_json::to_string_pretty(&settings)?.into_bytes());
429    }
430    let permissions = object
431        .entry("permissions")
432        .or_insert_with(|| serde_json::json!({}))
433        .as_object_mut()
434        .ok_or_else(|| {
435            TuffError::corrupt(format!(
436                "{settings_relpath} field 'permissions' must be an object"
437            ))
438        })?;
439    let not_a_list = |effect: PolicyEffect| {
440        TuffError::corrupt(format!(
441            "{settings_relpath} field 'permissions.{}' must be an array",
442            effect.as_str()
443        ))
444    };
445    for (effect, rule) in remove {
446        if let Some(list) = permissions.get_mut(effect.as_str()) {
447            let list = list.as_array_mut().ok_or_else(|| not_a_list(*effect))?;
448            list.retain(|entry| entry.as_str() != Some(rule.as_str()));
449        }
450    }
451    for (effect, rule) in add {
452        let list = permissions
453            .entry(effect.as_str())
454            .or_insert_with(|| serde_json::json!([]))
455            .as_array_mut()
456            .ok_or_else(|| not_a_list(*effect))?;
457        if !list
458            .iter()
459            .any(|entry| entry.as_str() == Some(rule.as_str()))
460        {
461            list.push(serde_json::Value::String(rule.clone()));
462        }
463    }
464    for effect in PolicyEffect::ALL {
465        let emptied_here = remove.iter().any(|(removed, _)| *removed == effect)
466            && permissions
467                .get(effect.as_str())
468                .and_then(serde_json::Value::as_array)
469                .is_some_and(Vec::is_empty);
470        if emptied_here {
471            permissions.remove(effect.as_str());
472        }
473    }
474    let now_empty = !remove.is_empty() && permissions.is_empty();
475    if now_empty {
476        object.remove("permissions");
477    }
478    Ok(serde_json::to_string_pretty(&settings)?.into_bytes())
479}
480
481/// Take recorded permission rules back out of their settings files.
482///
483/// A settings file that no longer exists holds nothing to remove. One that
484/// is not valid JSON stops the removal, before the caller deletes anything.
485pub fn remove_permissions(
486    repo_root: &std::path::Path,
487    managed: &[ManagedPermission],
488) -> Result<()> {
489    let mut by_file: std::collections::BTreeMap<&str, Vec<(PolicyEffect, String)>> =
490        std::collections::BTreeMap::new();
491    for permission in managed {
492        if let Some(effect) = PolicyEffect::parse(&permission.list) {
493            by_file
494                .entry(permission.settings_path.as_str())
495                .or_default()
496                .push((effect, permission.rule.clone()));
497        }
498    }
499    for (relpath, removals) in by_file {
500        let path = repo_root.join(relpath);
501        if !path.is_file() {
502            continue;
503        }
504        let bytes = std::fs::read(&path)?;
505        let mut merged = merge_permissions(relpath, Some(&bytes), &removals, &[])?;
506        if merged != bytes {
507            merged.push(b'\n');
508            std::fs::write(&path, merged)?;
509        }
510    }
511    Ok(())
512}
513
514/// Whether a recorded rule is still in its list: `clean` when it is,
515/// `missing` when the rule or the file is gone, `modified` when the file is
516/// no longer valid JSON.
517pub fn managed_permission_status(
518    repo_root: &std::path::Path,
519    permission: &ManagedPermission,
520) -> &'static str {
521    let Ok(raw) = std::fs::read_to_string(repo_root.join(&permission.settings_path)) else {
522        return "missing";
523    };
524    let Ok(settings) = serde_json::from_str::<serde_json::Value>(&raw) else {
525        return "modified";
526    };
527    let present = settings
528        .get("permissions")
529        .and_then(|permissions| permissions.get(&permission.list))
530        .and_then(serde_json::Value::as_array)
531        .is_some_and(|list| {
532            list.iter()
533                .any(|entry| entry.as_str() == Some(permission.rule.as_str()))
534        });
535    if present { "clean" } else { "missing" }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use crate::error::ErrorKind;
542
543    fn deny(rule: &str) -> (PolicyEffect, String) {
544        (PolicyEffect::Deny, rule.to_string())
545    }
546
547    fn ask(rule: &str) -> (PolicyEffect, String) {
548        (PolicyEffect::Ask, rule.to_string())
549    }
550
551    #[test]
552    fn merging_permissions_keeps_the_users_rules_and_adds_each_rule_once() {
553        let existing = br#"{"model": "opus", "permissions": {"deny": ["Bash(curl *)"], "allow": ["Bash(npm test *)"]}}"#;
554        let add = [
555            deny("Bash(git push --force *)"),
556            ask("Bash(terraform apply *)"),
557        ];
558        let once = merge_permissions(".claude/settings.json", Some(existing), &[], &add).unwrap();
559        let twice = merge_permissions(".claude/settings.json", Some(&once), &[], &add).unwrap();
560        assert_eq!(once, twice, "a redundant merge leaves the file unchanged");
561        let settings: serde_json::Value = serde_json::from_slice(&once).unwrap();
562        assert_eq!(settings["model"], "opus");
563        assert_eq!(
564            settings["permissions"]["deny"],
565            serde_json::json!(["Bash(curl *)", "Bash(git push --force *)"])
566        );
567        assert_eq!(
568            settings["permissions"]["ask"],
569            serde_json::json!(["Bash(terraform apply *)"])
570        );
571        assert_eq!(
572            settings["permissions"]["allow"],
573            serde_json::json!(["Bash(npm test *)"])
574        );
575    }
576
577    #[test]
578    fn removing_permissions_prunes_only_what_it_emptied() {
579        let existing = br#"{"permissions": {"deny": ["Bash(curl *)", "Bash(git push --force *)"], "ask": ["Bash(terraform apply *)"]}}"#;
580        let merged = merge_permissions(
581            "s.json",
582            Some(existing),
583            &[
584                deny("Bash(git push --force *)"),
585                ask("Bash(terraform apply *)"),
586            ],
587            &[],
588        )
589        .unwrap();
590        let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
591        assert_eq!(
592            settings,
593            serde_json::json!({"permissions": {"deny": ["Bash(curl *)"]}})
594        );
595
596        let only_ours =
597            br#"{"model": "opus", "permissions": {"ask": ["Bash(terraform apply *)"]}}"#;
598        let merged = merge_permissions(
599            "s.json",
600            Some(only_ours),
601            &[ask("Bash(terraform apply *)")],
602            &[],
603        )
604        .unwrap();
605        let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
606        assert_eq!(settings, serde_json::json!({"model": "opus"}));
607
608        let untouched = br#"{"permissions": {}}"#;
609        let merged = merge_permissions("s.json", Some(untouched), &[], &[]).unwrap();
610        let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
611        assert_eq!(
612            settings,
613            serde_json::json!({"permissions": {}}),
614            "nothing removed, nothing pruned"
615        );
616    }
617
618    #[test]
619    fn a_corrupt_settings_file_is_refused() {
620        for (bytes, expected) in [
621            (&b"{ not json"[..], "is not valid JSON"),
622            (&b"[]"[..], "must be a JSON object"),
623            (
624                &br#"{"permissions": []}"#[..],
625                "'permissions' must be an object",
626            ),
627            (
628                &br#"{"permissions": {"deny": "x"}}"#[..],
629                "'permissions.deny' must be an array",
630            ),
631        ] {
632            let error = merge_permissions(
633                ".claude/settings.json",
634                Some(bytes),
635                &[],
636                &[deny("Bash(rm *)")],
637            )
638            .unwrap_err();
639            assert_eq!(error.kind(), ErrorKind::Corrupt, "{error}");
640            assert!(error.to_string().contains(expected), "{error}");
641        }
642    }
643
644    #[test]
645    fn recorded_permission_status_and_removal_from_disk() {
646        let temp = tempfile::tempdir().unwrap();
647        std::fs::create_dir_all(temp.path().join(".claude")).unwrap();
648        let path = temp.path().join(".claude/settings.json");
649        std::fs::write(
650            &path,
651            r#"{"permissions": {"deny": ["Bash(curl *)", "Bash(rm *)"]}}"#,
652        )
653        .unwrap();
654        let ours = ManagedPermission {
655            settings_path: ".claude/settings.json".to_string(),
656            list: "deny".to_string(),
657            rule: "Bash(rm *)".to_string(),
658        };
659        assert_eq!(managed_permission_status(temp.path(), &ours), "clean");
660        remove_permissions(temp.path(), std::slice::from_ref(&ours)).unwrap();
661        assert_eq!(managed_permission_status(temp.path(), &ours), "missing");
662        let settings: serde_json::Value =
663            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
664        assert_eq!(
665            settings,
666            serde_json::json!({"permissions": {"deny": ["Bash(curl *)"]}})
667        );
668
669        std::fs::write(&path, "{ not json").unwrap();
670        assert_eq!(managed_permission_status(temp.path(), &ours), "modified");
671        assert!(remove_permissions(temp.path(), &[ours]).is_err());
672    }
673
674    #[test]
675    fn a_command_argument_cannot_be_a_pattern() {
676        let policy =
677            parse("[[policy.rules]]\neffect = \"deny\"\ncommand = [\"git\", \"push\", \"*\"]\n");
678        let error = validate_policy(&policy).unwrap_err();
679        assert!(
680            error.to_string().contains("'*' is not a pattern here"),
681            "{error}"
682        );
683    }
684
685    fn parse(toml_body: &str) -> PolicyConfig {
686        #[derive(Deserialize)]
687        struct Wrapper {
688            policy: PolicyConfig,
689        }
690        toml::from_str::<Wrapper>(toml_body)
691            .expect("valid TOML")
692            .policy
693    }
694
695    const INFRA: &str = r#"
696[[policy.rules]]
697effect = "deny"
698command = ["git", "push", "--force"]
699reason = "Force pushes rewrite shared history."
700
701[[policy.rules]]
702effect = "deny"
703read = [".env", "secrets/**"]
704
705[[policy.rules]]
706effect = "ask"
707command = ["terraform", "apply"]
708
709[[policy.rules]]
710effect = "deny"
711mcp = "github:delete_*"
712"#;
713
714    #[test]
715    fn the_infrastructure_example_is_a_valid_policy() {
716        let policy = parse(INFRA);
717        validate_policy(&policy).unwrap();
718        let kinds: Vec<_> = policy
719            .rules
720            .iter()
721            .map(|rule| (rule.effect().unwrap(), rule.subject().unwrap().kind()))
722            .collect();
723        assert_eq!(
724            kinds,
725            vec![
726                (PolicyEffect::Deny, PolicySubjectKind::Command),
727                (PolicyEffect::Deny, PolicySubjectKind::Read),
728                (PolicyEffect::Ask, PolicySubjectKind::Command),
729                (PolicyEffect::Deny, PolicySubjectKind::Mcp),
730            ]
731        );
732        assert_eq!(
733            policy.rules[0].describe(),
734            "deny command \"git push --force\""
735        );
736        assert_eq!(
737            policy.rules[1].describe(),
738            "deny read \".env\", \"secrets/**\""
739        );
740    }
741
742    #[test]
743    fn a_policy_cannot_allow_anything() {
744        let policy = parse("[[policy.rules]]\neffect = \"allow\"\ncommand = [\"rm\"]\n");
745        let error = validate_policy(&policy).unwrap_err();
746        assert_eq!(error.kind(), ErrorKind::Refused);
747        assert!(error.to_string().contains("policy rule 1"), "{error}");
748        assert!(error.to_string().contains("only narrow"), "{error}");
749    }
750
751    #[test]
752    fn each_rule_has_exactly_one_subject() {
753        let none = parse("[[policy.rules]]\neffect = \"deny\"\n");
754        assert!(
755            validate_policy(&none)
756                .unwrap_err()
757                .to_string()
758                .contains("needs a subject")
759        );
760        let two =
761            parse("[[policy.rules]]\neffect = \"deny\"\ncommand = [\"rm\"]\nread = [\".env\"]\n");
762        assert!(
763            validate_policy(&two)
764                .unwrap_err()
765                .to_string()
766                .contains("more than one subject (command, read)")
767        );
768    }
769
770    #[test]
771    fn malformed_rules_are_refused_with_the_rule_number() {
772        for (body, expected) in [
773            (
774                "effect = \"block\"\ncommand = [\"rm\"]",
775                "must be \"deny\" or \"ask\"",
776            ),
777            ("effect = \"deny\"\ncommand = []", "at least the program"),
778            (
779                "effect = \"deny\"\ncommand = [\"git push\"]",
780                "without spaces",
781            ),
782            ("effect = \"deny\"\nread = []", "at least one path pattern"),
783            (
784                "effect = \"deny\"\nread = [\"../outside\"]",
785                "relative to the project root",
786            ),
787            (
788                "effect = \"deny\"\nedit = [\"/etc/passwd\"]",
789                "relative to the project root",
790            ),
791            (
792                "effect = \"deny\"\nread = [\"~/.ssh/id_rsa\"]",
793                "relative to the project root",
794            ),
795            ("effect = \"deny\"\nmcp = \"github\"", "\"server:tool\""),
796            ("effect = \"deny\"\nmcp = \"git hub:x\"", "\"server:tool\""),
797            ("effect = \"deny\"\nmcp = \"github:\"", "\"server:tool\""),
798            (
799                "effect = \"deny\"\ncommand = [\"rm\"]\nreason = \" \"",
800                "must not be empty",
801            ),
802        ] {
803            let policy = parse(&format!(
804                "[[policy.rules]]\neffect = \"deny\"\ncommand = [\"ok\"]\n\n[[policy.rules]]\n{body}\n"
805            ));
806            let error = validate_policy(&policy).unwrap_err();
807            let text = error.to_string();
808            assert!(text.contains("policy rule 2"), "{body}: {text}");
809            assert!(text.contains(expected), "{body}: {text}");
810        }
811    }
812
813    #[test]
814    fn an_empty_policy_is_refused() {
815        let error = validate_policy(&PolicyConfig { rules: Vec::new() }).unwrap_err();
816        assert!(error.to_string().contains("at least one"), "{error}");
817    }
818
819    #[test]
820    fn unknown_keys_in_a_rule_are_a_parse_error() {
821        #[derive(Deserialize)]
822        #[allow(dead_code)]
823        struct Wrapper {
824            policy: PolicyConfig,
825        }
826        let result =
827            toml::from_str::<Wrapper>("[[policy.rules]]\neffect = \"deny\"\npath = [\".env\"]\n");
828        assert!(result.is_err(), "a misspelt subject must not be ignored");
829    }
830
831    #[test]
832    fn the_not_implemented_matrix_covers_every_effect_and_subject_as_unsupported() {
833        let matrix = not_implemented_matrix();
834        assert_eq!(
835            matrix.len(),
836            PolicyEffect::ALL.len() * PolicySubjectKind::ALL.len()
837        );
838        assert!(
839            matrix
840                .iter()
841                .all(|entry| entry.coverage == CoverageLevel::Unsupported && entry.caveat.is_some())
842        );
843    }
844
845    #[test]
846    fn a_rule_the_matrix_does_not_mention_is_never_treated_as_enforced() {
847        let policy = parse(INFRA);
848        let matrix = vec![PolicyCoverageEntry {
849            effect: PolicyEffect::Deny,
850            subject: PolicySubjectKind::Command,
851            coverage: CoverageLevel::Partial,
852            mechanism: Some("native".to_string()),
853            caveat: None,
854            source: None,
855        }];
856        let verdicts = verdicts(&policy, &matrix).unwrap();
857        let coverage: Vec<_> = verdicts
858            .iter()
859            .map(|verdict| verdict.entry.coverage)
860            .collect();
861        assert_eq!(
862            coverage,
863            vec![
864                CoverageLevel::Partial,
865                CoverageLevel::Unsupported,
866                CoverageLevel::Unsupported,
867                CoverageLevel::Unsupported,
868            ]
869        );
870    }
871}