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