1use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use super::paths::{Protected, normalise};
11
12pub const MCP_PREFIX: &str = "mcp__";
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Decision {
18 Allow,
20 Approval(String),
22 Deny(String),
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum Class {
30 Write,
32 Command,
34 Network,
36 Mcp,
38}
39
40impl Class {
41 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
60pub enum Object {
61 Path {
63 canonical: String,
65 asked: String,
67 },
68 Command {
70 argv: Vec<String>,
72 argv_sha256: String,
74 cwd: String,
76 executable: String,
78 network: bool,
80 },
81 Tool {
83 name: String,
85 digest: String,
87 },
88}
89
90pub const NOT_FOUND: &str = "not found";
92
93pub const NOT_ALLOWED: &str = "not a configured check or allowed command";
95
96#[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 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345#[serde(deny_unknown_fields)]
346pub struct Rejection {
347 pub object: Object,
349 pub reason: String,
351}
352
353#[derive(Debug, Clone, Default)]
355pub struct Policy {
356 checks: Vec<Vec<String>>,
359 exact: std::collections::BTreeSet<Vec<String>>,
361 prefix: Vec<Vec<String>>,
363 protected: Protected,
364 frozen: std::collections::BTreeSet<String>,
365 approved: Vec<Object>,
366 rejected: Vec<Rejection>,
367 mcp: std::collections::BTreeMap<String, String>,
369 authoring: bool,
371 network_exact: std::collections::BTreeSet<Vec<String>>,
373 network_prefix: Vec<Vec<String>>,
375 in_place: bool,
378 hidden: Vec<String>,
381 autopilot: bool,
384}
385
386pub const AUTOPILOT_DENIED: &str = "autopilot: not asked — listed for you";
388
389#[must_use]
392pub fn prefix_matches(prefix: &[String], argv: &[String]) -> bool {
393 prefix.len() >= 2 && argv.starts_with(prefix)
394}
395
396#[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];
421const 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
439impl Policy {
440 #[must_use]
444 pub fn new(checks: &[Vec<String>], exact: &[Vec<String>]) -> Self {
445 let mut allowed: Vec<Vec<String>> = checks
446 .iter()
447 .map(|argv| {
448 let prefix: Vec<String> = argv
449 .iter()
450 .take_while(|a| !a.starts_with('-'))
451 .cloned()
452 .collect();
453 if prefix.len() == 1 && argv.len() > 1 {
457 argv.clone()
458 } else {
459 prefix
460 }
461 })
462 .filter(|p: &Vec<String>| !p.is_empty())
463 .collect();
464 allowed.sort();
465 allowed.dedup();
466 Self {
467 checks: allowed,
468 exact: exact.iter().filter(|p| !p.is_empty()).cloned().collect(),
469 ..Self::default()
470 }
471 }
472
473 #[must_use]
476 pub fn with_prefixes(mut self, prefixes: Vec<Vec<String>>) -> Self {
477 self.prefix = prefixes.into_iter().filter(|p| p.len() >= 2).collect();
478 self
479 }
480
481 #[must_use]
483 pub fn check_prefixes(&self) -> &[Vec<String>] {
484 &self.checks
485 }
486
487 #[must_use]
490 pub fn with_hidden(mut self, hidden: impl IntoIterator<Item = String>) -> Self {
491 self.hidden = hidden.into_iter().collect();
492 self
493 }
494
495 fn is_hidden(&self, rel: &str) -> bool {
498 self.protected.write_class(rel).is_none()
499 && !self.protected.is_context(rel)
500 && self.hidden.iter().any(|h| match h.strip_suffix('/') {
501 Some(dir) => rel == dir || rel.starts_with(h.as_str()),
502 None => rel == h,
503 })
504 }
505
506 #[must_use]
509 pub fn in_place(mut self) -> Self {
510 self.in_place = true;
511 self
512 }
513
514 #[must_use]
516 pub const fn autopilot(mut self) -> Self {
517 self.autopilot = true;
518 self
519 }
520
521 #[must_use]
523 pub const fn is_autopilot(&self) -> bool {
524 self.autopilot
525 }
526
527 #[must_use]
532 pub fn settle(&self, decision: Decision) -> Decision {
533 match decision {
534 Decision::Approval(_) if self.autopilot => Decision::Deny(AUTOPILOT_DENIED.to_owned()),
535 d => d,
536 }
537 }
538
539 #[must_use]
543 pub fn authoring(mut self) -> Self {
544 self.authoring = true;
545 self
546 }
547
548 #[must_use]
551 pub fn rules(mut self, rules: &'static crate::format::Rules) -> Self {
552 self.protected = Protected::new(rules);
553 self
554 }
555
556 #[must_use]
558 pub fn with_network(mut self, exact: Vec<Vec<String>>) -> Self {
559 self.network_exact = exact.into_iter().filter(|p| !p.is_empty()).collect();
560 self
561 }
562
563 #[must_use]
566 pub fn with_network_prefixes(mut self, prefixes: Vec<Vec<String>>) -> Self {
567 self.network_prefix = prefixes.into_iter().filter(|p| p.len() >= 2).collect();
568 self
569 }
570
571 #[must_use]
574 pub fn network_allowed(&self, argv: &[String]) -> bool {
575 self.network_exact.contains(argv)
576 || self.network_prefix.iter().any(|p| prefix_matches(p, argv))
577 }
578
579 #[must_use]
581 pub fn with_mcp(mut self, tools: impl IntoIterator<Item = (String, String)>) -> Self {
582 self.mcp = tools.into_iter().collect();
583 self
584 }
585
586 #[must_use]
590 pub fn with_decisions(mut self, approved: Vec<Object>, rejected: Vec<Rejection>) -> Self {
591 self.approved = approved;
592 self.rejected = rejected;
593 self
594 }
595
596 #[must_use]
598 pub fn with_frozen(mut self, frozen: impl IntoIterator<Item = String>) -> Self {
599 self.frozen = frozen.into_iter().collect();
600 self
601 }
602
603 #[must_use]
605 pub fn protected(&self) -> &Protected {
606 &self.protected
607 }
608
609 #[must_use]
613 pub fn write_protected(&self, rel: &str) -> Option<super::paths::Class> {
614 if self.authoring
615 && (self.protected.rules().authoring_path)(rel)
616 && !self.protected.is_secret(rel)
617 && !self.protected.is_review(rel)
618 {
619 return None;
620 }
621 self.protected.write_class(rel)
622 }
623
624 #[must_use]
629 pub fn classify(&self, tool: &str, args: &Value, object: Option<&Object>) -> Decision {
630 let decision = self.base(tool, args);
631 if !matches!(decision, Decision::Approval(_)) {
632 return decision;
633 }
634 let Some(object) = object else {
635 return decision;
636 };
637 if let Some(r) = self.rejected.iter().find(|r| r.object == *object) {
638 return Decision::Deny(format!("a person rejected this: {}", r.reason));
639 }
640 if self.approved.iter().any(|a| a == object) {
641 return Decision::Allow;
642 }
643 decision
644 }
645
646 #[must_use]
648 pub fn approved(&self) -> &[Object] {
649 &self.approved
650 }
651
652 #[must_use]
656 pub fn rule(&self, tool: &str, args: &Value, object: Option<&Object>) -> String {
657 if self.autopilot && matches!(self.classify(tool, args, object), Decision::Approval(_)) {
658 return "autopilot".to_owned();
659 }
660 if let Some(object) = object
661 && matches!(self.base(tool, args), Decision::Approval(_))
662 {
663 if self.rejected.iter().any(|r| r.object == *object) {
664 return format!("rejection of {} for the task", object.describe());
665 }
666 if self.approved.iter().any(|a| a == object) {
667 return format!("grant {} for the task", object.describe());
668 }
669 }
670 if tool.starts_with(MCP_PREFIX) {
671 let entry = self.mcp.get(tool).map_or("unlisted", String::as_str);
672 return format!("[mcp] {entry}");
673 }
674 match tool {
675 "read_file" | "list_files" | "search_code" => "workspace read".to_owned(),
676 "write_file" | "edit_file" => "workspace write".to_owned(),
677 "git_status" | "git_diff" => "read-only git".to_owned(),
678 "run_command" => {
679 let argv = argv_of(args);
680 if self.exact.contains(&argv) {
681 return format!("exact command `{}`", argv.join(" "));
682 }
683 if let Some(p) = self
684 .checks
685 .iter()
686 .find(|p| argv.len() >= p.len() && argv[..p.len()] == p[..])
687 {
688 return format!("check prefix `{}`", p.join(" "));
689 }
690 match self.prefix.iter().find(|p| prefix_matches(p, &argv)) {
691 Some(p) => format!("allowed prefix `{}`", p.join(" ")),
692 None => "command policy".to_owned(),
693 }
694 }
695 _ => "tool policy".to_owned(),
696 }
697 }
698
699 #[must_use]
702 pub fn base(&self, tool: &str, args: &Value) -> Decision {
703 self.base_of(tool, args)
704 }
705
706 fn base_of(&self, tool: &str, args: &Value) -> Decision {
707 if tool.starts_with(MCP_PREFIX) {
708 return match self.mcp.get(tool).map(String::as_str) {
709 Some("allow") => Decision::Allow,
710 Some("deny") => Decision::Deny(format!("`{tool}` is denied in [mcp]")),
711 Some(_) => Decision::Approval(format!(
712 "`{tool}` is an MCP tool; speccoder cannot know what it does"
713 )),
714 None => Decision::Deny(format!("unknown tool `{tool}`")),
715 };
716 }
717 match tool {
718 "read_file" | "list_files" | "search_code" => self.read(args),
719 "write_file" | "edit_file" => self.write(args),
720 "run_command" => self.with_network_rule(args, self.command(args)),
721 "git_status" | "git_diff" => Decision::Allow,
722 other => Decision::Deny(format!("unknown tool `{other}`")),
723 }
724 }
725
726 fn with_network_rule(&self, args: &Value, decision: Decision) -> Decision {
729 if args.get("network").and_then(Value::as_bool) != Some(true)
730 || matches!(decision, Decision::Deny(_))
731 {
732 return decision;
733 }
734 let argv = argv_of(args);
735 if self.network_allowed(&argv) {
736 return decision;
737 }
738 Decision::Approval(format!(
739 "`{}` asks for the network; commands run without it",
740 argv.join(" ")
741 ))
742 }
743
744 fn read(&self, args: &Value) -> Decision {
745 let Some(path) = args
747 .get("path")
748 .and_then(Value::as_str)
749 .filter(|p| !matches!(p.trim(), "" | "." | "./"))
750 else {
751 return Decision::Allow; };
753 match normalise(path) {
754 Err(e) => Decision::Deny(e.0),
755 Ok(p) if self.protected.is_secret(&p) => {
756 Decision::Deny(format!("`{p}` looks like a credential"))
757 }
758 Ok(p) if self.is_hidden(&p) => Decision::Deny(format!(
759 "`{p}` is ignored by git in this checkout; an in-place run works only with what a worktree would hold"
760 )),
761 Ok(_) => Decision::Allow,
762 }
763 }
764
765 fn write(&self, args: &Value) -> Decision {
766 let Some(path) = args.get("path").and_then(Value::as_str) else {
767 return Decision::Deny("no `path`".to_owned());
768 };
769 let p = match normalise(path) {
770 Ok(p) => p,
771 Err(e) => return Decision::Deny(e.0),
772 };
773 if self.authoring
774 && (self.protected.rules().authoring_path)(&p)
775 && !self.protected.is_secret(&p)
776 && !self.protected.is_review(&p)
777 {
778 return Decision::Allow;
779 }
780 if self.is_hidden(&p) {
781 return Decision::Deny(format!(
782 "`{p}` is ignored by git in this checkout; an in-place run does not write it"
783 ));
784 }
785 if self.protected.is_review(&p) {
786 return Decision::Deny(format!(
787 "`{p}` is protected: it records a person's review of the spec; only the person writes it (`speccoder review`)"
788 ));
789 }
790 if let Some(class) = self.protected.write_class(&p) {
791 return Decision::Deny(format!(
792 "`{p}` is protected: {}",
793 self.protected.reason(class)
794 ));
795 }
796 if self.authoring {
797 return Decision::Approval((self.protected.rules().authoring_refusal)(&p));
798 }
799 if self.protected.is_context(&p) {
800 return Decision::Approval(format!(
801 "`{p}` is an agent context file: every later agent session reads it"
802 ));
803 }
804 if self
805 .frozen
806 .iter()
807 .any(|f| f.to_lowercase() == p.to_lowercase())
808 {
809 return Decision::Approval(format!(
810 "`{p}` is a frozen test (it existed when the attempt started)"
811 ));
812 }
813 if self.protected.is_manifest(&p) {
814 return Decision::Approval(format!("`{p}` is a dependency manifest"));
815 }
816 Decision::Allow
817 }
818
819 fn command(&self, args: &Value) -> Decision {
820 let argv = argv_of(args);
821 match (self.protected.rules().command)(&argv, self.authoring) {
822 Some(Ok(())) => return Decision::Allow,
823 Some(Err(why)) => return Decision::Deny(why),
824 None => {}
825 }
826 let Some(program) = argv.first() else {
827 return Decision::Deny("empty `argv`".to_owned());
828 };
829 let name = program.rsplit('/').next().unwrap_or(program);
830 let has = |flag: &str| argv.iter().any(|a| a == flag);
831
832 if PRIVILEGE.contains(&name) {
833 return Decision::Deny(format!("`{name}` raises privileges"));
834 }
835 if name == "git" {
836 let sub = argv.get(1).map_or("", String::as_str);
837 if GIT_REMOTE.contains(&sub) {
838 return Decision::Deny(format!(
839 "`git {sub}` talks to a remote; speccoder never does"
840 ));
841 }
842 let notes_action = argv[2..]
844 .iter()
845 .find(|a| !a.starts_with('-'))
846 .map_or("list", String::as_str);
847 if (sub == "notes" && !matches!(notes_action, "show" | "list"))
848 || (sub == "update-ref" && argv.iter().any(|a| a.starts_with("refs/notes")))
849 {
850 return Decision::Deny(
851 "git notes are speccoder's attestations; only speccoder writes them".to_owned(),
852 );
853 }
854 let listing_branches = sub == "branch"
856 && argv[2..].iter().all(|a| {
857 matches!(
858 a.as_str(),
859 "--show-current" | "--list" | "-l" | "-a" | "--all" | "-v" | "-vv"
860 )
861 });
862 if matches!(
863 sub,
864 "rev-parse" | "status" | "log" | "diff" | "show" | "ls-files" | "describe"
865 ) || listing_branches
866 {
867 return Decision::Allow;
868 }
869 if self.in_place
870 && !listing_branches
871 && (GIT_IN_PLACE.contains(&sub)
872 || (sub == "apply" && has("--index"))
873 || (sub == "restore" && has("--staged")))
874 {
875 return Decision::Deny(format!(
876 "`git {sub}` would move the checkout of an in-place run; speccoder commits and switches it"
877 ));
878 }
879 if sub == "clean" || (sub == "reset" && has("--hard")) || sub == "filter-branch" {
880 return Decision::Deny(format!("`git {sub}` destroys work"));
881 }
882 if matches!(
883 sub,
884 "commit"
885 | "checkout"
886 | "switch"
887 | "rebase"
888 | "merge"
889 | "stash"
890 | "branch"
891 | "tag"
892 | "config"
893 | "worktree"
894 ) {
895 return Decision::Approval(format!("`git {sub}` changes repository state"));
896 }
897 }
898 if SHELLS.contains(&name) || name == "eval" || name == "env" || name == "xargs" {
899 return Decision::Approval(format!("`{name}` runs arbitrary commands"));
900 }
901 if NETWORK.contains(&name) {
902 return Decision::Approval(format!("`{name}` uses the network"));
903 }
904 if name == "rm" && argv.iter().any(|a| a.starts_with('-') && a.contains('r')) {
905 return Decision::Approval("recursive `rm`".to_owned());
906 }
907 if argv.len() == 2 && matches!(argv[1].as_str(), "--version" | "-V" | "version") {
909 return Decision::Allow;
910 }
911 if self.exact.contains(&argv)
914 || self
915 .checks
916 .iter()
917 .any(|prefix| argv.len() >= prefix.len() && argv[..prefix.len()] == prefix[..])
918 || self.prefix.iter().any(|p| prefix_matches(p, &argv))
919 {
920 return Decision::Allow;
921 }
922 Decision::Approval(NOT_ALLOWED.to_owned())
923 }
924}
925
926#[must_use]
928pub fn argv_of(args: &Value) -> Vec<String> {
929 args.get("argv")
930 .and_then(Value::as_array)
931 .map(|a| {
932 a.iter()
933 .filter_map(|v| v.as_str().map(str::to_owned))
934 .collect()
935 })
936 .unwrap_or_default()
937}
938
939#[cfg(test)]
940mod tests {
941 use super::*;
942 use serde_json::json;
943
944 fn policy() -> Policy {
945 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
946 Policy::new(
947 &[
948 argv("cargo nextest run --workspace --profile speccoder"),
949 argv("cargo clippy --workspace -- -D warnings"),
950 ],
951 &[],
952 )
953 .with_prefixes(vec![argv("cargo fmt"), argv("cargo check")])
954 }
955
956 #[test]
957 fn an_unasked_call_is_named_with_how_to_let_it_run() {
958 let decide = "run with prompts";
959 let refused = policy().classify("run_command", &json!({"argv": ["npm", "i"]}), None);
960 let Decision::Approval(reason) = refused else {
961 panic!("{refused:?}")
962 };
963 let m = unasked(
964 &json!({"tool": "run_command", "arguments": {"argv": ["make", "it's"]}, "reason": reason}),
965 decide,
966 );
967 assert_eq!(
968 m,
969 "`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"
970 );
971 let m = unasked(
973 &json!({"tool": "run_command", "arguments": {"argv": ["sh", "-c", "x"]}, "reason": "`sh` runs arbitrary commands"}),
974 decide,
975 );
976 assert_eq!(
977 m,
978 "`sh -c x` needs approval: `sh` runs arbitrary commands — run with prompts"
979 );
980 let m = unasked(
981 &json!({"tool": "write_file", "arguments": {"path": "Cargo.toml"}, "reason": "`Cargo.toml` is a dependency manifest"}),
982 decide,
983 );
984 assert_eq!(
985 m,
986 "`write_file` `Cargo.toml` needs approval: `Cargo.toml` is a dependency manifest — run with prompts"
987 );
988 }
989
990 fn path(canonical: &str, asked: &str) -> Object {
991 Object::Path {
992 canonical: canonical.to_owned(),
993 asked: asked.to_owned(),
994 }
995 }
996
997 fn command(s: &str) -> Object {
998 Object::command(
999 s.split(' ').map(str::to_owned).collect(),
1000 ".".to_owned(),
1001 "/usr/bin/x".to_owned(),
1002 false,
1003 )
1004 }
1005
1006 fn cmd(s: &str) -> Value {
1007 json!({ "argv": s.split(' ').collect::<Vec<_>>() })
1008 }
1009
1010 #[test]
1011 fn classifies_the_table() {
1012 use Decision::{Allow, Approval, Deny};
1013 let p = policy();
1014 let kind = |d: Decision| match d {
1015 Allow => "allow",
1016 Approval(_) => "approval",
1017 Deny(_) => "deny",
1018 };
1019 let cases: &[(&str, Value, &str)] = &[
1020 ("read_file", json!({"path": "src/lib.rs"}), "allow"),
1021 ("read_file", json!({"path": ".env"}), "deny"),
1022 ("read_file", json!({"path": "../secret"}), "deny"),
1023 ("list_files", json!({}), "allow"),
1024 ("search_code", json!({"pattern": "fn main"}), "allow"),
1025 ("write_file", json!({"path": "src/new.rs"}), "allow"),
1026 ("edit_file", json!({"path": "specs/001-x/tasks.md"}), "deny"),
1027 ("write_file", json!({"path": "speccoder.toml"}), "deny"),
1028 (
1029 "write_file",
1030 json!({"path": ".github/workflows/ci.yml"}),
1031 "deny",
1032 ),
1033 (
1034 "write_file",
1035 json!({"path": ".git/hooks/pre-commit"}),
1036 "deny",
1037 ),
1038 ("edit_file", json!({"path": "Cargo.toml"}), "approval"),
1039 ("edit_file", json!({"path": "web/package.json"}), "approval"),
1040 ("run_command", cmd("cargo nextest run -p x"), "allow"),
1041 ("run_command", cmd("cargo clippy --all-targets"), "allow"),
1042 ("run_command", cmd("cargo fmt --all"), "allow"),
1043 ("run_command", cmd("cargo build"), "approval"),
1044 ("run_command", cmd("cargo --version"), "allow"),
1045 ("run_command", cmd("rustc -V"), "allow"),
1046 ("run_command", cmd("sudo --version"), "deny"),
1047 ("run_command", cmd("npm install left-pad"), "approval"),
1048 ("run_command", cmd("git push origin main"), "deny"),
1049 ("run_command", cmd("git fetch"), "deny"),
1050 ("run_command", cmd("git reset --hard HEAD"), "deny"),
1051 ("run_command", cmd("git clean -fdx"), "deny"),
1052 ("run_command", cmd("git commit -m x"), "approval"),
1053 (
1054 "run_command",
1055 cmd("git rev-parse --is-inside-work-tree"),
1056 "allow",
1057 ),
1058 ("run_command", cmd("git branch --show-current"), "allow"),
1059 ("run_command", cmd("git branch feature-x"), "approval"),
1060 ("run_command", cmd("git log --oneline -3"), "allow"),
1061 ("run_command", cmd("sh -c ls"), "approval"),
1062 ("run_command", cmd("sudo cargo test"), "deny"),
1063 ("run_command", cmd("curl https://example.com"), "approval"),
1064 ("run_command", cmd("rm -rf target"), "approval"),
1065 ("run_command", json!({"argv": []}), "deny"),
1066 ("git_status", json!({}), "allow"),
1067 ("git_diff", json!({}), "allow"),
1068 ("delete_everything", json!({}), "deny"),
1069 ];
1070 for (tool, args, want) in cases {
1071 assert_eq!(kind(p.classify(tool, args, None)), *want, "{tool} {args}");
1072 }
1073 }
1074
1075 #[test]
1076 fn decisions_cover_classes_and_never_override_a_denial() {
1077 let approved = vec![
1078 path("Cargo.toml", "Cargo.toml"),
1079 command("cargo build"),
1080 path("speccoder.toml", "speccoder.toml"),
1081 command("curl https://x"),
1083 ];
1084 let rejected = vec![Rejection {
1085 object: command("curl https://x"),
1086 reason: "no network".to_owned(),
1087 }];
1088 let p = policy().with_decisions(approved, rejected);
1089 assert_eq!(
1091 p.classify(
1092 "edit_file",
1093 &json!({"path": "./Cargo.toml", "old_string": "a"}),
1094 Some(&path("Cargo.toml", "./Cargo.toml"))
1095 ),
1096 Decision::Allow
1097 );
1098 assert!(matches!(
1100 p.classify("edit_file", &json!({"path": "Cargo.toml"}), None),
1101 Decision::Approval(_)
1102 ));
1103 assert_eq!(
1104 p.classify(
1105 "run_command",
1106 &cmd("cargo build"),
1107 Some(&command("cargo build"))
1108 ),
1109 Decision::Allow
1110 );
1111 assert!(matches!(
1112 p.classify(
1113 "run_command",
1114 &cmd("cargo build --release"),
1115 Some(&command("cargo build --release"))
1116 ),
1117 Decision::Approval(_)
1118 ));
1119 assert_eq!(
1121 p.classify(
1122 "run_command",
1123 &cmd("curl https://x"),
1124 Some(&command("curl https://x"))
1125 ),
1126 Decision::Deny("a person rejected this: no network".to_owned())
1127 );
1128 assert!(matches!(
1130 p.classify("write_file", &json!({"path": "speccoder.toml"}), None),
1131 Decision::Deny(_)
1132 ));
1133 let p = Policy::new(&[], &[]);
1135 for argv in [
1136 vec![
1137 "git",
1138 "notes",
1139 "--ref=speccoder",
1140 "add",
1141 "-f",
1142 "-m",
1143 "x",
1144 "HEAD",
1145 ],
1146 vec!["git", "notes", "remove", "HEAD"],
1147 vec!["git", "update-ref", "refs/notes/speccoder", "HEAD"],
1148 ] {
1149 assert!(
1150 matches!(
1151 p.classify("run_command", &json!({ "argv": argv }), None),
1152 Decision::Deny(_)
1153 ),
1154 "{argv:?}"
1155 );
1156 }
1157 for argv in [
1158 vec!["git", "notes", "--ref=speccoder", "show", "HEAD"],
1159 vec!["git", "notes", "list"],
1160 ] {
1161 assert!(
1162 !matches!(
1163 p.classify("run_command", &json!({ "argv": argv }), None),
1164 Decision::Deny(_)
1165 ),
1166 "{argv:?}"
1167 );
1168 }
1169 let in_place = Policy::new(&[], &[]).in_place();
1171 for argv in [
1172 vec!["git", "switch", "main"],
1173 vec!["git", "commit", "-m", "x"],
1174 vec!["git", "reset", "HEAD~1"],
1175 vec!["git", "stash"],
1176 vec!["git", "branch", "x"],
1177 vec!["git", "apply", "--index", "p.diff"],
1178 vec!["git", "restore", "--staged", "a"],
1179 vec!["git", "update-ref", "HEAD", "x"],
1180 ] {
1181 assert!(
1182 matches!(
1183 in_place.classify("run_command", &json!({ "argv": argv }), None),
1184 Decision::Deny(_)
1185 ),
1186 "{argv:?}"
1187 );
1188 }
1189 for argv in [
1190 vec!["git", "status"],
1191 vec!["git", "branch", "--show-current"],
1192 ] {
1193 assert_eq!(
1194 in_place.classify("run_command", &json!({ "argv": argv }), None),
1195 Decision::Allow,
1196 "{argv:?}"
1197 );
1198 }
1199 let hidden = Policy::new(&[], &[]).in_place().with_hidden([
1201 "target/".to_owned(),
1202 "notes.txt".to_owned(),
1203 "specs/".to_owned(),
1204 ]);
1205 for (tool, path) in [
1206 ("read_file", "target/debug/x"),
1207 ("read_file", "notes.txt"),
1208 ("list_files", "target"),
1209 ("write_file", "notes.txt"),
1210 ] {
1211 assert!(
1212 matches!(
1213 hidden.classify(tool, &json!({ "path": path }), None),
1214 Decision::Deny(_)
1215 ),
1216 "{tool} {path}"
1217 );
1218 }
1219 assert_eq!(
1220 hidden.classify("read_file", &json!({ "path": "specs/001-x/spec.md" }), None),
1221 Decision::Allow
1222 );
1223 assert_eq!(
1224 hidden.classify("read_file", &json!({ "path": "src/lib.rs" }), None),
1225 Decision::Allow
1226 );
1227 assert!(matches!(
1228 Policy::new(&[], &[]).classify(
1229 "run_command",
1230 &json!({ "argv": ["git", "switch", "main"] }),
1231 None
1232 ),
1233 Decision::Approval(_)
1234 ));
1235 }
1236
1237 #[test]
1238 fn a_check_whose_prefix_is_its_bare_program_allows_only_itself() {
1239 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1240 let p = Policy::new(
1241 &[argv("python3 -m pytest -q --junitxml=.speccoder/junit.xml")],
1242 &[],
1243 );
1244 assert_eq!(
1245 p.classify(
1246 "run_command",
1247 &cmd("python3 -m pytest -q --junitxml=.speccoder/junit.xml"),
1248 None
1249 ),
1250 Decision::Allow
1251 );
1252 assert!(matches!(
1253 p.classify("run_command", &cmd("python3 -c print(1)"), None),
1254 Decision::Approval(_)
1255 ));
1256 assert!(matches!(
1257 p.classify("run_command", &cmd("python3 -m pytest tests/x.py"), None),
1258 Decision::Approval(_)
1259 ));
1260 let q = Policy::new(&[], &[argv("python3 -m pytest")]);
1262 assert!(matches!(
1263 q.classify("run_command", &cmd("python3 -m pytest tests/x.py"), None),
1264 Decision::Approval(_)
1265 ));
1266 assert_eq!(
1267 q.classify("run_command", &cmd("python3 -m pytest"), None),
1268 Decision::Allow
1269 );
1270 }
1271
1272 #[test]
1273 fn mcp_tools_follow_their_configured_decision_and_unknown_ones_are_denied() {
1274 let p = Policy::new(&[], &[]).with_mcp([
1275 ("mcp__db__query".to_owned(), "allow".to_owned()),
1276 ("mcp__db__drop".to_owned(), "deny".to_owned()),
1277 ("mcp__db__insert".to_owned(), "approval".to_owned()),
1278 ]);
1279 assert_eq!(
1280 p.classify("mcp__db__query", &json!({}), None),
1281 Decision::Allow
1282 );
1283 assert!(matches!(
1284 p.classify("mcp__db__drop", &json!({}), None),
1285 Decision::Deny(_)
1286 ));
1287 assert!(matches!(
1288 p.classify("mcp__db__insert", &json!({}), None),
1289 Decision::Approval(_)
1290 ));
1291 assert!(matches!(
1292 p.classify("mcp__db__other", &json!({}), None),
1293 Decision::Deny(_)
1294 ));
1295 let tool = |name: &str, digest: &str| Object::Tool {
1297 name: name.to_owned(),
1298 digest: digest.to_owned(),
1299 };
1300 let q = p.clone().with_decisions(
1301 vec![tool("mcp__db__insert", "d1"), tool("mcp__db__drop", "d1")],
1302 Vec::new(),
1303 );
1304 assert_eq!(
1305 q.classify(
1306 "mcp__db__insert",
1307 &json!({ "row": 1 }),
1308 Some(&tool("mcp__db__insert", "d1"))
1309 ),
1310 Decision::Allow
1311 );
1312 assert!(matches!(
1314 q.classify(
1315 "mcp__db__insert",
1316 &json!({ "row": 1 }),
1317 Some(&tool("mcp__db__insert", "d2"))
1318 ),
1319 Decision::Approval(_)
1320 ));
1321 assert!(matches!(
1322 q.classify(
1323 "mcp__db__drop",
1324 &json!({}),
1325 Some(&tool("mcp__db__drop", "d1"))
1326 ),
1327 Decision::Deny(_)
1328 ));
1329 assert_eq!(
1330 tool("mcp__db__insert", "9f8e7d6c5b4a3210").short(),
1331 "mcp__db__insert@9f8e7d6c5b4a"
1332 );
1333 assert!(
1334 tool("mcp__db__insert", "d2")
1335 .changed_from(&tool("mcp__db__insert", "d1"))
1336 .is_some_and(|c| c.contains("definition changed"))
1337 );
1338 }
1339
1340 #[test]
1341 fn the_authoring_profile_writes_spec_kit_artifacts_and_runs_its_scripts_only() {
1342 let p = Policy::new(&[], &[]).authoring();
1343 let w = |path: &str| p.classify("write_file", &json!({ "path": path }), None);
1344 assert_eq!(w("specs/002-login/spec.md"), Decision::Allow);
1345 assert_eq!(w(".specify/feature.json"), Decision::Allow);
1346 assert_eq!(w(".specify/memory/constitution.md"), Decision::Allow);
1347 assert!(matches!(
1348 w(".specify/scripts/bash/common.sh"),
1349 Decision::Deny(_)
1350 ));
1351 assert!(matches!(
1352 w(".specify/templates/spec-template.md"),
1353 Decision::Deny(_)
1354 ));
1355 assert!(matches!(w("speccoder.toml"), Decision::Deny(_)));
1356 assert!(matches!(w("specs/.env"), Decision::Deny(_)));
1357 assert!(
1359 matches!(w("specs/002-login/review.json"), Decision::Deny(ref why) if why.contains("review")),
1360 "{:?}",
1361 w("specs/002-login/review.json")
1362 );
1363 assert!(p.write_protected("specs/002-login/review.json").is_some());
1364 assert!(matches!(w("src/lib.rs"), Decision::Approval(_)));
1365 let run = |argv: &[&str]| p.classify("run_command", &json!({ "argv": argv }), None);
1366 assert_eq!(
1367 run(&[".specify/scripts/bash/setup-plan.sh", "--json"]),
1368 Decision::Allow
1369 );
1370 assert_eq!(
1371 run(&[
1372 "bash",
1373 "./.specify/scripts/bash/check-prerequisites.sh",
1374 "--json"
1375 ]),
1376 Decision::Allow
1377 );
1378 assert!(matches!(
1379 run(&["bash", ".specify/scripts/bash/../../evil.sh"]),
1380 Decision::Approval(_)
1381 ));
1382 assert_eq!(
1383 run(&[
1384 "bash",
1385 ".specify/extensions/git/scripts/bash/create-new-feature-branch.sh",
1386 "--json"
1387 ]),
1388 Decision::Allow
1389 );
1390 assert!(matches!(
1391 run(&["bash", ".specify/extensions/git/other.sh"]),
1392 Decision::Approval(_)
1393 ));
1394 assert!(matches!(
1395 run(&["bash", "-c", "rm -rf /"]),
1396 Decision::Approval(_)
1397 ));
1398 assert!(matches!(
1400 Policy::new(&[], &[]).classify(
1401 "write_file",
1402 &json!({ "path": "specs/002-login/spec.md" }),
1403 None
1404 ),
1405 Decision::Deny(_)
1406 ));
1407 }
1408
1409 #[test]
1410 fn asking_for_the_network_needs_a_person_unless_allowed() {
1411 let checks = [vec!["cargo".to_owned(), "test".to_owned()]];
1412 let p = Policy::new(&checks, &[]);
1413 let net = json!({ "argv": ["cargo", "test"], "network": true });
1414 assert!(matches!(
1415 p.classify("run_command", &net, None),
1416 Decision::Approval(_)
1417 ));
1418 assert_eq!(
1420 p.classify("run_command", &json!({ "argv": ["cargo", "test"] }), None),
1421 Decision::Allow
1422 );
1423 assert!(matches!(
1424 p.classify(
1425 "run_command",
1426 &json!({ "argv": ["git", "push"], "network": true }),
1427 None
1428 ),
1429 Decision::Deny(_)
1430 ));
1431 let exact = p
1433 .clone()
1434 .with_network(vec![vec!["cargo".to_owned(), "test".to_owned()]]);
1435 assert_eq!(exact.classify("run_command", &net, None), Decision::Allow);
1436 let longer = json!({ "argv": ["cargo", "test", "--release"], "network": true });
1437 assert!(matches!(
1438 exact.classify("run_command", &longer, None),
1439 Decision::Approval(_)
1440 ));
1441 let bare = p.clone().with_network(vec![vec!["cargo".to_owned()]]);
1442 assert!(matches!(
1443 bare.classify("run_command", &net, None),
1444 Decision::Approval(_)
1445 ));
1446 let prefixed = p.clone().with_network_prefixes(vec![
1447 vec!["cargo".to_owned()],
1448 vec!["cargo".to_owned(), "test".to_owned()],
1449 ]);
1450 assert_eq!(
1451 prefixed.classify("run_command", &longer, None),
1452 Decision::Allow
1453 );
1454 let object = Object::command(
1455 vec!["cargo".to_owned(), "test".to_owned()],
1456 ".".to_owned(),
1457 "/usr/bin/cargo".to_owned(),
1458 true,
1459 );
1460 let approved = Policy::new(&checks, &[]).with_decisions(vec![object.clone()], vec![]);
1461 assert_eq!(
1462 approved.classify("run_command", &net, Some(&object)),
1463 Decision::Allow
1464 );
1465 let shadowed = Object::command(
1467 vec!["cargo".to_owned(), "test".to_owned()],
1468 ".".to_owned(),
1469 "/w/bin/cargo".to_owned(),
1470 true,
1471 );
1472 assert!(matches!(
1473 approved.classify("run_command", &net, Some(&shadowed)),
1474 Decision::Approval(_)
1475 ));
1476 assert!(
1477 shadowed
1478 .changed_from(&object)
1479 .is_some_and(|c| c == "executable /usr/bin/cargo → /w/bin/cargo")
1480 );
1481 }
1482
1483 #[test]
1484 fn commands_allow_is_exact_and_prefixes_need_two_elements() {
1485 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1486 let p = Policy::new(&[], &[argv("npm run build")]);
1487 assert_eq!(
1488 p.classify("run_command", &cmd("npm run build"), None),
1489 Decision::Allow
1490 );
1491 for longer in [
1492 "npm run build --script-shell=/tmp/x",
1493 "npm run build x",
1494 "npm run",
1495 ] {
1496 assert!(
1497 matches!(
1498 p.classify("run_command", &cmd(longer), None),
1499 Decision::Approval(_)
1500 ),
1501 "{longer}"
1502 );
1503 }
1504 assert_eq!(
1505 p.rule("run_command", &cmd("npm run build"), None),
1506 "exact command `npm run build`"
1507 );
1508 let q = Policy::new(&[], &[]).with_prefixes(vec![argv("npm"), argv("npm run")]);
1509 assert_eq!(
1510 q.classify("run_command", &cmd("npm run build"), None),
1511 Decision::Allow
1512 );
1513 assert_eq!(
1514 q.rule("run_command", &cmd("npm run build"), None),
1515 "allowed prefix `npm run`"
1516 );
1517 assert!(matches!(
1519 q.classify("run_command", &cmd("npm install x"), None),
1520 Decision::Approval(_)
1521 ));
1522 assert!(
1523 prefix_refusal(&argv("npm")).is_some() && prefix_refusal(&argv("npm run")).is_none()
1524 );
1525 let r = Policy::new(&[], &[argv("sh -c ls")]).with_prefixes(vec![argv("git push")]);
1527 assert!(matches!(
1528 r.classify("run_command", &cmd("sh -c ls"), None),
1529 Decision::Approval(_)
1530 ));
1531 assert!(matches!(
1532 r.classify("run_command", &cmd("git push origin"), None),
1533 Decision::Deny(_)
1534 ));
1535 }
1536
1537 #[test]
1538 fn autopilot_never_asks_never_lifts_a_denial_and_names_its_rule() {
1539 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1540 let p = Policy::new(&[], &[argv("cargo test")]).autopilot();
1541 assert!(p.is_autopilot());
1542 assert_eq!(
1544 p.settle(p.classify("run_command", &cmd("cargo test"), None)),
1545 Decision::Allow
1546 );
1547 assert_eq!(
1548 p.settle(p.classify("run_command", &cmd("cargo add x"), None)),
1549 Decision::Deny(AUTOPILOT_DENIED.to_owned())
1550 );
1551 assert_eq!(
1552 p.rule("run_command", &cmd("cargo add x"), None),
1553 "autopilot"
1554 );
1555 assert!(matches!(
1556 p.settle(p.classify("run_command", &cmd("git push origin"), None)),
1557 Decision::Deny(r) if r != AUTOPILOT_DENIED
1558 ));
1559 let q = Policy::new(&[], &[]);
1561 assert!(matches!(
1562 q.settle(q.classify("run_command", &cmd("cargo add x"), None)),
1563 Decision::Approval(_)
1564 ));
1565 let a = command("make check");
1567 let mut b = a.clone();
1568 if let Object::Command { cwd, .. } = &mut b {
1569 *cwd = "sub".to_owned();
1570 }
1571 assert_ne!(a.grant_id(), b.grant_id());
1572 assert_eq!(a.grant_id(), a.clone().grant_id());
1573 assert_eq!(
1574 path("src/b/x.rs", "src/a/x.rs").grant_id(),
1575 path("src/b/x.rs", "src/b/x.rs").grant_id()
1576 );
1577 assert!(a.grant_id().starts_with("g-") && a.grant_id().len() == 18);
1578 }
1579
1580 proptest::proptest! {
1581 #[test]
1582 fn on_autopilot_no_call_waits_for_a_person(words in proptest::collection::vec("[a-z]{1,6}", 1..4), tool in 0usize..4) {
1583 let p = Policy::new(&[], &[]).autopilot();
1584 let argv: Vec<String> = words.clone();
1585 let (name, args) = match tool {
1586 0 => ("run_command", json!({ "argv": argv })),
1587 1 => ("write_file", json!({ "path": format!("src/{}.rs", words[0]), "content": "" })),
1588 2 => ("write_file", json!({ "path": format!(".github/{}.yml", words[0]), "content": "" })),
1589 _ => ("mcp__x__y", json!({})),
1590 };
1591 let before = Policy::new(&[], &[]).classify(name, &args, None);
1592 let after = p.settle(p.classify(name, &args, None));
1593 proptest::prop_assert!(!matches!(after, Decision::Approval(_)));
1594 if matches!(before, Decision::Deny(_)) {
1595 proptest::prop_assert!(matches!(after, Decision::Deny(_)));
1596 }
1597 if before == Decision::Allow {
1598 proptest::prop_assert_eq!(after, Decision::Allow);
1599 }
1600 }
1601 }
1602
1603 #[test]
1604 fn a_path_object_is_its_canonical_file_whatever_the_spelling() {
1605 assert_eq!(
1606 path("src/b/x.rs", "src/a/x.rs"),
1607 path("src/b/x.rs", "src/b/x.rs")
1608 );
1609 assert_ne!(
1610 path("src/b/x.rs", "src/a/x.rs"),
1611 path("src/a/x.rs", "src/a/x.rs")
1612 );
1613 assert!(
1614 path("src/b/x.rs", "src/a/x.rs")
1615 .changed_from(&path("src/a/x.rs", "src/a/x.rs"))
1616 .is_some_and(|c| c == "resolves to src/b/x.rs, was src/a/x.rs")
1617 );
1618 let a = command("make check");
1620 let mut b = a.clone();
1621 if let Object::Command { cwd, .. } = &mut b {
1622 *cwd = "sub".to_owned();
1623 }
1624 assert_ne!(a, b);
1625 assert_eq!(a.class(), Class::Command);
1626 assert!(a.short().starts_with("argv#") && a.short().len() == 17);
1627 let back: Object = serde_json::from_value(serde_json::to_value(&a).unwrap()).unwrap();
1629 assert_eq!(back, a);
1630 assert!(serde_json::from_value::<Object>(json!({ "kind": "write", "path": "x" })).is_err());
1631 assert!(
1632 serde_json::from_value::<Rejection>(json!({ "grant": { "kind": "path", "canonical": "x", "asked": "x" }, "reason": "r" }))
1633 .is_err()
1634 );
1635 }
1636
1637 #[test]
1638 fn the_grant_path_has_no_pattern() {
1639 let source = include_str!("policy.rs");
1641 let classify = source
1642 .split("pub fn classify(")
1643 .nth(1)
1644 .and_then(|s| s.split("\n }\n").next())
1645 .unwrap_or_default();
1646 assert!(!classify.is_empty());
1647 for pattern in ["starts_with", "glob", "prefix"] {
1648 assert!(!classify.contains(pattern), "{pattern} in the grant path");
1649 }
1650 }
1651}