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