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