1use std::path::{Path, PathBuf};
15use std::sync::{Arc, Mutex};
16
17use serde_json::{Value, json};
18
19use super::calls::ToolRequest;
20use crate::approvals::{
21 Answer, Approver, Decision as Said, PresentContext, Request, Scope, Who, present,
22};
23use crate::progress::{Event, Progress};
24use crate::tools::WorkspaceTools;
25use crate::tools::policy::{Decision, Object, Policy};
26
27pub type Command = (Vec<String>, PathBuf, Vec<(String, String)>);
29
30pub const AGENT_CALL: &str = "agent_call";
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct PermissionOption {
36 pub id: String,
38 pub kind: String,
40}
41
42#[derive(Debug, Clone, PartialEq)]
44pub enum Verdict {
45 Allow(Option<Object>),
47 Deny(String),
49 Stop,
52}
53
54pub struct Governor {
56 root: PathBuf,
57 tools: WorkspaceTools,
58 policy: Policy,
59 approver: Option<Arc<dyn Approver>>,
60 present: PresentContext,
61 task: String,
62 task_title: String,
63 run: Option<String>,
64 frozen_additions: bool,
65 progress: Option<(String, Arc<dyn Progress>)>,
66 copied: Vec<String>,
68 agent: String,
70 asking: std::sync::atomic::AtomicU64,
72 asking_since: Mutex<Option<std::time::Instant>>,
74 state: Mutex<State>,
75}
76
77impl std::fmt::Debug for Governor {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 f.debug_struct("Governor")
80 .field("root", &self.root)
81 .field("task", &self.task)
82 .finish_non_exhaustive()
83 }
84}
85
86#[derive(Debug, Default)]
87struct State {
88 once: Vec<Object>,
90 remembered: Vec<(Object, &'static str)>,
92 log: Vec<Value>,
94 pending: Option<Value>,
96 said: String,
98 stopped: bool,
100 asked: u32,
102 decided: std::collections::HashSet<String>,
104 allowed: std::collections::HashSet<String>,
106 request: Option<String>,
108 unasked: u32,
110}
111
112pub struct Setup {
114 pub root: PathBuf,
116 pub tools: WorkspaceTools,
118 pub policy: Policy,
120 pub approver: Option<Arc<dyn Approver>>,
122 pub present: PresentContext,
124 pub task: String,
126 pub task_title: String,
128 pub run: Option<String>,
130 pub frozen_additions: bool,
132 pub run_approved: Vec<Object>,
134 pub approved_once: Vec<Object>,
136 pub progress: Option<(String, Arc<dyn Progress>)>,
138 pub copied: Vec<String>,
141 pub agent: String,
143}
144
145#[must_use]
149pub fn argv_of_line(line: &str) -> Vec<String> {
150 const SHELL: &[char] = &[
151 '|', '&', ';', '<', '>', '(', ')', '$', '`', '\\', '"', '\'', '*', '?', '[', ']', '{', '}',
152 '~', '!', '#', '\n', '=',
153 ];
154 let line = line.trim_matches(|c: char| c.is_ascii_whitespace());
155 let odd_space = line
156 .chars()
157 .any(|c| c.is_whitespace() && !c.is_ascii_whitespace());
158 if line.contains(SHELL)
159 || odd_space
160 || line
161 .chars()
162 .any(|c| c.is_control() && !c.is_ascii_whitespace())
163 {
164 return vec!["sh".to_owned(), "-c".to_owned(), line.to_owned()];
165 }
166 line.split_ascii_whitespace().map(str::to_owned).collect()
167}
168
169#[must_use]
172pub fn commands_of_line(line: &str) -> Option<Vec<Vec<String>>> {
173 let mut parts = Vec::new();
174 let mut rest = line.trim();
175 while !rest.is_empty() {
176 let at = ["&&", "||", ";", "\n"]
177 .iter()
178 .filter_map(|sep| rest.find(sep).map(|i| (i, sep.len())))
179 .min();
180 let (part, next) = match at {
181 Some((i, n)) => (&rest[..i], &rest[i + n..]),
182 None => (rest, ""),
183 };
184 let argv = argv_of_line(part);
185 if argv.is_empty() {
186 return None;
187 }
188 if argv.first().is_some_and(|p| p == "sh") && argv.get(1).is_some_and(|f| f == "-c") {
189 return None;
190 }
191 parts.push(argv);
192 rest = next.trim();
193 }
194 (!parts.is_empty()).then_some(parts)
195}
196
197fn shell_line(argv: &[String]) -> Option<&str> {
200 let [shell, flag, line] = argv else {
201 return None;
202 };
203 let name = shell.rsplit('/').next().unwrap_or(shell);
204 (matches!(name, "sh" | "bash" | "zsh" | "dash")
205 && matches!(flag.as_str(), "-c" | "-lc" | "-ic"))
206 .then_some(line.as_str())
207}
208
209const READS: &[(&str, &[&str])] = &[
212 ("cat", &["-n"]),
213 ("head", &["-n N", "-c N"]),
214 ("tail", &["-n N", "-c N"]),
215 ("wc", &["-l", "-w", "-c", "-m"]),
216];
217
218const COMMAND_KEYS: &[&str] = &[
220 "command", "commands", "cmd", "argv", "args", "script", "shell", "bash",
221];
222
223const PATH_KEYS: &[&str] = &[
225 "path",
226 "paths",
227 "file_path",
228 "filePath",
229 "fileName",
230 "file",
231 "files",
232 "notebook_path",
233 "destination",
234 "new_path",
235 "newPath",
236 "old_path",
237 "source",
238 "target",
239 "cwd",
240 "workdir",
241 "directory",
242 "dir",
243];
244
245fn known_keys(kind: &str) -> Option<&'static [&'static str]> {
248 Some(match kind {
249 "read" => &[
250 "path",
251 "paths",
252 "file_path",
253 "filePath",
254 "fileName",
255 "line",
256 "limit",
257 "offset",
258 "start_line",
259 "end_line",
260 ],
261 "search" => &[
262 "pattern",
263 "query",
264 "path",
265 "paths",
266 "glob",
267 "include",
268 "file_path",
269 ],
270 "edit" => &[
271 "path",
272 "file_path",
273 "filePath",
274 "fileName",
275 "old_string",
276 "new_string",
277 "replace_all",
278 "content",
279 "file_text",
280 "diff",
281 "edits",
282 ],
283 "delete" => &["path", "paths", "file_path", "filePath", "fileName"],
284 "move" => &[
285 "path",
286 "file_path",
287 "source",
288 "destination",
289 "new_path",
290 "newPath",
291 "target",
292 ],
293 "think" => &[
294 "thought",
295 "thoughts",
296 "todos",
297 "plan",
298 "text",
299 "description",
300 ],
301 _ => return None,
302 })
303}
304
305const EDIT_KEYS: &[&str] = &[
307 "path",
308 "file_path",
309 "old_string",
310 "new_string",
311 "replace_all",
312];
313
314const SECRET_NAMES: &[&str] = &[
317 "**/.git-credentials",
318 "**/*.tfstate*",
319 "**/*credentials*",
320 "**/*.p12",
321 "**/*.pfx",
322 "**/*.kdbx",
323 "**/id_*",
324 "**/.netrc",
325 "**/.pgpass",
326 "**/.npmrc",
327 "**/.pypirc",
328 "**/.dockercfg",
329 "**/.docker/config.json",
330 "**/kubeconfig",
331 "**/.kube/config",
332 "**/.htpasswd",
333 "**/*secret*",
334 "**/token",
335 "**/*.token",
336 "**/token.txt",
337 "**/*.jks",
338 "**/*.keystore",
339 "**/deploy_key*",
340];
341
342fn secret_name(rel: &str) -> bool {
343 static SET: std::sync::OnceLock<Option<globset::GlobSet>> = std::sync::OnceLock::new();
344 SET.get_or_init(|| {
345 let mut b = globset::GlobSetBuilder::new();
346 for g in SECRET_NAMES {
347 if let Ok(glob) = globset::GlobBuilder::new(g).case_insensitive(true).build() {
348 b.add(glob);
349 }
350 }
351 b.build().ok()
352 })
353 .as_ref()
354 .is_none_or(|s| s.is_match(rel))
355}
356
357#[must_use]
361pub fn plain_read(argv: &[String]) -> Option<String> {
362 let (program, args) = argv.split_first()?;
363 let (_, options) = READS.iter().find(|(p, _)| p == program)?;
364 let mut file = None;
365 let mut words = args.iter();
366 while let Some(a) = words.next() {
367 if let Some(flag) = a.strip_prefix('-') {
368 if options.contains(&a.as_str()) {
369 continue;
370 }
371 let mut chars = flag.chars();
373 let letter = chars.next()?;
374 let rest: String = chars.collect();
375 if !options.contains(&format!("-{letter} N").as_str()) {
376 return None;
377 }
378 let number = if rest.is_empty() {
379 words.next()?.clone()
380 } else {
381 rest
382 };
383 if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit()) {
384 return None;
385 }
386 continue;
387 }
388 if file.replace(a.clone()).is_some() {
389 return None;
390 }
391 }
392 file
393}
394
395pub const READS_GRANT: &str = "reads";
397
398fn canonical(v: &Value) -> String {
400 fn sorted(v: &Value) -> Value {
401 match v {
402 Value::Object(o) => {
403 let mut keys: Vec<&String> = o.keys().collect();
404 keys.sort();
405 let mut out = serde_json::Map::new();
406 for k in keys {
407 out.insert(k.clone(), sorted(&o[k]));
408 }
409 Value::Object(out)
410 }
411 Value::Array(a) => Value::Array(a.iter().map(sorted).collect()),
412 other => other.clone(),
413 }
414 }
415 sorted(v).to_string()
416}
417
418fn execute_keeps_to(raw: &Value, argv: &[String]) -> bool {
422 let Some(o) = raw.as_object() else {
423 return false;
424 };
425 let line = argv.join(" ");
426 o.iter().all(|(k, v)| match k.as_str() {
427 "command" => true,
428 "commands" => v.as_array().is_some_and(|a| {
429 a.len() == 1
430 && a[0].as_str().is_some_and(|c| {
431 c.split_ascii_whitespace().collect::<Vec<_>>().join(" ") == line
432 })
433 }),
434 "cwd" | "working_directory" | "workdir" => {
435 v.as_str().is_some_and(|c| c.is_empty() || c == ".")
436 }
437 "timeout" => v.is_u64(),
438 "description" => v.is_string(),
439 _ => false,
440 })
441}
442
443#[must_use]
447pub fn holds_a_credential(text: &str) -> bool {
448 static PATTERNS: std::sync::OnceLock<Option<regex::RegexSet>> = std::sync::OnceLock::new();
449 PATTERNS
450 .get_or_init(|| {
451 regex::RegexSet::new([
452 r"-----BEGIN [A-Z ]*PRIVATE KEY-----",
453 r"[a-zA-Z][a-zA-Z0-9+.-]*://[^\s/:@]+:[^\s/@]+@",
454 r"\bAKIA[0-9A-Z]{16}\b",
455 r"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}",
456 r"\bgithub_pat_[A-Za-z0-9_]{20,}",
457 r"\bxox[abprs]-[A-Za-z0-9-]{10,}",
458 r"\bsk-[A-Za-z0-9_-]{16,}",
459 r#"(?i)(api[_-]?key|secret|passw(or)?d|token|private[_-]?key)["']?\s*[:=]\s*["']?[^\s"']{4,}"#,
460 ])
461 .ok()
462 })
463 .as_ref()
464 .is_none_or(|set| set.is_match(text))
465}
466
467pub const RUNS_ITSELF: &str = "the agent runs it itself, outside speccoder's sandbox";
469
470#[derive(Debug, Clone, PartialEq)]
472enum Judged {
473 Granted(String, &'static str),
476 Think,
478 Deny(String),
480 Person(String, Object),
482}
483
484fn strings_at(v: &Value, keys: &[&str], out: &mut Vec<Value>) {
486 match v {
487 Value::Object(o) => {
488 for (k, x) in o {
489 if keys.contains(&k.as_str()) {
490 out.push(x.clone());
491 }
492 strings_at(x, keys, out);
493 }
494 }
495 Value::Array(a) => a.iter().for_each(|x| strings_at(x, keys, out)),
496 _ => {}
497 }
498}
499
500fn command_of(v: &Value) -> Vec<Vec<String>> {
502 match v {
503 Value::String(line) => vec![argv_of_line(line)],
504 Value::Array(a) if a.iter().all(Value::is_string) => {
505 vec![
506 a.iter()
507 .filter_map(|v| v.as_str().map(str::to_owned))
508 .collect(),
509 ]
510 }
511 Value::Array(a) => a.iter().flat_map(command_of).collect(),
512 other => vec![vec!["sh".to_owned(), "-c".to_owned(), other.to_string()]],
515 }
516}
517
518fn keeps_to(kind: &str, raw: &Value) -> bool {
520 let Some(keys) = known_keys(kind) else {
521 return false;
522 };
523 let Some(o) = raw.as_object() else {
524 return raw.is_null();
525 };
526 o.iter().all(|(k, v)| {
527 keys.contains(&k.as_str())
528 && match k.as_str() {
529 "line" | "limit" | "offset" | "start_line" | "end_line" => v.is_u64(),
530 "replace_all" => v.is_boolean(),
531 "paths" => v.as_array().is_some_and(|a| a.iter().all(Value::is_string)),
532 "todos" => v.is_array() || v.is_string(),
533 "edits" => v.as_array().is_some_and(|a| {
534 a.iter().all(|e| {
535 e.as_object().is_some_and(|e| {
536 e.iter().all(|(k, v)| {
537 EDIT_KEYS.contains(&k.as_str())
538 && (v.is_string() || (k == "replace_all" && v.is_boolean()))
539 })
540 })
541 })
542 }),
543 _ => v.is_string(),
544 }
545 })
546}
547
548impl Governor {
549 #[must_use]
551 pub fn new(s: Setup) -> Self {
552 let remembered = s
553 .run_approved
554 .into_iter()
555 .map(|o| (o, "run-grant"))
556 .collect();
557 Self {
558 root: s.root,
559 tools: s.tools,
560 policy: s.policy,
561 approver: s.approver,
562 present: s.present,
563 task: s.task,
564 task_title: s.task_title,
565 run: s.run,
566 frozen_additions: s.frozen_additions,
567 progress: s.progress,
568 copied: s.copied,
569 agent: s.agent,
570 asking: std::sync::atomic::AtomicU64::new(0),
571 asking_since: Mutex::new(None),
572 state: Mutex::new(State {
573 once: s.approved_once,
574 remembered,
575 ..State::default()
576 }),
577 }
578 }
579
580 #[must_use]
582 pub fn root(&self) -> &Path {
583 &self.root
584 }
585
586 #[must_use]
588 pub fn log(&self) -> Vec<Value> {
589 self.state.lock().map(|s| s.log.clone()).unwrap_or_default()
590 }
591
592 #[must_use]
594 pub fn pending(&self) -> Option<Value> {
595 self.state.lock().ok().and_then(|s| s.pending.clone())
596 }
597
598 #[must_use]
600 pub fn stopped(&self) -> bool {
601 self.state.lock().map_or(true, |s| s.stopped)
602 }
603
604 #[must_use]
606 pub fn asked(&self) -> u32 {
607 self.state.lock().map_or(0, |s| s.asked)
608 }
609
610 #[must_use]
613 pub fn asking(&self) -> std::time::Duration {
614 let done =
615 std::time::Duration::from_millis(self.asking.load(std::sync::atomic::Ordering::SeqCst));
616 let now = self
617 .asking_since
618 .lock()
619 .ok()
620 .and_then(|s| s.map(|t| t.elapsed()))
621 .unwrap_or_default();
622 done + now
623 }
624
625 pub fn said(&self, text: &str) {
627 if let Ok(mut s) = self.state.lock() {
628 s.said.push_str(text);
629 }
630 }
631
632 pub fn reported(&self, call: &ToolRequest) {
637 let mut arguments = call.raw_input.clone().unwrap_or(Value::Null);
638 cap(&mut arguments);
639 crate::secrets::mask_json(&mut arguments);
640 let ok = call.status.as_deref() == Some("completed");
641 let mut held = Vec::new();
642 strings_at(
643 call.raw_input.as_ref().unwrap_or(&Value::Null),
644 COMMAND_KEYS,
645 &mut held,
646 );
647 let effect = matches!(
648 call.kind.as_deref(),
649 Some("edit" | "delete" | "move" | "execute")
650 ) || !held.is_empty();
651 let entry = json!({
652 "tool": format!("agent:{}", call.kind.as_deref().unwrap_or("other")),
653 "arguments": arguments,
654 "title": crate::secrets::mask(&call.shown_title()),
655 "decision": "reported",
656 "ok": ok,
657 "by": "agent",
658 "call": call.id,
659 });
660 self.emit(&entry);
661 let unasked = if let Ok(mut s) = self.state.lock() {
662 s.said.clear();
663 let failed = call.status.as_deref() == Some("failed");
666 let new = effect
667 && ((ok && !s.allowed.contains(&call.id))
668 || (failed && !s.decided.contains(&call.id)));
669 if new {
670 s.unasked += 1;
671 }
672 s.log.push(entry);
673 new.then_some(s.unasked)
674 } else {
675 None
676 };
677 if let Some(calls) = unasked {
678 self.event(Event::AgentUnasked {
679 task: self.task_id(),
680 calls,
681 });
682 }
683 }
684
685 #[must_use]
689 pub fn unasked(&self) -> u32 {
690 self.state.lock().map_or(0, |s| s.unasked)
691 }
692
693 pub fn stop(&self) {
695 if let Ok(mut s) = self.state.lock() {
696 s.stopped = true;
697 }
698 }
699
700 fn rel(&self, path: &Path) -> Result<String, String> {
704 let full = if path.is_relative() {
705 self.root.join(path)
706 } else {
707 path.to_path_buf()
708 };
709 let mut base = full.clone();
712 let mut rest = Vec::new();
713 while std::fs::symlink_metadata(&base).is_err() {
714 match (
715 base.file_name().map(std::ffi::OsStr::to_os_string),
716 base.parent(),
717 ) {
718 (Some(name), Some(parent)) => {
719 rest.push(name);
720 base = parent.to_path_buf();
721 }
722 _ => break,
723 }
724 }
725 let outside = || {
726 format!(
727 "`{}` is outside the worktree; an attempt works only inside it",
728 path.display()
729 )
730 };
731 let mut canonical = std::fs::canonicalize(&base).map_err(|_| outside())?;
732 for name in rest.iter().rev() {
733 if name == ".." {
734 return Err(outside());
735 }
736 canonical.push(name);
737 }
738 let r = canonical
739 .strip_prefix(&self.root)
740 .map_err(|_| outside())?
741 .to_string_lossy()
742 .replace('\\', "/");
743 Ok(if r.is_empty() { ".".to_owned() } else { r })
744 }
745
746 fn plain_file(&self, rel: &str) -> bool {
754 use std::os::unix::fs::MetadataExt as _;
755 let folded = rel.to_lowercase();
757 if self.policy.protected().is_secret(rel)
758 || self.policy.protected().is_secret(&folded)
759 || secret_name(rel)
760 || self.copied.iter().any(|c| c.eq_ignore_ascii_case(rel))
761 || folded
762 .split('/')
763 .any(|part| part == ".git" || part.starts_with(".speccoder"))
764 {
765 return false;
766 }
767 let mut at = self.root.clone();
768 for part in Path::new(rel).components() {
769 at.push(part);
770 match std::fs::symlink_metadata(&at) {
771 Ok(m) if !m.file_type().is_symlink() => {}
772 _ => return false,
773 }
774 }
775 let Ok(m) = std::fs::symlink_metadata(&at) else {
776 return false;
777 };
778 if !m.is_file() || m.nlink() != 1 || m.len() > crate::tools::READ_CAP as u64 {
779 return false;
780 }
781 let not_ignored = std::process::Command::new("git")
783 .args(["check-ignore", "-q", "--"])
784 .arg(rel)
785 .current_dir(&self.root)
786 .status()
787 .is_ok_and(|s| s.code() == Some(1));
788 if !not_ignored {
789 return false;
790 }
791 std::fs::read(&at).is_ok_and(|b| {
792 let text = String::from_utf8_lossy(&b);
793 crate::secrets::mask(&text) == text && !holds_a_credential(&text)
794 })
795 }
796
797 fn judge(&self, call: &ToolRequest) -> Judged {
802 let raw = call.raw_input.clone().unwrap_or(Value::Null);
803 let kind = call.kind.as_deref().unwrap_or("other");
804 let mut held = Vec::new();
806 strings_at(&raw, COMMAND_KEYS, &mut held);
807 let mut argvs: Vec<Vec<String>> = Vec::new();
808 for v in &held {
809 for argv in command_of(v) {
810 match shell_line(&argv).and_then(commands_of_line) {
811 Some(parts) => argvs.extend(parts),
812 None => argvs.push(argv),
813 }
814 }
815 }
816 argvs.retain(|a| !a.is_empty());
817 let mut named = Vec::new();
819 strings_at(&raw, PATH_KEYS, &mut named);
820 let mut paths: Vec<PathBuf> = call.locations.clone();
821 for v in &named {
822 match v {
823 Value::String(p) => paths.push(PathBuf::from(p)),
824 Value::Array(a) => {
825 paths.extend(a.iter().filter_map(Value::as_str).map(PathBuf::from))
826 }
827 _ => {}
828 }
829 }
830 if let Some(changes) = raw.get("changes").and_then(Value::as_object) {
831 paths.extend(changes.keys().map(PathBuf::from));
832 }
833 let mut rels = Vec::new();
834 for p in &paths {
835 match self.rel(p) {
836 Ok(r) => rels.push(r),
837 Err(e) => return Judged::Deny(e),
838 }
839 }
840 let reading = argvs.len() == 1 && plain_read(&argvs[0]).is_some();
842 if reading && let Some(file) = plain_read(&argvs[0]) {
843 match self.rel(Path::new(&file)) {
844 Ok(r) => rels.push(r),
845 Err(e) => return Judged::Deny(e),
846 }
847 }
848 rels.sort();
849 rels.dedup();
850 let writes = matches!(kind, "edit" | "delete" | "move");
851 for r in &rels {
852 if self.policy.protected().is_secret(r)
853 || self.policy.protected().is_secret(&r.to_lowercase())
854 {
855 return Judged::Deny(format!("`{r}` looks like a credential"));
856 }
857 if writes && let Some(class) = self.policy.write_protected(r) {
858 return Judged::Deny(format!(
859 "`{r}` is protected: {}",
860 self.policy.protected().reason(class)
861 ));
862 }
863 }
864 let exact = Object::Agent {
867 agent: self.agent.clone(),
868 call: kind.to_owned(),
869 argvs: argvs.clone(),
870 paths: rels.clone(),
871 input: crate::integrity::sha256_hex(
872 canonical(&json!([kind, raw, call.locations])).as_bytes(),
873 ),
874 };
875 if kind == "fetch" {
876 return Judged::Deny(
877 "fetching from the network: an attempt's commands run without it".to_owned(),
878 );
879 }
880 if kind == "switch_mode" {
881 return Judged::Deny(
882 "the agent is kept in the mode that asks before acting".to_owned(),
883 );
884 }
885 if kind == "think" && argvs.is_empty() && keeps_to("think", &raw) {
886 return Judged::Think;
887 }
888 let read_shaped = match kind {
890 "read" => argvs.is_empty() && keeps_to("read", &raw) && rels.len() == 1,
891 "execute" => reading && rels.len() == 1 && execute_keeps_to(&raw, &argvs[0]),
892 _ => false,
893 };
894 if read_shaped && self.plain_file(&rels[0]) {
895 let reads = self.reads_object();
896 if let Some(scope) = self.granted(&reads) {
897 return Judged::Granted(rels[0].clone(), scope);
898 }
899 return Judged::Person(format!("the agent reads `{}` itself", rels[0]), reads);
900 }
901 let why = if !argvs.is_empty() || kind == "execute" {
902 RUNS_ITSELF.to_owned()
903 } else if !keeps_to(kind, &raw) {
904 "speccoder does not know everything this call carries".to_owned()
905 } else {
906 match kind {
907 "read" | "search" => "the agent reads files itself".to_owned(),
908 "edit" | "delete" | "move" => format!("the agent changes files itself ({kind})"),
909 _ => "no rule can tell what this call does".to_owned(),
910 }
911 };
912 Judged::Person(why, exact)
913 }
914
915 fn reads_object(&self) -> Object {
917 Object::Agent {
918 agent: self.agent.clone(),
919 call: READS_GRANT.to_owned(),
920 argvs: Vec::new(),
921 paths: Vec::new(),
922 input: String::new(),
923 }
924 }
925
926 pub fn permission(&self, call: &ToolRequest, options: &[PermissionOption]) -> Option<String> {
930 let pick = |kinds: &[&str]| {
931 kinds
932 .iter()
933 .find_map(|k| options.iter().find(|o| o.kind == *k))
934 .map(|o| o.id.clone())
935 };
936 let reject = || pick(&["reject_once", "reject_always"]);
937 if let Ok(mut s) = self.state.lock() {
938 s.decided.insert(call.id.clone());
939 s.request = Some(call.id.clone());
940 }
941 let args = call_args(call);
942 let verdict = if self.stopped() {
943 Verdict::Stop
944 } else {
945 match self.judge(call) {
946 Judged::Granted(rel, scope) => {
947 self.record(json!({ "tool": "read_file", "arguments": { "path": rel }, "decision": "allow", "ok": true, "by": "person", "approved_by": "person", "scope": scope, "object": self.reads_object(), "rule": "your reads for this task" }));
948 Verdict::Allow(None)
949 }
950 Judged::Think => {
951 self.record(json!({ "tool": AGENT_CALL, "arguments": args, "decision": "allow", "by": "rule", "rule": "thinking" }));
952 Verdict::Allow(None)
953 }
954 Judged::Deny(reason) => {
955 self.record(json!({ "tool": AGENT_CALL, "arguments": args, "decision": "deny", "reason": reason, "by": "rule", "rule": "agent call" }));
956 Verdict::Deny(reason)
957 }
958 Judged::Person(reason, object) => self.person(&args, &reason, object),
959 }
960 };
961 let answer = match &verdict {
962 Verdict::Allow(_) => pick(&["allow_once"]).or_else(|| {
965 self.record(json!({ "tool": AGENT_CALL, "arguments": args, "decision": "deny", "reason": "the agent offered no allow-once option", "by": "rule", "rule": "agent options" }));
966 reject()
967 }),
968 Verdict::Deny(_) => reject(),
969 Verdict::Stop => None,
970 };
971 if answer.is_some()
972 && answer == pick(&["allow_once"])
973 && let Ok(mut s) = self.state.lock()
974 {
975 s.allowed.insert(call.id.clone());
976 }
977 self.settle_request(
978 call,
979 match (&verdict, &answer) {
980 (Verdict::Allow(_), Some(a)) if Some(a) == pick(&["allow_once"]).as_ref() => {
981 "allow"
982 }
983 (Verdict::Stop, _) => "approval",
984 _ => "deny",
985 },
986 );
987 answer
988 }
989
990 fn settle_request(&self, call: &ToolRequest, decision: &str) {
992 let request = self.state.lock().ok().and_then(|mut s| s.request.take());
993 if request.is_some() {
994 let target = crate::secrets::mask(&call.shown_title());
995 self.emit(&json!({
996 "tool": format!("agent:{}", call.kind.as_deref().unwrap_or("other")),
997 "arguments": {},
998 "title": target,
999 "decision": decision,
1000 "ok": decision == "allow",
1001 }));
1002 }
1003 }
1004
1005 pub fn abandon(&self, call: &ToolRequest) {
1008 self.record(json!({ "tool": AGENT_CALL, "arguments": call_args(call), "decision": "deny", "reason": "the decision failed", "by": "rule", "rule": "failure" }));
1009 self.settle_request(call, "deny");
1010 }
1011
1012 fn person(&self, args: &Value, reason: &str, object: Object) -> Verdict {
1015 if let Some(r) = self.policy.rejection(&object) {
1016 let why = format!("a person rejected this: {}", r.reason);
1017 self.record(json!({ "tool": AGENT_CALL, "arguments": args, "decision": "deny", "reason": why, "object": object, "by": "person", "scope": "task-grant" }));
1018 return Verdict::Deny(why);
1019 }
1020 if let Some(scope) = self.granted(&object) {
1021 self.record(json!({ "tool": AGENT_CALL, "arguments": args, "decision": "allow", "ok": true, "object": object, "by": "person", "approved_by": "person", "scope": scope }));
1022 return Verdict::Allow(Some(object));
1023 }
1024 let mut request = self.request(AGENT_CALL, args, reason, Some(&object));
1025 if matches!(&object, Object::Agent { call, .. } if call == READS_GRANT) {
1027 request.kind = READS_GRANT.to_owned();
1028 }
1029 self.ask(AGENT_CALL, args, Some(object), reason, request)
1030 }
1031
1032 fn granted(&self, object: &Object) -> Option<&'static str> {
1035 if self.policy.approved().iter().any(|a| a == object) {
1036 return Some("task-grant");
1037 }
1038 let mut s = self.state.lock().ok()?;
1039 if let Some(at) = s.once.iter().position(|r| r == object) {
1040 s.once.remove(at);
1041 return Some("once-grant");
1042 }
1043 s.remembered
1044 .iter()
1045 .find(|(r, _)| r == object)
1046 .map(|(_, scope)| *scope)
1047 }
1048
1049 pub fn decide(&self, tool: &str, args: &Value, call: Option<&ToolRequest>) -> Verdict {
1051 let _ = call;
1052 self.decide_as(tool, args)
1053 }
1054
1055 fn decide_as(&self, tool: &str, args: &Value) -> Verdict {
1058 if self.stopped() {
1059 return Verdict::Deny("the attempt is ending".to_owned());
1060 }
1061 let mut decision = self.policy.base(tool, args);
1062 let mut additions_only = false;
1065 if self.frozen_additions
1066 && tool == "write_file"
1067 && matches!(&decision, Decision::Approval(r) if r.contains("frozen test"))
1068 && let (Some(path), Some(new)) = (args["path"].as_str(), args["content"].as_str())
1069 && let Ok(old) = std::fs::read_to_string(self.root.join(path))
1070 && crate::integrity::only_additions(&old, new)
1071 {
1072 decision = Decision::Allow;
1073 additions_only = true;
1074 }
1075 let mut object: Option<Object> = None;
1076 if matches!(decision, Decision::Approval(_)) {
1077 let resolved = self.tools.resolve(tool, args);
1078 if resolved["ok"] == true {
1079 object = serde_json::from_value(resolved["object"].clone()).ok();
1080 decision = self.policy.classify(tool, args, object.as_ref());
1081 } else {
1082 decision = Decision::Deny(
1083 resolved["error"]
1084 .as_str()
1085 .unwrap_or("the call's object cannot be resolved")
1086 .to_owned(),
1087 );
1088 }
1089 }
1090 let mut person: Option<Value> = None;
1091 if decision == Decision::Allow && object.is_some() {
1092 person = Some(
1093 json!({ "approved_by": "person", "scope": "task-grant", "grant": { "scope": "task", "run": self.run } }),
1094 );
1095 }
1096 if matches!(decision, Decision::Approval(_))
1097 && let Some(o) = &object
1098 && let Ok(mut s) = self.state.lock()
1099 {
1100 if let Some(at) = s.once.iter().position(|r| r == o) {
1101 s.once.remove(at);
1102 person = Some(
1103 json!({ "approved_by": "person", "scope": "once-grant", "grant": { "scope": "once", "run": self.run } }),
1104 );
1105 decision = Decision::Allow;
1106 } else if let Some((_, scope)) = s.remembered.iter().find(|(r, _)| r == o) {
1107 person = Some(
1108 json!({ "approved_by": "person", "scope": scope, "grant": { "scope": scope.trim_end_matches("-grant"), "run": self.run } }),
1109 );
1110 decision = Decision::Allow;
1111 }
1112 }
1113 decision = self.policy.settle(decision);
1114 match decision {
1115 Decision::Allow => {
1116 let mut entry =
1117 json!({ "tool": tool, "arguments": args, "decision": "allow", "ok": true });
1118 if tool == "run_command" {
1119 entry["executed_by"] = json!("speccoder");
1120 entry["sandboxed"] = json!(true);
1121 }
1122 self.decided_by(&mut entry, object.as_ref(), person.as_ref(), tool, args);
1123 if additions_only {
1124 entry["frozen_additions"] = json!(true);
1125 }
1126 self.record(entry);
1127 Verdict::Allow(object)
1128 }
1129 Decision::Deny(reason) => {
1130 let mut entry = json!({ "tool": tool, "arguments": args, "decision": "deny", "reason": reason });
1131 self.decided_by(&mut entry, object.as_ref(), person.as_ref(), tool, args);
1132 self.record(entry);
1133 Verdict::Deny(reason)
1134 }
1135 Decision::Approval(reason) => {
1136 let request = self.request(tool, args, &reason, object.as_ref());
1137 self.ask(tool, args, object, &reason, request)
1138 }
1139 }
1140 }
1141
1142 fn decided_by(
1144 &self,
1145 entry: &mut Value,
1146 object: Option<&Object>,
1147 person: Option<&Value>,
1148 tool: &str,
1149 args: &Value,
1150 ) {
1151 if let Some(o) = object {
1152 entry["object"] = json!(o);
1153 }
1154 match person.and_then(Value::as_object) {
1155 Some(p) => {
1156 entry["by"] = json!("person");
1157 if let Some(e) = entry.as_object_mut() {
1158 e.extend(p.clone());
1159 }
1160 }
1161 None => {
1162 entry["by"] = json!("rule");
1163 entry["rule"] = json!(self.policy.rule(tool, args, object));
1164 }
1165 }
1166 }
1167
1168 fn request(&self, tool: &str, args: &Value, reason: &str, object: Option<&Object>) -> Request {
1171 let kind = crate::approvals::kind_of(tool, args);
1172 let risk = crate::approvals::risk(tool, reason);
1173 let said = self
1174 .state
1175 .lock()
1176 .map(|s| s.said.clone())
1177 .unwrap_or_default();
1178 let changed = object.and_then(|o| {
1179 self.policy
1180 .approved()
1181 .iter()
1182 .find_map(|earlier| o.changed_from(earlier))
1183 });
1184 Request {
1185 task: self.task.clone(),
1186 task_title: self.task_title.clone(),
1187 turn: 0,
1188 tool: tool.to_owned(),
1189 arguments: args.clone(),
1190 kind: kind.to_owned(),
1191 reason: reason.to_owned(),
1192 object: object.cloned(),
1193 changed,
1194 risk: risk.to_owned(),
1195 claim: crate::approvals::claim(&crate::secrets::mask(&said)),
1196 options: crate::approvals::options(risk, object.is_some(), false, kind, risk),
1197 run: self.run.clone(),
1198 ..Request::default()
1199 }
1200 }
1201
1202 fn ask(
1204 &self,
1205 tool: &str,
1206 args: &Value,
1207 object: Option<Object>,
1208 reason: &str,
1209 mut request: Request,
1210 ) -> Verdict {
1211 self.event(Event::ApprovalRequested {
1212 task: self.task_id(),
1213 call: String::new(),
1214 tool: tool.to_owned(),
1215 what: format!("{tool} {}", crate::progress::target(tool, args))
1216 .chars()
1217 .filter(|c| !c.is_control())
1218 .take(60)
1219 .collect(),
1220 class: object
1221 .as_ref()
1222 .map(|o| o.class().as_str().to_owned())
1223 .unwrap_or_default(),
1224 object: object.as_ref().map(Object::short).unwrap_or_default(),
1225 });
1226 let Some(approver) = &self.approver else {
1227 return self.wait(tool, args, object, reason, request);
1228 };
1229 if let Ok(mut s) = self.state.lock() {
1230 s.asked += 1;
1231 request.asked = s.asked;
1232 }
1233 let context = PresentContext {
1234 root: Some(self.root.clone()),
1235 ..self.present.clone()
1236 };
1237 let began = std::time::Instant::now();
1238 if let Ok(mut s) = self.asking_since.lock() {
1239 *s = Some(began);
1240 }
1241 let answer: Answer = approver.ask(&present(&request, &context));
1242 self.asking.fetch_add(
1243 u64::try_from(began.elapsed().as_millis()).unwrap_or(u64::MAX),
1244 std::sync::atomic::Ordering::SeqCst,
1245 );
1246 if let Ok(mut s) = self.asking_since.lock() {
1247 *s = None;
1248 }
1249 let answer = if self.stopped() && answer.decision == Said::Approve {
1252 Answer::reject("the attempt ended while you decided", answer.by)
1253 } else {
1254 answer
1255 };
1256 let by = answer.by.clone();
1257 let approver_v = serde_json::to_value(&by).unwrap_or(Value::Null);
1258 let operator = by.as_ref().map(Who::view);
1259 self.event(Event::ApprovalDecided {
1260 task: self.task_id(),
1261 call: String::new(),
1262 decision: if answer.decision == Said::Approve {
1263 "approve"
1264 } else {
1265 "reject"
1266 }
1267 .to_owned(),
1268 scope: (answer.decision == Said::Approve).then(|| answer.scope.name().to_owned()),
1269 by: by
1270 .as_ref()
1271 .map_or_else(|| "unknown".to_owned(), |w| w.name.clone()),
1272 how: by
1273 .as_ref()
1274 .map_or_else(|| "unknown".to_owned(), |w| w.basis.clone()),
1275 class: object
1276 .as_ref()
1277 .map(|o| o.class().as_str().to_owned())
1278 .unwrap_or_default(),
1279 object: object.as_ref().map(Object::short).unwrap_or_default(),
1280 });
1281 let mut entry =
1282 json!({ "tool": tool, "arguments": args, "reason": reason, "by": "person" });
1283 if let Some(o) = &object {
1284 entry["object"] = json!(o);
1285 }
1286 match answer.decision {
1287 Said::Approve => {
1288 entry["decision"] = json!("allow");
1289 entry["ok"] = json!(true);
1290 entry["approved_by"] = json!("person");
1291 entry["scope"] = json!(answer.scope.name());
1292 entry["note"] = json!(answer.note);
1293 entry["approver"] = approver_v;
1294 entry["operator"] = json!(operator);
1295 if answer.always {
1296 entry["always"] = json!(true);
1297 }
1298 if let (Some(o), Scope::Task | Scope::Run) = (&object, answer.scope) {
1299 entry["grant"] = json!({ "scope": answer.scope.name(), "run": self.run });
1300 if let Ok(mut s) = self.state.lock() {
1301 s.remembered.push((
1302 o.clone(),
1303 if answer.scope == Scope::Run {
1304 "run-grant"
1305 } else {
1306 "task-grant"
1307 },
1308 ));
1309 }
1310 }
1311 self.record(entry);
1312 Verdict::Allow(object)
1313 }
1314 Said::Reject => {
1315 let why = answer
1316 .reason
1317 .unwrap_or_else(|| "no reason given".to_owned());
1318 entry["decision"] = json!("deny");
1319 entry["rejected_by"] = json!("person");
1320 entry["approver"] = approver_v;
1321 entry["operator"] = json!(operator);
1322 entry["rejection"] = json!(why);
1323 self.record(entry);
1324 Verdict::Deny(format!("a person rejected this: {why}"))
1325 }
1326 Said::Stop => self.wait(tool, args, object, reason, request),
1328 }
1329 }
1330
1331 fn wait(
1333 &self,
1334 tool: &str,
1335 args: &Value,
1336 object: Option<Object>,
1337 reason: &str,
1338 request: Request,
1339 ) -> Verdict {
1340 let mut entry =
1341 json!({ "tool": tool, "arguments": args, "decision": "approval", "reason": reason });
1342 if let Some(o) = &object {
1343 entry["object"] = json!(o);
1344 }
1345 self.record(entry);
1346 if let Ok(mut s) = self.state.lock() {
1347 if s.pending.is_none() {
1348 s.pending = Some(json!({
1349 "tool": tool,
1350 "arguments": args,
1351 "reason": reason,
1352 "object": object,
1353 "run": self.run,
1354 "request": request,
1355 }));
1356 }
1357 s.stopped = true;
1358 }
1359 Verdict::Stop
1360 }
1361
1362 pub async fn read_text_file(
1369 &self,
1370 path: &Path,
1371 line: Option<u32>,
1372 limit: Option<u32>,
1373 ) -> Result<String, String> {
1374 let rel = self.rel(path)?;
1375 let start = line.map(|l| l.max(1) as usize);
1376 let end = limit.map(|n| start.unwrap_or(1) + (n as usize).saturating_sub(1));
1377 let args = json!({ "path": rel, "start_line": start, "end_line": end });
1378 match self.decide("read_file", &json!({ "path": rel }), None) {
1379 Verdict::Allow(_) => {}
1380 Verdict::Deny(r) => return Err(format!("denied: {r}")),
1381 Verdict::Stop => return Err("the attempt is ending".to_owned()),
1382 }
1383 let out = self.tools.run("read_file", &args).await;
1384 if out["ok"] != true {
1385 return Err(out["error"].as_str().unwrap_or("not readable").to_owned());
1386 }
1387 if out["binary"] == true {
1388 return Err(format!("`{rel}` is not a text file"));
1389 }
1390 let mut content = out["content"].as_str().unwrap_or_default().to_owned();
1391 if out["end_line"] == out["total_lines"]
1393 && out["truncated"] != true
1394 && std::fs::read(self.root.join(&rel)).is_ok_and(|b| b.ends_with(b"\n"))
1395 {
1396 content.push('\n');
1397 }
1398 Ok(content)
1399 }
1400
1401 pub async fn write_text_file(&self, path: &Path, content: &str) -> Result<(), String> {
1408 let rel = self.rel(path)?;
1409 let args = json!({ "path": rel, "content": content });
1410 let object = match self.decide("write_file", &args, None) {
1411 Verdict::Allow(o) => o,
1412 Verdict::Deny(r) => return Err(format!("denied: {r}")),
1413 Verdict::Stop => {
1414 return Err("the attempt is ending: this write waits for a person".to_owned());
1415 }
1416 };
1417 let mut call = args;
1418 if let Some(o) = object {
1419 call[crate::tools::EXPECT] = json!(o);
1420 }
1421 let out = self.tools.run("write_file", &call).await;
1422 if out["ok"] == true {
1423 Ok(())
1424 } else {
1425 Err(out["error"].as_str().unwrap_or("not written").to_owned())
1426 }
1427 }
1428
1429 pub fn command(&self, argv: Vec<String>, cwd: Option<&Path>) -> Result<Command, String> {
1436 let mut args = json!({ "argv": argv });
1437 if let Some(c) = cwd {
1438 args["cwd"] = json!(self.rel(c)?);
1439 }
1440 let object = match self.decide("run_command", &args, None) {
1441 Verdict::Allow(o) => o,
1442 Verdict::Deny(r) => return Err(format!("denied: {r}")),
1443 Verdict::Stop => {
1444 return Err("the attempt is ending: this command waits for a person".to_owned());
1445 }
1446 };
1447 let dir = self.tools.command_cwd(args["cwd"].as_str()).map_err(|e| {
1448 e["error"]
1449 .as_str()
1450 .unwrap_or("no such directory")
1451 .to_owned()
1452 })?;
1453 if let Some(Object::Command { executable, .. }) = object {
1455 let now = self.tools.resolve("run_command", &args);
1456 if now["object"]["executable"] != json!(executable) {
1457 return Err(format!(
1458 "the object changed since it was approved: {executable} → {}",
1459 now["object"]["executable"]
1460 ));
1461 }
1462 }
1463 Ok((
1464 crate::tools::policy::argv_of(&args),
1465 dir,
1466 self.tools.command_env(None),
1467 ))
1468 }
1469
1470 fn task_id(&self) -> String {
1471 self.task
1472 .rsplit('/')
1473 .next()
1474 .unwrap_or(&self.task)
1475 .to_owned()
1476 }
1477
1478 fn event(&self, e: Event) {
1479 if let Some((_, p)) = &self.progress {
1480 p.event(&e);
1481 }
1482 }
1483
1484 fn emit(&self, entry: &Value) {
1485 let mut entry = entry.clone();
1486 cap(&mut entry);
1487 crate::secrets::mask_json(&mut entry);
1488 let entry = &entry;
1489 if let Some((task, p)) = &self.progress {
1490 let tool = entry["tool"].as_str().unwrap_or_default();
1491 p.event(&Event::ToolCall {
1492 task: task.clone(),
1493 turn: 0,
1494 tool: tool.to_owned(),
1495 target: match entry["title"].as_str() {
1496 Some(t) => crate::progress::target("", &json!({ "path": t })),
1497 None => crate::progress::target(tool, &entry["arguments"]),
1498 },
1499 decision: entry["decision"].as_str().unwrap_or_default().to_owned(),
1500 ok: entry["ok"].as_bool(),
1501 tail: Vec::new(),
1502 contained: false,
1504 });
1505 }
1506 }
1507
1508 fn record(&self, mut entry: Value) {
1509 entry["via"] = json!("acp");
1510 if entry["object"]["kind"] == "agent" {
1512 entry["executed_by"] = json!("agent");
1513 entry["sandboxed"] = json!(false);
1514 }
1515 cap(&mut entry);
1517 crate::secrets::mask_json(&mut entry);
1518 let Ok(mut s) = self.state.lock() else { return };
1519 match &s.request {
1521 Some(id) => entry["request"] = json!(id),
1522 None => self.emit(&entry),
1523 }
1524 s.log.push(entry);
1525 }
1526}
1527
1528fn call_args(call: &ToolRequest) -> Value {
1530 let mut raw = call.raw_input.clone().unwrap_or(Value::Null);
1531 cap(&mut raw);
1532 let mut v = json!({
1533 "title": call.shown_title(),
1534 "kind": call.kind,
1535 "rawInput": raw,
1536 "locations": call.locations,
1537 });
1538 crate::secrets::mask_json(&mut v);
1539 v
1540}
1541
1542const KEPT: usize = 4096;
1544
1545fn cap(v: &mut Value) {
1548 match v {
1549 Value::String(s) if s.len() > KEPT => {
1550 let mut at = KEPT;
1551 while !s.is_char_boundary(at) {
1552 at -= 1;
1553 }
1554 let n = s.len();
1555 s.truncate(at);
1556 s.push_str(&format!("… [{n} bytes]"));
1557 }
1558 Value::Array(a) => a.iter_mut().for_each(cap),
1559 Value::Object(o) => o.values_mut().for_each(cap),
1560 _ => {}
1561 }
1562}
1563
1564#[cfg(test)]
1565mod tests {
1566 use super::*;
1567
1568 struct Tree {
1571 _d: tempfile::TempDir,
1572 _o: tempfile::TempDir,
1573 root: PathBuf,
1574 out: PathBuf,
1575 }
1576
1577 fn tree() -> Tree {
1578 let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1579 let o = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1580 let root = std::fs::canonicalize(d.path()).unwrap_or_else(|e| panic!("{e}"));
1581 let out = std::fs::canonicalize(o.path()).unwrap_or_else(|e| panic!("{e}"));
1582 let w = |p: &str, t: &str| std::fs::write(root.join(p), t).unwrap_or_default();
1583 std::fs::create_dir_all(root.join("src")).unwrap_or_default();
1584 w("src/lib.rs", "x\n");
1585 w(".env", "TOKEN=abc\n");
1586 w("settings.local", "TOKEN=abc\n");
1587 w(".git-credentials", "https://u:p@example.com\n");
1588 w("terraform.tfstate", "{}\n");
1589 std::fs::write(out.join("credentials"), "secret\n").unwrap_or_default();
1590 std::fs::hard_link(out.join("credentials"), root.join("hard.txt")).unwrap_or_default();
1591 std::os::unix::fs::symlink(out.join("credentials"), root.join("notes.txt"))
1592 .unwrap_or_default();
1593 std::os::unix::fs::symlink(&out, root.join("docs")).unwrap_or_default();
1594 let _ = std::process::Command::new("git")
1595 .args(["init", "-q"])
1596 .current_dir(&root)
1597 .output();
1598 Tree {
1599 _d: d,
1600 _o: o,
1601 root,
1602 out,
1603 }
1604 }
1605
1606 fn governor_with(root: &Path, run_approved: Vec<Object>) -> Governor {
1607 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1608 let policy = Policy::new(
1609 &[argv("cargo test"), argv("git diff")],
1610 &[argv("make deploy")],
1611 );
1612 let tools = WorkspaceTools::new(root, policy.clone()).unwrap_or_else(|e| panic!("{e}"));
1613 Governor::new(Setup {
1614 root: root.to_path_buf(),
1615 tools,
1616 policy,
1617 approver: None,
1618 present: PresentContext::default(),
1619 task: "f/T1".to_owned(),
1620 task_title: "t".to_owned(),
1621 run: None,
1622 frozen_additions: false,
1623 run_approved,
1624 approved_once: Vec::new(),
1625 progress: None,
1626 copied: vec!["settings.local".to_owned()],
1627 agent: "copilot".to_owned(),
1628 })
1629 }
1630
1631 const OPTIONS: [(&str, &str); 2] = [("allow", "allow_once"), ("reject", "reject_once")];
1632
1633 fn options() -> Vec<PermissionOption> {
1634 OPTIONS
1635 .iter()
1636 .map(|(id, kind)| PermissionOption {
1637 id: (*id).to_owned(),
1638 kind: (*kind).to_owned(),
1639 })
1640 .collect()
1641 }
1642
1643 fn call(kind: &str, raw: Value, locations: Vec<PathBuf>) -> ToolRequest {
1644 ToolRequest {
1645 id: "c1".to_owned(),
1646 title: Some("Read file".to_owned()),
1647 kind: Some(kind.to_owned()),
1648 raw_input: Some(raw),
1649 locations,
1650 status: None,
1651 }
1652 }
1653
1654 fn answer(t: &Tree, kind: &str, raw: Value, locations: Vec<PathBuf>) -> Option<String> {
1657 governor_with(&t.root, Vec::new()).permission(&call(kind, raw, locations), &options())
1658 }
1659
1660 fn allow() -> Option<String> {
1661 Some("allow".to_owned())
1662 }
1663
1664 fn reads(t: &Tree) -> Governor {
1666 let g = governor_with(&t.root, Vec::new());
1667 let r = g.reads_object();
1668 governor_with(&t.root, vec![r])
1669 }
1670
1671 const PLAIN_READS: [(&str, &str); 5] = [
1672 ("execute", r#"{ "command": "cat src/lib.rs" }"#),
1673 ("execute", r#"{ "command": "cat\tsrc/lib.rs" }"#),
1674 ("execute", r#"{ "command": "head -n 5 src/lib.rs" }"#),
1675 (
1676 "execute",
1677 r#"{ "command": ["/bin/zsh", "-lc", "cat src/lib.rs"] }"#,
1678 ),
1679 ("read", r#"{ "path": "src/lib.rs", "line": 1, "limit": 5 }"#),
1680 ];
1681
1682 #[test]
1683 fn nothing_the_agent_does_itself_is_allowed_without_a_person() {
1684 let t = tree();
1685 for (kind, raw) in PLAIN_READS {
1686 let raw: Value = serde_json::from_str(raw).unwrap_or_default();
1687 assert_eq!(answer(&t, kind, raw.clone(), vec![]), None, "{raw}");
1688 }
1689 }
1690
1691 #[test]
1692 fn a_persons_reads_for_the_task_cover_plain_reads_of_plain_files_only() {
1693 let t = tree();
1694 for (kind, raw) in PLAIN_READS {
1695 let raw: Value = serde_json::from_str(raw).unwrap_or_default();
1696 assert_eq!(
1697 reads(&t).permission(&call(kind, raw.clone(), vec![]), &options()),
1698 allow(),
1699 "{raw}"
1700 );
1701 }
1702 let w = |p: &str, text: &str| {
1703 if let Some(dir) = Path::new(p).parent() {
1704 std::fs::create_dir_all(t.root.join(dir)).unwrap_or_default();
1705 }
1706 std::fs::write(t.root.join(p), text).unwrap_or_default();
1707 };
1708 w("deploy_key", "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n");
1709 w(
1710 "notes/key.pem.txt",
1711 "-----BEGIN RSA PRIVATE KEY-----\nabc\n",
1712 );
1713 w("config.ini", "password: hunter2hunter2\n");
1714 w(
1715 ".git/config",
1716 "[remote]\nurl = https://user:pass@example.com/r.git\n",
1717 );
1718 w(
1719 "conf/remote.txt",
1720 "url = https://user:pass@example.com/r.git\n",
1721 );
1722 w("secrets.yaml", "x: 1\n");
1723 w(".htpasswd", "admin:$apr1$abc$def\n");
1724 w("token.txt", "abcdefabcdefabcdef\n");
1725 w("ci.txt", "GH=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345\n");
1726 w(".speccoder/x", "x\n");
1727 for file in [
1728 "deploy_key",
1729 "notes/key.pem.txt",
1730 "config.ini",
1731 ".git/config",
1732 "conf/remote.txt",
1733 "secrets.yaml",
1734 ".htpasswd",
1735 "token.txt",
1736 "ci.txt",
1737 ".speccoder/x",
1738 "settings.local",
1739 "hard.txt",
1740 "terraform.tfstate",
1741 ] {
1742 let raw = json!({ "command": format!("cat {file}") });
1743 assert_ne!(
1744 reads(&t).permission(&call("execute", raw, vec![]), &options()),
1745 allow(),
1746 "{file}"
1747 );
1748 }
1749 for (kind, raw) in [
1752 ("edit", json!({ "command": "cat src/lib.rs" })),
1753 ("delete", json!({ "command": "cat src/lib.rs" })),
1754 ("move", json!({ "command": "cat src/lib.rs" })),
1755 ("other", json!({ "command": "cat src/lib.rs" })),
1756 ("think", json!({ "command": "cat src/lib.rs" })),
1757 (
1758 "switch_mode",
1759 json!({ "command": "cat src/lib.rs", "mode": "bypassPermissions" }),
1760 ),
1761 ("fetch", json!({ "command": "cat src/lib.rs" })),
1762 ("read", json!({ "command": "cat src/lib.rs" })),
1763 (
1764 "execute",
1765 json!({ "command": "cat lib.rs", "working_directory": "src" }),
1766 ),
1767 (
1768 "execute",
1769 json!({ "command": "cat src/lib.rs", "env": { "LD_PRELOAD": "x" } }),
1770 ),
1771 (
1772 "execute",
1773 json!({ "command": "cat src/lib.rs", "stdin": "x" }),
1774 ),
1775 ] {
1776 assert_ne!(
1777 reads(&t).permission(&call(kind, raw.clone(), vec![]), &options()),
1778 allow(),
1779 "{kind} {raw}"
1780 );
1781 }
1782 }
1783
1784 #[derive(Debug)]
1786 struct Answers(Mutex<Vec<Answer>>);
1787
1788 impl Approver for Answers {
1789 fn ask(&self, _: &crate::approvals::Presentation) -> Answer {
1790 let mut a = self
1791 .0
1792 .lock()
1793 .unwrap_or_else(std::sync::PoisonError::into_inner);
1794 if a.is_empty() {
1795 Answer::reject("no", None)
1796 } else {
1797 a.remove(0)
1798 }
1799 }
1800 }
1801
1802 #[test]
1803 fn for_this_task_covers_exactly_that_request_again() {
1804 let t = tree();
1805 for (granted, same, other) in [
1806 (
1807 json!({ "command": "make a && make b" }),
1808 json!({ "command": "make a && make b" }),
1809 json!({ "command": "make a; make b" }),
1810 ),
1811 (
1812 json!({ "command": "make" }),
1813 json!({ "command": "make" }),
1814 json!({ "command": "make", "cwd": "src" }),
1815 ),
1816 (
1817 json!({ "command": "make" }),
1818 json!({ "command": "make" }),
1819 json!({ "command": "make", "env": { "A": "1" } }),
1820 ),
1821 (
1822 json!({ "path": "src/a", "destination": "src/b" }),
1823 json!({ "path": "src/a", "destination": "src/b" }),
1824 json!({ "path": "src/b", "destination": "src/a" }),
1825 ),
1826 (
1827 json!({ "file_path": "src/lib.rs", "content": "a" }),
1828 json!({ "file_path": "src/lib.rs", "content": "a" }),
1829 json!({ "file_path": "src/lib.rs", "content": "b" }),
1830 ),
1831 ] {
1832 let kind = if granted.get("command").is_some() {
1833 "execute"
1834 } else if granted.get("destination").is_some() {
1835 "move"
1836 } else {
1837 "edit"
1838 };
1839 let mut g = governor_with(&t.root, Vec::new());
1840 g.approver = Some(Arc::new(Answers(Mutex::new(vec![Answer::approve(
1841 Scope::Task,
1842 None,
1843 None,
1844 )]))));
1845 assert_eq!(
1846 g.permission(&call(kind, granted.clone(), vec![]), &options()),
1847 allow()
1848 );
1849 assert_eq!(
1850 g.permission(&call(kind, same, vec![]), &options()),
1851 allow(),
1852 "{granted}"
1853 );
1854 assert_eq!(
1855 g.permission(&call(kind, other.clone(), vec![]), &options()),
1856 Some("reject".to_owned()),
1857 "{other}"
1858 );
1859 }
1860 }
1861
1862 #[test]
1863 fn a_request_naming_anything_outside_or_a_credential_is_refused() {
1864 let t = tree();
1865 let src = || vec![PathBuf::from("src/lib.rs")];
1866 let refused = Some("reject".to_owned());
1867 for (kind, raw, loc) in [
1868 ("read", json!({ "path": t.out.join("credentials") }), src()),
1869 (
1870 "edit",
1871 json!({ "file_path": t.out.join("x"), "content": "x" }),
1872 src(),
1873 ),
1874 (
1875 "move",
1876 json!({ "path": "src/lib.rs", "destination": t.out.join("lib.rs") }),
1877 vec![],
1878 ),
1879 (
1880 "edit",
1881 json!({ "edits": [{ "path": t.out.join("x"), "new_string": "x" }] }),
1882 src(),
1883 ),
1884 (
1885 "edit",
1886 json!({ "command": "ls", "file_path": t.out.join("x") }),
1887 vec![],
1888 ),
1889 ("edit", json!({ "file_path": "notes.txt" }), vec![]),
1890 ("read", json!({ "path": "notes.txt" }), vec![]),
1891 ("execute", json!({ "command": "cat notes.txt" }), vec![]),
1892 ("execute", json!({ "command": "cat .env" }), vec![]),
1893 ("execute", json!({ "command": "cat .ENV" }), vec![]),
1894 (
1895 "execute",
1896 json!({ "command": "cat src/lib.rs", "cwd": t.out }),
1897 vec![],
1898 ),
1899 ] {
1900 assert_eq!(answer(&t, kind, raw.clone(), loc), refused, "{kind} {raw}");
1901 }
1902 }
1903
1904 #[test]
1905 fn a_grant_of_speccoders_own_calls_never_covers_the_agents_and_back() {
1906 let t = tree();
1907 let tools =
1909 WorkspaceTools::new(&t.root, Policy::default()).unwrap_or_else(|e| panic!("{e}"));
1910 let native: Object = serde_json::from_value(
1911 tools.resolve("run_command", &json!({ "argv": ["make", "deploy"] }))["object"].clone(),
1912 )
1913 .unwrap_or_else(|e| panic!("{e}"));
1914 let g = governor_with(&t.root, vec![native]);
1915 assert_eq!(
1916 g.permission(
1917 &call("execute", json!({ "command": "make deploy" }), vec![]),
1918 &options()
1919 ),
1920 None
1921 );
1922 let mut g = governor_with(&t.root, Vec::new());
1925 g.approver = Some(Arc::new(Answers(Mutex::new(vec![Answer::approve(
1926 Scope::Task,
1927 None,
1928 None,
1929 )]))));
1930 let lint = call("execute", json!({ "command": "make lint" }), vec![]);
1931 assert_eq!(g.permission(&lint, &options()), allow());
1932 assert_eq!(g.permission(&lint, &options()), allow());
1933 assert!(
1934 g.command(vec!["make".to_owned(), "lint".to_owned()], None)
1935 .is_err()
1936 );
1937 }
1938
1939 #[test]
1940 fn a_refused_or_unasked_change_the_agent_made_counts_as_deciding_alone() {
1941 let t = tree();
1942 let g = governor_with(&t.root, Vec::new());
1943 let mut refused = call(
1944 "edit",
1945 json!({ "file_path": t.out.join("x"), "content": "x" }),
1946 vec![],
1947 );
1948 refused.id = "d1".to_owned();
1949 assert_eq!(
1950 g.permission(&refused, &options()),
1951 Some("reject".to_owned())
1952 );
1953 refused.status = Some("completed".to_owned());
1954 g.reported(&refused);
1955 let mut other = call("other", json!({ "command": "rm -rf x" }), vec![]);
1956 other.id = "u1".to_owned();
1957 other.status = Some("completed".to_owned());
1958 g.reported(&other);
1959 let mut failed = call("execute", json!({ "command": "rm -rf x" }), vec![]);
1960 failed.id = "u2".to_owned();
1961 failed.status = Some("failed".to_owned());
1962 g.reported(&failed);
1963 assert_eq!(g.unasked(), 3);
1964 let g = reads(&t);
1966 let mut read = call("read", json!({ "path": "src/lib.rs" }), vec![]);
1967 read.id = "r1".to_owned();
1968 assert_eq!(g.permission(&read, &options()), allow());
1969 read.status = Some("completed".to_owned());
1970 g.reported(&read);
1971 refused.id = "d2".to_owned();
1972 refused.status = None;
1973 let _ = g.permission(&refused, &options());
1974 refused.status = Some("failed".to_owned());
1975 g.reported(&refused);
1976 assert_eq!(g.unasked(), 0);
1977 }
1978
1979 #[derive(Debug)]
1981 struct YesTooLate(Arc<std::sync::OnceLock<Arc<Governor>>>);
1982
1983 impl Approver for YesTooLate {
1984 fn ask(&self, _: &crate::approvals::Presentation) -> Answer {
1985 if let Some(g) = self.0.get() {
1986 g.stop();
1987 }
1988 Answer::approve(Scope::Task, None, None)
1989 }
1990 }
1991
1992 #[test]
1993 fn a_yes_after_the_attempt_ended_allows_nothing() {
1994 let t = tree();
1995 let cell = Arc::new(std::sync::OnceLock::new());
1996 let mut g = governor_with(&t.root, Vec::new());
1997 g.approver = Some(Arc::new(YesTooLate(Arc::clone(&cell))));
1998 let g = Arc::new(g);
1999 let _ = cell.set(Arc::clone(&g));
2000 let c = call("execute", json!({ "command": "make lint" }), vec![]);
2001 assert_eq!(g.permission(&c, &options()), Some("reject".to_owned()));
2002 }
2003
2004 #[test]
2005 fn what_the_governor_records_is_masked_and_cut() {
2006 let t = tree();
2007 crate::secrets::register("GOV_TOKEN", "tok-Zx81-secret-value-gov");
2008 let g = governor_with(&t.root, Vec::new());
2009 let big = format!("tok-Zx81-secret-value-gov{}", "x".repeat(10_000));
2010 let mut c = call(
2011 "edit",
2012 json!({ "file_path": "src/lib.rs", "content": big }),
2013 vec![],
2014 );
2015 c.title = Some("Write tok-Zx81-secret-value-gov".to_owned());
2016 let _ = g.permission(&c, &options());
2017 let log = serde_json::to_string(&g.log()).unwrap_or_default();
2018 assert!(!log.contains("tok-Zx81-secret-value-gov"), "{log}");
2019 assert!(log.len() < 9000, "{}", log.len());
2020 }
2021
2022 #[test]
2023 fn a_plain_command_is_split_and_a_shell_line_is_left_to_sh() {
2024 assert_eq!(
2025 argv_of_line("cargo test -p core"),
2026 ["cargo", "test", "-p", "core"]
2027 );
2028 assert_eq!(argv_of_line("ls | grep x"), ["sh", "-c", "ls | grep x"]);
2029 assert_eq!(
2030 argv_of_line("git commit -m \"x\""),
2031 ["sh", "-c", "git commit -m \"x\""]
2032 );
2033 assert_eq!(argv_of_line("A=1 make"), ["sh", "-c", "A=1 make"]);
2034 assert_eq!(
2035 commands_of_line("cat src/lib.rs; ls && cargo test"),
2036 Some(vec![
2037 vec!["cat".to_owned(), "src/lib.rs".to_owned()],
2038 vec!["ls".to_owned()],
2039 vec!["cargo".to_owned(), "test".to_owned()],
2040 ])
2041 );
2042 assert_eq!(commands_of_line("cat a | grep b"), None);
2043 assert_eq!(commands_of_line("echo $(id)"), None);
2044 }
2045}