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/// What the engine does with a call.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum Decision {
15    /// Run it.
16    Allow,
17    /// Ask a person first.
18    Approval(String),
19    /// Never run it.
20    Deny(String),
21}
22
23/// The class of calls a person's decision covers: writing
24/// one path (any content — content hashes would make every retry a new
25/// approval), or one exact command.
26#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
27#[serde(tag = "kind", rename_all = "snake_case")]
28pub enum Grant {
29    /// `write_file` / `edit_file` of this workspace-relative path.
30    Write {
31        /// Normalised path.
32        path: String,
33    },
34    /// `run_command` with exactly this argv.
35    Command {
36        /// The argv.
37        argv: Vec<String>,
38    },
39    /// `run_command` with exactly this argv, with the network.
40    Network {
41        /// The argv.
42        argv: Vec<String>,
43    },
44    /// Any call of this MCP tool.
45    Tool {
46        /// `mcp__<server>__<tool>`.
47        name: String,
48    },
49}
50
51impl Grant {
52    /// The class a call belongs to, for the tools a person can decide on.
53    #[must_use]
54    pub fn of(tool: &str, args: &Value) -> Option<Self> {
55        match tool {
56            "write_file" | "edit_file" => args
57                .get("path")
58                .and_then(Value::as_str)
59                .and_then(|p| normalise(p).ok())
60                .map(|path| Self::Write { path }),
61            name if name.starts_with(crate::mcp::PREFIX) => Some(Self::Tool {
62                name: name.to_owned(),
63            }),
64            "run_command" => args.get("argv").and_then(Value::as_array).map(|a| {
65                let argv = a
66                    .iter()
67                    .filter_map(|v| v.as_str().map(str::to_owned))
68                    .collect();
69                if args.get("network").and_then(Value::as_bool) == Some(true) {
70                    Self::Network { argv }
71                } else {
72                    Self::Command { argv }
73                }
74            }),
75            _ => None,
76        }
77    }
78
79    /// One line for people.
80    #[must_use]
81    pub fn describe(&self) -> String {
82        match self {
83            Self::Write { path } => format!("write `{path}`"),
84            Self::Command { argv } => format!("run `{}`", argv.join(" ")),
85            Self::Network { argv } => format!("run `{}` with the network", argv.join(" ")),
86            Self::Tool { name } => format!("use `{name}`"),
87        }
88    }
89}
90
91/// A person's "no", with the reason the model is told.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct Rejection {
94    /// What was rejected.
95    pub grant: Grant,
96    /// Why.
97    pub reason: String,
98}
99
100/// The classifier for one project.
101#[derive(Debug, Clone, Default)]
102pub struct Policy {
103    allowed: Vec<Vec<String>>,
104    protected: Protected,
105    frozen: std::collections::BTreeSet<String>,
106    approved: Vec<Grant>,
107    rejected: Vec<Rejection>,
108    /// MCP tools by model-facing name: `allow`, `approval` or `deny`.
109    mcp: std::collections::BTreeMap<String, String>,
110    /// The planning profile for hosted planning skills.
111    authoring: bool,
112    /// Commands run without the network; these prefixes get it,
113    /// asking for it otherwise needs approval.
114    network: Vec<Vec<String>>,
115    /// An in-place attempt: git commands that move the person's checkout
116    /// are denied.
117    in_place: bool,
118    /// In place: what git ignored at the run's start (directories end in
119    /// `/`), invisible to the file tools unless protected.
120    hidden: Vec<String>,
121}
122
123const SHELLS: &[&str] = &[
124    "sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh", "pwsh",
125];
126const PRIVILEGE: &[&str] = &["sudo", "su", "doas", "pkexec"];
127const NETWORK: &[&str] = &[
128    "curl", "wget", "nc", "ncat", "ssh", "scp", "sftp", "rsync", "ftp", "telnet",
129];
130const GIT_REMOTE: &[&str] = &[
131    "push",
132    "fetch",
133    "pull",
134    "clone",
135    "remote",
136    "ls-remote",
137    "submodule",
138];
139/// What would move the checkout of an in-place run under speccoder.
140const GIT_IN_PLACE: &[&str] = &[
141    "checkout",
142    "switch",
143    "reset",
144    "commit",
145    "merge",
146    "rebase",
147    "cherry-pick",
148    "revert",
149    "stash",
150    "branch",
151    "tag",
152    "update-ref",
153    "worktree",
154    "am",
155];
156
157impl Policy {
158    /// A policy allowing the command prefixes of `checks` (program and
159    /// subcommands, up to the first option) and the `extra` prefixes.
160    #[must_use]
161    pub fn new(checks: &[Vec<String>], extra: &[Vec<String>]) -> Self {
162        let mut allowed: Vec<Vec<String>> = checks
163            .iter()
164            .map(|argv| {
165                let prefix: Vec<String> = argv
166                    .iter()
167                    .take_while(|a| !a.starts_with('-'))
168                    .cloned()
169                    .collect();
170                // `python3 -m pytest …` would leave the bare interpreter as
171                // the prefix — and allow `python3 -c <anything>`. A check
172                // whose prefix is only its program allows exactly itself.
173                if prefix.len() == 1 && argv.len() > 1 {
174                    argv.clone()
175                } else {
176                    prefix
177                }
178            })
179            .filter(|p: &Vec<String>| !p.is_empty())
180            .collect();
181        allowed.extend(extra.iter().filter(|p| !p.is_empty()).cloned());
182        allowed.sort();
183        allowed.dedup();
184        Self {
185            allowed,
186            protected: Protected::default(),
187            frozen: std::collections::BTreeSet::new(),
188            approved: Vec::new(),
189            rejected: Vec::new(),
190            mcp: std::collections::BTreeMap::new(),
191            authoring: false,
192            network: Vec::new(),
193            in_place: false,
194            hidden: Vec::new(),
195        }
196    }
197
198    /// Paths git ignored when an in-place run started: the file tools
199    /// refuse them, except the protected files (the intent stays readable).
200    #[must_use]
201    pub fn with_hidden(mut self, hidden: impl IntoIterator<Item = String>) -> Self {
202        self.hidden = hidden.into_iter().collect();
203        self
204    }
205
206    /// Whether `rel` is ignored by git in an in-place checkout and not one
207    /// of the files speccoder protects.
208    fn is_hidden(&self, rel: &str) -> bool {
209        self.protected.write_class(rel).is_none()
210            && !self.protected.is_context(rel)
211            && self.hidden.iter().any(|h| match h.strip_suffix('/') {
212                Some(dir) => rel == dir || rel.starts_with(h.as_str()),
213                None => rel == h,
214            })
215    }
216
217    /// An in-place attempt (`run --in-place`): the checkout is the
218    /// person's, so git commands that move it are denied.
219    #[must_use]
220    pub fn in_place(mut self) -> Self {
221        self.in_place = true;
222        self
223    }
224
225    /// The planning profile: the format's artifacts and scripts are the
226    /// work; its machinery, verification inputs and secrets stay denied;
227    /// anything else — source code included — needs approval.
228    #[must_use]
229    pub fn authoring(mut self) -> Self {
230        self.authoring = true;
231        self
232    }
233
234    /// The project's format's rules (intent, planning artifacts); the
235    /// reference format's by default.
236    #[must_use]
237    pub fn rules(mut self, rules: &'static crate::format::Rules) -> Self {
238        self.protected = Protected::new(rules);
239        self
240    }
241
242    /// `[sandbox] network_allow`: prefixes that get the network.
243    #[must_use]
244    pub fn with_network(mut self, allow: Vec<Vec<String>>) -> Self {
245        self.network = allow.into_iter().filter(|p| !p.is_empty()).collect();
246        self
247    }
248
249    /// Whether `argv` gets the network without asking.
250    #[must_use]
251    pub fn network_allowed(&self, argv: &[String]) -> bool {
252        self.network.iter().any(|p| argv.starts_with(p))
253    }
254
255    /// The project's MCP tools and their configured decisions.
256    #[must_use]
257    pub fn with_mcp(mut self, tools: impl IntoIterator<Item = (String, String)>) -> Self {
258        self.mcp = tools.into_iter().collect();
259        self
260    }
261
262    /// A person's decisions for this task: an approval turns a call that
263    /// needs approval into an allowed one (never a denied one); a rejection
264    /// turns it into a denial with the person's reason.
265    #[must_use]
266    pub fn with_decisions(mut self, approved: Vec<Grant>, rejected: Vec<Rejection>) -> Self {
267        self.approved = approved;
268        self.rejected = rejected;
269        self
270    }
271
272    /// Freezes these test files: writing them needs approval.
273    #[must_use]
274    pub fn with_frozen(mut self, frozen: impl IntoIterator<Item = String>) -> Self {
275        self.frozen = frozen.into_iter().collect();
276        self
277    }
278
279    /// The protected set this policy uses.
280    #[must_use]
281    pub fn protected(&self) -> &Protected {
282        &self.protected
283    }
284
285    /// Whether this (resolved, workspace-relative) path is protected for
286    /// writing under this policy: the authoring profile writes the format's
287    /// artifacts, nothing else of the protected set.
288    #[must_use]
289    pub fn write_protected(&self, rel: &str) -> Option<super::paths::Class> {
290        if self.authoring
291            && (self.protected.rules().authoring_path)(rel)
292            && !self.protected.is_secret(rel)
293            && !self.protected.is_review(rel)
294        {
295            return None;
296        }
297        self.protected.write_class(rel)
298    }
299
300    /// Classifies `tool` called with `args`.
301    #[must_use]
302    pub fn classify(&self, tool: &str, args: &Value) -> Decision {
303        let decision = self.base(tool, args);
304        if !matches!(decision, Decision::Approval(_)) {
305            return decision;
306        }
307        let Some(grant) = Grant::of(tool, args) else {
308            return decision;
309        };
310        if let Some(r) = self.rejected.iter().find(|r| r.grant == grant) {
311            return Decision::Deny(format!("a person rejected this: {}", r.reason));
312        }
313        if self.approved.contains(&grant) {
314            return Decision::Allow;
315        }
316        decision
317    }
318
319    /// The rule that decides `tool` with `args` when no person does: what a
320    /// call's record names, so a rule's decision is never read as a
321    /// person's.
322    #[must_use]
323    pub fn rule(&self, tool: &str, args: &Value) -> String {
324        if let Some(grant) = Grant::of(tool, args)
325            && matches!(self.base(tool, args), Decision::Approval(_))
326        {
327            if self.rejected.iter().any(|r| r.grant == grant) {
328                return format!("rejection of {} for the task", grant.describe());
329            }
330            if self.approved.contains(&grant) {
331                return format!("grant {} for the task", grant.describe());
332            }
333        }
334        if tool.starts_with(crate::mcp::PREFIX) {
335            let entry = self.mcp.get(tool).map_or("unlisted", String::as_str);
336            return format!("[mcp] {entry}");
337        }
338        match tool {
339            "read_file" | "list_files" | "search_code" => "workspace read".to_owned(),
340            "write_file" | "edit_file" => "workspace write".to_owned(),
341            "git_status" | "git_diff" => "read-only git".to_owned(),
342            "run_command" => {
343                let argv: Vec<String> = args["argv"]
344                    .as_array()
345                    .map(|a| {
346                        a.iter()
347                            .filter_map(|v| v.as_str().map(str::to_owned))
348                            .collect()
349                    })
350                    .unwrap_or_default();
351                match self
352                    .allowed
353                    .iter()
354                    .find(|p| argv.len() >= p.len() && argv[..p.len()] == p[..])
355                {
356                    Some(prefix) => format!("allowed command `{}`", prefix.join(" ")),
357                    None => "command policy".to_owned(),
358                }
359            }
360            _ => "tool policy".to_owned(),
361        }
362    }
363
364    fn base(&self, tool: &str, args: &Value) -> Decision {
365        if tool.starts_with(crate::mcp::PREFIX) {
366            return match self.mcp.get(tool).map(String::as_str) {
367                Some("allow") => Decision::Allow,
368                Some("deny") => Decision::Deny(format!("`{tool}` is denied in [mcp]")),
369                Some(_) => Decision::Approval(format!(
370                    "`{tool}` is an MCP tool; speccoder cannot know what it does"
371                )),
372                None => Decision::Deny(format!("unknown tool `{tool}`")),
373            };
374        }
375        match tool {
376            "read_file" | "list_files" | "search_code" => self.read(args),
377            "write_file" | "edit_file" => self.write(args),
378            "run_command" => self.with_network_rule(args, self.command(args)),
379            "git_status" | "git_diff" => Decision::Allow,
380            other => Decision::Deny(format!("unknown tool `{other}`")),
381        }
382    }
383
384    /// A command that asks for the network (`"network": true`) needs a
385    /// person, unless its prefix is in `[sandbox] network_allow`.
386    fn with_network_rule(&self, args: &Value, decision: Decision) -> Decision {
387        if args.get("network").and_then(Value::as_bool) != Some(true)
388            || matches!(decision, Decision::Deny(_))
389        {
390            return decision;
391        }
392        let argv: Vec<String> = args["argv"]
393            .as_array()
394            .map(|a| {
395                a.iter()
396                    .filter_map(|v| v.as_str().map(str::to_owned))
397                    .collect()
398            })
399            .unwrap_or_default();
400        if self.network_allowed(&argv) {
401            return decision;
402        }
403        Decision::Approval(format!(
404            "`{}` asks for the network; commands run without it",
405            argv.join(" ")
406        ))
407    }
408
409    fn read(&self, args: &Value) -> Decision {
410        // No path, `.`, `./` or "": the whole workspace (models pass all of them).
411        let Some(path) = args
412            .get("path")
413            .and_then(Value::as_str)
414            .filter(|p| !matches!(p.trim(), "" | "." | "./"))
415        else {
416            return Decision::Allow; // list/search over the whole workspace
417        };
418        match normalise(path) {
419            Err(e) => Decision::Deny(e.0),
420            Ok(p) if self.protected.is_secret(&p) => {
421                Decision::Deny(format!("`{p}` looks like a credential"))
422            }
423            Ok(p) if self.is_hidden(&p) => Decision::Deny(format!(
424                "`{p}` is ignored by git in this checkout; an in-place run works only with what a worktree would hold"
425            )),
426            Ok(_) => Decision::Allow,
427        }
428    }
429
430    fn write(&self, args: &Value) -> Decision {
431        let Some(path) = args.get("path").and_then(Value::as_str) else {
432            return Decision::Deny("no `path`".to_owned());
433        };
434        let p = match normalise(path) {
435            Ok(p) => p,
436            Err(e) => return Decision::Deny(e.0),
437        };
438        if self.authoring
439            && (self.protected.rules().authoring_path)(&p)
440            && !self.protected.is_secret(&p)
441            && !self.protected.is_review(&p)
442        {
443            return Decision::Allow;
444        }
445        if self.is_hidden(&p) {
446            return Decision::Deny(format!(
447                "`{p}` is ignored by git in this checkout; an in-place run does not write it"
448            ));
449        }
450        if self.protected.is_review(&p) {
451            return Decision::Deny(format!(
452                "`{p}` is protected: it records a person's review of the spec; only the person writes it (`speccoder review`)"
453            ));
454        }
455        if let Some(class) = self.protected.write_class(&p) {
456            return Decision::Deny(format!(
457                "`{p}` is protected: {}",
458                self.protected.reason(class)
459            ));
460        }
461        if self.authoring {
462            return Decision::Approval((self.protected.rules().authoring_refusal)(&p));
463        }
464        if self.protected.is_context(&p) {
465            return Decision::Approval(format!(
466                "`{p}` is an agent context file: every later agent session reads it"
467            ));
468        }
469        if self.frozen.contains(&p) {
470            return Decision::Approval(format!(
471                "`{p}` is a frozen test (it existed when the attempt started)"
472            ));
473        }
474        if self.protected.is_manifest(&p) {
475            return Decision::Approval(format!("`{p}` is a dependency manifest"));
476        }
477        Decision::Allow
478    }
479
480    fn command(&self, args: &Value) -> Decision {
481        let argv: Vec<String> = args
482            .get("argv")
483            .and_then(Value::as_array)
484            .map(|a| {
485                a.iter()
486                    .filter_map(|v| v.as_str().map(str::to_owned))
487                    .collect()
488            })
489            .unwrap_or_default();
490        match (self.protected.rules().command)(&argv, self.authoring) {
491            Some(Ok(())) => return Decision::Allow,
492            Some(Err(why)) => return Decision::Deny(why),
493            None => {}
494        }
495        let Some(program) = argv.first() else {
496            return Decision::Deny("empty `argv`".to_owned());
497        };
498        let name = program.rsplit('/').next().unwrap_or(program);
499        let has = |flag: &str| argv.iter().any(|a| a == flag);
500
501        if PRIVILEGE.contains(&name) {
502            return Decision::Deny(format!("`{name}` raises privileges"));
503        }
504        if name == "git" {
505            let sub = argv.get(1).map_or("", String::as_str);
506            if GIT_REMOTE.contains(&sub) {
507                return Decision::Deny(format!(
508                    "`git {sub}` talks to a remote; speccoder never does"
509                ));
510            }
511            // Attestations are git notes only speccoder writes.
512            let notes_action = argv[2..]
513                .iter()
514                .find(|a| !a.starts_with('-'))
515                .map_or("list", String::as_str);
516            if (sub == "notes" && !matches!(notes_action, "show" | "list"))
517                || (sub == "update-ref" && argv.iter().any(|a| a.starts_with("refs/notes")))
518            {
519                return Decision::Deny(
520                    "git notes are speccoder's attestations; only speccoder writes them".to_owned(),
521                );
522            }
523            // Read-only queries (skills and models ask where they are).
524            let listing_branches = sub == "branch"
525                && argv[2..].iter().all(|a| {
526                    matches!(
527                        a.as_str(),
528                        "--show-current" | "--list" | "-l" | "-a" | "--all" | "-v" | "-vv"
529                    )
530                });
531            if matches!(
532                sub,
533                "rev-parse" | "status" | "log" | "diff" | "show" | "ls-files" | "describe"
534            ) || listing_branches
535            {
536                return Decision::Allow;
537            }
538            if self.in_place
539                && !listing_branches
540                && (GIT_IN_PLACE.contains(&sub)
541                    || (sub == "apply" && has("--index"))
542                    || (sub == "restore" && has("--staged")))
543            {
544                return Decision::Deny(format!(
545                    "`git {sub}` would move the checkout of an in-place run; speccoder commits and switches it"
546                ));
547            }
548            if sub == "clean" || (sub == "reset" && has("--hard")) || sub == "filter-branch" {
549                return Decision::Deny(format!("`git {sub}` destroys work"));
550            }
551            if matches!(
552                sub,
553                "commit"
554                    | "checkout"
555                    | "switch"
556                    | "rebase"
557                    | "merge"
558                    | "stash"
559                    | "branch"
560                    | "tag"
561                    | "config"
562                    | "worktree"
563            ) {
564                return Decision::Approval(format!("`git {sub}` changes repository state"));
565            }
566        }
567        if SHELLS.contains(&name) || name == "eval" || name == "env" || name == "xargs" {
568            return Decision::Approval(format!("`{name}` runs arbitrary commands"));
569        }
570        if NETWORK.contains(&name) {
571            return Decision::Approval(format!("`{name}` uses the network"));
572        }
573        if name == "rm" && argv.iter().any(|a| a.starts_with('-') && a.contains('r')) {
574            return Decision::Approval("recursive `rm`".to_owned());
575        }
576        // `cargo --version`, `rustc -V`, `python3 --version`: harmless probes.
577        if argv.len() == 2 && matches!(argv[1].as_str(), "--version" | "-V" | "version") {
578            return Decision::Allow;
579        }
580        if self
581            .allowed
582            .iter()
583            .any(|prefix| argv.len() >= prefix.len() && argv[..prefix.len()] == prefix[..])
584        {
585            return Decision::Allow;
586        }
587        Decision::Approval("not a configured check or allowed command".to_owned())
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use serde_json::json;
595
596    fn policy() -> Policy {
597        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
598        Policy::new(
599            &[
600                argv("cargo nextest run --workspace --profile speccoder"),
601                argv("cargo clippy --workspace -- -D warnings"),
602            ],
603            &[argv("cargo fmt"), argv("cargo check")],
604        )
605    }
606
607    fn cmd(s: &str) -> Value {
608        json!({ "argv": s.split(' ').collect::<Vec<_>>() })
609    }
610
611    #[test]
612    fn classifies_the_table() {
613        use Decision::{Allow, Approval, Deny};
614        let p = policy();
615        let kind = |d: Decision| match d {
616            Allow => "allow",
617            Approval(_) => "approval",
618            Deny(_) => "deny",
619        };
620        let cases: &[(&str, Value, &str)] = &[
621            ("read_file", json!({"path": "src/lib.rs"}), "allow"),
622            ("read_file", json!({"path": ".env"}), "deny"),
623            ("read_file", json!({"path": "../secret"}), "deny"),
624            ("list_files", json!({}), "allow"),
625            ("search_code", json!({"pattern": "fn main"}), "allow"),
626            ("write_file", json!({"path": "src/new.rs"}), "allow"),
627            ("edit_file", json!({"path": "specs/001-x/tasks.md"}), "deny"),
628            ("write_file", json!({"path": "speccoder.toml"}), "deny"),
629            (
630                "write_file",
631                json!({"path": ".github/workflows/ci.yml"}),
632                "deny",
633            ),
634            (
635                "write_file",
636                json!({"path": ".git/hooks/pre-commit"}),
637                "deny",
638            ),
639            ("edit_file", json!({"path": "Cargo.toml"}), "approval"),
640            ("edit_file", json!({"path": "web/package.json"}), "approval"),
641            ("run_command", cmd("cargo nextest run -p x"), "allow"),
642            ("run_command", cmd("cargo clippy --all-targets"), "allow"),
643            ("run_command", cmd("cargo fmt --all"), "allow"),
644            ("run_command", cmd("cargo build"), "approval"),
645            ("run_command", cmd("cargo --version"), "allow"),
646            ("run_command", cmd("rustc -V"), "allow"),
647            ("run_command", cmd("sudo --version"), "deny"),
648            ("run_command", cmd("npm install left-pad"), "approval"),
649            ("run_command", cmd("git push origin main"), "deny"),
650            ("run_command", cmd("git fetch"), "deny"),
651            ("run_command", cmd("git reset --hard HEAD"), "deny"),
652            ("run_command", cmd("git clean -fdx"), "deny"),
653            ("run_command", cmd("git commit -m x"), "approval"),
654            (
655                "run_command",
656                cmd("git rev-parse --is-inside-work-tree"),
657                "allow",
658            ),
659            ("run_command", cmd("git branch --show-current"), "allow"),
660            ("run_command", cmd("git branch feature-x"), "approval"),
661            ("run_command", cmd("git log --oneline -3"), "allow"),
662            ("run_command", cmd("sh -c ls"), "approval"),
663            ("run_command", cmd("sudo cargo test"), "deny"),
664            ("run_command", cmd("curl https://example.com"), "approval"),
665            ("run_command", cmd("rm -rf target"), "approval"),
666            ("run_command", json!({"argv": []}), "deny"),
667            ("git_status", json!({}), "allow"),
668            ("git_diff", json!({}), "allow"),
669            ("delete_everything", json!({}), "deny"),
670        ];
671        for (tool, args, want) in cases {
672            assert_eq!(kind(p.classify(tool, args)), *want, "{tool} {args}");
673        }
674    }
675
676    #[test]
677    fn decisions_cover_classes_and_never_override_a_denial() {
678        let approved = vec![
679            Grant::Write {
680                path: "Cargo.toml".to_owned(),
681            },
682            Grant::Command {
683                argv: vec!["cargo".to_owned(), "build".to_owned()],
684            },
685            Grant::Write {
686                path: "speccoder.toml".to_owned(),
687            },
688        ];
689        let rejected = vec![Rejection {
690            grant: Grant::Command {
691                argv: vec!["curl".to_owned(), "https://x".to_owned()],
692            },
693            reason: "no network".to_owned(),
694        }];
695        let p = policy().with_decisions(approved, rejected);
696        // Any content for an approved path; the same exact command only.
697        assert_eq!(
698            p.classify(
699                "edit_file",
700                &json!({"path": "./Cargo.toml", "old_string": "a"})
701            ),
702            Decision::Allow
703        );
704        assert_eq!(
705            p.classify("run_command", &cmd("cargo build")),
706            Decision::Allow
707        );
708        assert!(matches!(
709            p.classify("run_command", &cmd("cargo build --release")),
710            Decision::Approval(_)
711        ));
712        // A rejection is a denial with the person's reason.
713        assert_eq!(
714            p.classify("run_command", &cmd("curl https://x")),
715            Decision::Deny("a person rejected this: no network".to_owned())
716        );
717        // A protected path stays denied whatever was approved.
718        assert!(matches!(
719            p.classify("write_file", &json!({"path": "speccoder.toml"})),
720            Decision::Deny(_)
721        ));
722        // Attestations are speccoder's: the model reads notes, never writes them.
723        let p = Policy::new(&[], &[]);
724        for argv in [
725            vec![
726                "git",
727                "notes",
728                "--ref=speccoder",
729                "add",
730                "-f",
731                "-m",
732                "x",
733                "HEAD",
734            ],
735            vec!["git", "notes", "remove", "HEAD"],
736            vec!["git", "update-ref", "refs/notes/speccoder", "HEAD"],
737        ] {
738            assert!(
739                matches!(
740                    p.classify("run_command", &json!({ "argv": argv })),
741                    Decision::Deny(_)
742                ),
743                "{argv:?}"
744            );
745        }
746        for argv in [
747            vec!["git", "notes", "--ref=speccoder", "show", "HEAD"],
748            vec!["git", "notes", "list"],
749        ] {
750            assert!(
751                !matches!(
752                    p.classify("run_command", &json!({ "argv": argv })),
753                    Decision::Deny(_)
754                ),
755                "{argv:?}"
756            );
757        }
758        // In place, what moves the checkout is denied; queries still run.
759        let in_place = Policy::new(&[], &[]).in_place();
760        for argv in [
761            vec!["git", "switch", "main"],
762            vec!["git", "commit", "-m", "x"],
763            vec!["git", "reset", "HEAD~1"],
764            vec!["git", "stash"],
765            vec!["git", "branch", "x"],
766            vec!["git", "apply", "--index", "p.diff"],
767            vec!["git", "restore", "--staged", "a"],
768            vec!["git", "update-ref", "HEAD", "x"],
769        ] {
770            assert!(
771                matches!(
772                    in_place.classify("run_command", &json!({ "argv": argv })),
773                    Decision::Deny(_)
774                ),
775                "{argv:?}"
776            );
777        }
778        for argv in [
779            vec!["git", "status"],
780            vec!["git", "branch", "--show-current"],
781        ] {
782            assert_eq!(
783                in_place.classify("run_command", &json!({ "argv": argv })),
784                Decision::Allow,
785                "{argv:?}"
786            );
787        }
788        // Ignored files are out of sight; the protected intent is not.
789        let hidden = Policy::new(&[], &[]).in_place().with_hidden([
790            "target/".to_owned(),
791            "notes.txt".to_owned(),
792            "specs/".to_owned(),
793        ]);
794        for (tool, path) in [
795            ("read_file", "target/debug/x"),
796            ("read_file", "notes.txt"),
797            ("list_files", "target"),
798            ("write_file", "notes.txt"),
799        ] {
800            assert!(
801                matches!(
802                    hidden.classify(tool, &json!({ "path": path })),
803                    Decision::Deny(_)
804                ),
805                "{tool} {path}"
806            );
807        }
808        assert_eq!(
809            hidden.classify("read_file", &json!({ "path": "specs/001-x/spec.md" })),
810            Decision::Allow
811        );
812        assert_eq!(
813            hidden.classify("read_file", &json!({ "path": "src/lib.rs" })),
814            Decision::Allow
815        );
816        assert!(matches!(
817            Policy::new(&[], &[])
818                .classify("run_command", &json!({ "argv": ["git", "switch", "main"] })),
819            Decision::Approval(_)
820        ));
821    }
822
823    #[test]
824    fn a_check_whose_prefix_is_its_bare_program_allows_only_itself() {
825        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
826        let p = Policy::new(
827            &[argv("python3 -m pytest -q --junitxml=.speccoder/junit.xml")],
828            &[],
829        );
830        assert_eq!(
831            p.classify(
832                "run_command",
833                &cmd("python3 -m pytest -q --junitxml=.speccoder/junit.xml")
834            ),
835            Decision::Allow
836        );
837        assert!(matches!(
838            p.classify("run_command", &cmd("python3 -c print(1)")),
839            Decision::Approval(_)
840        ));
841        assert!(matches!(
842            p.classify("run_command", &cmd("python3 -m pytest tests/x.py")),
843            Decision::Approval(_)
844        ));
845        // Explicit allowances stay prefixes.
846        let q = Policy::new(&[], &[argv("python3 -m pytest")]);
847        assert_eq!(
848            q.classify("run_command", &cmd("python3 -m pytest tests/x.py")),
849            Decision::Allow
850        );
851    }
852
853    #[test]
854    fn mcp_tools_follow_their_configured_decision_and_unknown_ones_are_denied() {
855        let p = Policy::new(&[], &[]).with_mcp([
856            ("mcp__db__query".to_owned(), "allow".to_owned()),
857            ("mcp__db__drop".to_owned(), "deny".to_owned()),
858            ("mcp__db__insert".to_owned(), "approval".to_owned()),
859        ]);
860        assert_eq!(p.classify("mcp__db__query", &json!({})), Decision::Allow);
861        assert!(matches!(
862            p.classify("mcp__db__drop", &json!({})),
863            Decision::Deny(_)
864        ));
865        assert!(matches!(
866            p.classify("mcp__db__insert", &json!({})),
867            Decision::Approval(_)
868        ));
869        assert!(matches!(
870            p.classify("mcp__db__other", &json!({})),
871            Decision::Deny(_)
872        ));
873        // An approval for the task covers the tool; it never lifts a denial.
874        let q = p.clone().with_decisions(
875            vec![
876                Grant::Tool {
877                    name: "mcp__db__insert".to_owned(),
878                },
879                Grant::Tool {
880                    name: "mcp__db__drop".to_owned(),
881                },
882            ],
883            Vec::new(),
884        );
885        assert_eq!(
886            q.classify("mcp__db__insert", &json!({ "row": 1 })),
887            Decision::Allow
888        );
889        assert!(matches!(
890            q.classify("mcp__db__drop", &json!({})),
891            Decision::Deny(_)
892        ));
893        assert_eq!(
894            Grant::of("mcp__db__insert", &json!({}))
895                .map(|g| g.describe())
896                .as_deref(),
897            Some("use `mcp__db__insert`")
898        );
899    }
900
901    #[test]
902    fn the_authoring_profile_writes_spec_kit_artifacts_and_runs_its_scripts_only() {
903        let p = Policy::new(&[], &[]).authoring();
904        let w = |path: &str| p.classify("write_file", &json!({ "path": path }));
905        assert_eq!(w("specs/002-login/spec.md"), Decision::Allow);
906        assert_eq!(w(".specify/feature.json"), Decision::Allow);
907        assert_eq!(w(".specify/memory/constitution.md"), Decision::Allow);
908        assert!(matches!(
909            w(".specify/scripts/bash/common.sh"),
910            Decision::Deny(_)
911        ));
912        assert!(matches!(
913            w(".specify/templates/spec-template.md"),
914            Decision::Deny(_)
915        ));
916        assert!(matches!(w("speccoder.toml"), Decision::Deny(_)));
917        assert!(matches!(w("specs/.env"), Decision::Deny(_)));
918        // A planning skill writes the spec, never the person's review of it.
919        assert!(
920            matches!(w("specs/002-login/review.json"), Decision::Deny(ref why) if why.contains("review")),
921            "{:?}",
922            w("specs/002-login/review.json")
923        );
924        assert!(p.write_protected("specs/002-login/review.json").is_some());
925        assert!(matches!(w("src/lib.rs"), Decision::Approval(_)));
926        let run = |argv: &[&str]| p.classify("run_command", &json!({ "argv": argv }));
927        assert_eq!(
928            run(&[".specify/scripts/bash/setup-plan.sh", "--json"]),
929            Decision::Allow
930        );
931        assert_eq!(
932            run(&[
933                "bash",
934                "./.specify/scripts/bash/check-prerequisites.sh",
935                "--json"
936            ]),
937            Decision::Allow
938        );
939        assert!(matches!(
940            run(&["bash", ".specify/scripts/bash/../../evil.sh"]),
941            Decision::Approval(_)
942        ));
943        assert_eq!(
944            run(&[
945                "bash",
946                ".specify/extensions/git/scripts/bash/create-new-feature-branch.sh",
947                "--json"
948            ]),
949            Decision::Allow
950        );
951        assert!(matches!(
952            run(&["bash", ".specify/extensions/git/other.sh"]),
953            Decision::Approval(_)
954        ));
955        assert!(matches!(
956            run(&["bash", "-c", "rm -rf /"]),
957            Decision::Approval(_)
958        ));
959        // Outside the profile, the spec stays protected.
960        assert!(matches!(
961            Policy::new(&[], &[])
962                .classify("write_file", &json!({ "path": "specs/002-login/spec.md" })),
963            Decision::Deny(_)
964        ));
965    }
966
967    #[test]
968    fn asking_for_the_network_needs_a_person_unless_allowed() {
969        let checks = [vec!["cargo".to_owned(), "test".to_owned()]];
970        let p = Policy::new(&checks, &[]);
971        let net = json!({ "argv": ["cargo", "test"], "network": true });
972        assert!(matches!(
973            p.classify("run_command", &net),
974            Decision::Approval(_)
975        ));
976        assert_eq!(
977            Grant::of("run_command", &net),
978            Some(Grant::Network {
979                argv: vec!["cargo".to_owned(), "test".to_owned()]
980            })
981        );
982        // Without the network the same command is allowed; a denial stays one.
983        assert_eq!(
984            p.classify("run_command", &json!({ "argv": ["cargo", "test"] })),
985            Decision::Allow
986        );
987        assert!(matches!(
988            p.classify(
989                "run_command",
990                &json!({ "argv": ["git", "push"], "network": true })
991            ),
992            Decision::Deny(_)
993        ));
994        let allowed = p.clone().with_network(vec![vec!["cargo".to_owned()]]);
995        assert_eq!(allowed.classify("run_command", &net), Decision::Allow);
996        let approved = Policy::new(&checks, &[]).with_decisions(
997            vec![Grant::Network {
998                argv: vec!["cargo".to_owned(), "test".to_owned()],
999            }],
1000            vec![],
1001        );
1002        assert_eq!(approved.classify("run_command", &net), Decision::Allow);
1003    }
1004}