Skip to main content

speccoder_runtime/tools/
policy.rs

1//! The policy classifier: every tool call is
2//! allowed, needs approval, or is denied — decided by code, before the call.
3//!
4//! It decides *which* calls run; what an allowed command can reach is the
5//! OS sandbox's job (`crate::sandbox`).
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use super::paths::{Protected, normalise};
11
12/// The prefix of an MCP tool's name as the model sees it (`crate::mcp`).
13pub const MCP_PREFIX: &str = "mcp__";
14
15/// What the engine does with a call.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Decision {
18    /// Run it.
19    Allow,
20    /// Ask a person first.
21    Approval(String),
22    /// Never run it.
23    Deny(String),
24}
25
26/// The kind of object a decision covers.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum Class {
30    /// A write or edit of one file.
31    Write,
32    /// One exact command.
33    Command,
34    /// One exact command with the network.
35    Network,
36    /// One MCP tool as defined now.
37    Mcp,
38}
39
40impl Class {
41    /// Its name.
42    #[must_use]
43    pub const fn as_str(self) -> &'static str {
44        match self {
45            Self::Write => "write",
46            Self::Command => "command",
47            Self::Network => "network",
48            Self::Mcp => "mcp",
49        }
50    }
51}
52
53/// What a person's decision covers: exactly one resolved object — never
54/// the request's spelling and never a pattern (constitution V). A path is
55/// the canonical file a write reaches; a command is its exact argv with
56/// the directory it runs in and the executable it resolves to; an MCP tool
57/// is its name with the digest of its definition.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
60pub enum Object {
61    /// `write_file` / `edit_file` of this file.
62    Path {
63        /// Workspace-relative, every existing ancestor's symlinks resolved.
64        canonical: String,
65        /// The path as the model sent it (normalised).
66        asked: String,
67    },
68    /// `run_command` with exactly this argv.
69    Command {
70        /// The argv.
71        argv: Vec<String>,
72        /// Hex SHA-256 over the argv as a JSON array.
73        argv_sha256: String,
74        /// The working directory, workspace-relative (`.` for the root).
75        cwd: String,
76        /// The executable's absolute canonical path, or `not found`.
77        executable: String,
78        /// Whether the call has the network.
79        network: bool,
80    },
81    /// This MCP tool, as its server defines it now.
82    Tool {
83        /// `mcp__<server>__<tool>`.
84        name: String,
85        /// agentplane's digest over the tool's definition.
86        digest: String,
87    },
88}
89
90/// What an executable that cannot be found is recorded as.
91pub const NOT_FOUND: &str = "not found";
92
93impl PartialEq for Object {
94    /// A path is the file it reaches (any spelling); a command and a tool
95    /// are every field.
96    fn eq(&self, other: &Self) -> bool {
97        match (self, other) {
98            (Self::Path { canonical: a, .. }, Self::Path { canonical: b, .. }) => a == b,
99            (
100                Self::Command {
101                    argv: a1,
102                    argv_sha256: a2,
103                    cwd: a3,
104                    executable: a4,
105                    network: a5,
106                },
107                Self::Command {
108                    argv: b1,
109                    argv_sha256: b2,
110                    cwd: b3,
111                    executable: b4,
112                    network: b5,
113                },
114            ) => a1 == b1 && a2 == b2 && a3 == b3 && a4 == b4 && a5 == b5,
115            (Self::Tool { name: a, digest: x }, Self::Tool { name: b, digest: y }) => {
116                a == b && x == y
117            }
118            _ => false,
119        }
120    }
121}
122
123impl Eq for Object {}
124
125/// Hex SHA-256 over `argv` as a JSON array.
126#[must_use]
127pub fn argv_sha256(argv: &[String]) -> String {
128    use sha2::{Digest as _, Sha256};
129    let json = serde_json::to_string(argv).unwrap_or_default();
130    Sha256::digest(json.as_bytes())
131        .iter()
132        .map(|b| format!("{b:02x}"))
133        .collect()
134}
135
136fn first12(hex: &str) -> &str {
137    hex.get(..12).unwrap_or(hex)
138}
139
140impl Object {
141    /// A command object; the argv's hash is computed.
142    #[must_use]
143    pub fn command(argv: Vec<String>, cwd: String, executable: String, network: bool) -> Self {
144        Self::Command {
145            argv_sha256: argv_sha256(&argv),
146            argv,
147            cwd,
148            executable,
149            network,
150        }
151    }
152
153    /// Its class.
154    #[must_use]
155    pub const fn class(&self) -> Class {
156        match self {
157            Self::Path { .. } => Class::Write,
158            Self::Command { network: true, .. } => Class::Network,
159            Self::Command { .. } => Class::Command,
160            Self::Tool { .. } => Class::Mcp,
161        }
162    }
163
164    /// One line for people.
165    #[must_use]
166    pub fn describe(&self) -> String {
167        match self {
168            Self::Path { canonical, .. } => format!("write `{canonical}`"),
169            Self::Command {
170                argv, cwd, network, ..
171            } => {
172                let place = if cwd == "." {
173                    String::new()
174                } else {
175                    format!(" in `{cwd}`")
176                };
177                let net = if *network { " with the network" } else { "" };
178                format!("run `{}`{place}{net}", argv.join(" "))
179            }
180            Self::Tool { name, digest } => format!("use `{name}`@{}", first12(digest)),
181        }
182    }
183
184    /// Its grant id: `g-<16 hex>` over what the object is — the
185    /// canonical path, every field of a command, a tool's name and digest —
186    /// so one id names one object and nothing else.
187    #[must_use]
188    pub fn grant_id(&self) -> String {
189        use sha2::{Digest as _, Sha256};
190        let identity = match self {
191            Self::Path { canonical, .. } => serde_json::json!({ "path": canonical }),
192            Self::Command {
193                argv,
194                argv_sha256,
195                cwd,
196                executable,
197                network,
198            } => serde_json::json!({
199                "argv": argv, "argv_sha256": argv_sha256, "cwd": cwd,
200                "executable": executable, "network": network,
201            }),
202            Self::Tool { name, digest } => serde_json::json!({ "tool": name, "digest": digest }),
203        };
204        let hex: String = Sha256::digest(identity.to_string().as_bytes())
205            .iter()
206            .take(8)
207            .map(|b| format!("{b:02x}"))
208            .collect();
209        format!("g-{hex}")
210    }
211
212    /// The short form events and lists carry: the path, `argv#<12 hex>`,
213    /// `<tool>@<12 hex>` — never more of the argv than the target shows.
214    #[must_use]
215    pub fn short(&self) -> String {
216        match self {
217            Self::Path { canonical, .. } => canonical.clone(),
218            Self::Command { argv_sha256, .. } => format!("argv#{}", first12(argv_sha256)),
219            Self::Tool { name, digest } => format!("{name}@{}", first12(digest)),
220        }
221    }
222
223    /// Whether `other` names the same thing as this one by the request's
224    /// spelling (the path as asked, the argv, the tool's name) — the case
225    /// in which a different object is worth telling the person about.
226    #[must_use]
227    pub fn same_name(&self, other: &Self) -> bool {
228        match (self, other) {
229            (Self::Path { asked: a, .. }, Self::Path { asked: b, .. }) => a == b,
230            (
231                Self::Command {
232                    argv: a,
233                    network: x,
234                    ..
235                },
236                Self::Command {
237                    argv: b,
238                    network: y,
239                    ..
240                },
241            ) => a == b && x == y,
242            (Self::Tool { name: a, .. }, Self::Tool { name: b, .. }) => a == b,
243            _ => false,
244        }
245    }
246
247    /// What differs from `earlier`, an object of the same name a person
248    /// approved: `executable /usr/bin/make → /w/bin/make`.
249    #[must_use]
250    pub fn changed_from(&self, earlier: &Self) -> Option<String> {
251        if self == earlier || !self.same_name(earlier) {
252            return None;
253        }
254        let mut what = Vec::new();
255        match (earlier, self) {
256            (Self::Path { canonical: a, .. }, Self::Path { canonical: b, .. }) => {
257                what.push(format!("resolves to {b}, was {a}"));
258            }
259            (
260                Self::Command {
261                    cwd: c1,
262                    executable: e1,
263                    ..
264                },
265                Self::Command {
266                    cwd: c2,
267                    executable: e2,
268                    ..
269                },
270            ) => {
271                if e1 != e2 {
272                    what.push(format!("executable {e1} → {e2}"));
273                }
274                if c1 != c2 {
275                    what.push(format!("directory {c1} → {c2}"));
276                }
277            }
278            (Self::Tool { digest: a, .. }, Self::Tool { digest: b, .. }) => {
279                what.push(format!(
280                    "definition @{} → @{} (the tool's definition changed since it was approved)",
281                    first12(a),
282                    first12(b)
283                ));
284            }
285            _ => {}
286        }
287        (!what.is_empty()).then(|| what.join("; "))
288    }
289
290    /// The MCP tool's name, for a tool object.
291    #[must_use]
292    pub fn tool_name(&self) -> Option<&str> {
293        match self {
294            Self::Tool { name, .. } => Some(name),
295            _ => None,
296        }
297    }
298
299    /// The canonical path, for a path object.
300    #[must_use]
301    pub fn path(&self) -> Option<&str> {
302        match self {
303            Self::Path { canonical, .. } => Some(canonical),
304            _ => None,
305        }
306    }
307}
308
309/// A person's "no", with the reason the model is told.
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(deny_unknown_fields)]
312pub struct Rejection {
313    /// What was rejected.
314    pub object: Object,
315    /// Why.
316    pub reason: String,
317}
318
319/// The classifier for one project.
320#[derive(Debug, Clone, Default)]
321pub struct Policy {
322    /// Check-derived prefixes: each configured check's program and
323    /// subcommands up to its first option (committed configuration).
324    checks: Vec<Vec<String>>,
325    /// `[commands] allow`: exact argvs.
326    exact: std::collections::BTreeSet<Vec<String>>,
327    /// `[commands] allow_prefix`: prefixes of at least two elements.
328    prefix: Vec<Vec<String>>,
329    protected: Protected,
330    frozen: std::collections::BTreeSet<String>,
331    approved: Vec<Object>,
332    rejected: Vec<Rejection>,
333    /// MCP tools by model-facing name: `allow`, `approval` or `deny`.
334    mcp: std::collections::BTreeMap<String, String>,
335    /// The planning profile for hosted planning skills.
336    authoring: bool,
337    /// `[sandbox] network_allow`: exact argvs that get the network.
338    network_exact: std::collections::BTreeSet<Vec<String>>,
339    /// `[sandbox] network_allow_prefix`: prefixes that get it.
340    network_prefix: Vec<Vec<String>>,
341    /// An in-place attempt: git commands that move the person's checkout
342    /// are denied.
343    in_place: bool,
344    /// In place: what git ignored at the run's start (directories end in
345    /// `/`), invisible to the file tools unless protected.
346    hidden: Vec<String>,
347    /// An autopilot run: a call that would ask a person, and that
348    /// no grant covers, is refused for the run instead.
349    autopilot: bool,
350}
351
352/// Why an autopilot run refused a call that would have asked a person.
353pub const AUTOPILOT_DENIED: &str = "autopilot: not asked — listed for you";
354
355/// Whether `argv` starts with `prefix`, a prefix of at least two elements
356/// (a bare program allows any argument, so it is never a prefix).
357#[must_use]
358pub fn prefix_matches(prefix: &[String], argv: &[String]) -> bool {
359    prefix.len() >= 2 && argv.starts_with(prefix)
360}
361
362/// Why a prefix entry is refused, if it is: a bare program allows any
363/// argument.
364#[must_use]
365pub fn prefix_refusal(prefix: &[String]) -> Option<&'static str> {
366    (prefix.len() < 2).then_some(
367        "a bare program allows any argument; add the exact command to [commands] allow instead",
368    )
369}
370
371const SHELLS: &[&str] = &[
372    "sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh", "pwsh",
373];
374const PRIVILEGE: &[&str] = &["sudo", "su", "doas", "pkexec"];
375const NETWORK: &[&str] = &[
376    "curl", "wget", "nc", "ncat", "ssh", "scp", "sftp", "rsync", "ftp", "telnet",
377];
378const GIT_REMOTE: &[&str] = &[
379    "push",
380    "fetch",
381    "pull",
382    "clone",
383    "remote",
384    "ls-remote",
385    "submodule",
386];
387/// What would move the checkout of an in-place run under speccoder.
388const GIT_IN_PLACE: &[&str] = &[
389    "checkout",
390    "switch",
391    "reset",
392    "commit",
393    "merge",
394    "rebase",
395    "cherry-pick",
396    "revert",
397    "stash",
398    "branch",
399    "tag",
400    "update-ref",
401    "worktree",
402    "am",
403];
404
405impl Policy {
406    /// A policy allowing the command prefixes of `checks` (program and
407    /// subcommands, up to the first option) and exactly the argvs of
408    /// `exact` (`[commands] allow`).
409    #[must_use]
410    pub fn new(checks: &[Vec<String>], exact: &[Vec<String>]) -> Self {
411        let mut allowed: Vec<Vec<String>> = checks
412            .iter()
413            .map(|argv| {
414                let prefix: Vec<String> = argv
415                    .iter()
416                    .take_while(|a| !a.starts_with('-'))
417                    .cloned()
418                    .collect();
419                // `python3 -m pytest …` would leave the bare interpreter as
420                // the prefix — and allow `python3 -c <anything>`. A check
421                // whose prefix is only its program allows exactly itself.
422                if prefix.len() == 1 && argv.len() > 1 {
423                    argv.clone()
424                } else {
425                    prefix
426                }
427            })
428            .filter(|p: &Vec<String>| !p.is_empty())
429            .collect();
430        allowed.sort();
431        allowed.dedup();
432        Self {
433            checks: allowed,
434            exact: exact.iter().filter(|p| !p.is_empty()).cloned().collect(),
435            ..Self::default()
436        }
437    }
438
439    /// `[commands] allow_prefix`: prefixes of two elements or more (a
440    /// shorter one is dropped; the configuration refuses it).
441    #[must_use]
442    pub fn with_prefixes(mut self, prefixes: Vec<Vec<String>>) -> Self {
443        self.prefix = prefixes.into_iter().filter(|p| p.len() >= 2).collect();
444        self
445    }
446
447    /// The check-derived prefixes, for a coverage view.
448    #[must_use]
449    pub fn check_prefixes(&self) -> &[Vec<String>] {
450        &self.checks
451    }
452
453    /// Paths git ignored when an in-place run started: the file tools
454    /// refuse them, except the protected files (the intent stays readable).
455    #[must_use]
456    pub fn with_hidden(mut self, hidden: impl IntoIterator<Item = String>) -> Self {
457        self.hidden = hidden.into_iter().collect();
458        self
459    }
460
461    /// Whether `rel` is ignored by git in an in-place checkout and not one
462    /// of the files speccoder protects.
463    fn is_hidden(&self, rel: &str) -> bool {
464        self.protected.write_class(rel).is_none()
465            && !self.protected.is_context(rel)
466            && self.hidden.iter().any(|h| match h.strip_suffix('/') {
467                Some(dir) => rel == dir || rel.starts_with(h.as_str()),
468                None => rel == h,
469            })
470    }
471
472    /// An in-place attempt (`run --in-place`): the checkout is the
473    /// person's, so git commands that move it are denied.
474    #[must_use]
475    pub fn in_place(mut self) -> Self {
476        self.in_place = true;
477        self
478    }
479
480    /// An autopilot run: nothing asks ([`Policy::settle`]).
481    #[must_use]
482    pub const fn autopilot(mut self) -> Self {
483        self.autopilot = true;
484        self
485    }
486
487    /// Whether it is an autopilot run's.
488    #[must_use]
489    pub const fn is_autopilot(&self) -> bool {
490        self.autopilot
491    }
492
493    /// What a decision comes to once every grant had its say: on
494    /// autopilot a call that would still ask a person is refused for the
495    /// run ([`AUTOPILOT_DENIED`]); every other decision stands — a denial,
496    /// a rejection or a protected path is never lifted.
497    #[must_use]
498    pub fn settle(&self, decision: Decision) -> Decision {
499        match decision {
500            Decision::Approval(_) if self.autopilot => Decision::Deny(AUTOPILOT_DENIED.to_owned()),
501            d => d,
502        }
503    }
504
505    /// The planning profile: the format's artifacts and scripts are the
506    /// work; its machinery, verification inputs and secrets stay denied;
507    /// anything else — source code included — needs approval.
508    #[must_use]
509    pub fn authoring(mut self) -> Self {
510        self.authoring = true;
511        self
512    }
513
514    /// The project's format's rules (intent, planning artifacts); the
515    /// reference format's by default.
516    #[must_use]
517    pub fn rules(mut self, rules: &'static crate::format::Rules) -> Self {
518        self.protected = Protected::new(rules);
519        self
520    }
521
522    /// `[sandbox] network_allow`: exact argvs that get the network.
523    #[must_use]
524    pub fn with_network(mut self, exact: Vec<Vec<String>>) -> Self {
525        self.network_exact = exact.into_iter().filter(|p| !p.is_empty()).collect();
526        self
527    }
528
529    /// `[sandbox] network_allow_prefix`: prefixes (two elements or more)
530    /// that get the network.
531    #[must_use]
532    pub fn with_network_prefixes(mut self, prefixes: Vec<Vec<String>>) -> Self {
533        self.network_prefix = prefixes.into_iter().filter(|p| p.len() >= 2).collect();
534        self
535    }
536
537    /// Whether `argv` gets the network without asking: an exact entry, or a
538    /// prefix entry it extends.
539    #[must_use]
540    pub fn network_allowed(&self, argv: &[String]) -> bool {
541        self.network_exact.contains(argv)
542            || self.network_prefix.iter().any(|p| prefix_matches(p, argv))
543    }
544
545    /// The project's MCP tools and their configured decisions.
546    #[must_use]
547    pub fn with_mcp(mut self, tools: impl IntoIterator<Item = (String, String)>) -> Self {
548        self.mcp = tools.into_iter().collect();
549        self
550    }
551
552    /// A person's decisions for this task: an approval turns a call that
553    /// needs approval into an allowed one (never a denied one); a rejection
554    /// turns it into a denial with the person's reason.
555    #[must_use]
556    pub fn with_decisions(mut self, approved: Vec<Object>, rejected: Vec<Rejection>) -> Self {
557        self.approved = approved;
558        self.rejected = rejected;
559        self
560    }
561
562    /// Freezes these test files: writing them needs approval.
563    #[must_use]
564    pub fn with_frozen(mut self, frozen: impl IntoIterator<Item = String>) -> Self {
565        self.frozen = frozen.into_iter().collect();
566        self
567    }
568
569    /// The protected set this policy uses.
570    #[must_use]
571    pub fn protected(&self) -> &Protected {
572        &self.protected
573    }
574
575    /// Whether this (resolved, workspace-relative) path is protected for
576    /// writing under this policy: the authoring profile writes the format's
577    /// artifacts, nothing else of the protected set.
578    #[must_use]
579    pub fn write_protected(&self, rel: &str) -> Option<super::paths::Class> {
580        if self.authoring
581            && (self.protected.rules().authoring_path)(rel)
582            && !self.protected.is_secret(rel)
583            && !self.protected.is_review(rel)
584        {
585            return None;
586        }
587        self.protected.write_class(rel)
588    }
589
590    /// Classifies `tool` called with `args`. A person's decision applies
591    /// only to the call's resolved `object` (none: the request alone was
592    /// seen, and nobody's decision covers it): a rejection of that very
593    /// object denies it, an approval of it allows it, never a denial.
594    #[must_use]
595    pub fn classify(&self, tool: &str, args: &Value, object: Option<&Object>) -> Decision {
596        let decision = self.base(tool, args);
597        if !matches!(decision, Decision::Approval(_)) {
598            return decision;
599        }
600        let Some(object) = object else {
601            return decision;
602        };
603        if let Some(r) = self.rejected.iter().find(|r| r.object == *object) {
604            return Decision::Deny(format!("a person rejected this: {}", r.reason));
605        }
606        if self.approved.iter().any(|a| a == object) {
607            return Decision::Allow;
608        }
609        decision
610    }
611
612    /// The person's rejections and approvals, for the changed-object line.
613    #[must_use]
614    pub fn approved(&self) -> &[Object] {
615        &self.approved
616    }
617
618    /// The rule that decides `tool` with `args` when no person does: what a
619    /// call's record names, so a rule's decision is never read as a
620    /// person's.
621    #[must_use]
622    pub fn rule(&self, tool: &str, args: &Value, object: Option<&Object>) -> String {
623        if self.autopilot && matches!(self.classify(tool, args, object), Decision::Approval(_)) {
624            return "autopilot".to_owned();
625        }
626        if let Some(object) = object
627            && matches!(self.base(tool, args), Decision::Approval(_))
628        {
629            if self.rejected.iter().any(|r| r.object == *object) {
630                return format!("rejection of {} for the task", object.describe());
631            }
632            if self.approved.iter().any(|a| a == object) {
633                return format!("grant {} for the task", object.describe());
634            }
635        }
636        if tool.starts_with(MCP_PREFIX) {
637            let entry = self.mcp.get(tool).map_or("unlisted", String::as_str);
638            return format!("[mcp] {entry}");
639        }
640        match tool {
641            "read_file" | "list_files" | "search_code" => "workspace read".to_owned(),
642            "write_file" | "edit_file" => "workspace write".to_owned(),
643            "git_status" | "git_diff" => "read-only git".to_owned(),
644            "run_command" => {
645                let argv = argv_of(args);
646                if self.exact.contains(&argv) {
647                    return format!("exact command `{}`", argv.join(" "));
648                }
649                if let Some(p) = self
650                    .checks
651                    .iter()
652                    .find(|p| argv.len() >= p.len() && argv[..p.len()] == p[..])
653                {
654                    return format!("check prefix `{}`", p.join(" "));
655                }
656                match self.prefix.iter().find(|p| prefix_matches(p, &argv)) {
657                    Some(p) => format!("allowed prefix `{}`", p.join(" ")),
658                    None => "command policy".to_owned(),
659                }
660            }
661            _ => "tool policy".to_owned(),
662        }
663    }
664
665    /// The decision by rule alone, before any person's: what decides
666    /// whether a call's object is resolved and a person asked.
667    #[must_use]
668    pub fn base(&self, tool: &str, args: &Value) -> Decision {
669        self.base_of(tool, args)
670    }
671
672    fn base_of(&self, tool: &str, args: &Value) -> Decision {
673        if tool.starts_with(MCP_PREFIX) {
674            return match self.mcp.get(tool).map(String::as_str) {
675                Some("allow") => Decision::Allow,
676                Some("deny") => Decision::Deny(format!("`{tool}` is denied in [mcp]")),
677                Some(_) => Decision::Approval(format!(
678                    "`{tool}` is an MCP tool; speccoder cannot know what it does"
679                )),
680                None => Decision::Deny(format!("unknown tool `{tool}`")),
681            };
682        }
683        match tool {
684            "read_file" | "list_files" | "search_code" => self.read(args),
685            "write_file" | "edit_file" => self.write(args),
686            "run_command" => self.with_network_rule(args, self.command(args)),
687            "git_status" | "git_diff" => Decision::Allow,
688            other => Decision::Deny(format!("unknown tool `{other}`")),
689        }
690    }
691
692    /// A command that asks for the network (`"network": true`) needs a
693    /// person, unless its prefix is in `[sandbox] network_allow`.
694    fn with_network_rule(&self, args: &Value, decision: Decision) -> Decision {
695        if args.get("network").and_then(Value::as_bool) != Some(true)
696            || matches!(decision, Decision::Deny(_))
697        {
698            return decision;
699        }
700        let argv = argv_of(args);
701        if self.network_allowed(&argv) {
702            return decision;
703        }
704        Decision::Approval(format!(
705            "`{}` asks for the network; commands run without it",
706            argv.join(" ")
707        ))
708    }
709
710    fn read(&self, args: &Value) -> Decision {
711        // No path, `.`, `./` or "": the whole workspace (models pass all of them).
712        let Some(path) = args
713            .get("path")
714            .and_then(Value::as_str)
715            .filter(|p| !matches!(p.trim(), "" | "." | "./"))
716        else {
717            return Decision::Allow; // list/search over the whole workspace
718        };
719        match normalise(path) {
720            Err(e) => Decision::Deny(e.0),
721            Ok(p) if self.protected.is_secret(&p) => {
722                Decision::Deny(format!("`{p}` looks like a credential"))
723            }
724            Ok(p) if self.is_hidden(&p) => Decision::Deny(format!(
725                "`{p}` is ignored by git in this checkout; an in-place run works only with what a worktree would hold"
726            )),
727            Ok(_) => Decision::Allow,
728        }
729    }
730
731    fn write(&self, args: &Value) -> Decision {
732        let Some(path) = args.get("path").and_then(Value::as_str) else {
733            return Decision::Deny("no `path`".to_owned());
734        };
735        let p = match normalise(path) {
736            Ok(p) => p,
737            Err(e) => return Decision::Deny(e.0),
738        };
739        if self.authoring
740            && (self.protected.rules().authoring_path)(&p)
741            && !self.protected.is_secret(&p)
742            && !self.protected.is_review(&p)
743        {
744            return Decision::Allow;
745        }
746        if self.is_hidden(&p) {
747            return Decision::Deny(format!(
748                "`{p}` is ignored by git in this checkout; an in-place run does not write it"
749            ));
750        }
751        if self.protected.is_review(&p) {
752            return Decision::Deny(format!(
753                "`{p}` is protected: it records a person's review of the spec; only the person writes it (`speccoder review`)"
754            ));
755        }
756        if let Some(class) = self.protected.write_class(&p) {
757            return Decision::Deny(format!(
758                "`{p}` is protected: {}",
759                self.protected.reason(class)
760            ));
761        }
762        if self.authoring {
763            return Decision::Approval((self.protected.rules().authoring_refusal)(&p));
764        }
765        if self.protected.is_context(&p) {
766            return Decision::Approval(format!(
767                "`{p}` is an agent context file: every later agent session reads it"
768            ));
769        }
770        if self
771            .frozen
772            .iter()
773            .any(|f| f.to_lowercase() == p.to_lowercase())
774        {
775            return Decision::Approval(format!(
776                "`{p}` is a frozen test (it existed when the attempt started)"
777            ));
778        }
779        if self.protected.is_manifest(&p) {
780            return Decision::Approval(format!("`{p}` is a dependency manifest"));
781        }
782        Decision::Allow
783    }
784
785    fn command(&self, args: &Value) -> Decision {
786        let argv = argv_of(args);
787        match (self.protected.rules().command)(&argv, self.authoring) {
788            Some(Ok(())) => return Decision::Allow,
789            Some(Err(why)) => return Decision::Deny(why),
790            None => {}
791        }
792        let Some(program) = argv.first() else {
793            return Decision::Deny("empty `argv`".to_owned());
794        };
795        let name = program.rsplit('/').next().unwrap_or(program);
796        let has = |flag: &str| argv.iter().any(|a| a == flag);
797
798        if PRIVILEGE.contains(&name) {
799            return Decision::Deny(format!("`{name}` raises privileges"));
800        }
801        if name == "git" {
802            let sub = argv.get(1).map_or("", String::as_str);
803            if GIT_REMOTE.contains(&sub) {
804                return Decision::Deny(format!(
805                    "`git {sub}` talks to a remote; speccoder never does"
806                ));
807            }
808            // Attestations are git notes only speccoder writes.
809            let notes_action = argv[2..]
810                .iter()
811                .find(|a| !a.starts_with('-'))
812                .map_or("list", String::as_str);
813            if (sub == "notes" && !matches!(notes_action, "show" | "list"))
814                || (sub == "update-ref" && argv.iter().any(|a| a.starts_with("refs/notes")))
815            {
816                return Decision::Deny(
817                    "git notes are speccoder's attestations; only speccoder writes them".to_owned(),
818                );
819            }
820            // Read-only queries (skills and models ask where they are).
821            let listing_branches = sub == "branch"
822                && argv[2..].iter().all(|a| {
823                    matches!(
824                        a.as_str(),
825                        "--show-current" | "--list" | "-l" | "-a" | "--all" | "-v" | "-vv"
826                    )
827                });
828            if matches!(
829                sub,
830                "rev-parse" | "status" | "log" | "diff" | "show" | "ls-files" | "describe"
831            ) || listing_branches
832            {
833                return Decision::Allow;
834            }
835            if self.in_place
836                && !listing_branches
837                && (GIT_IN_PLACE.contains(&sub)
838                    || (sub == "apply" && has("--index"))
839                    || (sub == "restore" && has("--staged")))
840            {
841                return Decision::Deny(format!(
842                    "`git {sub}` would move the checkout of an in-place run; speccoder commits and switches it"
843                ));
844            }
845            if sub == "clean" || (sub == "reset" && has("--hard")) || sub == "filter-branch" {
846                return Decision::Deny(format!("`git {sub}` destroys work"));
847            }
848            if matches!(
849                sub,
850                "commit"
851                    | "checkout"
852                    | "switch"
853                    | "rebase"
854                    | "merge"
855                    | "stash"
856                    | "branch"
857                    | "tag"
858                    | "config"
859                    | "worktree"
860            ) {
861                return Decision::Approval(format!("`git {sub}` changes repository state"));
862            }
863        }
864        if SHELLS.contains(&name) || name == "eval" || name == "env" || name == "xargs" {
865            return Decision::Approval(format!("`{name}` runs arbitrary commands"));
866        }
867        if NETWORK.contains(&name) {
868            return Decision::Approval(format!("`{name}` uses the network"));
869        }
870        if name == "rm" && argv.iter().any(|a| a.starts_with('-') && a.contains('r')) {
871            return Decision::Approval("recursive `rm`".to_owned());
872        }
873        // `cargo --version`, `rustc -V`, `python3 --version`: harmless probes.
874        if argv.len() == 2 && matches!(argv[1].as_str(), "--version" | "-V" | "version") {
875            return Decision::Allow;
876        }
877        // `[commands] allow` is exact; a check allows its prefix; a
878        // prefix entry (two elements or more) what extends it.
879        if self.exact.contains(&argv)
880            || self
881                .checks
882                .iter()
883                .any(|prefix| argv.len() >= prefix.len() && argv[..prefix.len()] == prefix[..])
884            || self.prefix.iter().any(|p| prefix_matches(p, &argv))
885        {
886            return Decision::Allow;
887        }
888        Decision::Approval("not a configured check or allowed command".to_owned())
889    }
890}
891
892/// The argv a `run_command` call asks for (strings only).
893#[must_use]
894pub fn argv_of(args: &Value) -> Vec<String> {
895    args.get("argv")
896        .and_then(Value::as_array)
897        .map(|a| {
898            a.iter()
899                .filter_map(|v| v.as_str().map(str::to_owned))
900                .collect()
901        })
902        .unwrap_or_default()
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908    use serde_json::json;
909
910    fn policy() -> Policy {
911        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
912        Policy::new(
913            &[
914                argv("cargo nextest run --workspace --profile speccoder"),
915                argv("cargo clippy --workspace -- -D warnings"),
916            ],
917            &[],
918        )
919        .with_prefixes(vec![argv("cargo fmt"), argv("cargo check")])
920    }
921
922    fn path(canonical: &str, asked: &str) -> Object {
923        Object::Path {
924            canonical: canonical.to_owned(),
925            asked: asked.to_owned(),
926        }
927    }
928
929    fn command(s: &str) -> Object {
930        Object::command(
931            s.split(' ').map(str::to_owned).collect(),
932            ".".to_owned(),
933            "/usr/bin/x".to_owned(),
934            false,
935        )
936    }
937
938    fn cmd(s: &str) -> Value {
939        json!({ "argv": s.split(' ').collect::<Vec<_>>() })
940    }
941
942    #[test]
943    fn classifies_the_table() {
944        use Decision::{Allow, Approval, Deny};
945        let p = policy();
946        let kind = |d: Decision| match d {
947            Allow => "allow",
948            Approval(_) => "approval",
949            Deny(_) => "deny",
950        };
951        let cases: &[(&str, Value, &str)] = &[
952            ("read_file", json!({"path": "src/lib.rs"}), "allow"),
953            ("read_file", json!({"path": ".env"}), "deny"),
954            ("read_file", json!({"path": "../secret"}), "deny"),
955            ("list_files", json!({}), "allow"),
956            ("search_code", json!({"pattern": "fn main"}), "allow"),
957            ("write_file", json!({"path": "src/new.rs"}), "allow"),
958            ("edit_file", json!({"path": "specs/001-x/tasks.md"}), "deny"),
959            ("write_file", json!({"path": "speccoder.toml"}), "deny"),
960            (
961                "write_file",
962                json!({"path": ".github/workflows/ci.yml"}),
963                "deny",
964            ),
965            (
966                "write_file",
967                json!({"path": ".git/hooks/pre-commit"}),
968                "deny",
969            ),
970            ("edit_file", json!({"path": "Cargo.toml"}), "approval"),
971            ("edit_file", json!({"path": "web/package.json"}), "approval"),
972            ("run_command", cmd("cargo nextest run -p x"), "allow"),
973            ("run_command", cmd("cargo clippy --all-targets"), "allow"),
974            ("run_command", cmd("cargo fmt --all"), "allow"),
975            ("run_command", cmd("cargo build"), "approval"),
976            ("run_command", cmd("cargo --version"), "allow"),
977            ("run_command", cmd("rustc -V"), "allow"),
978            ("run_command", cmd("sudo --version"), "deny"),
979            ("run_command", cmd("npm install left-pad"), "approval"),
980            ("run_command", cmd("git push origin main"), "deny"),
981            ("run_command", cmd("git fetch"), "deny"),
982            ("run_command", cmd("git reset --hard HEAD"), "deny"),
983            ("run_command", cmd("git clean -fdx"), "deny"),
984            ("run_command", cmd("git commit -m x"), "approval"),
985            (
986                "run_command",
987                cmd("git rev-parse --is-inside-work-tree"),
988                "allow",
989            ),
990            ("run_command", cmd("git branch --show-current"), "allow"),
991            ("run_command", cmd("git branch feature-x"), "approval"),
992            ("run_command", cmd("git log --oneline -3"), "allow"),
993            ("run_command", cmd("sh -c ls"), "approval"),
994            ("run_command", cmd("sudo cargo test"), "deny"),
995            ("run_command", cmd("curl https://example.com"), "approval"),
996            ("run_command", cmd("rm -rf target"), "approval"),
997            ("run_command", json!({"argv": []}), "deny"),
998            ("git_status", json!({}), "allow"),
999            ("git_diff", json!({}), "allow"),
1000            ("delete_everything", json!({}), "deny"),
1001        ];
1002        for (tool, args, want) in cases {
1003            assert_eq!(kind(p.classify(tool, args, None)), *want, "{tool} {args}");
1004        }
1005    }
1006
1007    #[test]
1008    fn decisions_cover_classes_and_never_override_a_denial() {
1009        let approved = vec![
1010            path("Cargo.toml", "Cargo.toml"),
1011            command("cargo build"),
1012            path("speccoder.toml", "speccoder.toml"),
1013            // Approved and rejected alike: the rejection wins.
1014            command("curl https://x"),
1015        ];
1016        let rejected = vec![Rejection {
1017            object: command("curl https://x"),
1018            reason: "no network".to_owned(),
1019        }];
1020        let p = policy().with_decisions(approved, rejected);
1021        // Any content for an approved file, whatever spelling reaches it.
1022        assert_eq!(
1023            p.classify(
1024                "edit_file",
1025                &json!({"path": "./Cargo.toml", "old_string": "a"}),
1026                Some(&path("Cargo.toml", "./Cargo.toml"))
1027            ),
1028            Decision::Allow
1029        );
1030        // The object, not the request: no object, no grant.
1031        assert!(matches!(
1032            p.classify("edit_file", &json!({"path": "Cargo.toml"}), None),
1033            Decision::Approval(_)
1034        ));
1035        assert_eq!(
1036            p.classify(
1037                "run_command",
1038                &cmd("cargo build"),
1039                Some(&command("cargo build"))
1040            ),
1041            Decision::Allow
1042        );
1043        assert!(matches!(
1044            p.classify(
1045                "run_command",
1046                &cmd("cargo build --release"),
1047                Some(&command("cargo build --release"))
1048            ),
1049            Decision::Approval(_)
1050        ));
1051        // A rejection is a denial with the person's reason, approved or not.
1052        assert_eq!(
1053            p.classify(
1054                "run_command",
1055                &cmd("curl https://x"),
1056                Some(&command("curl https://x"))
1057            ),
1058            Decision::Deny("a person rejected this: no network".to_owned())
1059        );
1060        // A protected path stays denied whatever was approved.
1061        assert!(matches!(
1062            p.classify("write_file", &json!({"path": "speccoder.toml"}), None),
1063            Decision::Deny(_)
1064        ));
1065        // Attestations are speccoder's: the model reads notes, never writes them.
1066        let p = Policy::new(&[], &[]);
1067        for argv in [
1068            vec![
1069                "git",
1070                "notes",
1071                "--ref=speccoder",
1072                "add",
1073                "-f",
1074                "-m",
1075                "x",
1076                "HEAD",
1077            ],
1078            vec!["git", "notes", "remove", "HEAD"],
1079            vec!["git", "update-ref", "refs/notes/speccoder", "HEAD"],
1080        ] {
1081            assert!(
1082                matches!(
1083                    p.classify("run_command", &json!({ "argv": argv }), None),
1084                    Decision::Deny(_)
1085                ),
1086                "{argv:?}"
1087            );
1088        }
1089        for argv in [
1090            vec!["git", "notes", "--ref=speccoder", "show", "HEAD"],
1091            vec!["git", "notes", "list"],
1092        ] {
1093            assert!(
1094                !matches!(
1095                    p.classify("run_command", &json!({ "argv": argv }), None),
1096                    Decision::Deny(_)
1097                ),
1098                "{argv:?}"
1099            );
1100        }
1101        // In place, what moves the checkout is denied; queries still run.
1102        let in_place = Policy::new(&[], &[]).in_place();
1103        for argv in [
1104            vec!["git", "switch", "main"],
1105            vec!["git", "commit", "-m", "x"],
1106            vec!["git", "reset", "HEAD~1"],
1107            vec!["git", "stash"],
1108            vec!["git", "branch", "x"],
1109            vec!["git", "apply", "--index", "p.diff"],
1110            vec!["git", "restore", "--staged", "a"],
1111            vec!["git", "update-ref", "HEAD", "x"],
1112        ] {
1113            assert!(
1114                matches!(
1115                    in_place.classify("run_command", &json!({ "argv": argv }), None),
1116                    Decision::Deny(_)
1117                ),
1118                "{argv:?}"
1119            );
1120        }
1121        for argv in [
1122            vec!["git", "status"],
1123            vec!["git", "branch", "--show-current"],
1124        ] {
1125            assert_eq!(
1126                in_place.classify("run_command", &json!({ "argv": argv }), None),
1127                Decision::Allow,
1128                "{argv:?}"
1129            );
1130        }
1131        // Ignored files are out of sight; the protected intent is not.
1132        let hidden = Policy::new(&[], &[]).in_place().with_hidden([
1133            "target/".to_owned(),
1134            "notes.txt".to_owned(),
1135            "specs/".to_owned(),
1136        ]);
1137        for (tool, path) in [
1138            ("read_file", "target/debug/x"),
1139            ("read_file", "notes.txt"),
1140            ("list_files", "target"),
1141            ("write_file", "notes.txt"),
1142        ] {
1143            assert!(
1144                matches!(
1145                    hidden.classify(tool, &json!({ "path": path }), None),
1146                    Decision::Deny(_)
1147                ),
1148                "{tool} {path}"
1149            );
1150        }
1151        assert_eq!(
1152            hidden.classify("read_file", &json!({ "path": "specs/001-x/spec.md" }), None),
1153            Decision::Allow
1154        );
1155        assert_eq!(
1156            hidden.classify("read_file", &json!({ "path": "src/lib.rs" }), None),
1157            Decision::Allow
1158        );
1159        assert!(matches!(
1160            Policy::new(&[], &[]).classify(
1161                "run_command",
1162                &json!({ "argv": ["git", "switch", "main"] }),
1163                None
1164            ),
1165            Decision::Approval(_)
1166        ));
1167    }
1168
1169    #[test]
1170    fn a_check_whose_prefix_is_its_bare_program_allows_only_itself() {
1171        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1172        let p = Policy::new(
1173            &[argv("python3 -m pytest -q --junitxml=.speccoder/junit.xml")],
1174            &[],
1175        );
1176        assert_eq!(
1177            p.classify(
1178                "run_command",
1179                &cmd("python3 -m pytest -q --junitxml=.speccoder/junit.xml"),
1180                None
1181            ),
1182            Decision::Allow
1183        );
1184        assert!(matches!(
1185            p.classify("run_command", &cmd("python3 -c print(1)"), None),
1186            Decision::Approval(_)
1187        ));
1188        assert!(matches!(
1189            p.classify("run_command", &cmd("python3 -m pytest tests/x.py"), None),
1190            Decision::Approval(_)
1191        ));
1192        // `[commands] allow` is exact: the entry allows itself only.
1193        let q = Policy::new(&[], &[argv("python3 -m pytest")]);
1194        assert!(matches!(
1195            q.classify("run_command", &cmd("python3 -m pytest tests/x.py"), None),
1196            Decision::Approval(_)
1197        ));
1198        assert_eq!(
1199            q.classify("run_command", &cmd("python3 -m pytest"), None),
1200            Decision::Allow
1201        );
1202    }
1203
1204    #[test]
1205    fn mcp_tools_follow_their_configured_decision_and_unknown_ones_are_denied() {
1206        let p = Policy::new(&[], &[]).with_mcp([
1207            ("mcp__db__query".to_owned(), "allow".to_owned()),
1208            ("mcp__db__drop".to_owned(), "deny".to_owned()),
1209            ("mcp__db__insert".to_owned(), "approval".to_owned()),
1210        ]);
1211        assert_eq!(
1212            p.classify("mcp__db__query", &json!({}), None),
1213            Decision::Allow
1214        );
1215        assert!(matches!(
1216            p.classify("mcp__db__drop", &json!({}), None),
1217            Decision::Deny(_)
1218        ));
1219        assert!(matches!(
1220            p.classify("mcp__db__insert", &json!({}), None),
1221            Decision::Approval(_)
1222        ));
1223        assert!(matches!(
1224            p.classify("mcp__db__other", &json!({}), None),
1225            Decision::Deny(_)
1226        ));
1227        // An approval covers the tool as defined then; it never lifts a denial.
1228        let tool = |name: &str, digest: &str| Object::Tool {
1229            name: name.to_owned(),
1230            digest: digest.to_owned(),
1231        };
1232        let q = p.clone().with_decisions(
1233            vec![tool("mcp__db__insert", "d1"), tool("mcp__db__drop", "d1")],
1234            Vec::new(),
1235        );
1236        assert_eq!(
1237            q.classify(
1238                "mcp__db__insert",
1239                &json!({ "row": 1 }),
1240                Some(&tool("mcp__db__insert", "d1"))
1241            ),
1242            Decision::Allow
1243        );
1244        // Its server changed the definition: asked again.
1245        assert!(matches!(
1246            q.classify(
1247                "mcp__db__insert",
1248                &json!({ "row": 1 }),
1249                Some(&tool("mcp__db__insert", "d2"))
1250            ),
1251            Decision::Approval(_)
1252        ));
1253        assert!(matches!(
1254            q.classify(
1255                "mcp__db__drop",
1256                &json!({}),
1257                Some(&tool("mcp__db__drop", "d1"))
1258            ),
1259            Decision::Deny(_)
1260        ));
1261        assert_eq!(
1262            tool("mcp__db__insert", "9f8e7d6c5b4a3210").short(),
1263            "mcp__db__insert@9f8e7d6c5b4a"
1264        );
1265        assert!(
1266            tool("mcp__db__insert", "d2")
1267                .changed_from(&tool("mcp__db__insert", "d1"))
1268                .is_some_and(|c| c.contains("definition changed"))
1269        );
1270    }
1271
1272    #[test]
1273    fn the_authoring_profile_writes_spec_kit_artifacts_and_runs_its_scripts_only() {
1274        let p = Policy::new(&[], &[]).authoring();
1275        let w = |path: &str| p.classify("write_file", &json!({ "path": path }), None);
1276        assert_eq!(w("specs/002-login/spec.md"), Decision::Allow);
1277        assert_eq!(w(".specify/feature.json"), Decision::Allow);
1278        assert_eq!(w(".specify/memory/constitution.md"), Decision::Allow);
1279        assert!(matches!(
1280            w(".specify/scripts/bash/common.sh"),
1281            Decision::Deny(_)
1282        ));
1283        assert!(matches!(
1284            w(".specify/templates/spec-template.md"),
1285            Decision::Deny(_)
1286        ));
1287        assert!(matches!(w("speccoder.toml"), Decision::Deny(_)));
1288        assert!(matches!(w("specs/.env"), Decision::Deny(_)));
1289        // A planning skill writes the spec, never the person's review of it.
1290        assert!(
1291            matches!(w("specs/002-login/review.json"), Decision::Deny(ref why) if why.contains("review")),
1292            "{:?}",
1293            w("specs/002-login/review.json")
1294        );
1295        assert!(p.write_protected("specs/002-login/review.json").is_some());
1296        assert!(matches!(w("src/lib.rs"), Decision::Approval(_)));
1297        let run = |argv: &[&str]| p.classify("run_command", &json!({ "argv": argv }), None);
1298        assert_eq!(
1299            run(&[".specify/scripts/bash/setup-plan.sh", "--json"]),
1300            Decision::Allow
1301        );
1302        assert_eq!(
1303            run(&[
1304                "bash",
1305                "./.specify/scripts/bash/check-prerequisites.sh",
1306                "--json"
1307            ]),
1308            Decision::Allow
1309        );
1310        assert!(matches!(
1311            run(&["bash", ".specify/scripts/bash/../../evil.sh"]),
1312            Decision::Approval(_)
1313        ));
1314        assert_eq!(
1315            run(&[
1316                "bash",
1317                ".specify/extensions/git/scripts/bash/create-new-feature-branch.sh",
1318                "--json"
1319            ]),
1320            Decision::Allow
1321        );
1322        assert!(matches!(
1323            run(&["bash", ".specify/extensions/git/other.sh"]),
1324            Decision::Approval(_)
1325        ));
1326        assert!(matches!(
1327            run(&["bash", "-c", "rm -rf /"]),
1328            Decision::Approval(_)
1329        ));
1330        // Outside the profile, the spec stays protected.
1331        assert!(matches!(
1332            Policy::new(&[], &[]).classify(
1333                "write_file",
1334                &json!({ "path": "specs/002-login/spec.md" }),
1335                None
1336            ),
1337            Decision::Deny(_)
1338        ));
1339    }
1340
1341    #[test]
1342    fn asking_for_the_network_needs_a_person_unless_allowed() {
1343        let checks = [vec!["cargo".to_owned(), "test".to_owned()]];
1344        let p = Policy::new(&checks, &[]);
1345        let net = json!({ "argv": ["cargo", "test"], "network": true });
1346        assert!(matches!(
1347            p.classify("run_command", &net, None),
1348            Decision::Approval(_)
1349        ));
1350        // Without the network the same command is allowed; a denial stays one.
1351        assert_eq!(
1352            p.classify("run_command", &json!({ "argv": ["cargo", "test"] }), None),
1353            Decision::Allow
1354        );
1355        assert!(matches!(
1356            p.classify(
1357                "run_command",
1358                &json!({ "argv": ["git", "push"], "network": true }),
1359                None
1360            ),
1361            Decision::Deny(_)
1362        ));
1363        // `[sandbox] network_allow` is exact; a prefix entry extends.
1364        let exact = p
1365            .clone()
1366            .with_network(vec![vec!["cargo".to_owned(), "test".to_owned()]]);
1367        assert_eq!(exact.classify("run_command", &net, None), Decision::Allow);
1368        let longer = json!({ "argv": ["cargo", "test", "--release"], "network": true });
1369        assert!(matches!(
1370            exact.classify("run_command", &longer, None),
1371            Decision::Approval(_)
1372        ));
1373        let bare = p.clone().with_network(vec![vec!["cargo".to_owned()]]);
1374        assert!(matches!(
1375            bare.classify("run_command", &net, None),
1376            Decision::Approval(_)
1377        ));
1378        let prefixed = p.clone().with_network_prefixes(vec![
1379            vec!["cargo".to_owned()],
1380            vec!["cargo".to_owned(), "test".to_owned()],
1381        ]);
1382        assert_eq!(
1383            prefixed.classify("run_command", &longer, None),
1384            Decision::Allow
1385        );
1386        let object = Object::command(
1387            vec!["cargo".to_owned(), "test".to_owned()],
1388            ".".to_owned(),
1389            "/usr/bin/cargo".to_owned(),
1390            true,
1391        );
1392        let approved = Policy::new(&checks, &[]).with_decisions(vec![object.clone()], vec![]);
1393        assert_eq!(
1394            approved.classify("run_command", &net, Some(&object)),
1395            Decision::Allow
1396        );
1397        // The same argv resolving to another executable is another object.
1398        let shadowed = Object::command(
1399            vec!["cargo".to_owned(), "test".to_owned()],
1400            ".".to_owned(),
1401            "/w/bin/cargo".to_owned(),
1402            true,
1403        );
1404        assert!(matches!(
1405            approved.classify("run_command", &net, Some(&shadowed)),
1406            Decision::Approval(_)
1407        ));
1408        assert!(
1409            shadowed
1410                .changed_from(&object)
1411                .is_some_and(|c| c == "executable /usr/bin/cargo → /w/bin/cargo")
1412        );
1413    }
1414
1415    #[test]
1416    fn commands_allow_is_exact_and_prefixes_need_two_elements() {
1417        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1418        let p = Policy::new(&[], &[argv("npm run build")]);
1419        assert_eq!(
1420            p.classify("run_command", &cmd("npm run build"), None),
1421            Decision::Allow
1422        );
1423        for longer in [
1424            "npm run build --script-shell=/tmp/x",
1425            "npm run build x",
1426            "npm run",
1427        ] {
1428            assert!(
1429                matches!(
1430                    p.classify("run_command", &cmd(longer), None),
1431                    Decision::Approval(_)
1432                ),
1433                "{longer}"
1434            );
1435        }
1436        assert_eq!(
1437            p.rule("run_command", &cmd("npm run build"), None),
1438            "exact command `npm run build`"
1439        );
1440        let q = Policy::new(&[], &[]).with_prefixes(vec![argv("npm"), argv("npm run")]);
1441        assert_eq!(
1442            q.classify("run_command", &cmd("npm run build"), None),
1443            Decision::Allow
1444        );
1445        assert_eq!(
1446            q.rule("run_command", &cmd("npm run build"), None),
1447            "allowed prefix `npm run`"
1448        );
1449        // The one-element entry was dropped: a bare program allows anything.
1450        assert!(matches!(
1451            q.classify("run_command", &cmd("npm install x"), None),
1452            Decision::Approval(_)
1453        ));
1454        assert!(
1455            prefix_refusal(&argv("npm")).is_some() && prefix_refusal(&argv("npm run")).is_none()
1456        );
1457        // Fixed rules decide before every list.
1458        let r = Policy::new(&[], &[argv("sh -c ls")]).with_prefixes(vec![argv("git push")]);
1459        assert!(matches!(
1460            r.classify("run_command", &cmd("sh -c ls"), None),
1461            Decision::Approval(_)
1462        ));
1463        assert!(matches!(
1464            r.classify("run_command", &cmd("git push origin"), None),
1465            Decision::Deny(_)
1466        ));
1467    }
1468
1469    #[test]
1470    fn autopilot_never_asks_never_lifts_a_denial_and_names_its_rule() {
1471        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1472        let p = Policy::new(&[], &[argv("cargo test")]).autopilot();
1473        assert!(p.is_autopilot());
1474        // Allowed stays allowed; asking becomes refusing; denied stays denied.
1475        assert_eq!(
1476            p.settle(p.classify("run_command", &cmd("cargo test"), None)),
1477            Decision::Allow
1478        );
1479        assert_eq!(
1480            p.settle(p.classify("run_command", &cmd("cargo add x"), None)),
1481            Decision::Deny(AUTOPILOT_DENIED.to_owned())
1482        );
1483        assert_eq!(
1484            p.rule("run_command", &cmd("cargo add x"), None),
1485            "autopilot"
1486        );
1487        assert!(matches!(
1488            p.settle(p.classify("run_command", &cmd("git push origin"), None)),
1489            Decision::Deny(r) if r != AUTOPILOT_DENIED
1490        ));
1491        // Without autopilot nothing changes.
1492        let q = Policy::new(&[], &[]);
1493        assert!(matches!(
1494            q.settle(q.classify("run_command", &cmd("cargo add x"), None)),
1495            Decision::Approval(_)
1496        ));
1497        // A grant id names exactly one object.
1498        let a = command("make check");
1499        let mut b = a.clone();
1500        if let Object::Command { cwd, .. } = &mut b {
1501            *cwd = "sub".to_owned();
1502        }
1503        assert_ne!(a.grant_id(), b.grant_id());
1504        assert_eq!(a.grant_id(), a.clone().grant_id());
1505        assert_eq!(
1506            path("src/b/x.rs", "src/a/x.rs").grant_id(),
1507            path("src/b/x.rs", "src/b/x.rs").grant_id()
1508        );
1509        assert!(a.grant_id().starts_with("g-") && a.grant_id().len() == 18);
1510    }
1511
1512    proptest::proptest! {
1513        #[test]
1514        fn on_autopilot_no_call_waits_for_a_person(words in proptest::collection::vec("[a-z]{1,6}", 1..4), tool in 0usize..4) {
1515            let p = Policy::new(&[], &[]).autopilot();
1516            let argv: Vec<String> = words.clone();
1517            let (name, args) = match tool {
1518                0 => ("run_command", json!({ "argv": argv })),
1519                1 => ("write_file", json!({ "path": format!("src/{}.rs", words[0]), "content": "" })),
1520                2 => ("write_file", json!({ "path": format!(".github/{}.yml", words[0]), "content": "" })),
1521                _ => ("mcp__x__y", json!({})),
1522            };
1523            let before = Policy::new(&[], &[]).classify(name, &args, None);
1524            let after = p.settle(p.classify(name, &args, None));
1525            proptest::prop_assert!(!matches!(after, Decision::Approval(_)));
1526            if matches!(before, Decision::Deny(_)) {
1527                proptest::prop_assert!(matches!(after, Decision::Deny(_)));
1528            }
1529            if before == Decision::Allow {
1530                proptest::prop_assert_eq!(after, Decision::Allow);
1531            }
1532        }
1533    }
1534
1535    #[test]
1536    fn a_path_object_is_its_canonical_file_whatever_the_spelling() {
1537        assert_eq!(
1538            path("src/b/x.rs", "src/a/x.rs"),
1539            path("src/b/x.rs", "src/b/x.rs")
1540        );
1541        assert_ne!(
1542            path("src/b/x.rs", "src/a/x.rs"),
1543            path("src/a/x.rs", "src/a/x.rs")
1544        );
1545        assert!(
1546            path("src/b/x.rs", "src/a/x.rs")
1547                .changed_from(&path("src/a/x.rs", "src/a/x.rs"))
1548                .is_some_and(|c| c == "resolves to src/b/x.rs, was src/a/x.rs")
1549        );
1550        // Commands differ by every field.
1551        let a = command("make check");
1552        let mut b = a.clone();
1553        if let Object::Command { cwd, .. } = &mut b {
1554            *cwd = "sub".to_owned();
1555        }
1556        assert_ne!(a, b);
1557        assert_eq!(a.class(), Class::Command);
1558        assert!(a.short().starts_with("argv#") && a.short().len() == 17);
1559        // The record round-trips; an older grant shape does not read.
1560        let back: Object = serde_json::from_value(serde_json::to_value(&a).unwrap()).unwrap();
1561        assert_eq!(back, a);
1562        assert!(serde_json::from_value::<Object>(json!({ "kind": "write", "path": "x" })).is_err());
1563        assert!(
1564            serde_json::from_value::<Rejection>(json!({ "grant": { "kind": "path", "canonical": "x", "asked": "x" }, "reason": "r" }))
1565                .is_err()
1566        );
1567    }
1568
1569    #[test]
1570    fn the_grant_path_has_no_pattern() {
1571        // Matching a person's decision is object equality only.
1572        let source = include_str!("policy.rs");
1573        let classify = source
1574            .split("pub fn classify(")
1575            .nth(1)
1576            .and_then(|s| s.split("\n    }\n").next())
1577            .unwrap_or_default();
1578        assert!(!classify.is_empty());
1579        for pattern in ["starts_with", "glob", "prefix"] {
1580            assert!(!classify.contains(pattern), "{pattern} in the grant path");
1581        }
1582    }
1583}