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    /// One action an external agent takes itself.
39    Agent,
40}
41
42impl Class {
43    /// Its name.
44    #[must_use]
45    pub const fn as_str(self) -> &'static str {
46        match self {
47            Self::Write => "write",
48            Self::Command => "command",
49            Self::Network => "network",
50            Self::Mcp => "mcp",
51            Self::Agent => "agent",
52        }
53    }
54}
55
56/// What a person's decision covers: exactly one resolved object — never
57/// the request's spelling and never a pattern (constitution V). A path is
58/// the canonical file a write reaches; a command is its exact argv with
59/// the directory it runs in and the executable it resolves to; an MCP tool
60/// is its name with the digest of its definition.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
63pub enum Object {
64    /// `write_file` / `edit_file` of this file.
65    Path {
66        /// Workspace-relative, every existing ancestor's symlinks resolved.
67        canonical: String,
68        /// The path as the model sent it (normalised).
69        asked: String,
70    },
71    /// `run_command` with exactly this argv.
72    Command {
73        /// The argv.
74        argv: Vec<String>,
75        /// Hex SHA-256 over the argv as a JSON array.
76        argv_sha256: String,
77        /// The working directory, workspace-relative (`.` for the root).
78        cwd: String,
79        /// The executable's absolute canonical path, or `not found`.
80        executable: String,
81        /// Whether the call has the network.
82        network: bool,
83    },
84    /// This MCP tool, as its server defines it now.
85    Tool {
86        /// `mcp__<server>__<tool>`.
87        name: String,
88        /// agentplane's digest over the tool's definition.
89        digest: String,
90    },
91    /// An external agent's own action, in its own process: exactly these
92    /// commands and paths — never covered by a grant of speccoder's own
93    /// calls, nor covering one.
94    Agent {
95        /// The agent (its preset id).
96        agent: String,
97        /// `execute`, `edit`, `read`, … as the agent declared it.
98        call: String,
99        /// The commands it runs, exactly.
100        argvs: Vec<Vec<String>>,
101        /// The worktree paths it names, canonical.
102        paths: Vec<String>,
103        /// A digest of what else it carries, when that decides (a call
104        /// speccoder cannot read); empty otherwise.
105        input: String,
106    },
107}
108
109/// What an executable that cannot be found is recorded as.
110pub const NOT_FOUND: &str = "not found";
111
112/// Why a command no rule allows needs a person.
113pub const NOT_ALLOWED: &str = "not a configured check or allowed command";
114
115/// A call that stopped for a person nobody could ask then, in a person's
116/// words: the call (a command's argv as typed), why, and how to let it run
117/// — for a command no rule allows, its exact `[commands] allow` entry;
118/// `decide` says how else (e.g. ``approve it: `speccoder task approve T001` ``).
119#[must_use]
120pub fn unasked(pending: &Value, decide: &str) -> String {
121    let tool = pending["tool"].as_str().unwrap_or_default();
122    let reason = pending["reason"].as_str().unwrap_or_default();
123    let arguments = &pending["arguments"];
124    if tool == "run_command" {
125        let argv = argv_of(arguments);
126        let shown = crate::approvals::present::shell_quote(&argv);
127        if reason == NOT_ALLOWED {
128            let entry = argv
129                .iter()
130                .map(|a| serde_json::to_string(a).unwrap_or_default())
131                .collect::<Vec<_>>()
132                .join(", ");
133            return format!(
134                "`{shown}` needs approval: it is {NOT_ALLOWED} — allow exactly it in speccoder.toml (`[commands] allow = [[{entry}]]`), or {decide}"
135            );
136        }
137        return format!("`{shown}` needs approval: {reason} — {decide}");
138    }
139    // An external agent's own action: what it is, in words.
140    if let Ok(o @ Object::Agent { .. }) =
141        serde_json::from_value::<Object>(pending["object"].clone())
142    {
143        return format!("{} needs approval: {reason} — {decide}", o.describe());
144    }
145    let object = arguments["path"]
146        .as_str()
147        .map(|p| format!(" `{p}`"))
148        .unwrap_or_default();
149    format!("`{tool}`{object} needs approval: {reason} — {decide}")
150}
151
152impl PartialEq for Object {
153    /// A path is the file it reaches (any spelling); a command and a tool
154    /// are every field.
155    fn eq(&self, other: &Self) -> bool {
156        match (self, other) {
157            (Self::Path { canonical: a, .. }, Self::Path { canonical: b, .. }) => a == b,
158            (
159                Self::Command {
160                    argv: a1,
161                    argv_sha256: a2,
162                    cwd: a3,
163                    executable: a4,
164                    network: a5,
165                },
166                Self::Command {
167                    argv: b1,
168                    argv_sha256: b2,
169                    cwd: b3,
170                    executable: b4,
171                    network: b5,
172                },
173            ) => a1 == b1 && a2 == b2 && a3 == b3 && a4 == b4 && a5 == b5,
174            (Self::Tool { name: a, digest: x }, Self::Tool { name: b, digest: y }) => {
175                a == b && x == y
176            }
177            (
178                Self::Agent {
179                    agent: a1,
180                    call: a2,
181                    argvs: a3,
182                    paths: a4,
183                    input: a5,
184                },
185                Self::Agent {
186                    agent: b1,
187                    call: b2,
188                    argvs: b3,
189                    paths: b4,
190                    input: b5,
191                },
192            ) => a1 == b1 && a2 == b2 && a3 == b3 && a4 == b4 && a5 == b5,
193            _ => false,
194        }
195    }
196}
197
198impl Eq for Object {}
199
200/// Hex SHA-256 over `argv` as a JSON array.
201#[must_use]
202pub fn argv_sha256(argv: &[String]) -> String {
203    use sha2::{Digest as _, Sha256};
204    let json = serde_json::to_string(argv).unwrap_or_default();
205    Sha256::digest(json.as_bytes())
206        .iter()
207        .map(|b| format!("{b:02x}"))
208        .collect()
209}
210
211fn first12(hex: &str) -> &str {
212    hex.get(..12).unwrap_or(hex)
213}
214
215impl Object {
216    /// A command object; the argv's hash is computed.
217    #[must_use]
218    pub fn command(argv: Vec<String>, cwd: String, executable: String, network: bool) -> Self {
219        Self::Command {
220            argv_sha256: argv_sha256(&argv),
221            argv,
222            cwd,
223            executable,
224            network,
225        }
226    }
227
228    /// Its class.
229    #[must_use]
230    pub const fn class(&self) -> Class {
231        match self {
232            Self::Path { .. } => Class::Write,
233            Self::Command { network: true, .. } => Class::Network,
234            Self::Command { .. } => Class::Command,
235            Self::Tool { .. } => Class::Mcp,
236            Self::Agent { .. } => Class::Agent,
237        }
238    }
239
240    /// One line for people.
241    #[must_use]
242    pub fn describe(&self) -> String {
243        match self {
244            Self::Path { canonical, .. } => format!("write `{canonical}`"),
245            Self::Command {
246                argv, cwd, network, ..
247            } => {
248                let place = if cwd == "." {
249                    String::new()
250                } else {
251                    format!(" in `{cwd}`")
252                };
253                let net = if *network { " with the network" } else { "" };
254                format!("run `{}`{place}{net}", argv.join(" "))
255            }
256            Self::Tool { name, digest } => format!("use `{name}`@{}", first12(digest)),
257            Self::Agent {
258                agent,
259                call: kind,
260                argvs,
261                paths,
262                ..
263            } => {
264                if kind == "reads" {
265                    return format!("{agent}'s plain reads of one file each, for the task");
266                }
267                let what: Vec<String> = argvs
268                    .iter()
269                    .map(|a| format!("`{}`", a.join(" ")))
270                    .chain(paths.iter().map(|p| format!("`{p}`")))
271                    .collect();
272                format!("{agent} itself: {kind} {}", what.join(", "))
273            }
274        }
275    }
276
277    /// Its grant id: `g-<16 hex>` over what the object is — the
278    /// canonical path, every field of a command, a tool's name and digest —
279    /// so one id names one object and nothing else.
280    #[must_use]
281    pub fn grant_id(&self) -> String {
282        use sha2::{Digest as _, Sha256};
283        let identity = match self {
284            Self::Path { canonical, .. } => serde_json::json!({ "path": canonical }),
285            Self::Command {
286                argv,
287                argv_sha256,
288                cwd,
289                executable,
290                network,
291            } => serde_json::json!({
292                "argv": argv, "argv_sha256": argv_sha256, "cwd": cwd,
293                "executable": executable, "network": network,
294            }),
295            Self::Tool { name, digest } => serde_json::json!({ "tool": name, "digest": digest }),
296            Self::Agent {
297                agent,
298                call,
299                argvs,
300                paths,
301                input,
302            } => {
303                serde_json::json!({ "agent": agent, "call": call, "argvs": argvs, "paths": paths, "input": input })
304            }
305        };
306        let hex: String = Sha256::digest(identity.to_string().as_bytes())
307            .iter()
308            .take(8)
309            .map(|b| format!("{b:02x}"))
310            .collect();
311        format!("g-{hex}")
312    }
313
314    /// The short form events and lists carry: the path, `argv#<12 hex>`,
315    /// `<tool>@<12 hex>` — never more of the argv than the target shows.
316    #[must_use]
317    pub fn short(&self) -> String {
318        match self {
319            Self::Path { canonical, .. } => canonical.clone(),
320            Self::Command { argv_sha256, .. } => format!("argv#{}", first12(argv_sha256)),
321            Self::Tool { name, digest } => format!("{name}@{}", first12(digest)),
322            Self::Agent { agent, call, .. } => {
323                format!("{agent}:{call}#{}", first12(&self.grant_id()))
324            }
325        }
326    }
327
328    /// Whether `other` names the same thing as this one by the request's
329    /// spelling (the path as asked, the argv, the tool's name) — the case
330    /// in which a different object is worth telling the person about.
331    #[must_use]
332    pub fn same_name(&self, other: &Self) -> bool {
333        match (self, other) {
334            (Self::Path { asked: a, .. }, Self::Path { asked: b, .. }) => a == b,
335            (
336                Self::Command {
337                    argv: a,
338                    network: x,
339                    ..
340                },
341                Self::Command {
342                    argv: b,
343                    network: y,
344                    ..
345                },
346            ) => a == b && x == y,
347            (Self::Tool { name: a, .. }, Self::Tool { name: b, .. }) => a == b,
348            _ => false,
349        }
350    }
351
352    /// What differs from `earlier`, an object of the same name a person
353    /// approved: `executable /usr/bin/make → /w/bin/make`.
354    #[must_use]
355    pub fn changed_from(&self, earlier: &Self) -> Option<String> {
356        if self == earlier || !self.same_name(earlier) {
357            return None;
358        }
359        let mut what = Vec::new();
360        match (earlier, self) {
361            (Self::Path { canonical: a, .. }, Self::Path { canonical: b, .. }) => {
362                what.push(format!("resolves to {b}, was {a}"));
363            }
364            (
365                Self::Command {
366                    cwd: c1,
367                    executable: e1,
368                    ..
369                },
370                Self::Command {
371                    cwd: c2,
372                    executable: e2,
373                    ..
374                },
375            ) => {
376                if e1 != e2 {
377                    what.push(format!("executable {e1} → {e2}"));
378                }
379                if c1 != c2 {
380                    what.push(format!("directory {c1} → {c2}"));
381                }
382            }
383            (Self::Tool { digest: a, .. }, Self::Tool { digest: b, .. }) => {
384                what.push(format!(
385                    "definition @{} → @{} (the tool's definition changed since it was approved)",
386                    first12(a),
387                    first12(b)
388                ));
389            }
390            _ => {}
391        }
392        (!what.is_empty()).then(|| what.join("; "))
393    }
394
395    /// The MCP tool's name, for a tool object.
396    #[must_use]
397    pub fn tool_name(&self) -> Option<&str> {
398        match self {
399            Self::Tool { name, .. } => Some(name),
400            _ => None,
401        }
402    }
403
404    /// The canonical path, for a path object.
405    #[must_use]
406    pub fn path(&self) -> Option<&str> {
407        match self {
408            Self::Path { canonical, .. } => Some(canonical),
409            _ => None,
410        }
411    }
412}
413
414/// A person's "no", with the reason the model is told.
415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
416#[serde(deny_unknown_fields)]
417pub struct Rejection {
418    /// What was rejected.
419    pub object: Object,
420    /// Why.
421    pub reason: String,
422}
423
424/// The classifier for one project.
425#[derive(Debug, Clone, Default)]
426pub struct Policy {
427    /// Check-derived prefixes: each configured check's program and
428    /// subcommands up to its first option (committed configuration).
429    checks: Vec<Vec<String>>,
430    /// `[commands] allow`: exact argvs.
431    exact: std::collections::BTreeSet<Vec<String>>,
432    /// `[commands] allow_prefix`: prefixes of at least two elements.
433    prefix: Vec<Vec<String>>,
434    protected: Protected,
435    frozen: std::collections::BTreeSet<String>,
436    approved: Vec<Object>,
437    rejected: Vec<Rejection>,
438    /// MCP tools by model-facing name: `allow`, `approval` or `deny`.
439    mcp: std::collections::BTreeMap<String, String>,
440    /// The planning profile for hosted planning skills.
441    authoring: bool,
442    /// A planning task's unit directory (worktree-relative): its design
443    /// documents are the task's work, its judged files are not.
444    planning: Option<String>,
445    /// `[sandbox] network_allow`: exact argvs that get the network.
446    network_exact: std::collections::BTreeSet<Vec<String>>,
447    /// `[sandbox] network_allow_prefix`: prefixes that get it.
448    network_prefix: Vec<Vec<String>>,
449    /// An in-place attempt: git commands that move the person's checkout
450    /// are denied.
451    in_place: bool,
452    /// In place: what git ignored at the run's start (directories end in
453    /// `/`), invisible to the file tools unless protected.
454    hidden: Vec<String>,
455    /// An autopilot run: a call that would ask a person, and that
456    /// no grant covers, is refused for the run instead.
457    autopilot: bool,
458    /// The enforced sandbox's mechanism when contained calls run without
459    /// asking (`[approvals] contained`, network off or loopback).
460    contained: Option<String>,
461    /// Exploring: the paths it may write (the format's own exploration
462    /// artifacts) and the read-only commands it may run. Nothing asks.
463    exploring: Option<Exploring>,
464}
465
466/// What an exploring run may do beyond reading.
467#[derive(Debug, Clone, Copy)]
468pub struct Exploring {
469    /// Paths (workspace-relative) the format's exploration skill writes.
470    pub paths: fn(&str) -> bool,
471    /// Argv prefixes of its read-only commands.
472    pub commands: &'static [&'static [&'static str]],
473}
474
475/// Why an exploring run's write was refused.
476pub const EXPLORING_WRITE: &str = "exploring writes nothing — the brief is made when the person chooses, and *Start spec* writes the spec";
477
478/// Why an autopilot run refused a call that would have asked a person.
479pub const AUTOPILOT_DENIED: &str = "autopilot: not asked — listed for you";
480
481/// Whether `argv` starts with `prefix`, a prefix of at least two elements
482/// (a bare program allows any argument, so it is never a prefix).
483#[must_use]
484pub fn prefix_matches(prefix: &[String], argv: &[String]) -> bool {
485    prefix.len() >= 2 && argv.starts_with(prefix)
486}
487
488/// Whether `argv` is `check` given tests to run: its elements in order,
489/// with positional arguments — never an option — after its last positional
490/// one, among its trailing options (`pytest -q tests/x.py --junitxml=…`).
491fn narrows(check: &[String], argv: &[String]) -> bool {
492    let head = check
493        .iter()
494        .rposition(|a| !a.starts_with('-'))
495        .map_or(0, |i| i + 1);
496    if argv.len() <= check.len() || !argv.starts_with(&check[..head]) {
497        return false;
498    }
499    let mut rest = check[head..].iter().peekable();
500    for a in &argv[head..] {
501        if rest.peek() == Some(&a) {
502            rest.next();
503        } else if a.starts_with('-') {
504            return false;
505        }
506    }
507    rest.peek().is_none()
508}
509
510/// Why a prefix entry is refused, if it is: a bare program allows any
511/// argument.
512#[must_use]
513pub fn prefix_refusal(prefix: &[String]) -> Option<&'static str> {
514    (prefix.len() < 2).then_some(
515        "a bare program allows any argument; add the exact command to [commands] allow instead",
516    )
517}
518
519const SHELLS: &[&str] = &[
520    "sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh", "pwsh",
521];
522const PRIVILEGE: &[&str] = &["sudo", "su", "doas", "pkexec"];
523const NETWORK: &[&str] = &[
524    "curl", "wget", "nc", "ncat", "ssh", "scp", "sftp", "rsync", "ftp", "telnet",
525];
526const GIT_REMOTE: &[&str] = &[
527    "push",
528    "fetch",
529    "pull",
530    "clone",
531    "remote",
532    "ls-remote",
533    "submodule",
534];
535/// What would move the checkout of an in-place run under speccoder.
536const GIT_IN_PLACE: &[&str] = &[
537    "checkout",
538    "switch",
539    "reset",
540    "commit",
541    "merge",
542    "rebase",
543    "cherry-pick",
544    "revert",
545    "stash",
546    "branch",
547    "tag",
548    "update-ref",
549    "worktree",
550    "am",
551];
552
553/// Prefixes that run whatever program follows them.
554const RUNNERS: &[&[&str]] = &[
555    &["uv", "run"],
556    &["uv", "tool", "run"],
557    &["uvx"],
558    &["poetry", "run"],
559    &["pipx", "run"],
560    &["pdm", "run"],
561    &["hatch", "run"],
562];
563
564impl Policy {
565    /// A policy allowing the command prefixes of `checks` (program and
566    /// subcommands, up to the first option) and exactly the argvs of
567    /// `exact` (`[commands] allow`).
568    #[must_use]
569    pub fn new(checks: &[Vec<String>], exact: &[Vec<String>]) -> Self {
570        let mut allowed: Vec<Vec<String>> = checks
571            .iter()
572            .map(|argv| {
573                let prefix: Vec<String> = argv
574                    .iter()
575                    .take_while(|a| !a.starts_with('-'))
576                    .cloned()
577                    .collect();
578                // `python3 -m pytest …` would leave the bare interpreter as
579                // the prefix — and allow `python3 -c <anything>`; `uv run`
580                // would allow any program. A check whose prefix is only its
581                // program, or a runner of another program, allows exactly
582                // itself.
583                if (prefix.len() == 1
584                    || RUNNERS.iter().any(|r| {
585                        prefix
586                            .iter()
587                            .map(String::as_str)
588                            .take(r.len())
589                            .eq(r.iter().copied())
590                    }))
591                    && argv.len() > prefix.len()
592                {
593                    argv.clone()
594                } else {
595                    prefix
596                }
597            })
598            .filter(|p: &Vec<String>| !p.is_empty())
599            .collect();
600        allowed.sort();
601        allowed.dedup();
602        Self {
603            checks: allowed,
604            exact: exact.iter().filter(|p| !p.is_empty()).cloned().collect(),
605            ..Self::default()
606        }
607    }
608
609    /// `[commands] allow_prefix`: prefixes of two elements or more (a
610    /// shorter one is dropped; the configuration refuses it).
611    #[must_use]
612    pub fn with_prefixes(mut self, prefixes: Vec<Vec<String>>) -> Self {
613        self.prefix = prefixes.into_iter().filter(|p| p.len() >= 2).collect();
614        self
615    }
616
617    /// The check-derived prefixes, for a coverage view.
618    #[must_use]
619    pub fn check_prefixes(&self) -> &[Vec<String>] {
620        &self.checks
621    }
622
623    /// Paths git ignored when an in-place run started: the file tools
624    /// refuse them, except the protected files (the intent stays readable).
625    #[must_use]
626    pub fn with_hidden(mut self, hidden: impl IntoIterator<Item = String>) -> Self {
627        self.hidden = hidden.into_iter().collect();
628        self
629    }
630
631    /// Whether `rel` is ignored by git in an in-place checkout and not one
632    /// of the files speccoder protects.
633    fn is_hidden(&self, rel: &str) -> bool {
634        self.protected.write_class(rel).is_none()
635            && !self.protected.is_context(rel)
636            && self.hidden.iter().any(|h| match h.strip_suffix('/') {
637                Some(dir) => rel == dir || rel.starts_with(h.as_str()),
638                None => rel == h,
639            })
640    }
641
642    /// An in-place attempt (`run --in-place`): the checkout is the
643    /// person's, so git commands that move it are denied.
644    #[must_use]
645    pub fn in_place(mut self) -> Self {
646        self.in_place = true;
647        self
648    }
649
650    /// An autopilot run: nothing asks ([`Policy::settle`]).
651    #[must_use]
652    pub const fn autopilot(mut self) -> Self {
653        self.autopilot = true;
654        self
655    }
656
657    /// Commands the enforced sandbox (`mechanism`) confines run without
658    /// asking ([`Policy::contains`]).
659    #[must_use]
660    pub fn contained(mut self, mechanism: &str) -> Self {
661        self.contained = Some(mechanism.to_owned());
662        self
663    }
664
665    /// Whether `tool` with `args`, which the policy would ask about, runs
666    /// without asking because the enforced sandbox confines it: a command
667    /// asked about only for what it runs — not a check or allowed command,
668    /// a shell, a recursive `rm` — never for the network, the repository's
669    /// state or a path; never in planning or exploring.
670    #[must_use]
671    pub fn contains(&self, tool: &str, args: &Value) -> bool {
672        if self.contained.is_none()
673            || tool != "run_command"
674            || self.authoring
675            || self.planning.is_some()
676            || self.exploring.is_some()
677            || args["network"] == true
678            || self.network_allowed(&argv_of(args))
679        {
680            return false;
681        }
682        matches!(self.base(tool, args), Decision::Approval(r)
683            if r == NOT_ALLOWED || r.ends_with("runs arbitrary commands") || r == "recursive `rm`")
684    }
685
686    /// Whether it is an autopilot run's.
687    #[must_use]
688    pub const fn is_autopilot(&self) -> bool {
689        self.autopilot
690    }
691
692    /// What a decision comes to once every grant had its say: on
693    /// autopilot a call that would still ask a person is refused for the
694    /// run ([`AUTOPILOT_DENIED`]); every other decision stands — a denial,
695    /// a rejection or a protected path is never lifted.
696    #[must_use]
697    pub fn settle(&self, decision: Decision) -> Decision {
698        match decision {
699            Decision::Approval(_) if self.autopilot => Decision::Deny(AUTOPILOT_DENIED.to_owned()),
700            d => d,
701        }
702    }
703
704    /// The explore profile: reading is allowed; a write only inside the
705    /// format's exploration paths (and only where the rules allow it), a
706    /// command only from its read-only list, never with the network —
707    /// and nothing goes to a person: what is not allowed is denied.
708    #[must_use]
709    pub const fn exploring(mut self, e: Exploring) -> Self {
710        self.exploring = Some(e);
711        self
712    }
713
714    /// The planning profile: the format's artifacts and scripts are the
715    /// work; its machinery, verification inputs and secrets stay denied;
716    /// anything else — source code included — needs approval.
717    #[must_use]
718    pub fn authoring(mut self) -> Self {
719        self.authoring = true;
720        self
721    }
722
723    /// A planning task's profile: writes inside the unit's directory
724    /// `dir` (worktree-relative) are its work, except the format's judged
725    /// files ([`crate::format::Rules::judged`]), a credential and the
726    /// person's review; everything else is as the policy says.
727    #[must_use]
728    pub fn planning(mut self, dir: &str) -> Self {
729        let dir = dir.trim_matches('/');
730        self.planning = (!dir.is_empty()).then(|| dir.to_owned());
731        self
732    }
733
734    /// Whether `rel` (resolved, worktree-relative) is a planning task's
735    /// to write: a design document inside its unit's directory
736    /// ([`Protected::is_design_doc`]), not judged by the format.
737    #[must_use]
738    pub fn is_planning_write(&self, rel: &str) -> bool {
739        let Some(dir) = &self.planning else {
740            return false;
741        };
742        rel.strip_prefix(dir.as_str())
743            .and_then(|r| r.strip_prefix('/'))
744            .is_some_and(|r| {
745                !r.is_empty()
746                    && !r.split('/').any(|s| s == ".." || s == ".")
747                    && !(self.protected.rules().judged)(r.trim_end_matches('/'))
748                    && self.protected.is_design_doc(rel)
749                    && !self.protected.is_review(rel)
750            })
751    }
752
753    /// The project's format's rules (intent, planning artifacts); the
754    /// reference format's by default.
755    #[must_use]
756    pub fn rules(mut self, rules: &'static crate::format::Rules) -> Self {
757        self.protected = Protected::new(rules);
758        self
759    }
760
761    /// The files the project's hooks run: the model may not write them.
762    #[must_use]
763    pub fn with_hook_files(mut self, files: Vec<String>) -> Self {
764        self.protected = self.protected.with_hook_files(files);
765        self
766    }
767
768    /// `[sandbox] network_allow`: exact argvs that get the network.
769    #[must_use]
770    pub fn with_network(mut self, exact: Vec<Vec<String>>) -> Self {
771        self.network_exact = exact.into_iter().filter(|p| !p.is_empty()).collect();
772        self
773    }
774
775    /// `[sandbox] network_allow_prefix`: prefixes (two elements or more)
776    /// that get the network.
777    #[must_use]
778    pub fn with_network_prefixes(mut self, prefixes: Vec<Vec<String>>) -> Self {
779        self.network_prefix = prefixes.into_iter().filter(|p| p.len() >= 2).collect();
780        self
781    }
782
783    /// Whether `argv` gets the network without asking: an exact entry, or a
784    /// prefix entry it extends.
785    #[must_use]
786    pub fn network_allowed(&self, argv: &[String]) -> bool {
787        self.network_exact.contains(argv)
788            || self.network_prefix.iter().any(|p| prefix_matches(p, argv))
789    }
790
791    /// The project's MCP tools and their configured decisions.
792    #[must_use]
793    pub fn with_mcp(mut self, tools: impl IntoIterator<Item = (String, String)>) -> Self {
794        self.mcp = tools.into_iter().collect();
795        self
796    }
797
798    /// A person's decisions for this task: an approval turns a call that
799    /// needs approval into an allowed one (never a denied one); a rejection
800    /// turns it into a denial with the person's reason.
801    #[must_use]
802    pub fn with_decisions(mut self, approved: Vec<Object>, rejected: Vec<Rejection>) -> Self {
803        self.approved = approved;
804        self.rejected = rejected;
805        self
806    }
807
808    /// Freezes these test files: writing them needs approval.
809    #[must_use]
810    pub fn with_frozen(mut self, frozen: impl IntoIterator<Item = String>) -> Self {
811        self.frozen = frozen.into_iter().collect();
812        self
813    }
814
815    /// The protected set this policy uses.
816    #[must_use]
817    pub fn protected(&self) -> &Protected {
818        &self.protected
819    }
820
821    /// Whether this (resolved, workspace-relative) path is protected for
822    /// writing under this policy: the authoring profile writes the format's
823    /// artifacts, nothing else of the protected set.
824    #[must_use]
825    pub fn write_protected(&self, rel: &str) -> Option<super::paths::Class> {
826        if self.is_planning_write(rel) {
827            return None;
828        }
829        if self.authoring
830            && (self.protected.rules().authoring_path)(rel)
831            && !self.protected.is_secret(rel)
832            && !self.protected.is_review(rel)
833        {
834            return None;
835        }
836        self.protected.write_class(rel)
837    }
838
839    /// Classifies `tool` called with `args`. A person's decision applies
840    /// only to the call's resolved `object` (none: the request alone was
841    /// seen, and nobody's decision covers it): a rejection of that very
842    /// object denies it, an approval of it allows it, never a denial.
843    #[must_use]
844    pub fn classify(&self, tool: &str, args: &Value, object: Option<&Object>) -> Decision {
845        let decision = self.base(tool, args);
846        if !matches!(decision, Decision::Approval(_)) {
847            return decision;
848        }
849        if let Some(object) = object {
850            if let Some(r) = self.rejected.iter().find(|r| r.object == *object) {
851                return Decision::Deny(format!("a person rejected this: {}", r.reason));
852            }
853            if self.approved.iter().any(|a| a == object) {
854                return Decision::Allow;
855            }
856        }
857        if self.contains(tool, args) {
858            Decision::Allow
859        } else {
860            decision
861        }
862    }
863
864    /// A person's rejection of exactly `object`, if one holds.
865    #[must_use]
866    pub fn rejection(&self, object: &Object) -> Option<&Rejection> {
867        self.rejected.iter().find(|r| r.object == *object)
868    }
869
870    /// The person's rejections and approvals, for the changed-object line.
871    #[must_use]
872    pub fn approved(&self) -> &[Object] {
873        &self.approved
874    }
875
876    /// The rule that decides `tool` with `args` when no person does: what a
877    /// call's record names, so a rule's decision is never read as a
878    /// person's.
879    #[must_use]
880    pub fn rule(&self, tool: &str, args: &Value, object: Option<&Object>) -> String {
881        if self.autopilot && matches!(self.classify(tool, args, object), Decision::Approval(_)) {
882            return "autopilot".to_owned();
883        }
884        if let Some(object) = object
885            && matches!(self.base(tool, args), Decision::Approval(_))
886        {
887            if self.rejected.iter().any(|r| r.object == *object) {
888                return format!("rejection of {} for the task", object.describe());
889            }
890            if self.approved.iter().any(|a| a == object) {
891                return format!("grant {} for the task", object.describe());
892            }
893        }
894        if tool.starts_with(MCP_PREFIX) {
895            let entry = self.mcp.get(tool).map_or("unlisted", String::as_str);
896            return format!("[mcp] {entry}");
897        }
898        match tool {
899            "read_file" | "list_files" | "search_code" => "workspace read".to_owned(),
900            "write_file" | "edit_file" => "workspace write".to_owned(),
901            "git_status" | "git_diff" => "read-only git".to_owned(),
902            "run_command" => {
903                let argv = argv_of(args);
904                if self.exact.contains(&argv) {
905                    return format!("exact command `{}`", argv.join(" "));
906                }
907                if let Some(p) = self
908                    .checks
909                    .iter()
910                    .find(|p| argv.len() >= p.len() && argv[..p.len()] == p[..])
911                {
912                    return format!("check prefix `{}`", p.join(" "));
913                }
914                match self.prefix.iter().find(|p| prefix_matches(p, &argv)) {
915                    Some(p) => format!("allowed prefix `{}`", p.join(" ")),
916                    None => match &self.contained {
917                        Some(m) if self.contains(tool, args) => format!("contained ({m})"),
918                        _ => "command policy".to_owned(),
919                    },
920                }
921            }
922            _ => "tool policy".to_owned(),
923        }
924    }
925
926    /// The decision by rule alone, before any person's: what decides
927    /// whether a call's object is resolved and a person asked.
928    #[must_use]
929    pub fn base(&self, tool: &str, args: &Value) -> Decision {
930        self.base_of(tool, args)
931    }
932
933    fn base_of(&self, tool: &str, args: &Value) -> Decision {
934        if let Some(e) = self.exploring {
935            return self.explore_of(e, tool, args);
936        }
937        if tool.starts_with(MCP_PREFIX) {
938            return match self.mcp.get(tool).map(String::as_str) {
939                Some("allow") => Decision::Allow,
940                Some("deny") => Decision::Deny(format!("`{tool}` is denied in [mcp]")),
941                Some(_) => Decision::Approval(format!(
942                    "`{tool}` is an MCP tool; speccoder cannot know what it does"
943                )),
944                None => Decision::Deny(format!("unknown tool `{tool}`")),
945            };
946        }
947        match tool {
948            "read_file" | "list_files" | "search_code" => self.read(args),
949            "write_file" | "edit_file" => self.write(args),
950            "run_command" => self.with_network_rule(args, self.command(args)),
951            "git_status" | "git_diff" => Decision::Allow,
952            // Ask's readers (offered only on the Ask plane): history,
953            // accounts, evidence, codes — reading through runtime functions.
954            "git_log" | "git_show" | "journal_account" | "task_evidence" | "report" | "inbox"
955            | "explain" => Decision::Allow,
956            other => Decision::Deny(format!("unknown tool `{other}`")),
957        }
958    }
959
960    /// An exploring run's decision: never an approval.
961    fn explore_of(&self, e: Exploring, tool: &str, args: &Value) -> Decision {
962        match tool {
963            "read_file" | "list_files" | "search_code" => self.read(args),
964            "git_status" | "git_diff" => Decision::Allow,
965            "write_file" | "edit_file" => {
966                let rel = args["path"]
967                    .as_str()
968                    .and_then(|p| super::paths::normalise(p).ok());
969                // The format's declared exploration artifacts are its
970                // own to write — never a credential or the person's review.
971                match rel {
972                    Some(rel)
973                        if (e.paths)(&rel)
974                            && !self.protected.is_secret(&rel)
975                            && !self.protected.is_review(&rel) =>
976                    {
977                        Decision::Allow
978                    }
979                    _ => Decision::Deny(EXPLORING_WRITE.to_owned()),
980                }
981            }
982            "run_command" => {
983                let argv = argv_of(args);
984                if args.get("network").and_then(Value::as_bool) == Some(true) {
985                    return Decision::Deny("exploring has no network".to_owned());
986                }
987                if e.commands.iter().any(|p| {
988                    argv.len() >= p.len() && argv.iter().zip(p.iter()).all(|(a, b)| a == b)
989                }) {
990                    Decision::Allow
991                } else {
992                    Decision::Deny(format!(
993                        "exploring runs only the format's read-only commands; `{}` is not one",
994                        argv.join(" ")
995                    ))
996                }
997            }
998            other => Decision::Deny(format!("`{other}` is not used while exploring")),
999        }
1000    }
1001
1002    /// A command that asks for the network (`"network": true`) needs a
1003    /// person, unless its prefix is in `[sandbox] network_allow`.
1004    fn with_network_rule(&self, args: &Value, decision: Decision) -> Decision {
1005        if args.get("network").and_then(Value::as_bool) != Some(true)
1006            || matches!(decision, Decision::Deny(_))
1007        {
1008            return decision;
1009        }
1010        let argv = argv_of(args);
1011        if self.network_allowed(&argv) {
1012            return decision;
1013        }
1014        Decision::Approval(format!(
1015            "`{}` asks for the network; commands run without it",
1016            argv.join(" ")
1017        ))
1018    }
1019
1020    fn read(&self, args: &Value) -> Decision {
1021        let Some(path) = super::below_root(args.get("path").and_then(Value::as_str)) else {
1022            return Decision::Allow; // list/search over the whole workspace
1023        };
1024        match normalise(path) {
1025            Err(e) => Decision::Deny(e.0),
1026            Ok(p) if self.protected.is_secret(&p) => {
1027                Decision::Deny(format!("`{p}` looks like a credential"))
1028            }
1029            Ok(p) if self.is_hidden(&p) => Decision::Deny(format!(
1030                "`{p}` is ignored by git in this checkout; an in-place run works only with what a worktree would hold"
1031            )),
1032            Ok(_) => Decision::Allow,
1033        }
1034    }
1035
1036    fn write(&self, args: &Value) -> Decision {
1037        let Some(path) = args.get("path").and_then(Value::as_str) else {
1038            return Decision::Deny("no `path`".to_owned());
1039        };
1040        let p = match normalise(path) {
1041            Ok(p) => p,
1042            Err(e) => return Decision::Deny(e.0),
1043        };
1044        if (self.authoring
1045            && (self.protected.rules().authoring_path)(&p)
1046            && !self.protected.is_secret(&p)
1047            && !self.protected.is_review(&p))
1048            || self.is_planning_write(&p)
1049        {
1050            return Decision::Allow;
1051        }
1052        if self.is_hidden(&p) {
1053            return Decision::Deny(format!(
1054                "`{p}` is ignored by git in this checkout; an in-place run does not write it"
1055            ));
1056        }
1057        if self.protected.is_review(&p) {
1058            return Decision::Deny(format!(
1059                "`{p}` is protected: it records a person's review of the spec; only the person writes it (`speccoder review`)"
1060            ));
1061        }
1062        if let Some(class) = self.protected.write_class(&p) {
1063            return Decision::Deny(format!(
1064                "`{p}` is protected: {}",
1065                self.protected.reason(class)
1066            ));
1067        }
1068        if self.authoring {
1069            return Decision::Approval((self.protected.rules().authoring_refusal)(&p));
1070        }
1071        if self.protected.is_context(&p) {
1072            return Decision::Approval(format!(
1073                "`{p}` is an agent context file: every later agent session reads it"
1074            ));
1075        }
1076        if self
1077            .frozen
1078            .iter()
1079            .any(|f| f.to_lowercase() == p.to_lowercase())
1080        {
1081            return Decision::Approval(format!(
1082                "`{p}` is a frozen test (it existed when the attempt started)"
1083            ));
1084        }
1085        if self.protected.is_manifest(&p) {
1086            return Decision::Approval(format!("`{p}` is a dependency manifest"));
1087        }
1088        Decision::Allow
1089    }
1090
1091    fn command(&self, args: &Value) -> Decision {
1092        let argv = argv_of(args);
1093        match (self.protected.rules().command)(&argv, self.authoring) {
1094            Some(Ok(())) => return Decision::Allow,
1095            Some(Err(why)) => return Decision::Deny(why),
1096            None => {}
1097        }
1098        let Some(program) = argv.first() else {
1099            return Decision::Deny("empty `argv`".to_owned());
1100        };
1101        let name = program.rsplit('/').next().unwrap_or(program);
1102        let has = |flag: &str| argv.iter().any(|a| a == flag);
1103
1104        if PRIVILEGE.contains(&name) {
1105            return Decision::Deny(format!("`{name}` raises privileges"));
1106        }
1107        if name == "git" {
1108            let sub = argv.get(1).map_or("", String::as_str);
1109            if GIT_REMOTE.contains(&sub) {
1110                return Decision::Deny(format!(
1111                    "`git {sub}` talks to a remote; speccoder never does"
1112                ));
1113            }
1114            // Attestations are git notes only speccoder writes.
1115            let notes_action = argv[2..]
1116                .iter()
1117                .find(|a| !a.starts_with('-'))
1118                .map_or("list", String::as_str);
1119            if (sub == "notes" && !matches!(notes_action, "show" | "list"))
1120                || (sub == "update-ref" && argv.iter().any(|a| a.starts_with("refs/notes")))
1121            {
1122                return Decision::Deny(
1123                    "git notes are speccoder's attestations; only speccoder writes them".to_owned(),
1124                );
1125            }
1126            // Read-only queries (skills and models ask where they are).
1127            let listing_branches = sub == "branch"
1128                && argv[2..].iter().all(|a| {
1129                    matches!(
1130                        a.as_str(),
1131                        "--show-current" | "--list" | "-l" | "-a" | "--all" | "-v" | "-vv"
1132                    )
1133                });
1134            if matches!(
1135                sub,
1136                "rev-parse" | "status" | "log" | "diff" | "show" | "ls-files" | "describe"
1137            ) || listing_branches
1138            {
1139                return Decision::Allow;
1140            }
1141            if self.in_place
1142                && !listing_branches
1143                && (GIT_IN_PLACE.contains(&sub)
1144                    || (sub == "apply" && has("--index"))
1145                    || (sub == "restore" && has("--staged")))
1146            {
1147                return Decision::Deny(format!(
1148                    "`git {sub}` would move the checkout of an in-place run; speccoder commits and switches it"
1149                ));
1150            }
1151            if sub == "clean" || (sub == "reset" && has("--hard")) || sub == "filter-branch" {
1152                return Decision::Deny(format!("`git {sub}` destroys work"));
1153            }
1154            if matches!(
1155                sub,
1156                "commit"
1157                    | "checkout"
1158                    | "switch"
1159                    | "rebase"
1160                    | "merge"
1161                    | "stash"
1162                    | "branch"
1163                    | "tag"
1164                    | "config"
1165                    | "worktree"
1166            ) {
1167                return Decision::Approval(format!("`git {sub}` changes repository state"));
1168            }
1169        }
1170        if SHELLS.contains(&name) || name == "eval" || name == "env" || name == "xargs" {
1171            return Decision::Approval(format!("`{name}` runs arbitrary commands"));
1172        }
1173        if NETWORK.contains(&name) {
1174            return Decision::Approval(format!("`{name}` uses the network"));
1175        }
1176        if name == "rm" && argv.iter().any(|a| a.starts_with('-') && a.contains('r')) {
1177            return Decision::Approval("recursive `rm`".to_owned());
1178        }
1179        // `cargo --version`, `rustc -V`, `python3 --version`: harmless probes.
1180        if argv.len() == 2 && matches!(argv[1].as_str(), "--version" | "-V" | "version") {
1181            return Decision::Allow;
1182        }
1183        // `[commands] allow` is exact; a check allows its prefix, and
1184        // itself narrowed to tests; a prefix entry (two elements or more)
1185        // what extends it.
1186        if self.exact.contains(&argv)
1187            || self.checks.iter().any(|prefix| {
1188                (argv.len() >= prefix.len() && argv[..prefix.len()] == prefix[..])
1189                    || narrows(prefix, &argv)
1190            })
1191            || self.prefix.iter().any(|p| prefix_matches(p, &argv))
1192        {
1193            return Decision::Allow;
1194        }
1195        Decision::Approval(NOT_ALLOWED.to_owned())
1196    }
1197}
1198
1199/// The argv a `run_command` call asks for (strings only).
1200#[must_use]
1201pub fn argv_of(args: &Value) -> Vec<String> {
1202    args.get("argv")
1203        .and_then(Value::as_array)
1204        .map(|a| {
1205            a.iter()
1206                .filter_map(|v| v.as_str().map(str::to_owned))
1207                .collect()
1208        })
1209        .unwrap_or_default()
1210}
1211
1212#[cfg(test)]
1213mod tests {
1214    use super::*;
1215    use serde_json::json;
1216
1217    fn policy() -> Policy {
1218        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1219        Policy::new(
1220            &[
1221                argv("cargo nextest run --workspace --profile speccoder"),
1222                argv("cargo clippy --workspace -- -D warnings"),
1223            ],
1224            &[],
1225        )
1226        .with_prefixes(vec![argv("cargo fmt"), argv("cargo check")])
1227    }
1228
1229    #[test]
1230    fn an_unasked_call_is_named_with_how_to_let_it_run() {
1231        let decide = "run with prompts";
1232        let refused = policy().classify("run_command", &json!({"argv": ["npm", "i"]}), None);
1233        let Decision::Approval(reason) = refused else {
1234            panic!("{refused:?}")
1235        };
1236        let m = unasked(
1237            &json!({"tool": "run_command", "arguments": {"argv": ["make", "it's"]}, "reason": reason}),
1238            decide,
1239        );
1240        assert_eq!(
1241            m,
1242            "`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"
1243        );
1244        // No allow entry lets a shell run: only a person can.
1245        let m = unasked(
1246            &json!({"tool": "run_command", "arguments": {"argv": ["sh", "-c", "x"]}, "reason": "`sh` runs arbitrary commands"}),
1247            decide,
1248        );
1249        assert_eq!(
1250            m,
1251            "`sh -c x` needs approval: `sh` runs arbitrary commands — run with prompts"
1252        );
1253        let m = unasked(
1254            &json!({"tool": "write_file", "arguments": {"path": "Cargo.toml"}, "reason": "`Cargo.toml` is a dependency manifest"}),
1255            decide,
1256        );
1257        assert_eq!(
1258            m,
1259            "`write_file` `Cargo.toml` needs approval: `Cargo.toml` is a dependency manifest — run with prompts"
1260        );
1261    }
1262
1263    fn path(canonical: &str, asked: &str) -> Object {
1264        Object::Path {
1265            canonical: canonical.to_owned(),
1266            asked: asked.to_owned(),
1267        }
1268    }
1269
1270    fn command(s: &str) -> Object {
1271        Object::command(
1272            s.split(' ').map(str::to_owned).collect(),
1273            ".".to_owned(),
1274            "/usr/bin/x".to_owned(),
1275            false,
1276        )
1277    }
1278
1279    fn cmd(s: &str) -> Value {
1280        json!({ "argv": s.split(' ').collect::<Vec<_>>() })
1281    }
1282
1283    #[test]
1284    fn classifies_the_table() {
1285        use Decision::{Allow, Approval, Deny};
1286        let p = policy();
1287        let kind = |d: Decision| match d {
1288            Allow => "allow",
1289            Approval(_) => "approval",
1290            Deny(_) => "deny",
1291        };
1292        let cases: &[(&str, Value, &str)] = &[
1293            ("read_file", json!({"path": "src/lib.rs"}), "allow"),
1294            ("read_file", json!({"path": ".env"}), "deny"),
1295            ("read_file", json!({"path": "../secret"}), "deny"),
1296            ("list_files", json!({}), "allow"),
1297            ("search_code", json!({"pattern": "fn main"}), "allow"),
1298            ("write_file", json!({"path": "src/new.rs"}), "allow"),
1299            ("edit_file", json!({"path": "specs/001-x/tasks.md"}), "deny"),
1300            ("write_file", json!({"path": "speccoder.toml"}), "deny"),
1301            (
1302                "write_file",
1303                json!({"path": ".github/workflows/ci.yml"}),
1304                "deny",
1305            ),
1306            (
1307                "write_file",
1308                json!({"path": ".git/hooks/pre-commit"}),
1309                "deny",
1310            ),
1311            ("edit_file", json!({"path": "Cargo.toml"}), "approval"),
1312            ("edit_file", json!({"path": "web/package.json"}), "approval"),
1313            ("run_command", cmd("cargo nextest run -p x"), "allow"),
1314            ("run_command", cmd("cargo clippy --all-targets"), "allow"),
1315            ("run_command", cmd("cargo fmt --all"), "allow"),
1316            ("run_command", cmd("cargo build"), "approval"),
1317            ("run_command", cmd("cargo --version"), "allow"),
1318            ("run_command", cmd("rustc -V"), "allow"),
1319            ("run_command", cmd("sudo --version"), "deny"),
1320            ("run_command", cmd("npm install left-pad"), "approval"),
1321            ("run_command", cmd("git push origin main"), "deny"),
1322            ("run_command", cmd("git fetch"), "deny"),
1323            ("run_command", cmd("git reset --hard HEAD"), "deny"),
1324            ("run_command", cmd("git clean -fdx"), "deny"),
1325            ("run_command", cmd("git commit -m x"), "approval"),
1326            (
1327                "run_command",
1328                cmd("git rev-parse --is-inside-work-tree"),
1329                "allow",
1330            ),
1331            ("run_command", cmd("git branch --show-current"), "allow"),
1332            ("run_command", cmd("git branch feature-x"), "approval"),
1333            ("run_command", cmd("git log --oneline -3"), "allow"),
1334            ("run_command", cmd("sh -c ls"), "approval"),
1335            ("run_command", cmd("sudo cargo test"), "deny"),
1336            ("run_command", cmd("curl https://example.com"), "approval"),
1337            ("run_command", cmd("rm -rf target"), "approval"),
1338            ("run_command", json!({"argv": []}), "deny"),
1339            ("git_status", json!({}), "allow"),
1340            ("git_diff", json!({}), "allow"),
1341            ("delete_everything", json!({}), "deny"),
1342        ];
1343        for (tool, args, want) in cases {
1344            assert_eq!(kind(p.classify(tool, args, None)), *want, "{tool} {args}");
1345        }
1346    }
1347
1348    /// The md2csv example's T012 (2026-11-04): its probes asked five times
1349    /// inside the enforced sandbox. Contained, they run; what leaves the
1350    /// sandbox, or a person rejected, still decides.
1351    #[test]
1352    fn a_contained_command_runs_and_what_leaves_the_sandbox_does_not() {
1353        let p = policy().contained("Seatbelt");
1354        for c in [
1355            "python3 -c print(1)",
1356            "sh -c make",
1357            "rm -r build",
1358            "uv run python -m md2csv",
1359        ] {
1360            assert_eq!(
1361                p.classify("run_command", &cmd(c), None),
1362                Decision::Allow,
1363                "{c}"
1364            );
1365            assert_eq!(
1366                p.rule("run_command", &cmd(c), None),
1367                "contained (Seatbelt)",
1368                "{c}"
1369            );
1370        }
1371        for c in ["curl https://x", "git commit -m x", "ssh host"] {
1372            assert!(
1373                !matches!(p.classify("run_command", &cmd(c), None), Decision::Allow),
1374                "{c}"
1375            );
1376        }
1377        let net = json!({ "argv": ["python3", "-c", "x"], "network": true });
1378        assert!(matches!(
1379            p.classify("run_command", &net, None),
1380            Decision::Approval(_)
1381        ));
1382        let allowed = policy()
1383            .with_network(vec![vec![
1384                "pip".to_owned(),
1385                "install".to_owned(),
1386                "x".to_owned(),
1387            ]])
1388            .contained("Seatbelt");
1389        assert!(!allowed.contains("run_command", &cmd("pip install x")));
1390        assert!(!p.contains("write_file", &json!({ "path": "tests/frozen.rs" })));
1391        assert!(!p.contains("mcp__x__y", &json!({})));
1392        // A person's rejection of the very object wins.
1393        let rejected = p.clone().with_decisions(
1394            vec![],
1395            vec![Rejection {
1396                object: command("python3 -c print(1)"),
1397                reason: "no".to_owned(),
1398            }],
1399        );
1400        assert!(matches!(
1401            rejected.classify(
1402                "run_command",
1403                &cmd("python3 -c print(1)"),
1404                Some(&command("python3 -c print(1)"))
1405            ),
1406            Decision::Deny(_)
1407        ));
1408        // On autopilot it runs, and only what still asks is refused.
1409        let auto = p.clone().autopilot();
1410        assert_eq!(
1411            auto.settle(auto.classify("run_command", &cmd("python3 -c x"), None)),
1412            Decision::Allow
1413        );
1414        assert!(matches!(
1415            auto.settle(auto.classify("run_command", &cmd("curl https://x"), None)),
1416            Decision::Deny(_)
1417        ));
1418        // Not contained: it asks.
1419        assert!(matches!(
1420            policy().classify("run_command", &cmd("python3 -c print(1)"), None),
1421            Decision::Approval(_)
1422        ));
1423    }
1424
1425    #[test]
1426    fn decisions_cover_classes_and_never_override_a_denial() {
1427        let approved = vec![
1428            path("Cargo.toml", "Cargo.toml"),
1429            command("cargo build"),
1430            path("speccoder.toml", "speccoder.toml"),
1431            // Approved and rejected alike: the rejection wins.
1432            command("curl https://x"),
1433        ];
1434        let rejected = vec![Rejection {
1435            object: command("curl https://x"),
1436            reason: "no network".to_owned(),
1437        }];
1438        let p = policy().with_decisions(approved, rejected);
1439        // Any content for an approved file, whatever spelling reaches it.
1440        assert_eq!(
1441            p.classify(
1442                "edit_file",
1443                &json!({"path": "./Cargo.toml", "old_string": "a"}),
1444                Some(&path("Cargo.toml", "./Cargo.toml"))
1445            ),
1446            Decision::Allow
1447        );
1448        // The object, not the request: no object, no grant.
1449        assert!(matches!(
1450            p.classify("edit_file", &json!({"path": "Cargo.toml"}), None),
1451            Decision::Approval(_)
1452        ));
1453        assert_eq!(
1454            p.classify(
1455                "run_command",
1456                &cmd("cargo build"),
1457                Some(&command("cargo build"))
1458            ),
1459            Decision::Allow
1460        );
1461        assert!(matches!(
1462            p.classify(
1463                "run_command",
1464                &cmd("cargo build --release"),
1465                Some(&command("cargo build --release"))
1466            ),
1467            Decision::Approval(_)
1468        ));
1469        // A rejection is a denial with the person's reason, approved or not.
1470        assert_eq!(
1471            p.classify(
1472                "run_command",
1473                &cmd("curl https://x"),
1474                Some(&command("curl https://x"))
1475            ),
1476            Decision::Deny("a person rejected this: no network".to_owned())
1477        );
1478        // A protected path stays denied whatever was approved.
1479        assert!(matches!(
1480            p.classify("write_file", &json!({"path": "speccoder.toml"}), None),
1481            Decision::Deny(_)
1482        ));
1483        // Attestations are speccoder's: the model reads notes, never writes them.
1484        let p = Policy::new(&[], &[]);
1485        for argv in [
1486            vec![
1487                "git",
1488                "notes",
1489                "--ref=speccoder",
1490                "add",
1491                "-f",
1492                "-m",
1493                "x",
1494                "HEAD",
1495            ],
1496            vec!["git", "notes", "remove", "HEAD"],
1497            vec!["git", "update-ref", "refs/notes/speccoder", "HEAD"],
1498        ] {
1499            assert!(
1500                matches!(
1501                    p.classify("run_command", &json!({ "argv": argv }), None),
1502                    Decision::Deny(_)
1503                ),
1504                "{argv:?}"
1505            );
1506        }
1507        for argv in [
1508            vec!["git", "notes", "--ref=speccoder", "show", "HEAD"],
1509            vec!["git", "notes", "list"],
1510        ] {
1511            assert!(
1512                !matches!(
1513                    p.classify("run_command", &json!({ "argv": argv }), None),
1514                    Decision::Deny(_)
1515                ),
1516                "{argv:?}"
1517            );
1518        }
1519        // In place, what moves the checkout is denied; queries still run.
1520        let in_place = Policy::new(&[], &[]).in_place();
1521        for argv in [
1522            vec!["git", "switch", "main"],
1523            vec!["git", "commit", "-m", "x"],
1524            vec!["git", "reset", "HEAD~1"],
1525            vec!["git", "stash"],
1526            vec!["git", "branch", "x"],
1527            vec!["git", "apply", "--index", "p.diff"],
1528            vec!["git", "restore", "--staged", "a"],
1529            vec!["git", "update-ref", "HEAD", "x"],
1530        ] {
1531            assert!(
1532                matches!(
1533                    in_place.classify("run_command", &json!({ "argv": argv }), None),
1534                    Decision::Deny(_)
1535                ),
1536                "{argv:?}"
1537            );
1538        }
1539        for argv in [
1540            vec!["git", "status"],
1541            vec!["git", "branch", "--show-current"],
1542        ] {
1543            assert_eq!(
1544                in_place.classify("run_command", &json!({ "argv": argv }), None),
1545                Decision::Allow,
1546                "{argv:?}"
1547            );
1548        }
1549        // Ignored files are out of sight; the protected intent is not.
1550        let hidden = Policy::new(&[], &[]).in_place().with_hidden([
1551            "target/".to_owned(),
1552            "notes.txt".to_owned(),
1553            "specs/".to_owned(),
1554        ]);
1555        for (tool, path) in [
1556            ("read_file", "target/debug/x"),
1557            ("read_file", "notes.txt"),
1558            ("list_files", "target"),
1559            ("write_file", "notes.txt"),
1560        ] {
1561            assert!(
1562                matches!(
1563                    hidden.classify(tool, &json!({ "path": path }), None),
1564                    Decision::Deny(_)
1565                ),
1566                "{tool} {path}"
1567            );
1568        }
1569        assert_eq!(
1570            hidden.classify("read_file", &json!({ "path": "specs/001-x/spec.md" }), None),
1571            Decision::Allow
1572        );
1573        assert_eq!(
1574            hidden.classify("read_file", &json!({ "path": "src/lib.rs" }), None),
1575            Decision::Allow
1576        );
1577        assert!(matches!(
1578            Policy::new(&[], &[]).classify(
1579                "run_command",
1580                &json!({ "argv": ["git", "switch", "main"] }),
1581                None
1582            ),
1583            Decision::Approval(_)
1584        ));
1585    }
1586
1587    #[test]
1588    fn a_check_through_a_runner_allows_only_itself() {
1589        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1590        let check = "uv run --locked --offline python -m pytest -q --junitxml=.speccoder/junit.xml";
1591        let p = Policy::new(&[argv(check), argv("poetry run pytest -q")], &[]);
1592        assert_eq!(
1593            p.classify("run_command", &cmd(check), None),
1594            Decision::Allow
1595        );
1596        for other in [
1597            "uv run python -c print(1)",
1598            "uv run --locked --offline sh",
1599            "poetry run sh",
1600        ] {
1601            assert!(
1602                matches!(
1603                    p.classify("run_command", &cmd(other), None),
1604                    Decision::Approval(_)
1605                ),
1606                "{other}"
1607            );
1608        }
1609    }
1610
1611    #[test]
1612    fn a_check_whose_prefix_is_its_bare_program_allows_only_itself() {
1613        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1614        let p = Policy::new(
1615            &[argv("python3 -m pytest -q --junitxml=.speccoder/junit.xml")],
1616            &[],
1617        );
1618        assert_eq!(
1619            p.classify(
1620                "run_command",
1621                &cmd("python3 -m pytest -q --junitxml=.speccoder/junit.xml"),
1622                None
1623            ),
1624            Decision::Allow
1625        );
1626        assert!(matches!(
1627            p.classify("run_command", &cmd("python3 -c print(1)"), None),
1628            Decision::Approval(_)
1629        ));
1630        assert!(matches!(
1631            p.classify("run_command", &cmd("python3 -m pytest tests/x.py"), None),
1632            Decision::Approval(_)
1633        ));
1634        // Narrowed to a test, its options kept: the check, on less.
1635        let uv = Policy::new(
1636            &[argv("uv run --locked python -m pytest -q --junitxml=j.xml")],
1637            &[],
1638        );
1639        assert_eq!(
1640            uv.classify(
1641                "run_command",
1642                &cmd("uv run --locked python -m pytest -q tests/x.py --junitxml=j.xml"),
1643                None
1644            ),
1645            Decision::Allow
1646        );
1647        for other in [
1648            "uv run --locked python -m pytest -q tests/x.py",
1649            "uv run --locked python -m pytest -q -p evil tests/x.py --junitxml=j.xml",
1650            "uv run --locked python -c tests/x.py -m pytest -q --junitxml=j.xml",
1651        ] {
1652            assert!(
1653                matches!(
1654                    uv.classify("run_command", &cmd(other), None),
1655                    Decision::Approval(_)
1656                ),
1657                "{other}"
1658            );
1659        }
1660        // `[commands] allow` is exact: the entry allows itself only.
1661        let q = Policy::new(&[], &[argv("python3 -m pytest")]);
1662        assert!(matches!(
1663            q.classify("run_command", &cmd("python3 -m pytest tests/x.py"), None),
1664            Decision::Approval(_)
1665        ));
1666        assert_eq!(
1667            q.classify("run_command", &cmd("python3 -m pytest"), None),
1668            Decision::Allow
1669        );
1670    }
1671
1672    #[test]
1673    fn mcp_tools_follow_their_configured_decision_and_unknown_ones_are_denied() {
1674        let p = Policy::new(&[], &[]).with_mcp([
1675            ("mcp__db__query".to_owned(), "allow".to_owned()),
1676            ("mcp__db__drop".to_owned(), "deny".to_owned()),
1677            ("mcp__db__insert".to_owned(), "approval".to_owned()),
1678        ]);
1679        assert_eq!(
1680            p.classify("mcp__db__query", &json!({}), None),
1681            Decision::Allow
1682        );
1683        assert!(matches!(
1684            p.classify("mcp__db__drop", &json!({}), None),
1685            Decision::Deny(_)
1686        ));
1687        assert!(matches!(
1688            p.classify("mcp__db__insert", &json!({}), None),
1689            Decision::Approval(_)
1690        ));
1691        assert!(matches!(
1692            p.classify("mcp__db__other", &json!({}), None),
1693            Decision::Deny(_)
1694        ));
1695        // An approval covers the tool as defined then; it never lifts a denial.
1696        let tool = |name: &str, digest: &str| Object::Tool {
1697            name: name.to_owned(),
1698            digest: digest.to_owned(),
1699        };
1700        let q = p.clone().with_decisions(
1701            vec![tool("mcp__db__insert", "d1"), tool("mcp__db__drop", "d1")],
1702            Vec::new(),
1703        );
1704        assert_eq!(
1705            q.classify(
1706                "mcp__db__insert",
1707                &json!({ "row": 1 }),
1708                Some(&tool("mcp__db__insert", "d1"))
1709            ),
1710            Decision::Allow
1711        );
1712        // Its server changed the definition: asked again.
1713        assert!(matches!(
1714            q.classify(
1715                "mcp__db__insert",
1716                &json!({ "row": 1 }),
1717                Some(&tool("mcp__db__insert", "d2"))
1718            ),
1719            Decision::Approval(_)
1720        ));
1721        assert!(matches!(
1722            q.classify(
1723                "mcp__db__drop",
1724                &json!({}),
1725                Some(&tool("mcp__db__drop", "d1"))
1726            ),
1727            Decision::Deny(_)
1728        ));
1729        assert_eq!(
1730            tool("mcp__db__insert", "9f8e7d6c5b4a3210").short(),
1731            "mcp__db__insert@9f8e7d6c5b4a"
1732        );
1733        assert!(
1734            tool("mcp__db__insert", "d2")
1735                .changed_from(&tool("mcp__db__insert", "d1"))
1736                .is_some_and(|c| c.contains("definition changed"))
1737        );
1738    }
1739
1740    #[test]
1741    fn a_planning_task_writes_design_docs_in_its_unit_never_a_judged_file() {
1742        let p = Policy::new(&[], &[]).planning("specs/001-x/");
1743        let w = |path: &str| p.classify("write_file", &json!({ "path": path }), None);
1744        assert_eq!(w("specs/001-x/contracts/cli.md"), Decision::Allow);
1745        assert_eq!(w("specs/001-x/data-model.md"), Decision::Allow);
1746        assert_eq!(w("specs/001-x/plan.md"), Decision::Allow);
1747        assert_eq!(p.write_protected("specs/001-x/contracts/cli.md"), None);
1748        // The judged files, the person's review, the constitution, another
1749        // unit: as the normal policy says (protected).
1750        for judged in [
1751            "specs/001-x/spec.md",
1752            "specs/001-x/tasks.md",
1753            "specs/001-x/review.json",
1754            "specs/001-x/checklists/ux.md",
1755            "specs/001-x/contracts/../spec.md",
1756            ".specify/memory/constitution.md",
1757            "specs/002-y/contracts/cli.md",
1758            "specs/001-x/.env",
1759            // Inside the unit, but no design document: git, judge inputs,
1760            // agent configuration and context, manifests, hidden files.
1761            "specs/001-x/.git/config",
1762            "specs/001-x/speccoder.toml",
1763            "specs/001-x/vitest.config.ts",
1764            "specs/001-x/.mcp.json",
1765            "specs/001-x/.claude/settings.json",
1766            "specs/001-x/AGENTS.md",
1767            "specs/001-x/CLAUDE.md",
1768            "specs/001-x/package.json",
1769            "specs/001-x/Cargo.toml",
1770            "specs/001-x/.hidden.md",
1771        ] {
1772            assert!(matches!(w(judged), Decision::Deny(_)), "{judged}");
1773            assert!(!p.is_planning_write(judged), "{judged}");
1774        }
1775        assert!(p.write_protected("specs/001-x/spec.md").is_some());
1776        // Outside the unit: the normal policy.
1777        assert_eq!(w("src/lib.rs"), Decision::Allow);
1778        assert!(matches!(w("Cargo.toml"), Decision::Approval(_)));
1779        // Without the profile, the spec tree is protected as ever.
1780        let normal = Policy::new(&[], &[]);
1781        assert!(matches!(
1782            normal.classify(
1783                "write_file",
1784                &json!({ "path": "specs/001-x/contracts/cli.md" }),
1785                None
1786            ),
1787            Decision::Deny(_)
1788        ));
1789        // OpenSpec: a change's proposal, tasks and spec deltas are judged.
1790        let o = Policy::new(&[], &[])
1791            .rules(crate::format::Adapter::rules(
1792                &crate::format::openspec::OPENSPEC,
1793            ))
1794            .planning("openspec/changes/add-x");
1795        assert!(o.is_planning_write("openspec/changes/add-x/design.md"));
1796        for judged in ["proposal.md", "tasks.md", "specs/auth/spec.md"] {
1797            assert!(
1798                !o.is_planning_write(&format!("openspec/changes/add-x/{judged}")),
1799                "{judged}"
1800            );
1801        }
1802    }
1803
1804    #[test]
1805    fn the_authoring_profile_writes_spec_kit_artifacts_and_runs_its_scripts_only() {
1806        let p = Policy::new(&[], &[]).authoring();
1807        let w = |path: &str| p.classify("write_file", &json!({ "path": path }), None);
1808        assert_eq!(w("specs/002-login/spec.md"), Decision::Allow);
1809        assert_eq!(w(".specify/feature.json"), Decision::Allow);
1810        assert_eq!(w(".specify/memory/constitution.md"), Decision::Allow);
1811        assert!(matches!(
1812            w(".specify/scripts/bash/common.sh"),
1813            Decision::Deny(_)
1814        ));
1815        assert!(matches!(
1816            w(".specify/templates/spec-template.md"),
1817            Decision::Deny(_)
1818        ));
1819        assert!(matches!(w("speccoder.toml"), Decision::Deny(_)));
1820        assert!(matches!(w("specs/.env"), Decision::Deny(_)));
1821        // A planning skill writes the spec, never the person's review of it.
1822        assert!(
1823            matches!(w("specs/002-login/review.json"), Decision::Deny(ref why) if why.contains("review")),
1824            "{:?}",
1825            w("specs/002-login/review.json")
1826        );
1827        assert!(p.write_protected("specs/002-login/review.json").is_some());
1828        assert!(matches!(w("src/lib.rs"), Decision::Approval(_)));
1829        let run = |argv: &[&str]| p.classify("run_command", &json!({ "argv": argv }), None);
1830        assert_eq!(
1831            run(&[".specify/scripts/bash/setup-plan.sh", "--json"]),
1832            Decision::Allow
1833        );
1834        assert_eq!(
1835            run(&[
1836                "bash",
1837                "./.specify/scripts/bash/check-prerequisites.sh",
1838                "--json"
1839            ]),
1840            Decision::Allow
1841        );
1842        assert!(matches!(
1843            run(&["bash", ".specify/scripts/bash/../../evil.sh"]),
1844            Decision::Approval(_)
1845        ));
1846        assert_eq!(
1847            run(&[
1848                "bash",
1849                ".specify/extensions/git/scripts/bash/create-new-feature-branch.sh",
1850                "--json"
1851            ]),
1852            Decision::Allow
1853        );
1854        assert!(matches!(
1855            run(&["bash", ".specify/extensions/git/other.sh"]),
1856            Decision::Approval(_)
1857        ));
1858        assert!(matches!(
1859            run(&["bash", "-c", "rm -rf /"]),
1860            Decision::Approval(_)
1861        ));
1862        // Outside the profile, the spec stays protected.
1863        assert!(matches!(
1864            Policy::new(&[], &[]).classify(
1865                "write_file",
1866                &json!({ "path": "specs/002-login/spec.md" }),
1867                None
1868            ),
1869            Decision::Deny(_)
1870        ));
1871    }
1872
1873    #[test]
1874    fn asking_for_the_network_needs_a_person_unless_allowed() {
1875        let checks = [vec!["cargo".to_owned(), "test".to_owned()]];
1876        let p = Policy::new(&checks, &[]);
1877        let net = json!({ "argv": ["cargo", "test"], "network": true });
1878        assert!(matches!(
1879            p.classify("run_command", &net, None),
1880            Decision::Approval(_)
1881        ));
1882        // Without the network the same command is allowed; a denial stays one.
1883        assert_eq!(
1884            p.classify("run_command", &json!({ "argv": ["cargo", "test"] }), None),
1885            Decision::Allow
1886        );
1887        assert!(matches!(
1888            p.classify(
1889                "run_command",
1890                &json!({ "argv": ["git", "push"], "network": true }),
1891                None
1892            ),
1893            Decision::Deny(_)
1894        ));
1895        // `[sandbox] network_allow` is exact; a prefix entry extends.
1896        let exact = p
1897            .clone()
1898            .with_network(vec![vec!["cargo".to_owned(), "test".to_owned()]]);
1899        assert_eq!(exact.classify("run_command", &net, None), Decision::Allow);
1900        let longer = json!({ "argv": ["cargo", "test", "--release"], "network": true });
1901        assert!(matches!(
1902            exact.classify("run_command", &longer, None),
1903            Decision::Approval(_)
1904        ));
1905        let bare = p.clone().with_network(vec![vec!["cargo".to_owned()]]);
1906        assert!(matches!(
1907            bare.classify("run_command", &net, None),
1908            Decision::Approval(_)
1909        ));
1910        let prefixed = p.clone().with_network_prefixes(vec![
1911            vec!["cargo".to_owned()],
1912            vec!["cargo".to_owned(), "test".to_owned()],
1913        ]);
1914        assert_eq!(
1915            prefixed.classify("run_command", &longer, None),
1916            Decision::Allow
1917        );
1918        let object = Object::command(
1919            vec!["cargo".to_owned(), "test".to_owned()],
1920            ".".to_owned(),
1921            "/usr/bin/cargo".to_owned(),
1922            true,
1923        );
1924        let approved = Policy::new(&checks, &[]).with_decisions(vec![object.clone()], vec![]);
1925        assert_eq!(
1926            approved.classify("run_command", &net, Some(&object)),
1927            Decision::Allow
1928        );
1929        // The same argv resolving to another executable is another object.
1930        let shadowed = Object::command(
1931            vec!["cargo".to_owned(), "test".to_owned()],
1932            ".".to_owned(),
1933            "/w/bin/cargo".to_owned(),
1934            true,
1935        );
1936        assert!(matches!(
1937            approved.classify("run_command", &net, Some(&shadowed)),
1938            Decision::Approval(_)
1939        ));
1940        assert!(
1941            shadowed
1942                .changed_from(&object)
1943                .is_some_and(|c| c == "executable /usr/bin/cargo → /w/bin/cargo")
1944        );
1945    }
1946
1947    #[test]
1948    fn commands_allow_is_exact_and_prefixes_need_two_elements() {
1949        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1950        let p = Policy::new(&[], &[argv("npm run build")]);
1951        assert_eq!(
1952            p.classify("run_command", &cmd("npm run build"), None),
1953            Decision::Allow
1954        );
1955        for longer in [
1956            "npm run build --script-shell=/tmp/x",
1957            "npm run build x",
1958            "npm run",
1959        ] {
1960            assert!(
1961                matches!(
1962                    p.classify("run_command", &cmd(longer), None),
1963                    Decision::Approval(_)
1964                ),
1965                "{longer}"
1966            );
1967        }
1968        assert_eq!(
1969            p.rule("run_command", &cmd("npm run build"), None),
1970            "exact command `npm run build`"
1971        );
1972        let q = Policy::new(&[], &[]).with_prefixes(vec![argv("npm"), argv("npm run")]);
1973        assert_eq!(
1974            q.classify("run_command", &cmd("npm run build"), None),
1975            Decision::Allow
1976        );
1977        assert_eq!(
1978            q.rule("run_command", &cmd("npm run build"), None),
1979            "allowed prefix `npm run`"
1980        );
1981        // The one-element entry was dropped: a bare program allows anything.
1982        assert!(matches!(
1983            q.classify("run_command", &cmd("npm install x"), None),
1984            Decision::Approval(_)
1985        ));
1986        assert!(
1987            prefix_refusal(&argv("npm")).is_some() && prefix_refusal(&argv("npm run")).is_none()
1988        );
1989        // Fixed rules decide before every list.
1990        let r = Policy::new(&[], &[argv("sh -c ls")]).with_prefixes(vec![argv("git push")]);
1991        assert!(matches!(
1992            r.classify("run_command", &cmd("sh -c ls"), None),
1993            Decision::Approval(_)
1994        ));
1995        assert!(matches!(
1996            r.classify("run_command", &cmd("git push origin"), None),
1997            Decision::Deny(_)
1998        ));
1999    }
2000
2001    #[test]
2002    fn autopilot_never_asks_never_lifts_a_denial_and_names_its_rule() {
2003        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
2004        let p = Policy::new(&[], &[argv("cargo test")]).autopilot();
2005        assert!(p.is_autopilot());
2006        // Allowed stays allowed; asking becomes refusing; denied stays denied.
2007        assert_eq!(
2008            p.settle(p.classify("run_command", &cmd("cargo test"), None)),
2009            Decision::Allow
2010        );
2011        assert_eq!(
2012            p.settle(p.classify("run_command", &cmd("cargo add x"), None)),
2013            Decision::Deny(AUTOPILOT_DENIED.to_owned())
2014        );
2015        assert_eq!(
2016            p.rule("run_command", &cmd("cargo add x"), None),
2017            "autopilot"
2018        );
2019        assert!(matches!(
2020            p.settle(p.classify("run_command", &cmd("git push origin"), None)),
2021            Decision::Deny(r) if r != AUTOPILOT_DENIED
2022        ));
2023        // Without autopilot nothing changes.
2024        let q = Policy::new(&[], &[]);
2025        assert!(matches!(
2026            q.settle(q.classify("run_command", &cmd("cargo add x"), None)),
2027            Decision::Approval(_)
2028        ));
2029        // A grant id names exactly one object.
2030        let a = command("make check");
2031        let mut b = a.clone();
2032        if let Object::Command { cwd, .. } = &mut b {
2033            *cwd = "sub".to_owned();
2034        }
2035        assert_ne!(a.grant_id(), b.grant_id());
2036        assert_eq!(a.grant_id(), a.clone().grant_id());
2037        assert_eq!(
2038            path("src/b/x.rs", "src/a/x.rs").grant_id(),
2039            path("src/b/x.rs", "src/b/x.rs").grant_id()
2040        );
2041        assert!(a.grant_id().starts_with("g-") && a.grant_id().len() == 18);
2042    }
2043
2044    proptest::proptest! {
2045        #[test]
2046        fn on_autopilot_no_call_waits_for_a_person(words in proptest::collection::vec("[a-z]{1,6}", 1..4), tool in 0usize..4) {
2047            let p = Policy::new(&[], &[]).autopilot();
2048            let argv: Vec<String> = words.clone();
2049            let (name, args) = match tool {
2050                0 => ("run_command", json!({ "argv": argv })),
2051                1 => ("write_file", json!({ "path": format!("src/{}.rs", words[0]), "content": "" })),
2052                2 => ("write_file", json!({ "path": format!(".github/{}.yml", words[0]), "content": "" })),
2053                _ => ("mcp__x__y", json!({})),
2054            };
2055            let before = Policy::new(&[], &[]).classify(name, &args, None);
2056            let after = p.settle(p.classify(name, &args, None));
2057            proptest::prop_assert!(!matches!(after, Decision::Approval(_)));
2058            if matches!(before, Decision::Deny(_)) {
2059                proptest::prop_assert!(matches!(after, Decision::Deny(_)));
2060            }
2061            if before == Decision::Allow {
2062                proptest::prop_assert_eq!(after, Decision::Allow);
2063            }
2064        }
2065    }
2066
2067    const OPENSPEC_READS: &[&[&str]] = &[&["openspec", "list"], &["openspec", "show"]];
2068
2069    fn exploring() -> Policy {
2070        Policy::new(&[vec!["cargo".to_owned(), "test".to_owned()]], &[]).exploring(Exploring {
2071            paths: |rel| rel.starts_with(".specify/assessments/"),
2072            commands: OPENSPEC_READS,
2073        })
2074    }
2075
2076    proptest::proptest! {
2077        /// Exploring never asks a person (SC-002): every write outside the
2078        /// exploration paths and every unlisted command is denied — even a
2079        /// configured check, even with the network.
2080        #[test]
2081        fn exploring_denies_what_it_does_not_allow_and_never_asks(words in proptest::collection::vec("[a-z]{1,6}", 1..4), tool in 0usize..6) {
2082            let p = exploring();
2083            let (name, args) = match tool {
2084                0 => ("run_command", json!({ "argv": words })),
2085                1 => ("run_command", json!({ "argv": ["cargo", "test"] })),
2086                2 => ("write_file", json!({ "path": format!("src/{}.rs", words[0]), "content": "" })),
2087                3 => ("edit_file", json!({ "path": format!("specs/{}/spec.md", words[0]), "old_string": "a", "new_string": "b" })),
2088                4 => ("run_command", json!({ "argv": ["openspec", "list", words[0].clone()], "network": true })),
2089                _ => ("mcp__x__y", json!({})),
2090            };
2091            let d = p.classify(name, &args, None);
2092            proptest::prop_assert!(matches!(d, Decision::Deny(_)), "{name} {args} → {d:?}");
2093        }
2094    }
2095
2096    #[test]
2097    fn exploring_reads_and_runs_the_formats_read_only_commands() {
2098        let p = exploring();
2099        assert_eq!(
2100            p.classify("read_file", &json!({ "path": "src/lib.rs" }), None),
2101            Decision::Allow
2102        );
2103        assert_eq!(
2104            p.classify(
2105                "run_command",
2106                &json!({ "argv": ["openspec", "list", "--json"] }),
2107                None
2108            ),
2109            Decision::Allow
2110        );
2111        assert_eq!(
2112            p.classify(
2113                "write_file",
2114                &json!({ "path": ".specify/assessments/csv/notes.md", "content": "" }),
2115                None
2116            ),
2117            Decision::Allow
2118        );
2119        let Decision::Deny(why) = p.classify(
2120            "write_file",
2121            &json!({ "path": "notes.md", "content": "" }),
2122            None,
2123        ) else {
2124            panic!("a write outside the exploration paths is denied");
2125        };
2126        assert_eq!(why, EXPLORING_WRITE);
2127    }
2128
2129    #[test]
2130    fn a_path_object_is_its_canonical_file_whatever_the_spelling() {
2131        assert_eq!(
2132            path("src/b/x.rs", "src/a/x.rs"),
2133            path("src/b/x.rs", "src/b/x.rs")
2134        );
2135        assert_ne!(
2136            path("src/b/x.rs", "src/a/x.rs"),
2137            path("src/a/x.rs", "src/a/x.rs")
2138        );
2139        assert!(
2140            path("src/b/x.rs", "src/a/x.rs")
2141                .changed_from(&path("src/a/x.rs", "src/a/x.rs"))
2142                .is_some_and(|c| c == "resolves to src/b/x.rs, was src/a/x.rs")
2143        );
2144        // Commands differ by every field.
2145        let a = command("make check");
2146        let mut b = a.clone();
2147        if let Object::Command { cwd, .. } = &mut b {
2148            *cwd = "sub".to_owned();
2149        }
2150        assert_ne!(a, b);
2151        assert_eq!(a.class(), Class::Command);
2152        assert!(a.short().starts_with("argv#") && a.short().len() == 17);
2153        // The record round-trips; an older grant shape does not read.
2154        let back: Object = serde_json::from_value(serde_json::to_value(&a).unwrap()).unwrap();
2155        assert_eq!(back, a);
2156        assert!(serde_json::from_value::<Object>(json!({ "kind": "write", "path": "x" })).is_err());
2157        assert!(
2158            serde_json::from_value::<Rejection>(json!({ "grant": { "kind": "path", "canonical": "x", "asked": "x" }, "reason": "r" }))
2159                .is_err()
2160        );
2161    }
2162
2163    #[test]
2164    fn the_grant_path_has_no_pattern() {
2165        // Matching a person's decision is object equality only.
2166        let source = include_str!("policy.rs");
2167        let classify = source
2168            .split("pub fn classify(")
2169            .nth(1)
2170            .and_then(|s| s.split("\n    }\n").next())
2171            .unwrap_or_default();
2172        assert!(!classify.is_empty());
2173        for pattern in ["starts_with", "glob", "prefix"] {
2174            assert!(!classify.contains(pattern), "{pattern} in the grant path");
2175        }
2176    }
2177}