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