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 Agent,
40}
41
42impl Class {
43 #[must_use]
45 pub const fn as_str(self) -> &'static str {
46 match self {
47 Self::Write => "write",
48 Self::Command => "command",
49 Self::Network => "network",
50 Self::Mcp => "mcp",
51 Self::Agent => "agent",
52 }
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
63pub enum Object {
64 Path {
66 canonical: String,
68 asked: String,
70 },
71 Command {
73 argv: Vec<String>,
75 argv_sha256: String,
77 cwd: String,
79 executable: String,
81 network: bool,
83 },
84 Tool {
86 name: String,
88 digest: String,
90 },
91 Agent {
95 agent: String,
97 call: String,
99 argvs: Vec<Vec<String>>,
101 paths: Vec<String>,
103 input: String,
106 },
107}
108
109pub const NOT_FOUND: &str = "not found";
111
112pub const NOT_ALLOWED: &str = "not a configured check or allowed command";
114
115#[must_use]
120pub fn unasked(pending: &Value, decide: &str) -> String {
121 let tool = pending["tool"].as_str().unwrap_or_default();
122 let reason = pending["reason"].as_str().unwrap_or_default();
123 let arguments = &pending["arguments"];
124 if tool == "run_command" {
125 let argv = argv_of(arguments);
126 let shown = crate::approvals::present::shell_quote(&argv);
127 if reason == NOT_ALLOWED {
128 let entry = argv
129 .iter()
130 .map(|a| serde_json::to_string(a).unwrap_or_default())
131 .collect::<Vec<_>>()
132 .join(", ");
133 return format!(
134 "`{shown}` needs approval: it is {NOT_ALLOWED} — allow exactly it in speccoder.toml (`[commands] allow = [[{entry}]]`), or {decide}"
135 );
136 }
137 return format!("`{shown}` needs approval: {reason} — {decide}");
138 }
139 if let Ok(o @ Object::Agent { .. }) =
141 serde_json::from_value::<Object>(pending["object"].clone())
142 {
143 return format!("{} needs approval: {reason} — {decide}", o.describe());
144 }
145 let object = arguments["path"]
146 .as_str()
147 .map(|p| format!(" `{p}`"))
148 .unwrap_or_default();
149 format!("`{tool}`{object} needs approval: {reason} — {decide}")
150}
151
152impl PartialEq for Object {
153 fn eq(&self, other: &Self) -> bool {
156 match (self, other) {
157 (Self::Path { canonical: a, .. }, Self::Path { canonical: b, .. }) => a == b,
158 (
159 Self::Command {
160 argv: a1,
161 argv_sha256: a2,
162 cwd: a3,
163 executable: a4,
164 network: a5,
165 },
166 Self::Command {
167 argv: b1,
168 argv_sha256: b2,
169 cwd: b3,
170 executable: b4,
171 network: b5,
172 },
173 ) => a1 == b1 && a2 == b2 && a3 == b3 && a4 == b4 && a5 == b5,
174 (Self::Tool { name: a, digest: x }, Self::Tool { name: b, digest: y }) => {
175 a == b && x == y
176 }
177 (
178 Self::Agent {
179 agent: a1,
180 call: a2,
181 argvs: a3,
182 paths: a4,
183 input: a5,
184 },
185 Self::Agent {
186 agent: b1,
187 call: b2,
188 argvs: b3,
189 paths: b4,
190 input: b5,
191 },
192 ) => a1 == b1 && a2 == b2 && a3 == b3 && a4 == b4 && a5 == b5,
193 _ => false,
194 }
195 }
196}
197
198impl Eq for Object {}
199
200#[must_use]
202pub fn argv_sha256(argv: &[String]) -> String {
203 use sha2::{Digest as _, Sha256};
204 let json = serde_json::to_string(argv).unwrap_or_default();
205 Sha256::digest(json.as_bytes())
206 .iter()
207 .map(|b| format!("{b:02x}"))
208 .collect()
209}
210
211fn first12(hex: &str) -> &str {
212 hex.get(..12).unwrap_or(hex)
213}
214
215impl Object {
216 #[must_use]
218 pub fn command(argv: Vec<String>, cwd: String, executable: String, network: bool) -> Self {
219 Self::Command {
220 argv_sha256: argv_sha256(&argv),
221 argv,
222 cwd,
223 executable,
224 network,
225 }
226 }
227
228 #[must_use]
230 pub const fn class(&self) -> Class {
231 match self {
232 Self::Path { .. } => Class::Write,
233 Self::Command { network: true, .. } => Class::Network,
234 Self::Command { .. } => Class::Command,
235 Self::Tool { .. } => Class::Mcp,
236 Self::Agent { .. } => Class::Agent,
237 }
238 }
239
240 #[must_use]
242 pub fn describe(&self) -> String {
243 match self {
244 Self::Path { canonical, .. } => format!("write `{canonical}`"),
245 Self::Command {
246 argv, cwd, network, ..
247 } => {
248 let place = if cwd == "." {
249 String::new()
250 } else {
251 format!(" in `{cwd}`")
252 };
253 let net = if *network { " with the network" } else { "" };
254 format!("run `{}`{place}{net}", argv.join(" "))
255 }
256 Self::Tool { name, digest } => format!("use `{name}`@{}", first12(digest)),
257 Self::Agent {
258 agent,
259 call: kind,
260 argvs,
261 paths,
262 ..
263 } => {
264 if kind == "reads" {
265 return format!("{agent}'s plain reads of one file each, for the task");
266 }
267 let what: Vec<String> = argvs
268 .iter()
269 .map(|a| format!("`{}`", a.join(" ")))
270 .chain(paths.iter().map(|p| format!("`{p}`")))
271 .collect();
272 format!("{agent} itself: {kind} {}", what.join(", "))
273 }
274 }
275 }
276
277 #[must_use]
281 pub fn grant_id(&self) -> String {
282 use sha2::{Digest as _, Sha256};
283 let identity = match self {
284 Self::Path { canonical, .. } => serde_json::json!({ "path": canonical }),
285 Self::Command {
286 argv,
287 argv_sha256,
288 cwd,
289 executable,
290 network,
291 } => serde_json::json!({
292 "argv": argv, "argv_sha256": argv_sha256, "cwd": cwd,
293 "executable": executable, "network": network,
294 }),
295 Self::Tool { name, digest } => serde_json::json!({ "tool": name, "digest": digest }),
296 Self::Agent {
297 agent,
298 call,
299 argvs,
300 paths,
301 input,
302 } => {
303 serde_json::json!({ "agent": agent, "call": call, "argvs": argvs, "paths": paths, "input": input })
304 }
305 };
306 let hex: String = Sha256::digest(identity.to_string().as_bytes())
307 .iter()
308 .take(8)
309 .map(|b| format!("{b:02x}"))
310 .collect();
311 format!("g-{hex}")
312 }
313
314 #[must_use]
317 pub fn short(&self) -> String {
318 match self {
319 Self::Path { canonical, .. } => canonical.clone(),
320 Self::Command { argv_sha256, .. } => format!("argv#{}", first12(argv_sha256)),
321 Self::Tool { name, digest } => format!("{name}@{}", first12(digest)),
322 Self::Agent { agent, call, .. } => {
323 format!("{agent}:{call}#{}", first12(&self.grant_id()))
324 }
325 }
326 }
327
328 #[must_use]
332 pub fn same_name(&self, other: &Self) -> bool {
333 match (self, other) {
334 (Self::Path { asked: a, .. }, Self::Path { asked: b, .. }) => a == b,
335 (
336 Self::Command {
337 argv: a,
338 network: x,
339 ..
340 },
341 Self::Command {
342 argv: b,
343 network: y,
344 ..
345 },
346 ) => a == b && x == y,
347 (Self::Tool { name: a, .. }, Self::Tool { name: b, .. }) => a == b,
348 _ => false,
349 }
350 }
351
352 #[must_use]
355 pub fn changed_from(&self, earlier: &Self) -> Option<String> {
356 if self == earlier || !self.same_name(earlier) {
357 return None;
358 }
359 let mut what = Vec::new();
360 match (earlier, self) {
361 (Self::Path { canonical: a, .. }, Self::Path { canonical: b, .. }) => {
362 what.push(format!("resolves to {b}, was {a}"));
363 }
364 (
365 Self::Command {
366 cwd: c1,
367 executable: e1,
368 ..
369 },
370 Self::Command {
371 cwd: c2,
372 executable: e2,
373 ..
374 },
375 ) => {
376 if e1 != e2 {
377 what.push(format!("executable {e1} → {e2}"));
378 }
379 if c1 != c2 {
380 what.push(format!("directory {c1} → {c2}"));
381 }
382 }
383 (Self::Tool { digest: a, .. }, Self::Tool { digest: b, .. }) => {
384 what.push(format!(
385 "definition @{} → @{} (the tool's definition changed since it was approved)",
386 first12(a),
387 first12(b)
388 ));
389 }
390 _ => {}
391 }
392 (!what.is_empty()).then(|| what.join("; "))
393 }
394
395 #[must_use]
397 pub fn tool_name(&self) -> Option<&str> {
398 match self {
399 Self::Tool { name, .. } => Some(name),
400 _ => None,
401 }
402 }
403
404 #[must_use]
406 pub fn path(&self) -> Option<&str> {
407 match self {
408 Self::Path { canonical, .. } => Some(canonical),
409 _ => None,
410 }
411 }
412}
413
414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
416#[serde(deny_unknown_fields)]
417pub struct Rejection {
418 pub object: Object,
420 pub reason: String,
422}
423
424#[derive(Debug, Clone, Default)]
426pub struct Policy {
427 checks: Vec<Vec<String>>,
430 exact: std::collections::BTreeSet<Vec<String>>,
432 prefix: Vec<Vec<String>>,
434 protected: Protected,
435 frozen: std::collections::BTreeSet<String>,
436 approved: Vec<Object>,
437 rejected: Vec<Rejection>,
438 mcp: std::collections::BTreeMap<String, String>,
440 authoring: bool,
442 planning: Option<String>,
445 network_exact: std::collections::BTreeSet<Vec<String>>,
447 network_prefix: Vec<Vec<String>>,
449 in_place: bool,
452 hidden: Vec<String>,
455 autopilot: bool,
458}
459
460pub const AUTOPILOT_DENIED: &str = "autopilot: not asked — listed for you";
462
463#[must_use]
466pub fn prefix_matches(prefix: &[String], argv: &[String]) -> bool {
467 prefix.len() >= 2 && argv.starts_with(prefix)
468}
469
470#[must_use]
473pub fn prefix_refusal(prefix: &[String]) -> Option<&'static str> {
474 (prefix.len() < 2).then_some(
475 "a bare program allows any argument; add the exact command to [commands] allow instead",
476 )
477}
478
479const SHELLS: &[&str] = &[
480 "sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh", "pwsh",
481];
482const PRIVILEGE: &[&str] = &["sudo", "su", "doas", "pkexec"];
483const NETWORK: &[&str] = &[
484 "curl", "wget", "nc", "ncat", "ssh", "scp", "sftp", "rsync", "ftp", "telnet",
485];
486const GIT_REMOTE: &[&str] = &[
487 "push",
488 "fetch",
489 "pull",
490 "clone",
491 "remote",
492 "ls-remote",
493 "submodule",
494];
495const GIT_IN_PLACE: &[&str] = &[
497 "checkout",
498 "switch",
499 "reset",
500 "commit",
501 "merge",
502 "rebase",
503 "cherry-pick",
504 "revert",
505 "stash",
506 "branch",
507 "tag",
508 "update-ref",
509 "worktree",
510 "am",
511];
512
513const RUNNERS: &[&[&str]] = &[
515 &["uv", "run"],
516 &["uv", "tool", "run"],
517 &["uvx"],
518 &["poetry", "run"],
519 &["pipx", "run"],
520 &["pdm", "run"],
521 &["hatch", "run"],
522];
523
524impl Policy {
525 #[must_use]
529 pub fn new(checks: &[Vec<String>], exact: &[Vec<String>]) -> Self {
530 let mut allowed: Vec<Vec<String>> = checks
531 .iter()
532 .map(|argv| {
533 let prefix: Vec<String> = argv
534 .iter()
535 .take_while(|a| !a.starts_with('-'))
536 .cloned()
537 .collect();
538 if (prefix.len() == 1
544 || RUNNERS.iter().any(|r| {
545 prefix
546 .iter()
547 .map(String::as_str)
548 .take(r.len())
549 .eq(r.iter().copied())
550 }))
551 && argv.len() > prefix.len()
552 {
553 argv.clone()
554 } else {
555 prefix
556 }
557 })
558 .filter(|p: &Vec<String>| !p.is_empty())
559 .collect();
560 allowed.sort();
561 allowed.dedup();
562 Self {
563 checks: allowed,
564 exact: exact.iter().filter(|p| !p.is_empty()).cloned().collect(),
565 ..Self::default()
566 }
567 }
568
569 #[must_use]
572 pub fn with_prefixes(mut self, prefixes: Vec<Vec<String>>) -> Self {
573 self.prefix = prefixes.into_iter().filter(|p| p.len() >= 2).collect();
574 self
575 }
576
577 #[must_use]
579 pub fn check_prefixes(&self) -> &[Vec<String>] {
580 &self.checks
581 }
582
583 #[must_use]
586 pub fn with_hidden(mut self, hidden: impl IntoIterator<Item = String>) -> Self {
587 self.hidden = hidden.into_iter().collect();
588 self
589 }
590
591 fn is_hidden(&self, rel: &str) -> bool {
594 self.protected.write_class(rel).is_none()
595 && !self.protected.is_context(rel)
596 && self.hidden.iter().any(|h| match h.strip_suffix('/') {
597 Some(dir) => rel == dir || rel.starts_with(h.as_str()),
598 None => rel == h,
599 })
600 }
601
602 #[must_use]
605 pub fn in_place(mut self) -> Self {
606 self.in_place = true;
607 self
608 }
609
610 #[must_use]
612 pub const fn autopilot(mut self) -> Self {
613 self.autopilot = true;
614 self
615 }
616
617 #[must_use]
619 pub const fn is_autopilot(&self) -> bool {
620 self.autopilot
621 }
622
623 #[must_use]
628 pub fn settle(&self, decision: Decision) -> Decision {
629 match decision {
630 Decision::Approval(_) if self.autopilot => Decision::Deny(AUTOPILOT_DENIED.to_owned()),
631 d => d,
632 }
633 }
634
635 #[must_use]
639 pub fn authoring(mut self) -> Self {
640 self.authoring = true;
641 self
642 }
643
644 #[must_use]
649 pub fn planning(mut self, dir: &str) -> Self {
650 let dir = dir.trim_matches('/');
651 self.planning = (!dir.is_empty()).then(|| dir.to_owned());
652 self
653 }
654
655 #[must_use]
659 pub fn is_planning_write(&self, rel: &str) -> bool {
660 let Some(dir) = &self.planning else {
661 return false;
662 };
663 rel.strip_prefix(dir.as_str())
664 .and_then(|r| r.strip_prefix('/'))
665 .is_some_and(|r| {
666 !r.is_empty()
667 && !r.split('/').any(|s| s == ".." || s == ".")
668 && !(self.protected.rules().judged)(r.trim_end_matches('/'))
669 && self.protected.is_design_doc(rel)
670 && !self.protected.is_review(rel)
671 })
672 }
673
674 #[must_use]
677 pub fn rules(mut self, rules: &'static crate::format::Rules) -> Self {
678 self.protected = Protected::new(rules);
679 self
680 }
681
682 #[must_use]
684 pub fn with_network(mut self, exact: Vec<Vec<String>>) -> Self {
685 self.network_exact = exact.into_iter().filter(|p| !p.is_empty()).collect();
686 self
687 }
688
689 #[must_use]
692 pub fn with_network_prefixes(mut self, prefixes: Vec<Vec<String>>) -> Self {
693 self.network_prefix = prefixes.into_iter().filter(|p| p.len() >= 2).collect();
694 self
695 }
696
697 #[must_use]
700 pub fn network_allowed(&self, argv: &[String]) -> bool {
701 self.network_exact.contains(argv)
702 || self.network_prefix.iter().any(|p| prefix_matches(p, argv))
703 }
704
705 #[must_use]
707 pub fn with_mcp(mut self, tools: impl IntoIterator<Item = (String, String)>) -> Self {
708 self.mcp = tools.into_iter().collect();
709 self
710 }
711
712 #[must_use]
716 pub fn with_decisions(mut self, approved: Vec<Object>, rejected: Vec<Rejection>) -> Self {
717 self.approved = approved;
718 self.rejected = rejected;
719 self
720 }
721
722 #[must_use]
724 pub fn with_frozen(mut self, frozen: impl IntoIterator<Item = String>) -> Self {
725 self.frozen = frozen.into_iter().collect();
726 self
727 }
728
729 #[must_use]
731 pub fn protected(&self) -> &Protected {
732 &self.protected
733 }
734
735 #[must_use]
739 pub fn write_protected(&self, rel: &str) -> Option<super::paths::Class> {
740 if self.is_planning_write(rel) {
741 return None;
742 }
743 if self.authoring
744 && (self.protected.rules().authoring_path)(rel)
745 && !self.protected.is_secret(rel)
746 && !self.protected.is_review(rel)
747 {
748 return None;
749 }
750 self.protected.write_class(rel)
751 }
752
753 #[must_use]
758 pub fn classify(&self, tool: &str, args: &Value, object: Option<&Object>) -> Decision {
759 let decision = self.base(tool, args);
760 if !matches!(decision, Decision::Approval(_)) {
761 return decision;
762 }
763 let Some(object) = object else {
764 return decision;
765 };
766 if let Some(r) = self.rejected.iter().find(|r| r.object == *object) {
767 return Decision::Deny(format!("a person rejected this: {}", r.reason));
768 }
769 if self.approved.iter().any(|a| a == object) {
770 return Decision::Allow;
771 }
772 decision
773 }
774
775 #[must_use]
777 pub fn rejection(&self, object: &Object) -> Option<&Rejection> {
778 self.rejected.iter().find(|r| r.object == *object)
779 }
780
781 #[must_use]
783 pub fn approved(&self) -> &[Object] {
784 &self.approved
785 }
786
787 #[must_use]
791 pub fn rule(&self, tool: &str, args: &Value, object: Option<&Object>) -> String {
792 if self.autopilot && matches!(self.classify(tool, args, object), Decision::Approval(_)) {
793 return "autopilot".to_owned();
794 }
795 if let Some(object) = object
796 && matches!(self.base(tool, args), Decision::Approval(_))
797 {
798 if self.rejected.iter().any(|r| r.object == *object) {
799 return format!("rejection of {} for the task", object.describe());
800 }
801 if self.approved.iter().any(|a| a == object) {
802 return format!("grant {} for the task", object.describe());
803 }
804 }
805 if tool.starts_with(MCP_PREFIX) {
806 let entry = self.mcp.get(tool).map_or("unlisted", String::as_str);
807 return format!("[mcp] {entry}");
808 }
809 match tool {
810 "read_file" | "list_files" | "search_code" => "workspace read".to_owned(),
811 "write_file" | "edit_file" => "workspace write".to_owned(),
812 "git_status" | "git_diff" => "read-only git".to_owned(),
813 "run_command" => {
814 let argv = argv_of(args);
815 if self.exact.contains(&argv) {
816 return format!("exact command `{}`", argv.join(" "));
817 }
818 if let Some(p) = self
819 .checks
820 .iter()
821 .find(|p| argv.len() >= p.len() && argv[..p.len()] == p[..])
822 {
823 return format!("check prefix `{}`", p.join(" "));
824 }
825 match self.prefix.iter().find(|p| prefix_matches(p, &argv)) {
826 Some(p) => format!("allowed prefix `{}`", p.join(" ")),
827 None => "command policy".to_owned(),
828 }
829 }
830 _ => "tool policy".to_owned(),
831 }
832 }
833
834 #[must_use]
837 pub fn base(&self, tool: &str, args: &Value) -> Decision {
838 self.base_of(tool, args)
839 }
840
841 fn base_of(&self, tool: &str, args: &Value) -> Decision {
842 if tool.starts_with(MCP_PREFIX) {
843 return match self.mcp.get(tool).map(String::as_str) {
844 Some("allow") => Decision::Allow,
845 Some("deny") => Decision::Deny(format!("`{tool}` is denied in [mcp]")),
846 Some(_) => Decision::Approval(format!(
847 "`{tool}` is an MCP tool; speccoder cannot know what it does"
848 )),
849 None => Decision::Deny(format!("unknown tool `{tool}`")),
850 };
851 }
852 match tool {
853 "read_file" | "list_files" | "search_code" => self.read(args),
854 "write_file" | "edit_file" => self.write(args),
855 "run_command" => self.with_network_rule(args, self.command(args)),
856 "git_status" | "git_diff" => Decision::Allow,
857 other => Decision::Deny(format!("unknown tool `{other}`")),
858 }
859 }
860
861 fn with_network_rule(&self, args: &Value, decision: Decision) -> Decision {
864 if args.get("network").and_then(Value::as_bool) != Some(true)
865 || matches!(decision, Decision::Deny(_))
866 {
867 return decision;
868 }
869 let argv = argv_of(args);
870 if self.network_allowed(&argv) {
871 return decision;
872 }
873 Decision::Approval(format!(
874 "`{}` asks for the network; commands run without it",
875 argv.join(" ")
876 ))
877 }
878
879 fn read(&self, args: &Value) -> Decision {
880 let Some(path) = super::below_root(args.get("path").and_then(Value::as_str)) else {
881 return Decision::Allow; };
883 match normalise(path) {
884 Err(e) => Decision::Deny(e.0),
885 Ok(p) if self.protected.is_secret(&p) => {
886 Decision::Deny(format!("`{p}` looks like a credential"))
887 }
888 Ok(p) if self.is_hidden(&p) => Decision::Deny(format!(
889 "`{p}` is ignored by git in this checkout; an in-place run works only with what a worktree would hold"
890 )),
891 Ok(_) => Decision::Allow,
892 }
893 }
894
895 fn write(&self, args: &Value) -> Decision {
896 let Some(path) = args.get("path").and_then(Value::as_str) else {
897 return Decision::Deny("no `path`".to_owned());
898 };
899 let p = match normalise(path) {
900 Ok(p) => p,
901 Err(e) => return Decision::Deny(e.0),
902 };
903 if (self.authoring
904 && (self.protected.rules().authoring_path)(&p)
905 && !self.protected.is_secret(&p)
906 && !self.protected.is_review(&p))
907 || self.is_planning_write(&p)
908 {
909 return Decision::Allow;
910 }
911 if self.is_hidden(&p) {
912 return Decision::Deny(format!(
913 "`{p}` is ignored by git in this checkout; an in-place run does not write it"
914 ));
915 }
916 if self.protected.is_review(&p) {
917 return Decision::Deny(format!(
918 "`{p}` is protected: it records a person's review of the spec; only the person writes it (`speccoder review`)"
919 ));
920 }
921 if let Some(class) = self.protected.write_class(&p) {
922 return Decision::Deny(format!(
923 "`{p}` is protected: {}",
924 self.protected.reason(class)
925 ));
926 }
927 if self.authoring {
928 return Decision::Approval((self.protected.rules().authoring_refusal)(&p));
929 }
930 if self.protected.is_context(&p) {
931 return Decision::Approval(format!(
932 "`{p}` is an agent context file: every later agent session reads it"
933 ));
934 }
935 if self
936 .frozen
937 .iter()
938 .any(|f| f.to_lowercase() == p.to_lowercase())
939 {
940 return Decision::Approval(format!(
941 "`{p}` is a frozen test (it existed when the attempt started)"
942 ));
943 }
944 if self.protected.is_manifest(&p) {
945 return Decision::Approval(format!("`{p}` is a dependency manifest"));
946 }
947 Decision::Allow
948 }
949
950 fn command(&self, args: &Value) -> Decision {
951 let argv = argv_of(args);
952 match (self.protected.rules().command)(&argv, self.authoring) {
953 Some(Ok(())) => return Decision::Allow,
954 Some(Err(why)) => return Decision::Deny(why),
955 None => {}
956 }
957 let Some(program) = argv.first() else {
958 return Decision::Deny("empty `argv`".to_owned());
959 };
960 let name = program.rsplit('/').next().unwrap_or(program);
961 let has = |flag: &str| argv.iter().any(|a| a == flag);
962
963 if PRIVILEGE.contains(&name) {
964 return Decision::Deny(format!("`{name}` raises privileges"));
965 }
966 if name == "git" {
967 let sub = argv.get(1).map_or("", String::as_str);
968 if GIT_REMOTE.contains(&sub) {
969 return Decision::Deny(format!(
970 "`git {sub}` talks to a remote; speccoder never does"
971 ));
972 }
973 let notes_action = argv[2..]
975 .iter()
976 .find(|a| !a.starts_with('-'))
977 .map_or("list", String::as_str);
978 if (sub == "notes" && !matches!(notes_action, "show" | "list"))
979 || (sub == "update-ref" && argv.iter().any(|a| a.starts_with("refs/notes")))
980 {
981 return Decision::Deny(
982 "git notes are speccoder's attestations; only speccoder writes them".to_owned(),
983 );
984 }
985 let listing_branches = sub == "branch"
987 && argv[2..].iter().all(|a| {
988 matches!(
989 a.as_str(),
990 "--show-current" | "--list" | "-l" | "-a" | "--all" | "-v" | "-vv"
991 )
992 });
993 if matches!(
994 sub,
995 "rev-parse" | "status" | "log" | "diff" | "show" | "ls-files" | "describe"
996 ) || listing_branches
997 {
998 return Decision::Allow;
999 }
1000 if self.in_place
1001 && !listing_branches
1002 && (GIT_IN_PLACE.contains(&sub)
1003 || (sub == "apply" && has("--index"))
1004 || (sub == "restore" && has("--staged")))
1005 {
1006 return Decision::Deny(format!(
1007 "`git {sub}` would move the checkout of an in-place run; speccoder commits and switches it"
1008 ));
1009 }
1010 if sub == "clean" || (sub == "reset" && has("--hard")) || sub == "filter-branch" {
1011 return Decision::Deny(format!("`git {sub}` destroys work"));
1012 }
1013 if matches!(
1014 sub,
1015 "commit"
1016 | "checkout"
1017 | "switch"
1018 | "rebase"
1019 | "merge"
1020 | "stash"
1021 | "branch"
1022 | "tag"
1023 | "config"
1024 | "worktree"
1025 ) {
1026 return Decision::Approval(format!("`git {sub}` changes repository state"));
1027 }
1028 }
1029 if SHELLS.contains(&name) || name == "eval" || name == "env" || name == "xargs" {
1030 return Decision::Approval(format!("`{name}` runs arbitrary commands"));
1031 }
1032 if NETWORK.contains(&name) {
1033 return Decision::Approval(format!("`{name}` uses the network"));
1034 }
1035 if name == "rm" && argv.iter().any(|a| a.starts_with('-') && a.contains('r')) {
1036 return Decision::Approval("recursive `rm`".to_owned());
1037 }
1038 if argv.len() == 2 && matches!(argv[1].as_str(), "--version" | "-V" | "version") {
1040 return Decision::Allow;
1041 }
1042 if self.exact.contains(&argv)
1045 || self
1046 .checks
1047 .iter()
1048 .any(|prefix| argv.len() >= prefix.len() && argv[..prefix.len()] == prefix[..])
1049 || self.prefix.iter().any(|p| prefix_matches(p, &argv))
1050 {
1051 return Decision::Allow;
1052 }
1053 Decision::Approval(NOT_ALLOWED.to_owned())
1054 }
1055}
1056
1057#[must_use]
1059pub fn argv_of(args: &Value) -> Vec<String> {
1060 args.get("argv")
1061 .and_then(Value::as_array)
1062 .map(|a| {
1063 a.iter()
1064 .filter_map(|v| v.as_str().map(str::to_owned))
1065 .collect()
1066 })
1067 .unwrap_or_default()
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072 use super::*;
1073 use serde_json::json;
1074
1075 fn policy() -> Policy {
1076 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1077 Policy::new(
1078 &[
1079 argv("cargo nextest run --workspace --profile speccoder"),
1080 argv("cargo clippy --workspace -- -D warnings"),
1081 ],
1082 &[],
1083 )
1084 .with_prefixes(vec![argv("cargo fmt"), argv("cargo check")])
1085 }
1086
1087 #[test]
1088 fn an_unasked_call_is_named_with_how_to_let_it_run() {
1089 let decide = "run with prompts";
1090 let refused = policy().classify("run_command", &json!({"argv": ["npm", "i"]}), None);
1091 let Decision::Approval(reason) = refused else {
1092 panic!("{refused:?}")
1093 };
1094 let m = unasked(
1095 &json!({"tool": "run_command", "arguments": {"argv": ["make", "it's"]}, "reason": reason}),
1096 decide,
1097 );
1098 assert_eq!(
1099 m,
1100 "`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"
1101 );
1102 let m = unasked(
1104 &json!({"tool": "run_command", "arguments": {"argv": ["sh", "-c", "x"]}, "reason": "`sh` runs arbitrary commands"}),
1105 decide,
1106 );
1107 assert_eq!(
1108 m,
1109 "`sh -c x` needs approval: `sh` runs arbitrary commands — run with prompts"
1110 );
1111 let m = unasked(
1112 &json!({"tool": "write_file", "arguments": {"path": "Cargo.toml"}, "reason": "`Cargo.toml` is a dependency manifest"}),
1113 decide,
1114 );
1115 assert_eq!(
1116 m,
1117 "`write_file` `Cargo.toml` needs approval: `Cargo.toml` is a dependency manifest — run with prompts"
1118 );
1119 }
1120
1121 fn path(canonical: &str, asked: &str) -> Object {
1122 Object::Path {
1123 canonical: canonical.to_owned(),
1124 asked: asked.to_owned(),
1125 }
1126 }
1127
1128 fn command(s: &str) -> Object {
1129 Object::command(
1130 s.split(' ').map(str::to_owned).collect(),
1131 ".".to_owned(),
1132 "/usr/bin/x".to_owned(),
1133 false,
1134 )
1135 }
1136
1137 fn cmd(s: &str) -> Value {
1138 json!({ "argv": s.split(' ').collect::<Vec<_>>() })
1139 }
1140
1141 #[test]
1142 fn classifies_the_table() {
1143 use Decision::{Allow, Approval, Deny};
1144 let p = policy();
1145 let kind = |d: Decision| match d {
1146 Allow => "allow",
1147 Approval(_) => "approval",
1148 Deny(_) => "deny",
1149 };
1150 let cases: &[(&str, Value, &str)] = &[
1151 ("read_file", json!({"path": "src/lib.rs"}), "allow"),
1152 ("read_file", json!({"path": ".env"}), "deny"),
1153 ("read_file", json!({"path": "../secret"}), "deny"),
1154 ("list_files", json!({}), "allow"),
1155 ("search_code", json!({"pattern": "fn main"}), "allow"),
1156 ("write_file", json!({"path": "src/new.rs"}), "allow"),
1157 ("edit_file", json!({"path": "specs/001-x/tasks.md"}), "deny"),
1158 ("write_file", json!({"path": "speccoder.toml"}), "deny"),
1159 (
1160 "write_file",
1161 json!({"path": ".github/workflows/ci.yml"}),
1162 "deny",
1163 ),
1164 (
1165 "write_file",
1166 json!({"path": ".git/hooks/pre-commit"}),
1167 "deny",
1168 ),
1169 ("edit_file", json!({"path": "Cargo.toml"}), "approval"),
1170 ("edit_file", json!({"path": "web/package.json"}), "approval"),
1171 ("run_command", cmd("cargo nextest run -p x"), "allow"),
1172 ("run_command", cmd("cargo clippy --all-targets"), "allow"),
1173 ("run_command", cmd("cargo fmt --all"), "allow"),
1174 ("run_command", cmd("cargo build"), "approval"),
1175 ("run_command", cmd("cargo --version"), "allow"),
1176 ("run_command", cmd("rustc -V"), "allow"),
1177 ("run_command", cmd("sudo --version"), "deny"),
1178 ("run_command", cmd("npm install left-pad"), "approval"),
1179 ("run_command", cmd("git push origin main"), "deny"),
1180 ("run_command", cmd("git fetch"), "deny"),
1181 ("run_command", cmd("git reset --hard HEAD"), "deny"),
1182 ("run_command", cmd("git clean -fdx"), "deny"),
1183 ("run_command", cmd("git commit -m x"), "approval"),
1184 (
1185 "run_command",
1186 cmd("git rev-parse --is-inside-work-tree"),
1187 "allow",
1188 ),
1189 ("run_command", cmd("git branch --show-current"), "allow"),
1190 ("run_command", cmd("git branch feature-x"), "approval"),
1191 ("run_command", cmd("git log --oneline -3"), "allow"),
1192 ("run_command", cmd("sh -c ls"), "approval"),
1193 ("run_command", cmd("sudo cargo test"), "deny"),
1194 ("run_command", cmd("curl https://example.com"), "approval"),
1195 ("run_command", cmd("rm -rf target"), "approval"),
1196 ("run_command", json!({"argv": []}), "deny"),
1197 ("git_status", json!({}), "allow"),
1198 ("git_diff", json!({}), "allow"),
1199 ("delete_everything", json!({}), "deny"),
1200 ];
1201 for (tool, args, want) in cases {
1202 assert_eq!(kind(p.classify(tool, args, None)), *want, "{tool} {args}");
1203 }
1204 }
1205
1206 #[test]
1207 fn decisions_cover_classes_and_never_override_a_denial() {
1208 let approved = vec![
1209 path("Cargo.toml", "Cargo.toml"),
1210 command("cargo build"),
1211 path("speccoder.toml", "speccoder.toml"),
1212 command("curl https://x"),
1214 ];
1215 let rejected = vec![Rejection {
1216 object: command("curl https://x"),
1217 reason: "no network".to_owned(),
1218 }];
1219 let p = policy().with_decisions(approved, rejected);
1220 assert_eq!(
1222 p.classify(
1223 "edit_file",
1224 &json!({"path": "./Cargo.toml", "old_string": "a"}),
1225 Some(&path("Cargo.toml", "./Cargo.toml"))
1226 ),
1227 Decision::Allow
1228 );
1229 assert!(matches!(
1231 p.classify("edit_file", &json!({"path": "Cargo.toml"}), None),
1232 Decision::Approval(_)
1233 ));
1234 assert_eq!(
1235 p.classify(
1236 "run_command",
1237 &cmd("cargo build"),
1238 Some(&command("cargo build"))
1239 ),
1240 Decision::Allow
1241 );
1242 assert!(matches!(
1243 p.classify(
1244 "run_command",
1245 &cmd("cargo build --release"),
1246 Some(&command("cargo build --release"))
1247 ),
1248 Decision::Approval(_)
1249 ));
1250 assert_eq!(
1252 p.classify(
1253 "run_command",
1254 &cmd("curl https://x"),
1255 Some(&command("curl https://x"))
1256 ),
1257 Decision::Deny("a person rejected this: no network".to_owned())
1258 );
1259 assert!(matches!(
1261 p.classify("write_file", &json!({"path": "speccoder.toml"}), None),
1262 Decision::Deny(_)
1263 ));
1264 let p = Policy::new(&[], &[]);
1266 for argv in [
1267 vec![
1268 "git",
1269 "notes",
1270 "--ref=speccoder",
1271 "add",
1272 "-f",
1273 "-m",
1274 "x",
1275 "HEAD",
1276 ],
1277 vec!["git", "notes", "remove", "HEAD"],
1278 vec!["git", "update-ref", "refs/notes/speccoder", "HEAD"],
1279 ] {
1280 assert!(
1281 matches!(
1282 p.classify("run_command", &json!({ "argv": argv }), None),
1283 Decision::Deny(_)
1284 ),
1285 "{argv:?}"
1286 );
1287 }
1288 for argv in [
1289 vec!["git", "notes", "--ref=speccoder", "show", "HEAD"],
1290 vec!["git", "notes", "list"],
1291 ] {
1292 assert!(
1293 !matches!(
1294 p.classify("run_command", &json!({ "argv": argv }), None),
1295 Decision::Deny(_)
1296 ),
1297 "{argv:?}"
1298 );
1299 }
1300 let in_place = Policy::new(&[], &[]).in_place();
1302 for argv in [
1303 vec!["git", "switch", "main"],
1304 vec!["git", "commit", "-m", "x"],
1305 vec!["git", "reset", "HEAD~1"],
1306 vec!["git", "stash"],
1307 vec!["git", "branch", "x"],
1308 vec!["git", "apply", "--index", "p.diff"],
1309 vec!["git", "restore", "--staged", "a"],
1310 vec!["git", "update-ref", "HEAD", "x"],
1311 ] {
1312 assert!(
1313 matches!(
1314 in_place.classify("run_command", &json!({ "argv": argv }), None),
1315 Decision::Deny(_)
1316 ),
1317 "{argv:?}"
1318 );
1319 }
1320 for argv in [
1321 vec!["git", "status"],
1322 vec!["git", "branch", "--show-current"],
1323 ] {
1324 assert_eq!(
1325 in_place.classify("run_command", &json!({ "argv": argv }), None),
1326 Decision::Allow,
1327 "{argv:?}"
1328 );
1329 }
1330 let hidden = Policy::new(&[], &[]).in_place().with_hidden([
1332 "target/".to_owned(),
1333 "notes.txt".to_owned(),
1334 "specs/".to_owned(),
1335 ]);
1336 for (tool, path) in [
1337 ("read_file", "target/debug/x"),
1338 ("read_file", "notes.txt"),
1339 ("list_files", "target"),
1340 ("write_file", "notes.txt"),
1341 ] {
1342 assert!(
1343 matches!(
1344 hidden.classify(tool, &json!({ "path": path }), None),
1345 Decision::Deny(_)
1346 ),
1347 "{tool} {path}"
1348 );
1349 }
1350 assert_eq!(
1351 hidden.classify("read_file", &json!({ "path": "specs/001-x/spec.md" }), None),
1352 Decision::Allow
1353 );
1354 assert_eq!(
1355 hidden.classify("read_file", &json!({ "path": "src/lib.rs" }), None),
1356 Decision::Allow
1357 );
1358 assert!(matches!(
1359 Policy::new(&[], &[]).classify(
1360 "run_command",
1361 &json!({ "argv": ["git", "switch", "main"] }),
1362 None
1363 ),
1364 Decision::Approval(_)
1365 ));
1366 }
1367
1368 #[test]
1369 fn a_check_through_a_runner_allows_only_itself() {
1370 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1371 let check = "uv run --locked --offline python -m pytest -q --junitxml=.speccoder/junit.xml";
1372 let p = Policy::new(&[argv(check), argv("poetry run pytest -q")], &[]);
1373 assert_eq!(
1374 p.classify("run_command", &cmd(check), None),
1375 Decision::Allow
1376 );
1377 for other in [
1378 "uv run python -c print(1)",
1379 "uv run --locked --offline sh",
1380 "poetry run sh",
1381 ] {
1382 assert!(
1383 matches!(
1384 p.classify("run_command", &cmd(other), None),
1385 Decision::Approval(_)
1386 ),
1387 "{other}"
1388 );
1389 }
1390 }
1391
1392 #[test]
1393 fn a_check_whose_prefix_is_its_bare_program_allows_only_itself() {
1394 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1395 let p = Policy::new(
1396 &[argv("python3 -m pytest -q --junitxml=.speccoder/junit.xml")],
1397 &[],
1398 );
1399 assert_eq!(
1400 p.classify(
1401 "run_command",
1402 &cmd("python3 -m pytest -q --junitxml=.speccoder/junit.xml"),
1403 None
1404 ),
1405 Decision::Allow
1406 );
1407 assert!(matches!(
1408 p.classify("run_command", &cmd("python3 -c print(1)"), None),
1409 Decision::Approval(_)
1410 ));
1411 assert!(matches!(
1412 p.classify("run_command", &cmd("python3 -m pytest tests/x.py"), None),
1413 Decision::Approval(_)
1414 ));
1415 let q = Policy::new(&[], &[argv("python3 -m pytest")]);
1417 assert!(matches!(
1418 q.classify("run_command", &cmd("python3 -m pytest tests/x.py"), None),
1419 Decision::Approval(_)
1420 ));
1421 assert_eq!(
1422 q.classify("run_command", &cmd("python3 -m pytest"), None),
1423 Decision::Allow
1424 );
1425 }
1426
1427 #[test]
1428 fn mcp_tools_follow_their_configured_decision_and_unknown_ones_are_denied() {
1429 let p = Policy::new(&[], &[]).with_mcp([
1430 ("mcp__db__query".to_owned(), "allow".to_owned()),
1431 ("mcp__db__drop".to_owned(), "deny".to_owned()),
1432 ("mcp__db__insert".to_owned(), "approval".to_owned()),
1433 ]);
1434 assert_eq!(
1435 p.classify("mcp__db__query", &json!({}), None),
1436 Decision::Allow
1437 );
1438 assert!(matches!(
1439 p.classify("mcp__db__drop", &json!({}), None),
1440 Decision::Deny(_)
1441 ));
1442 assert!(matches!(
1443 p.classify("mcp__db__insert", &json!({}), None),
1444 Decision::Approval(_)
1445 ));
1446 assert!(matches!(
1447 p.classify("mcp__db__other", &json!({}), None),
1448 Decision::Deny(_)
1449 ));
1450 let tool = |name: &str, digest: &str| Object::Tool {
1452 name: name.to_owned(),
1453 digest: digest.to_owned(),
1454 };
1455 let q = p.clone().with_decisions(
1456 vec![tool("mcp__db__insert", "d1"), tool("mcp__db__drop", "d1")],
1457 Vec::new(),
1458 );
1459 assert_eq!(
1460 q.classify(
1461 "mcp__db__insert",
1462 &json!({ "row": 1 }),
1463 Some(&tool("mcp__db__insert", "d1"))
1464 ),
1465 Decision::Allow
1466 );
1467 assert!(matches!(
1469 q.classify(
1470 "mcp__db__insert",
1471 &json!({ "row": 1 }),
1472 Some(&tool("mcp__db__insert", "d2"))
1473 ),
1474 Decision::Approval(_)
1475 ));
1476 assert!(matches!(
1477 q.classify(
1478 "mcp__db__drop",
1479 &json!({}),
1480 Some(&tool("mcp__db__drop", "d1"))
1481 ),
1482 Decision::Deny(_)
1483 ));
1484 assert_eq!(
1485 tool("mcp__db__insert", "9f8e7d6c5b4a3210").short(),
1486 "mcp__db__insert@9f8e7d6c5b4a"
1487 );
1488 assert!(
1489 tool("mcp__db__insert", "d2")
1490 .changed_from(&tool("mcp__db__insert", "d1"))
1491 .is_some_and(|c| c.contains("definition changed"))
1492 );
1493 }
1494
1495 #[test]
1496 fn a_planning_task_writes_design_docs_in_its_unit_never_a_judged_file() {
1497 let p = Policy::new(&[], &[]).planning("specs/001-x/");
1498 let w = |path: &str| p.classify("write_file", &json!({ "path": path }), None);
1499 assert_eq!(w("specs/001-x/contracts/cli.md"), Decision::Allow);
1500 assert_eq!(w("specs/001-x/data-model.md"), Decision::Allow);
1501 assert_eq!(w("specs/001-x/plan.md"), Decision::Allow);
1502 assert_eq!(p.write_protected("specs/001-x/contracts/cli.md"), None);
1503 for judged in [
1506 "specs/001-x/spec.md",
1507 "specs/001-x/tasks.md",
1508 "specs/001-x/review.json",
1509 "specs/001-x/checklists/ux.md",
1510 "specs/001-x/contracts/../spec.md",
1511 ".specify/memory/constitution.md",
1512 "specs/002-y/contracts/cli.md",
1513 "specs/001-x/.env",
1514 "specs/001-x/.git/config",
1517 "specs/001-x/speccoder.toml",
1518 "specs/001-x/vitest.config.ts",
1519 "specs/001-x/.mcp.json",
1520 "specs/001-x/.claude/settings.json",
1521 "specs/001-x/AGENTS.md",
1522 "specs/001-x/CLAUDE.md",
1523 "specs/001-x/package.json",
1524 "specs/001-x/Cargo.toml",
1525 "specs/001-x/.hidden.md",
1526 ] {
1527 assert!(matches!(w(judged), Decision::Deny(_)), "{judged}");
1528 assert!(!p.is_planning_write(judged), "{judged}");
1529 }
1530 assert!(p.write_protected("specs/001-x/spec.md").is_some());
1531 assert_eq!(w("src/lib.rs"), Decision::Allow);
1533 assert!(matches!(w("Cargo.toml"), Decision::Approval(_)));
1534 let normal = Policy::new(&[], &[]);
1536 assert!(matches!(
1537 normal.classify(
1538 "write_file",
1539 &json!({ "path": "specs/001-x/contracts/cli.md" }),
1540 None
1541 ),
1542 Decision::Deny(_)
1543 ));
1544 let o = Policy::new(&[], &[])
1546 .rules(crate::format::Adapter::rules(
1547 &crate::format::openspec::OPENSPEC,
1548 ))
1549 .planning("openspec/changes/add-x");
1550 assert!(o.is_planning_write("openspec/changes/add-x/design.md"));
1551 for judged in ["proposal.md", "tasks.md", "specs/auth/spec.md"] {
1552 assert!(
1553 !o.is_planning_write(&format!("openspec/changes/add-x/{judged}")),
1554 "{judged}"
1555 );
1556 }
1557 }
1558
1559 #[test]
1560 fn the_authoring_profile_writes_spec_kit_artifacts_and_runs_its_scripts_only() {
1561 let p = Policy::new(&[], &[]).authoring();
1562 let w = |path: &str| p.classify("write_file", &json!({ "path": path }), None);
1563 assert_eq!(w("specs/002-login/spec.md"), Decision::Allow);
1564 assert_eq!(w(".specify/feature.json"), Decision::Allow);
1565 assert_eq!(w(".specify/memory/constitution.md"), Decision::Allow);
1566 assert!(matches!(
1567 w(".specify/scripts/bash/common.sh"),
1568 Decision::Deny(_)
1569 ));
1570 assert!(matches!(
1571 w(".specify/templates/spec-template.md"),
1572 Decision::Deny(_)
1573 ));
1574 assert!(matches!(w("speccoder.toml"), Decision::Deny(_)));
1575 assert!(matches!(w("specs/.env"), Decision::Deny(_)));
1576 assert!(
1578 matches!(w("specs/002-login/review.json"), Decision::Deny(ref why) if why.contains("review")),
1579 "{:?}",
1580 w("specs/002-login/review.json")
1581 );
1582 assert!(p.write_protected("specs/002-login/review.json").is_some());
1583 assert!(matches!(w("src/lib.rs"), Decision::Approval(_)));
1584 let run = |argv: &[&str]| p.classify("run_command", &json!({ "argv": argv }), None);
1585 assert_eq!(
1586 run(&[".specify/scripts/bash/setup-plan.sh", "--json"]),
1587 Decision::Allow
1588 );
1589 assert_eq!(
1590 run(&[
1591 "bash",
1592 "./.specify/scripts/bash/check-prerequisites.sh",
1593 "--json"
1594 ]),
1595 Decision::Allow
1596 );
1597 assert!(matches!(
1598 run(&["bash", ".specify/scripts/bash/../../evil.sh"]),
1599 Decision::Approval(_)
1600 ));
1601 assert_eq!(
1602 run(&[
1603 "bash",
1604 ".specify/extensions/git/scripts/bash/create-new-feature-branch.sh",
1605 "--json"
1606 ]),
1607 Decision::Allow
1608 );
1609 assert!(matches!(
1610 run(&["bash", ".specify/extensions/git/other.sh"]),
1611 Decision::Approval(_)
1612 ));
1613 assert!(matches!(
1614 run(&["bash", "-c", "rm -rf /"]),
1615 Decision::Approval(_)
1616 ));
1617 assert!(matches!(
1619 Policy::new(&[], &[]).classify(
1620 "write_file",
1621 &json!({ "path": "specs/002-login/spec.md" }),
1622 None
1623 ),
1624 Decision::Deny(_)
1625 ));
1626 }
1627
1628 #[test]
1629 fn asking_for_the_network_needs_a_person_unless_allowed() {
1630 let checks = [vec!["cargo".to_owned(), "test".to_owned()]];
1631 let p = Policy::new(&checks, &[]);
1632 let net = json!({ "argv": ["cargo", "test"], "network": true });
1633 assert!(matches!(
1634 p.classify("run_command", &net, None),
1635 Decision::Approval(_)
1636 ));
1637 assert_eq!(
1639 p.classify("run_command", &json!({ "argv": ["cargo", "test"] }), None),
1640 Decision::Allow
1641 );
1642 assert!(matches!(
1643 p.classify(
1644 "run_command",
1645 &json!({ "argv": ["git", "push"], "network": true }),
1646 None
1647 ),
1648 Decision::Deny(_)
1649 ));
1650 let exact = p
1652 .clone()
1653 .with_network(vec![vec!["cargo".to_owned(), "test".to_owned()]]);
1654 assert_eq!(exact.classify("run_command", &net, None), Decision::Allow);
1655 let longer = json!({ "argv": ["cargo", "test", "--release"], "network": true });
1656 assert!(matches!(
1657 exact.classify("run_command", &longer, None),
1658 Decision::Approval(_)
1659 ));
1660 let bare = p.clone().with_network(vec![vec!["cargo".to_owned()]]);
1661 assert!(matches!(
1662 bare.classify("run_command", &net, None),
1663 Decision::Approval(_)
1664 ));
1665 let prefixed = p.clone().with_network_prefixes(vec![
1666 vec!["cargo".to_owned()],
1667 vec!["cargo".to_owned(), "test".to_owned()],
1668 ]);
1669 assert_eq!(
1670 prefixed.classify("run_command", &longer, None),
1671 Decision::Allow
1672 );
1673 let object = Object::command(
1674 vec!["cargo".to_owned(), "test".to_owned()],
1675 ".".to_owned(),
1676 "/usr/bin/cargo".to_owned(),
1677 true,
1678 );
1679 let approved = Policy::new(&checks, &[]).with_decisions(vec![object.clone()], vec![]);
1680 assert_eq!(
1681 approved.classify("run_command", &net, Some(&object)),
1682 Decision::Allow
1683 );
1684 let shadowed = Object::command(
1686 vec!["cargo".to_owned(), "test".to_owned()],
1687 ".".to_owned(),
1688 "/w/bin/cargo".to_owned(),
1689 true,
1690 );
1691 assert!(matches!(
1692 approved.classify("run_command", &net, Some(&shadowed)),
1693 Decision::Approval(_)
1694 ));
1695 assert!(
1696 shadowed
1697 .changed_from(&object)
1698 .is_some_and(|c| c == "executable /usr/bin/cargo → /w/bin/cargo")
1699 );
1700 }
1701
1702 #[test]
1703 fn commands_allow_is_exact_and_prefixes_need_two_elements() {
1704 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1705 let p = Policy::new(&[], &[argv("npm run build")]);
1706 assert_eq!(
1707 p.classify("run_command", &cmd("npm run build"), None),
1708 Decision::Allow
1709 );
1710 for longer in [
1711 "npm run build --script-shell=/tmp/x",
1712 "npm run build x",
1713 "npm run",
1714 ] {
1715 assert!(
1716 matches!(
1717 p.classify("run_command", &cmd(longer), None),
1718 Decision::Approval(_)
1719 ),
1720 "{longer}"
1721 );
1722 }
1723 assert_eq!(
1724 p.rule("run_command", &cmd("npm run build"), None),
1725 "exact command `npm run build`"
1726 );
1727 let q = Policy::new(&[], &[]).with_prefixes(vec![argv("npm"), argv("npm run")]);
1728 assert_eq!(
1729 q.classify("run_command", &cmd("npm run build"), None),
1730 Decision::Allow
1731 );
1732 assert_eq!(
1733 q.rule("run_command", &cmd("npm run build"), None),
1734 "allowed prefix `npm run`"
1735 );
1736 assert!(matches!(
1738 q.classify("run_command", &cmd("npm install x"), None),
1739 Decision::Approval(_)
1740 ));
1741 assert!(
1742 prefix_refusal(&argv("npm")).is_some() && prefix_refusal(&argv("npm run")).is_none()
1743 );
1744 let r = Policy::new(&[], &[argv("sh -c ls")]).with_prefixes(vec![argv("git push")]);
1746 assert!(matches!(
1747 r.classify("run_command", &cmd("sh -c ls"), None),
1748 Decision::Approval(_)
1749 ));
1750 assert!(matches!(
1751 r.classify("run_command", &cmd("git push origin"), None),
1752 Decision::Deny(_)
1753 ));
1754 }
1755
1756 #[test]
1757 fn autopilot_never_asks_never_lifts_a_denial_and_names_its_rule() {
1758 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1759 let p = Policy::new(&[], &[argv("cargo test")]).autopilot();
1760 assert!(p.is_autopilot());
1761 assert_eq!(
1763 p.settle(p.classify("run_command", &cmd("cargo test"), None)),
1764 Decision::Allow
1765 );
1766 assert_eq!(
1767 p.settle(p.classify("run_command", &cmd("cargo add x"), None)),
1768 Decision::Deny(AUTOPILOT_DENIED.to_owned())
1769 );
1770 assert_eq!(
1771 p.rule("run_command", &cmd("cargo add x"), None),
1772 "autopilot"
1773 );
1774 assert!(matches!(
1775 p.settle(p.classify("run_command", &cmd("git push origin"), None)),
1776 Decision::Deny(r) if r != AUTOPILOT_DENIED
1777 ));
1778 let q = Policy::new(&[], &[]);
1780 assert!(matches!(
1781 q.settle(q.classify("run_command", &cmd("cargo add x"), None)),
1782 Decision::Approval(_)
1783 ));
1784 let a = command("make check");
1786 let mut b = a.clone();
1787 if let Object::Command { cwd, .. } = &mut b {
1788 *cwd = "sub".to_owned();
1789 }
1790 assert_ne!(a.grant_id(), b.grant_id());
1791 assert_eq!(a.grant_id(), a.clone().grant_id());
1792 assert_eq!(
1793 path("src/b/x.rs", "src/a/x.rs").grant_id(),
1794 path("src/b/x.rs", "src/b/x.rs").grant_id()
1795 );
1796 assert!(a.grant_id().starts_with("g-") && a.grant_id().len() == 18);
1797 }
1798
1799 proptest::proptest! {
1800 #[test]
1801 fn on_autopilot_no_call_waits_for_a_person(words in proptest::collection::vec("[a-z]{1,6}", 1..4), tool in 0usize..4) {
1802 let p = Policy::new(&[], &[]).autopilot();
1803 let argv: Vec<String> = words.clone();
1804 let (name, args) = match tool {
1805 0 => ("run_command", json!({ "argv": argv })),
1806 1 => ("write_file", json!({ "path": format!("src/{}.rs", words[0]), "content": "" })),
1807 2 => ("write_file", json!({ "path": format!(".github/{}.yml", words[0]), "content": "" })),
1808 _ => ("mcp__x__y", json!({})),
1809 };
1810 let before = Policy::new(&[], &[]).classify(name, &args, None);
1811 let after = p.settle(p.classify(name, &args, None));
1812 proptest::prop_assert!(!matches!(after, Decision::Approval(_)));
1813 if matches!(before, Decision::Deny(_)) {
1814 proptest::prop_assert!(matches!(after, Decision::Deny(_)));
1815 }
1816 if before == Decision::Allow {
1817 proptest::prop_assert_eq!(after, Decision::Allow);
1818 }
1819 }
1820 }
1821
1822 #[test]
1823 fn a_path_object_is_its_canonical_file_whatever_the_spelling() {
1824 assert_eq!(
1825 path("src/b/x.rs", "src/a/x.rs"),
1826 path("src/b/x.rs", "src/b/x.rs")
1827 );
1828 assert_ne!(
1829 path("src/b/x.rs", "src/a/x.rs"),
1830 path("src/a/x.rs", "src/a/x.rs")
1831 );
1832 assert!(
1833 path("src/b/x.rs", "src/a/x.rs")
1834 .changed_from(&path("src/a/x.rs", "src/a/x.rs"))
1835 .is_some_and(|c| c == "resolves to src/b/x.rs, was src/a/x.rs")
1836 );
1837 let a = command("make check");
1839 let mut b = a.clone();
1840 if let Object::Command { cwd, .. } = &mut b {
1841 *cwd = "sub".to_owned();
1842 }
1843 assert_ne!(a, b);
1844 assert_eq!(a.class(), Class::Command);
1845 assert!(a.short().starts_with("argv#") && a.short().len() == 17);
1846 let back: Object = serde_json::from_value(serde_json::to_value(&a).unwrap()).unwrap();
1848 assert_eq!(back, a);
1849 assert!(serde_json::from_value::<Object>(json!({ "kind": "write", "path": "x" })).is_err());
1850 assert!(
1851 serde_json::from_value::<Rejection>(json!({ "grant": { "kind": "path", "canonical": "x", "asked": "x" }, "reason": "r" }))
1852 .is_err()
1853 );
1854 }
1855
1856 #[test]
1857 fn the_grant_path_has_no_pattern() {
1858 let source = include_str!("policy.rs");
1860 let classify = source
1861 .split("pub fn classify(")
1862 .nth(1)
1863 .and_then(|s| s.split("\n }\n").next())
1864 .unwrap_or_default();
1865 assert!(!classify.is_empty());
1866 for pattern in ["starts_with", "glob", "prefix"] {
1867 assert!(!classify.contains(pattern), "{pattern} in the grant path");
1868 }
1869 }
1870}