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 contained: Option<String>,
461 exploring: Option<Exploring>,
464}
465
466#[derive(Debug, Clone, Copy)]
468pub struct Exploring {
469 pub paths: fn(&str) -> bool,
471 pub commands: &'static [&'static [&'static str]],
473}
474
475pub const EXPLORING_WRITE: &str = "exploring writes nothing — the brief is made when the person chooses, and *Start spec* writes the spec";
477
478pub const AUTOPILOT_DENIED: &str = "autopilot: not asked — listed for you";
480
481#[must_use]
484pub fn prefix_matches(prefix: &[String], argv: &[String]) -> bool {
485 prefix.len() >= 2 && argv.starts_with(prefix)
486}
487
488fn narrows(check: &[String], argv: &[String]) -> bool {
492 let head = check
493 .iter()
494 .rposition(|a| !a.starts_with('-'))
495 .map_or(0, |i| i + 1);
496 if argv.len() <= check.len() || !argv.starts_with(&check[..head]) {
497 return false;
498 }
499 let mut rest = check[head..].iter().peekable();
500 for a in &argv[head..] {
501 if rest.peek() == Some(&a) {
502 rest.next();
503 } else if a.starts_with('-') {
504 return false;
505 }
506 }
507 rest.peek().is_none()
508}
509
510#[must_use]
513pub fn prefix_refusal(prefix: &[String]) -> Option<&'static str> {
514 (prefix.len() < 2).then_some(
515 "a bare program allows any argument; add the exact command to [commands] allow instead",
516 )
517}
518
519const SHELLS: &[&str] = &[
520 "sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh", "pwsh",
521];
522const PRIVILEGE: &[&str] = &["sudo", "su", "doas", "pkexec"];
523const NETWORK: &[&str] = &[
524 "curl", "wget", "nc", "ncat", "ssh", "scp", "sftp", "rsync", "ftp", "telnet",
525];
526const GIT_REMOTE: &[&str] = &[
527 "push",
528 "fetch",
529 "pull",
530 "clone",
531 "remote",
532 "ls-remote",
533 "submodule",
534];
535const GIT_IN_PLACE: &[&str] = &[
537 "checkout",
538 "switch",
539 "reset",
540 "commit",
541 "merge",
542 "rebase",
543 "cherry-pick",
544 "revert",
545 "stash",
546 "branch",
547 "tag",
548 "update-ref",
549 "worktree",
550 "am",
551];
552
553const RUNNERS: &[&[&str]] = &[
555 &["uv", "run"],
556 &["uv", "tool", "run"],
557 &["uvx"],
558 &["poetry", "run"],
559 &["pipx", "run"],
560 &["pdm", "run"],
561 &["hatch", "run"],
562];
563
564impl Policy {
565 #[must_use]
569 pub fn new(checks: &[Vec<String>], exact: &[Vec<String>]) -> Self {
570 let mut allowed: Vec<Vec<String>> = checks
571 .iter()
572 .map(|argv| {
573 let prefix: Vec<String> = argv
574 .iter()
575 .take_while(|a| !a.starts_with('-'))
576 .cloned()
577 .collect();
578 if (prefix.len() == 1
584 || RUNNERS.iter().any(|r| {
585 prefix
586 .iter()
587 .map(String::as_str)
588 .take(r.len())
589 .eq(r.iter().copied())
590 }))
591 && argv.len() > prefix.len()
592 {
593 argv.clone()
594 } else {
595 prefix
596 }
597 })
598 .filter(|p: &Vec<String>| !p.is_empty())
599 .collect();
600 allowed.sort();
601 allowed.dedup();
602 Self {
603 checks: allowed,
604 exact: exact.iter().filter(|p| !p.is_empty()).cloned().collect(),
605 ..Self::default()
606 }
607 }
608
609 #[must_use]
612 pub fn with_prefixes(mut self, prefixes: Vec<Vec<String>>) -> Self {
613 self.prefix = prefixes.into_iter().filter(|p| p.len() >= 2).collect();
614 self
615 }
616
617 #[must_use]
619 pub fn check_prefixes(&self) -> &[Vec<String>] {
620 &self.checks
621 }
622
623 #[must_use]
626 pub fn with_hidden(mut self, hidden: impl IntoIterator<Item = String>) -> Self {
627 self.hidden = hidden.into_iter().collect();
628 self
629 }
630
631 fn is_hidden(&self, rel: &str) -> bool {
634 self.protected.write_class(rel).is_none()
635 && !self.protected.is_context(rel)
636 && self.hidden.iter().any(|h| match h.strip_suffix('/') {
637 Some(dir) => rel == dir || rel.starts_with(h.as_str()),
638 None => rel == h,
639 })
640 }
641
642 #[must_use]
645 pub fn in_place(mut self) -> Self {
646 self.in_place = true;
647 self
648 }
649
650 #[must_use]
652 pub const fn autopilot(mut self) -> Self {
653 self.autopilot = true;
654 self
655 }
656
657 #[must_use]
660 pub fn contained(mut self, mechanism: &str) -> Self {
661 self.contained = Some(mechanism.to_owned());
662 self
663 }
664
665 #[must_use]
671 pub fn contains(&self, tool: &str, args: &Value) -> bool {
672 if self.contained.is_none()
673 || tool != "run_command"
674 || self.authoring
675 || self.planning.is_some()
676 || self.exploring.is_some()
677 || args["network"] == true
678 || self.network_allowed(&argv_of(args))
679 {
680 return false;
681 }
682 matches!(self.base(tool, args), Decision::Approval(r)
683 if r == NOT_ALLOWED || r.ends_with("runs arbitrary commands") || r == "recursive `rm`")
684 }
685
686 #[must_use]
688 pub const fn is_autopilot(&self) -> bool {
689 self.autopilot
690 }
691
692 #[must_use]
697 pub fn settle(&self, decision: Decision) -> Decision {
698 match decision {
699 Decision::Approval(_) if self.autopilot => Decision::Deny(AUTOPILOT_DENIED.to_owned()),
700 d => d,
701 }
702 }
703
704 #[must_use]
709 pub const fn exploring(mut self, e: Exploring) -> Self {
710 self.exploring = Some(e);
711 self
712 }
713
714 #[must_use]
718 pub fn authoring(mut self) -> Self {
719 self.authoring = true;
720 self
721 }
722
723 #[must_use]
728 pub fn planning(mut self, dir: &str) -> Self {
729 let dir = dir.trim_matches('/');
730 self.planning = (!dir.is_empty()).then(|| dir.to_owned());
731 self
732 }
733
734 #[must_use]
738 pub fn is_planning_write(&self, rel: &str) -> bool {
739 let Some(dir) = &self.planning else {
740 return false;
741 };
742 rel.strip_prefix(dir.as_str())
743 .and_then(|r| r.strip_prefix('/'))
744 .is_some_and(|r| {
745 !r.is_empty()
746 && !r.split('/').any(|s| s == ".." || s == ".")
747 && !(self.protected.rules().judged)(r.trim_end_matches('/'))
748 && self.protected.is_design_doc(rel)
749 && !self.protected.is_review(rel)
750 })
751 }
752
753 #[must_use]
756 pub fn rules(mut self, rules: &'static crate::format::Rules) -> Self {
757 self.protected = Protected::new(rules);
758 self
759 }
760
761 #[must_use]
763 pub fn with_hook_files(mut self, files: Vec<String>) -> Self {
764 self.protected = self.protected.with_hook_files(files);
765 self
766 }
767
768 #[must_use]
770 pub fn with_network(mut self, exact: Vec<Vec<String>>) -> Self {
771 self.network_exact = exact.into_iter().filter(|p| !p.is_empty()).collect();
772 self
773 }
774
775 #[must_use]
778 pub fn with_network_prefixes(mut self, prefixes: Vec<Vec<String>>) -> Self {
779 self.network_prefix = prefixes.into_iter().filter(|p| p.len() >= 2).collect();
780 self
781 }
782
783 #[must_use]
786 pub fn network_allowed(&self, argv: &[String]) -> bool {
787 self.network_exact.contains(argv)
788 || self.network_prefix.iter().any(|p| prefix_matches(p, argv))
789 }
790
791 #[must_use]
793 pub fn with_mcp(mut self, tools: impl IntoIterator<Item = (String, String)>) -> Self {
794 self.mcp = tools.into_iter().collect();
795 self
796 }
797
798 #[must_use]
802 pub fn with_decisions(mut self, approved: Vec<Object>, rejected: Vec<Rejection>) -> Self {
803 self.approved = approved;
804 self.rejected = rejected;
805 self
806 }
807
808 #[must_use]
810 pub fn with_frozen(mut self, frozen: impl IntoIterator<Item = String>) -> Self {
811 self.frozen = frozen.into_iter().collect();
812 self
813 }
814
815 #[must_use]
817 pub fn protected(&self) -> &Protected {
818 &self.protected
819 }
820
821 #[must_use]
825 pub fn write_protected(&self, rel: &str) -> Option<super::paths::Class> {
826 if self.is_planning_write(rel) {
827 return None;
828 }
829 if self.authoring
830 && (self.protected.rules().authoring_path)(rel)
831 && !self.protected.is_secret(rel)
832 && !self.protected.is_review(rel)
833 {
834 return None;
835 }
836 self.protected.write_class(rel)
837 }
838
839 #[must_use]
844 pub fn classify(&self, tool: &str, args: &Value, object: Option<&Object>) -> Decision {
845 let decision = self.base(tool, args);
846 if !matches!(decision, Decision::Approval(_)) {
847 return decision;
848 }
849 if let Some(object) = object {
850 if let Some(r) = self.rejected.iter().find(|r| r.object == *object) {
851 return Decision::Deny(format!("a person rejected this: {}", r.reason));
852 }
853 if self.approved.iter().any(|a| a == object) {
854 return Decision::Allow;
855 }
856 }
857 if self.contains(tool, args) {
858 Decision::Allow
859 } else {
860 decision
861 }
862 }
863
864 #[must_use]
866 pub fn rejection(&self, object: &Object) -> Option<&Rejection> {
867 self.rejected.iter().find(|r| r.object == *object)
868 }
869
870 #[must_use]
872 pub fn approved(&self) -> &[Object] {
873 &self.approved
874 }
875
876 #[must_use]
880 pub fn rule(&self, tool: &str, args: &Value, object: Option<&Object>) -> String {
881 if self.autopilot && matches!(self.classify(tool, args, object), Decision::Approval(_)) {
882 return "autopilot".to_owned();
883 }
884 if let Some(object) = object
885 && matches!(self.base(tool, args), Decision::Approval(_))
886 {
887 if self.rejected.iter().any(|r| r.object == *object) {
888 return format!("rejection of {} for the task", object.describe());
889 }
890 if self.approved.iter().any(|a| a == object) {
891 return format!("grant {} for the task", object.describe());
892 }
893 }
894 if tool.starts_with(MCP_PREFIX) {
895 let entry = self.mcp.get(tool).map_or("unlisted", String::as_str);
896 return format!("[mcp] {entry}");
897 }
898 match tool {
899 "read_file" | "list_files" | "search_code" => "workspace read".to_owned(),
900 "write_file" | "edit_file" => "workspace write".to_owned(),
901 "git_status" | "git_diff" => "read-only git".to_owned(),
902 "run_command" => {
903 let argv = argv_of(args);
904 if self.exact.contains(&argv) {
905 return format!("exact command `{}`", argv.join(" "));
906 }
907 if let Some(p) = self
908 .checks
909 .iter()
910 .find(|p| argv.len() >= p.len() && argv[..p.len()] == p[..])
911 {
912 return format!("check prefix `{}`", p.join(" "));
913 }
914 match self.prefix.iter().find(|p| prefix_matches(p, &argv)) {
915 Some(p) => format!("allowed prefix `{}`", p.join(" ")),
916 None => match &self.contained {
917 Some(m) if self.contains(tool, args) => format!("contained ({m})"),
918 _ => "command policy".to_owned(),
919 },
920 }
921 }
922 _ => "tool policy".to_owned(),
923 }
924 }
925
926 #[must_use]
929 pub fn base(&self, tool: &str, args: &Value) -> Decision {
930 self.base_of(tool, args)
931 }
932
933 fn base_of(&self, tool: &str, args: &Value) -> Decision {
934 if let Some(e) = self.exploring {
935 return self.explore_of(e, tool, args);
936 }
937 if tool.starts_with(MCP_PREFIX) {
938 return match self.mcp.get(tool).map(String::as_str) {
939 Some("allow") => Decision::Allow,
940 Some("deny") => Decision::Deny(format!("`{tool}` is denied in [mcp]")),
941 Some(_) => Decision::Approval(format!(
942 "`{tool}` is an MCP tool; speccoder cannot know what it does"
943 )),
944 None => Decision::Deny(format!("unknown tool `{tool}`")),
945 };
946 }
947 match tool {
948 "read_file" | "list_files" | "search_code" => self.read(args),
949 "write_file" | "edit_file" => self.write(args),
950 "run_command" => self.with_network_rule(args, self.command(args)),
951 "git_status" | "git_diff" => Decision::Allow,
952 "git_log" | "git_show" | "journal_account" | "task_evidence" | "report" | "inbox"
955 | "explain" => Decision::Allow,
956 other => Decision::Deny(format!("unknown tool `{other}`")),
957 }
958 }
959
960 fn explore_of(&self, e: Exploring, tool: &str, args: &Value) -> Decision {
962 match tool {
963 "read_file" | "list_files" | "search_code" => self.read(args),
964 "git_status" | "git_diff" => Decision::Allow,
965 "write_file" | "edit_file" => {
966 let rel = args["path"]
967 .as_str()
968 .and_then(|p| super::paths::normalise(p).ok());
969 match rel {
972 Some(rel)
973 if (e.paths)(&rel)
974 && !self.protected.is_secret(&rel)
975 && !self.protected.is_review(&rel) =>
976 {
977 Decision::Allow
978 }
979 _ => Decision::Deny(EXPLORING_WRITE.to_owned()),
980 }
981 }
982 "run_command" => {
983 let argv = argv_of(args);
984 if args.get("network").and_then(Value::as_bool) == Some(true) {
985 return Decision::Deny("exploring has no network".to_owned());
986 }
987 if e.commands.iter().any(|p| {
988 argv.len() >= p.len() && argv.iter().zip(p.iter()).all(|(a, b)| a == b)
989 }) {
990 Decision::Allow
991 } else {
992 Decision::Deny(format!(
993 "exploring runs only the format's read-only commands; `{}` is not one",
994 argv.join(" ")
995 ))
996 }
997 }
998 other => Decision::Deny(format!("`{other}` is not used while exploring")),
999 }
1000 }
1001
1002 fn with_network_rule(&self, args: &Value, decision: Decision) -> Decision {
1005 if args.get("network").and_then(Value::as_bool) != Some(true)
1006 || matches!(decision, Decision::Deny(_))
1007 {
1008 return decision;
1009 }
1010 let argv = argv_of(args);
1011 if self.network_allowed(&argv) {
1012 return decision;
1013 }
1014 Decision::Approval(format!(
1015 "`{}` asks for the network; commands run without it",
1016 argv.join(" ")
1017 ))
1018 }
1019
1020 fn read(&self, args: &Value) -> Decision {
1021 let Some(path) = super::below_root(args.get("path").and_then(Value::as_str)) else {
1022 return Decision::Allow; };
1024 match normalise(path) {
1025 Err(e) => Decision::Deny(e.0),
1026 Ok(p) if self.protected.is_secret(&p) => {
1027 Decision::Deny(format!("`{p}` looks like a credential"))
1028 }
1029 Ok(p) if self.is_hidden(&p) => Decision::Deny(format!(
1030 "`{p}` is ignored by git in this checkout; an in-place run works only with what a worktree would hold"
1031 )),
1032 Ok(_) => Decision::Allow,
1033 }
1034 }
1035
1036 fn write(&self, args: &Value) -> Decision {
1037 let Some(path) = args.get("path").and_then(Value::as_str) else {
1038 return Decision::Deny("no `path`".to_owned());
1039 };
1040 let p = match normalise(path) {
1041 Ok(p) => p,
1042 Err(e) => return Decision::Deny(e.0),
1043 };
1044 if (self.authoring
1045 && (self.protected.rules().authoring_path)(&p)
1046 && !self.protected.is_secret(&p)
1047 && !self.protected.is_review(&p))
1048 || self.is_planning_write(&p)
1049 {
1050 return Decision::Allow;
1051 }
1052 if self.is_hidden(&p) {
1053 return Decision::Deny(format!(
1054 "`{p}` is ignored by git in this checkout; an in-place run does not write it"
1055 ));
1056 }
1057 if self.protected.is_review(&p) {
1058 return Decision::Deny(format!(
1059 "`{p}` is protected: it records a person's review of the spec; only the person writes it (`speccoder review`)"
1060 ));
1061 }
1062 if let Some(class) = self.protected.write_class(&p) {
1063 return Decision::Deny(format!(
1064 "`{p}` is protected: {}",
1065 self.protected.reason(class)
1066 ));
1067 }
1068 if self.authoring {
1069 return Decision::Approval((self.protected.rules().authoring_refusal)(&p));
1070 }
1071 if self.protected.is_context(&p) {
1072 return Decision::Approval(format!(
1073 "`{p}` is an agent context file: every later agent session reads it"
1074 ));
1075 }
1076 if self
1077 .frozen
1078 .iter()
1079 .any(|f| f.to_lowercase() == p.to_lowercase())
1080 {
1081 return Decision::Approval(format!(
1082 "`{p}` is a frozen test (it existed when the attempt started)"
1083 ));
1084 }
1085 if self.protected.is_manifest(&p) {
1086 return Decision::Approval(format!("`{p}` is a dependency manifest"));
1087 }
1088 Decision::Allow
1089 }
1090
1091 fn command(&self, args: &Value) -> Decision {
1092 let argv = argv_of(args);
1093 match (self.protected.rules().command)(&argv, self.authoring) {
1094 Some(Ok(())) => return Decision::Allow,
1095 Some(Err(why)) => return Decision::Deny(why),
1096 None => {}
1097 }
1098 let Some(program) = argv.first() else {
1099 return Decision::Deny("empty `argv`".to_owned());
1100 };
1101 let name = program.rsplit('/').next().unwrap_or(program);
1102 let has = |flag: &str| argv.iter().any(|a| a == flag);
1103
1104 if PRIVILEGE.contains(&name) {
1105 return Decision::Deny(format!("`{name}` raises privileges"));
1106 }
1107 if name == "git" {
1108 let sub = argv.get(1).map_or("", String::as_str);
1109 if GIT_REMOTE.contains(&sub) {
1110 return Decision::Deny(format!(
1111 "`git {sub}` talks to a remote; speccoder never does"
1112 ));
1113 }
1114 let notes_action = argv[2..]
1116 .iter()
1117 .find(|a| !a.starts_with('-'))
1118 .map_or("list", String::as_str);
1119 if (sub == "notes" && !matches!(notes_action, "show" | "list"))
1120 || (sub == "update-ref" && argv.iter().any(|a| a.starts_with("refs/notes")))
1121 {
1122 return Decision::Deny(
1123 "git notes are speccoder's attestations; only speccoder writes them".to_owned(),
1124 );
1125 }
1126 let listing_branches = sub == "branch"
1128 && argv[2..].iter().all(|a| {
1129 matches!(
1130 a.as_str(),
1131 "--show-current" | "--list" | "-l" | "-a" | "--all" | "-v" | "-vv"
1132 )
1133 });
1134 if matches!(
1135 sub,
1136 "rev-parse" | "status" | "log" | "diff" | "show" | "ls-files" | "describe"
1137 ) || listing_branches
1138 {
1139 return Decision::Allow;
1140 }
1141 if self.in_place
1142 && !listing_branches
1143 && (GIT_IN_PLACE.contains(&sub)
1144 || (sub == "apply" && has("--index"))
1145 || (sub == "restore" && has("--staged")))
1146 {
1147 return Decision::Deny(format!(
1148 "`git {sub}` would move the checkout of an in-place run; speccoder commits and switches it"
1149 ));
1150 }
1151 if sub == "clean" || (sub == "reset" && has("--hard")) || sub == "filter-branch" {
1152 return Decision::Deny(format!("`git {sub}` destroys work"));
1153 }
1154 if matches!(
1155 sub,
1156 "commit"
1157 | "checkout"
1158 | "switch"
1159 | "rebase"
1160 | "merge"
1161 | "stash"
1162 | "branch"
1163 | "tag"
1164 | "config"
1165 | "worktree"
1166 ) {
1167 return Decision::Approval(format!("`git {sub}` changes repository state"));
1168 }
1169 }
1170 if SHELLS.contains(&name) || name == "eval" || name == "env" || name == "xargs" {
1171 return Decision::Approval(format!("`{name}` runs arbitrary commands"));
1172 }
1173 if NETWORK.contains(&name) {
1174 return Decision::Approval(format!("`{name}` uses the network"));
1175 }
1176 if name == "rm" && argv.iter().any(|a| a.starts_with('-') && a.contains('r')) {
1177 return Decision::Approval("recursive `rm`".to_owned());
1178 }
1179 if argv.len() == 2 && matches!(argv[1].as_str(), "--version" | "-V" | "version") {
1181 return Decision::Allow;
1182 }
1183 if self.exact.contains(&argv)
1187 || self.checks.iter().any(|prefix| {
1188 (argv.len() >= prefix.len() && argv[..prefix.len()] == prefix[..])
1189 || narrows(prefix, &argv)
1190 })
1191 || self.prefix.iter().any(|p| prefix_matches(p, &argv))
1192 {
1193 return Decision::Allow;
1194 }
1195 Decision::Approval(NOT_ALLOWED.to_owned())
1196 }
1197}
1198
1199#[must_use]
1201pub fn argv_of(args: &Value) -> Vec<String> {
1202 args.get("argv")
1203 .and_then(Value::as_array)
1204 .map(|a| {
1205 a.iter()
1206 .filter_map(|v| v.as_str().map(str::to_owned))
1207 .collect()
1208 })
1209 .unwrap_or_default()
1210}
1211
1212#[cfg(test)]
1213mod tests {
1214 use super::*;
1215 use serde_json::json;
1216
1217 fn policy() -> Policy {
1218 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1219 Policy::new(
1220 &[
1221 argv("cargo nextest run --workspace --profile speccoder"),
1222 argv("cargo clippy --workspace -- -D warnings"),
1223 ],
1224 &[],
1225 )
1226 .with_prefixes(vec![argv("cargo fmt"), argv("cargo check")])
1227 }
1228
1229 #[test]
1230 fn an_unasked_call_is_named_with_how_to_let_it_run() {
1231 let decide = "run with prompts";
1232 let refused = policy().classify("run_command", &json!({"argv": ["npm", "i"]}), None);
1233 let Decision::Approval(reason) = refused else {
1234 panic!("{refused:?}")
1235 };
1236 let m = unasked(
1237 &json!({"tool": "run_command", "arguments": {"argv": ["make", "it's"]}, "reason": reason}),
1238 decide,
1239 );
1240 assert_eq!(
1241 m,
1242 "`make 'it'\\''s'` needs approval: it is not a configured check or allowed command — allow exactly it in speccoder.toml (`[commands] allow = [[\"make\", \"it's\"]]`), or run with prompts"
1243 );
1244 let m = unasked(
1246 &json!({"tool": "run_command", "arguments": {"argv": ["sh", "-c", "x"]}, "reason": "`sh` runs arbitrary commands"}),
1247 decide,
1248 );
1249 assert_eq!(
1250 m,
1251 "`sh -c x` needs approval: `sh` runs arbitrary commands — run with prompts"
1252 );
1253 let m = unasked(
1254 &json!({"tool": "write_file", "arguments": {"path": "Cargo.toml"}, "reason": "`Cargo.toml` is a dependency manifest"}),
1255 decide,
1256 );
1257 assert_eq!(
1258 m,
1259 "`write_file` `Cargo.toml` needs approval: `Cargo.toml` is a dependency manifest — run with prompts"
1260 );
1261 }
1262
1263 fn path(canonical: &str, asked: &str) -> Object {
1264 Object::Path {
1265 canonical: canonical.to_owned(),
1266 asked: asked.to_owned(),
1267 }
1268 }
1269
1270 fn command(s: &str) -> Object {
1271 Object::command(
1272 s.split(' ').map(str::to_owned).collect(),
1273 ".".to_owned(),
1274 "/usr/bin/x".to_owned(),
1275 false,
1276 )
1277 }
1278
1279 fn cmd(s: &str) -> Value {
1280 json!({ "argv": s.split(' ').collect::<Vec<_>>() })
1281 }
1282
1283 #[test]
1284 fn classifies_the_table() {
1285 use Decision::{Allow, Approval, Deny};
1286 let p = policy();
1287 let kind = |d: Decision| match d {
1288 Allow => "allow",
1289 Approval(_) => "approval",
1290 Deny(_) => "deny",
1291 };
1292 let cases: &[(&str, Value, &str)] = &[
1293 ("read_file", json!({"path": "src/lib.rs"}), "allow"),
1294 ("read_file", json!({"path": ".env"}), "deny"),
1295 ("read_file", json!({"path": "../secret"}), "deny"),
1296 ("list_files", json!({}), "allow"),
1297 ("search_code", json!({"pattern": "fn main"}), "allow"),
1298 ("write_file", json!({"path": "src/new.rs"}), "allow"),
1299 ("edit_file", json!({"path": "specs/001-x/tasks.md"}), "deny"),
1300 ("write_file", json!({"path": "speccoder.toml"}), "deny"),
1301 (
1302 "write_file",
1303 json!({"path": ".github/workflows/ci.yml"}),
1304 "deny",
1305 ),
1306 (
1307 "write_file",
1308 json!({"path": ".git/hooks/pre-commit"}),
1309 "deny",
1310 ),
1311 ("edit_file", json!({"path": "Cargo.toml"}), "approval"),
1312 ("edit_file", json!({"path": "web/package.json"}), "approval"),
1313 ("run_command", cmd("cargo nextest run -p x"), "allow"),
1314 ("run_command", cmd("cargo clippy --all-targets"), "allow"),
1315 ("run_command", cmd("cargo fmt --all"), "allow"),
1316 ("run_command", cmd("cargo build"), "approval"),
1317 ("run_command", cmd("cargo --version"), "allow"),
1318 ("run_command", cmd("rustc -V"), "allow"),
1319 ("run_command", cmd("sudo --version"), "deny"),
1320 ("run_command", cmd("npm install left-pad"), "approval"),
1321 ("run_command", cmd("git push origin main"), "deny"),
1322 ("run_command", cmd("git fetch"), "deny"),
1323 ("run_command", cmd("git reset --hard HEAD"), "deny"),
1324 ("run_command", cmd("git clean -fdx"), "deny"),
1325 ("run_command", cmd("git commit -m x"), "approval"),
1326 (
1327 "run_command",
1328 cmd("git rev-parse --is-inside-work-tree"),
1329 "allow",
1330 ),
1331 ("run_command", cmd("git branch --show-current"), "allow"),
1332 ("run_command", cmd("git branch feature-x"), "approval"),
1333 ("run_command", cmd("git log --oneline -3"), "allow"),
1334 ("run_command", cmd("sh -c ls"), "approval"),
1335 ("run_command", cmd("sudo cargo test"), "deny"),
1336 ("run_command", cmd("curl https://example.com"), "approval"),
1337 ("run_command", cmd("rm -rf target"), "approval"),
1338 ("run_command", json!({"argv": []}), "deny"),
1339 ("git_status", json!({}), "allow"),
1340 ("git_diff", json!({}), "allow"),
1341 ("delete_everything", json!({}), "deny"),
1342 ];
1343 for (tool, args, want) in cases {
1344 assert_eq!(kind(p.classify(tool, args, None)), *want, "{tool} {args}");
1345 }
1346 }
1347
1348 #[test]
1352 fn a_contained_command_runs_and_what_leaves_the_sandbox_does_not() {
1353 let p = policy().contained("Seatbelt");
1354 for c in [
1355 "python3 -c print(1)",
1356 "sh -c make",
1357 "rm -r build",
1358 "uv run python -m md2csv",
1359 ] {
1360 assert_eq!(
1361 p.classify("run_command", &cmd(c), None),
1362 Decision::Allow,
1363 "{c}"
1364 );
1365 assert_eq!(
1366 p.rule("run_command", &cmd(c), None),
1367 "contained (Seatbelt)",
1368 "{c}"
1369 );
1370 }
1371 for c in ["curl https://x", "git commit -m x", "ssh host"] {
1372 assert!(
1373 !matches!(p.classify("run_command", &cmd(c), None), Decision::Allow),
1374 "{c}"
1375 );
1376 }
1377 let net = json!({ "argv": ["python3", "-c", "x"], "network": true });
1378 assert!(matches!(
1379 p.classify("run_command", &net, None),
1380 Decision::Approval(_)
1381 ));
1382 let allowed = policy()
1383 .with_network(vec![vec![
1384 "pip".to_owned(),
1385 "install".to_owned(),
1386 "x".to_owned(),
1387 ]])
1388 .contained("Seatbelt");
1389 assert!(!allowed.contains("run_command", &cmd("pip install x")));
1390 assert!(!p.contains("write_file", &json!({ "path": "tests/frozen.rs" })));
1391 assert!(!p.contains("mcp__x__y", &json!({})));
1392 let rejected = p.clone().with_decisions(
1394 vec![],
1395 vec![Rejection {
1396 object: command("python3 -c print(1)"),
1397 reason: "no".to_owned(),
1398 }],
1399 );
1400 assert!(matches!(
1401 rejected.classify(
1402 "run_command",
1403 &cmd("python3 -c print(1)"),
1404 Some(&command("python3 -c print(1)"))
1405 ),
1406 Decision::Deny(_)
1407 ));
1408 let auto = p.clone().autopilot();
1410 assert_eq!(
1411 auto.settle(auto.classify("run_command", &cmd("python3 -c x"), None)),
1412 Decision::Allow
1413 );
1414 assert!(matches!(
1415 auto.settle(auto.classify("run_command", &cmd("curl https://x"), None)),
1416 Decision::Deny(_)
1417 ));
1418 assert!(matches!(
1420 policy().classify("run_command", &cmd("python3 -c print(1)"), None),
1421 Decision::Approval(_)
1422 ));
1423 }
1424
1425 #[test]
1426 fn decisions_cover_classes_and_never_override_a_denial() {
1427 let approved = vec![
1428 path("Cargo.toml", "Cargo.toml"),
1429 command("cargo build"),
1430 path("speccoder.toml", "speccoder.toml"),
1431 command("curl https://x"),
1433 ];
1434 let rejected = vec![Rejection {
1435 object: command("curl https://x"),
1436 reason: "no network".to_owned(),
1437 }];
1438 let p = policy().with_decisions(approved, rejected);
1439 assert_eq!(
1441 p.classify(
1442 "edit_file",
1443 &json!({"path": "./Cargo.toml", "old_string": "a"}),
1444 Some(&path("Cargo.toml", "./Cargo.toml"))
1445 ),
1446 Decision::Allow
1447 );
1448 assert!(matches!(
1450 p.classify("edit_file", &json!({"path": "Cargo.toml"}), None),
1451 Decision::Approval(_)
1452 ));
1453 assert_eq!(
1454 p.classify(
1455 "run_command",
1456 &cmd("cargo build"),
1457 Some(&command("cargo build"))
1458 ),
1459 Decision::Allow
1460 );
1461 assert!(matches!(
1462 p.classify(
1463 "run_command",
1464 &cmd("cargo build --release"),
1465 Some(&command("cargo build --release"))
1466 ),
1467 Decision::Approval(_)
1468 ));
1469 assert_eq!(
1471 p.classify(
1472 "run_command",
1473 &cmd("curl https://x"),
1474 Some(&command("curl https://x"))
1475 ),
1476 Decision::Deny("a person rejected this: no network".to_owned())
1477 );
1478 assert!(matches!(
1480 p.classify("write_file", &json!({"path": "speccoder.toml"}), None),
1481 Decision::Deny(_)
1482 ));
1483 let p = Policy::new(&[], &[]);
1485 for argv in [
1486 vec![
1487 "git",
1488 "notes",
1489 "--ref=speccoder",
1490 "add",
1491 "-f",
1492 "-m",
1493 "x",
1494 "HEAD",
1495 ],
1496 vec!["git", "notes", "remove", "HEAD"],
1497 vec!["git", "update-ref", "refs/notes/speccoder", "HEAD"],
1498 ] {
1499 assert!(
1500 matches!(
1501 p.classify("run_command", &json!({ "argv": argv }), None),
1502 Decision::Deny(_)
1503 ),
1504 "{argv:?}"
1505 );
1506 }
1507 for argv in [
1508 vec!["git", "notes", "--ref=speccoder", "show", "HEAD"],
1509 vec!["git", "notes", "list"],
1510 ] {
1511 assert!(
1512 !matches!(
1513 p.classify("run_command", &json!({ "argv": argv }), None),
1514 Decision::Deny(_)
1515 ),
1516 "{argv:?}"
1517 );
1518 }
1519 let in_place = Policy::new(&[], &[]).in_place();
1521 for argv in [
1522 vec!["git", "switch", "main"],
1523 vec!["git", "commit", "-m", "x"],
1524 vec!["git", "reset", "HEAD~1"],
1525 vec!["git", "stash"],
1526 vec!["git", "branch", "x"],
1527 vec!["git", "apply", "--index", "p.diff"],
1528 vec!["git", "restore", "--staged", "a"],
1529 vec!["git", "update-ref", "HEAD", "x"],
1530 ] {
1531 assert!(
1532 matches!(
1533 in_place.classify("run_command", &json!({ "argv": argv }), None),
1534 Decision::Deny(_)
1535 ),
1536 "{argv:?}"
1537 );
1538 }
1539 for argv in [
1540 vec!["git", "status"],
1541 vec!["git", "branch", "--show-current"],
1542 ] {
1543 assert_eq!(
1544 in_place.classify("run_command", &json!({ "argv": argv }), None),
1545 Decision::Allow,
1546 "{argv:?}"
1547 );
1548 }
1549 let hidden = Policy::new(&[], &[]).in_place().with_hidden([
1551 "target/".to_owned(),
1552 "notes.txt".to_owned(),
1553 "specs/".to_owned(),
1554 ]);
1555 for (tool, path) in [
1556 ("read_file", "target/debug/x"),
1557 ("read_file", "notes.txt"),
1558 ("list_files", "target"),
1559 ("write_file", "notes.txt"),
1560 ] {
1561 assert!(
1562 matches!(
1563 hidden.classify(tool, &json!({ "path": path }), None),
1564 Decision::Deny(_)
1565 ),
1566 "{tool} {path}"
1567 );
1568 }
1569 assert_eq!(
1570 hidden.classify("read_file", &json!({ "path": "specs/001-x/spec.md" }), None),
1571 Decision::Allow
1572 );
1573 assert_eq!(
1574 hidden.classify("read_file", &json!({ "path": "src/lib.rs" }), None),
1575 Decision::Allow
1576 );
1577 assert!(matches!(
1578 Policy::new(&[], &[]).classify(
1579 "run_command",
1580 &json!({ "argv": ["git", "switch", "main"] }),
1581 None
1582 ),
1583 Decision::Approval(_)
1584 ));
1585 }
1586
1587 #[test]
1588 fn a_check_through_a_runner_allows_only_itself() {
1589 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1590 let check = "uv run --locked --offline python -m pytest -q --junitxml=.speccoder/junit.xml";
1591 let p = Policy::new(&[argv(check), argv("poetry run pytest -q")], &[]);
1592 assert_eq!(
1593 p.classify("run_command", &cmd(check), None),
1594 Decision::Allow
1595 );
1596 for other in [
1597 "uv run python -c print(1)",
1598 "uv run --locked --offline sh",
1599 "poetry run sh",
1600 ] {
1601 assert!(
1602 matches!(
1603 p.classify("run_command", &cmd(other), None),
1604 Decision::Approval(_)
1605 ),
1606 "{other}"
1607 );
1608 }
1609 }
1610
1611 #[test]
1612 fn a_check_whose_prefix_is_its_bare_program_allows_only_itself() {
1613 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1614 let p = Policy::new(
1615 &[argv("python3 -m pytest -q --junitxml=.speccoder/junit.xml")],
1616 &[],
1617 );
1618 assert_eq!(
1619 p.classify(
1620 "run_command",
1621 &cmd("python3 -m pytest -q --junitxml=.speccoder/junit.xml"),
1622 None
1623 ),
1624 Decision::Allow
1625 );
1626 assert!(matches!(
1627 p.classify("run_command", &cmd("python3 -c print(1)"), None),
1628 Decision::Approval(_)
1629 ));
1630 assert!(matches!(
1631 p.classify("run_command", &cmd("python3 -m pytest tests/x.py"), None),
1632 Decision::Approval(_)
1633 ));
1634 let uv = Policy::new(
1636 &[argv("uv run --locked python -m pytest -q --junitxml=j.xml")],
1637 &[],
1638 );
1639 assert_eq!(
1640 uv.classify(
1641 "run_command",
1642 &cmd("uv run --locked python -m pytest -q tests/x.py --junitxml=j.xml"),
1643 None
1644 ),
1645 Decision::Allow
1646 );
1647 for other in [
1648 "uv run --locked python -m pytest -q tests/x.py",
1649 "uv run --locked python -m pytest -q -p evil tests/x.py --junitxml=j.xml",
1650 "uv run --locked python -c tests/x.py -m pytest -q --junitxml=j.xml",
1651 ] {
1652 assert!(
1653 matches!(
1654 uv.classify("run_command", &cmd(other), None),
1655 Decision::Approval(_)
1656 ),
1657 "{other}"
1658 );
1659 }
1660 let q = Policy::new(&[], &[argv("python3 -m pytest")]);
1662 assert!(matches!(
1663 q.classify("run_command", &cmd("python3 -m pytest tests/x.py"), None),
1664 Decision::Approval(_)
1665 ));
1666 assert_eq!(
1667 q.classify("run_command", &cmd("python3 -m pytest"), None),
1668 Decision::Allow
1669 );
1670 }
1671
1672 #[test]
1673 fn mcp_tools_follow_their_configured_decision_and_unknown_ones_are_denied() {
1674 let p = Policy::new(&[], &[]).with_mcp([
1675 ("mcp__db__query".to_owned(), "allow".to_owned()),
1676 ("mcp__db__drop".to_owned(), "deny".to_owned()),
1677 ("mcp__db__insert".to_owned(), "approval".to_owned()),
1678 ]);
1679 assert_eq!(
1680 p.classify("mcp__db__query", &json!({}), None),
1681 Decision::Allow
1682 );
1683 assert!(matches!(
1684 p.classify("mcp__db__drop", &json!({}), None),
1685 Decision::Deny(_)
1686 ));
1687 assert!(matches!(
1688 p.classify("mcp__db__insert", &json!({}), None),
1689 Decision::Approval(_)
1690 ));
1691 assert!(matches!(
1692 p.classify("mcp__db__other", &json!({}), None),
1693 Decision::Deny(_)
1694 ));
1695 let tool = |name: &str, digest: &str| Object::Tool {
1697 name: name.to_owned(),
1698 digest: digest.to_owned(),
1699 };
1700 let q = p.clone().with_decisions(
1701 vec![tool("mcp__db__insert", "d1"), tool("mcp__db__drop", "d1")],
1702 Vec::new(),
1703 );
1704 assert_eq!(
1705 q.classify(
1706 "mcp__db__insert",
1707 &json!({ "row": 1 }),
1708 Some(&tool("mcp__db__insert", "d1"))
1709 ),
1710 Decision::Allow
1711 );
1712 assert!(matches!(
1714 q.classify(
1715 "mcp__db__insert",
1716 &json!({ "row": 1 }),
1717 Some(&tool("mcp__db__insert", "d2"))
1718 ),
1719 Decision::Approval(_)
1720 ));
1721 assert!(matches!(
1722 q.classify(
1723 "mcp__db__drop",
1724 &json!({}),
1725 Some(&tool("mcp__db__drop", "d1"))
1726 ),
1727 Decision::Deny(_)
1728 ));
1729 assert_eq!(
1730 tool("mcp__db__insert", "9f8e7d6c5b4a3210").short(),
1731 "mcp__db__insert@9f8e7d6c5b4a"
1732 );
1733 assert!(
1734 tool("mcp__db__insert", "d2")
1735 .changed_from(&tool("mcp__db__insert", "d1"))
1736 .is_some_and(|c| c.contains("definition changed"))
1737 );
1738 }
1739
1740 #[test]
1741 fn a_planning_task_writes_design_docs_in_its_unit_never_a_judged_file() {
1742 let p = Policy::new(&[], &[]).planning("specs/001-x/");
1743 let w = |path: &str| p.classify("write_file", &json!({ "path": path }), None);
1744 assert_eq!(w("specs/001-x/contracts/cli.md"), Decision::Allow);
1745 assert_eq!(w("specs/001-x/data-model.md"), Decision::Allow);
1746 assert_eq!(w("specs/001-x/plan.md"), Decision::Allow);
1747 assert_eq!(p.write_protected("specs/001-x/contracts/cli.md"), None);
1748 for judged in [
1751 "specs/001-x/spec.md",
1752 "specs/001-x/tasks.md",
1753 "specs/001-x/review.json",
1754 "specs/001-x/checklists/ux.md",
1755 "specs/001-x/contracts/../spec.md",
1756 ".specify/memory/constitution.md",
1757 "specs/002-y/contracts/cli.md",
1758 "specs/001-x/.env",
1759 "specs/001-x/.git/config",
1762 "specs/001-x/speccoder.toml",
1763 "specs/001-x/vitest.config.ts",
1764 "specs/001-x/.mcp.json",
1765 "specs/001-x/.claude/settings.json",
1766 "specs/001-x/AGENTS.md",
1767 "specs/001-x/CLAUDE.md",
1768 "specs/001-x/package.json",
1769 "specs/001-x/Cargo.toml",
1770 "specs/001-x/.hidden.md",
1771 ] {
1772 assert!(matches!(w(judged), Decision::Deny(_)), "{judged}");
1773 assert!(!p.is_planning_write(judged), "{judged}");
1774 }
1775 assert!(p.write_protected("specs/001-x/spec.md").is_some());
1776 assert_eq!(w("src/lib.rs"), Decision::Allow);
1778 assert!(matches!(w("Cargo.toml"), Decision::Approval(_)));
1779 let normal = Policy::new(&[], &[]);
1781 assert!(matches!(
1782 normal.classify(
1783 "write_file",
1784 &json!({ "path": "specs/001-x/contracts/cli.md" }),
1785 None
1786 ),
1787 Decision::Deny(_)
1788 ));
1789 let o = Policy::new(&[], &[])
1791 .rules(crate::format::Adapter::rules(
1792 &crate::format::openspec::OPENSPEC,
1793 ))
1794 .planning("openspec/changes/add-x");
1795 assert!(o.is_planning_write("openspec/changes/add-x/design.md"));
1796 for judged in ["proposal.md", "tasks.md", "specs/auth/spec.md"] {
1797 assert!(
1798 !o.is_planning_write(&format!("openspec/changes/add-x/{judged}")),
1799 "{judged}"
1800 );
1801 }
1802 }
1803
1804 #[test]
1805 fn the_authoring_profile_writes_spec_kit_artifacts_and_runs_its_scripts_only() {
1806 let p = Policy::new(&[], &[]).authoring();
1807 let w = |path: &str| p.classify("write_file", &json!({ "path": path }), None);
1808 assert_eq!(w("specs/002-login/spec.md"), Decision::Allow);
1809 assert_eq!(w(".specify/feature.json"), Decision::Allow);
1810 assert_eq!(w(".specify/memory/constitution.md"), Decision::Allow);
1811 assert!(matches!(
1812 w(".specify/scripts/bash/common.sh"),
1813 Decision::Deny(_)
1814 ));
1815 assert!(matches!(
1816 w(".specify/templates/spec-template.md"),
1817 Decision::Deny(_)
1818 ));
1819 assert!(matches!(w("speccoder.toml"), Decision::Deny(_)));
1820 assert!(matches!(w("specs/.env"), Decision::Deny(_)));
1821 assert!(
1823 matches!(w("specs/002-login/review.json"), Decision::Deny(ref why) if why.contains("review")),
1824 "{:?}",
1825 w("specs/002-login/review.json")
1826 );
1827 assert!(p.write_protected("specs/002-login/review.json").is_some());
1828 assert!(matches!(w("src/lib.rs"), Decision::Approval(_)));
1829 let run = |argv: &[&str]| p.classify("run_command", &json!({ "argv": argv }), None);
1830 assert_eq!(
1831 run(&[".specify/scripts/bash/setup-plan.sh", "--json"]),
1832 Decision::Allow
1833 );
1834 assert_eq!(
1835 run(&[
1836 "bash",
1837 "./.specify/scripts/bash/check-prerequisites.sh",
1838 "--json"
1839 ]),
1840 Decision::Allow
1841 );
1842 assert!(matches!(
1843 run(&["bash", ".specify/scripts/bash/../../evil.sh"]),
1844 Decision::Approval(_)
1845 ));
1846 assert_eq!(
1847 run(&[
1848 "bash",
1849 ".specify/extensions/git/scripts/bash/create-new-feature-branch.sh",
1850 "--json"
1851 ]),
1852 Decision::Allow
1853 );
1854 assert!(matches!(
1855 run(&["bash", ".specify/extensions/git/other.sh"]),
1856 Decision::Approval(_)
1857 ));
1858 assert!(matches!(
1859 run(&["bash", "-c", "rm -rf /"]),
1860 Decision::Approval(_)
1861 ));
1862 assert!(matches!(
1864 Policy::new(&[], &[]).classify(
1865 "write_file",
1866 &json!({ "path": "specs/002-login/spec.md" }),
1867 None
1868 ),
1869 Decision::Deny(_)
1870 ));
1871 }
1872
1873 #[test]
1874 fn asking_for_the_network_needs_a_person_unless_allowed() {
1875 let checks = [vec!["cargo".to_owned(), "test".to_owned()]];
1876 let p = Policy::new(&checks, &[]);
1877 let net = json!({ "argv": ["cargo", "test"], "network": true });
1878 assert!(matches!(
1879 p.classify("run_command", &net, None),
1880 Decision::Approval(_)
1881 ));
1882 assert_eq!(
1884 p.classify("run_command", &json!({ "argv": ["cargo", "test"] }), None),
1885 Decision::Allow
1886 );
1887 assert!(matches!(
1888 p.classify(
1889 "run_command",
1890 &json!({ "argv": ["git", "push"], "network": true }),
1891 None
1892 ),
1893 Decision::Deny(_)
1894 ));
1895 let exact = p
1897 .clone()
1898 .with_network(vec![vec!["cargo".to_owned(), "test".to_owned()]]);
1899 assert_eq!(exact.classify("run_command", &net, None), Decision::Allow);
1900 let longer = json!({ "argv": ["cargo", "test", "--release"], "network": true });
1901 assert!(matches!(
1902 exact.classify("run_command", &longer, None),
1903 Decision::Approval(_)
1904 ));
1905 let bare = p.clone().with_network(vec![vec!["cargo".to_owned()]]);
1906 assert!(matches!(
1907 bare.classify("run_command", &net, None),
1908 Decision::Approval(_)
1909 ));
1910 let prefixed = p.clone().with_network_prefixes(vec![
1911 vec!["cargo".to_owned()],
1912 vec!["cargo".to_owned(), "test".to_owned()],
1913 ]);
1914 assert_eq!(
1915 prefixed.classify("run_command", &longer, None),
1916 Decision::Allow
1917 );
1918 let object = Object::command(
1919 vec!["cargo".to_owned(), "test".to_owned()],
1920 ".".to_owned(),
1921 "/usr/bin/cargo".to_owned(),
1922 true,
1923 );
1924 let approved = Policy::new(&checks, &[]).with_decisions(vec![object.clone()], vec![]);
1925 assert_eq!(
1926 approved.classify("run_command", &net, Some(&object)),
1927 Decision::Allow
1928 );
1929 let shadowed = Object::command(
1931 vec!["cargo".to_owned(), "test".to_owned()],
1932 ".".to_owned(),
1933 "/w/bin/cargo".to_owned(),
1934 true,
1935 );
1936 assert!(matches!(
1937 approved.classify("run_command", &net, Some(&shadowed)),
1938 Decision::Approval(_)
1939 ));
1940 assert!(
1941 shadowed
1942 .changed_from(&object)
1943 .is_some_and(|c| c == "executable /usr/bin/cargo → /w/bin/cargo")
1944 );
1945 }
1946
1947 #[test]
1948 fn commands_allow_is_exact_and_prefixes_need_two_elements() {
1949 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1950 let p = Policy::new(&[], &[argv("npm run build")]);
1951 assert_eq!(
1952 p.classify("run_command", &cmd("npm run build"), None),
1953 Decision::Allow
1954 );
1955 for longer in [
1956 "npm run build --script-shell=/tmp/x",
1957 "npm run build x",
1958 "npm run",
1959 ] {
1960 assert!(
1961 matches!(
1962 p.classify("run_command", &cmd(longer), None),
1963 Decision::Approval(_)
1964 ),
1965 "{longer}"
1966 );
1967 }
1968 assert_eq!(
1969 p.rule("run_command", &cmd("npm run build"), None),
1970 "exact command `npm run build`"
1971 );
1972 let q = Policy::new(&[], &[]).with_prefixes(vec![argv("npm"), argv("npm run")]);
1973 assert_eq!(
1974 q.classify("run_command", &cmd("npm run build"), None),
1975 Decision::Allow
1976 );
1977 assert_eq!(
1978 q.rule("run_command", &cmd("npm run build"), None),
1979 "allowed prefix `npm run`"
1980 );
1981 assert!(matches!(
1983 q.classify("run_command", &cmd("npm install x"), None),
1984 Decision::Approval(_)
1985 ));
1986 assert!(
1987 prefix_refusal(&argv("npm")).is_some() && prefix_refusal(&argv("npm run")).is_none()
1988 );
1989 let r = Policy::new(&[], &[argv("sh -c ls")]).with_prefixes(vec![argv("git push")]);
1991 assert!(matches!(
1992 r.classify("run_command", &cmd("sh -c ls"), None),
1993 Decision::Approval(_)
1994 ));
1995 assert!(matches!(
1996 r.classify("run_command", &cmd("git push origin"), None),
1997 Decision::Deny(_)
1998 ));
1999 }
2000
2001 #[test]
2002 fn autopilot_never_asks_never_lifts_a_denial_and_names_its_rule() {
2003 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
2004 let p = Policy::new(&[], &[argv("cargo test")]).autopilot();
2005 assert!(p.is_autopilot());
2006 assert_eq!(
2008 p.settle(p.classify("run_command", &cmd("cargo test"), None)),
2009 Decision::Allow
2010 );
2011 assert_eq!(
2012 p.settle(p.classify("run_command", &cmd("cargo add x"), None)),
2013 Decision::Deny(AUTOPILOT_DENIED.to_owned())
2014 );
2015 assert_eq!(
2016 p.rule("run_command", &cmd("cargo add x"), None),
2017 "autopilot"
2018 );
2019 assert!(matches!(
2020 p.settle(p.classify("run_command", &cmd("git push origin"), None)),
2021 Decision::Deny(r) if r != AUTOPILOT_DENIED
2022 ));
2023 let q = Policy::new(&[], &[]);
2025 assert!(matches!(
2026 q.settle(q.classify("run_command", &cmd("cargo add x"), None)),
2027 Decision::Approval(_)
2028 ));
2029 let a = command("make check");
2031 let mut b = a.clone();
2032 if let Object::Command { cwd, .. } = &mut b {
2033 *cwd = "sub".to_owned();
2034 }
2035 assert_ne!(a.grant_id(), b.grant_id());
2036 assert_eq!(a.grant_id(), a.clone().grant_id());
2037 assert_eq!(
2038 path("src/b/x.rs", "src/a/x.rs").grant_id(),
2039 path("src/b/x.rs", "src/b/x.rs").grant_id()
2040 );
2041 assert!(a.grant_id().starts_with("g-") && a.grant_id().len() == 18);
2042 }
2043
2044 proptest::proptest! {
2045 #[test]
2046 fn on_autopilot_no_call_waits_for_a_person(words in proptest::collection::vec("[a-z]{1,6}", 1..4), tool in 0usize..4) {
2047 let p = Policy::new(&[], &[]).autopilot();
2048 let argv: Vec<String> = words.clone();
2049 let (name, args) = match tool {
2050 0 => ("run_command", json!({ "argv": argv })),
2051 1 => ("write_file", json!({ "path": format!("src/{}.rs", words[0]), "content": "" })),
2052 2 => ("write_file", json!({ "path": format!(".github/{}.yml", words[0]), "content": "" })),
2053 _ => ("mcp__x__y", json!({})),
2054 };
2055 let before = Policy::new(&[], &[]).classify(name, &args, None);
2056 let after = p.settle(p.classify(name, &args, None));
2057 proptest::prop_assert!(!matches!(after, Decision::Approval(_)));
2058 if matches!(before, Decision::Deny(_)) {
2059 proptest::prop_assert!(matches!(after, Decision::Deny(_)));
2060 }
2061 if before == Decision::Allow {
2062 proptest::prop_assert_eq!(after, Decision::Allow);
2063 }
2064 }
2065 }
2066
2067 const OPENSPEC_READS: &[&[&str]] = &[&["openspec", "list"], &["openspec", "show"]];
2068
2069 fn exploring() -> Policy {
2070 Policy::new(&[vec!["cargo".to_owned(), "test".to_owned()]], &[]).exploring(Exploring {
2071 paths: |rel| rel.starts_with(".specify/assessments/"),
2072 commands: OPENSPEC_READS,
2073 })
2074 }
2075
2076 proptest::proptest! {
2077 #[test]
2081 fn exploring_denies_what_it_does_not_allow_and_never_asks(words in proptest::collection::vec("[a-z]{1,6}", 1..4), tool in 0usize..6) {
2082 let p = exploring();
2083 let (name, args) = match tool {
2084 0 => ("run_command", json!({ "argv": words })),
2085 1 => ("run_command", json!({ "argv": ["cargo", "test"] })),
2086 2 => ("write_file", json!({ "path": format!("src/{}.rs", words[0]), "content": "" })),
2087 3 => ("edit_file", json!({ "path": format!("specs/{}/spec.md", words[0]), "old_string": "a", "new_string": "b" })),
2088 4 => ("run_command", json!({ "argv": ["openspec", "list", words[0].clone()], "network": true })),
2089 _ => ("mcp__x__y", json!({})),
2090 };
2091 let d = p.classify(name, &args, None);
2092 proptest::prop_assert!(matches!(d, Decision::Deny(_)), "{name} {args} → {d:?}");
2093 }
2094 }
2095
2096 #[test]
2097 fn exploring_reads_and_runs_the_formats_read_only_commands() {
2098 let p = exploring();
2099 assert_eq!(
2100 p.classify("read_file", &json!({ "path": "src/lib.rs" }), None),
2101 Decision::Allow
2102 );
2103 assert_eq!(
2104 p.classify(
2105 "run_command",
2106 &json!({ "argv": ["openspec", "list", "--json"] }),
2107 None
2108 ),
2109 Decision::Allow
2110 );
2111 assert_eq!(
2112 p.classify(
2113 "write_file",
2114 &json!({ "path": ".specify/assessments/csv/notes.md", "content": "" }),
2115 None
2116 ),
2117 Decision::Allow
2118 );
2119 let Decision::Deny(why) = p.classify(
2120 "write_file",
2121 &json!({ "path": "notes.md", "content": "" }),
2122 None,
2123 ) else {
2124 panic!("a write outside the exploration paths is denied");
2125 };
2126 assert_eq!(why, EXPLORING_WRITE);
2127 }
2128
2129 #[test]
2130 fn a_path_object_is_its_canonical_file_whatever_the_spelling() {
2131 assert_eq!(
2132 path("src/b/x.rs", "src/a/x.rs"),
2133 path("src/b/x.rs", "src/b/x.rs")
2134 );
2135 assert_ne!(
2136 path("src/b/x.rs", "src/a/x.rs"),
2137 path("src/a/x.rs", "src/a/x.rs")
2138 );
2139 assert!(
2140 path("src/b/x.rs", "src/a/x.rs")
2141 .changed_from(&path("src/a/x.rs", "src/a/x.rs"))
2142 .is_some_and(|c| c == "resolves to src/b/x.rs, was src/a/x.rs")
2143 );
2144 let a = command("make check");
2146 let mut b = a.clone();
2147 if let Object::Command { cwd, .. } = &mut b {
2148 *cwd = "sub".to_owned();
2149 }
2150 assert_ne!(a, b);
2151 assert_eq!(a.class(), Class::Command);
2152 assert!(a.short().starts_with("argv#") && a.short().len() == 17);
2153 let back: Object = serde_json::from_value(serde_json::to_value(&a).unwrap()).unwrap();
2155 assert_eq!(back, a);
2156 assert!(serde_json::from_value::<Object>(json!({ "kind": "write", "path": "x" })).is_err());
2157 assert!(
2158 serde_json::from_value::<Rejection>(json!({ "grant": { "kind": "path", "canonical": "x", "asked": "x" }, "reason": "r" }))
2159 .is_err()
2160 );
2161 }
2162
2163 #[test]
2164 fn the_grant_path_has_no_pattern() {
2165 let source = include_str!("policy.rs");
2167 let classify = source
2168 .split("pub fn classify(")
2169 .nth(1)
2170 .and_then(|s| s.split("\n }\n").next())
2171 .unwrap_or_default();
2172 assert!(!classify.is_empty());
2173 for pattern in ["starts_with", "glob", "prefix"] {
2174 assert!(!classify.contains(pattern), "{pattern} in the grant path");
2175 }
2176 }
2177}