use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use super::calls::ToolRequest;
use crate::approvals::{
Answer, Approver, Decision as Said, PresentContext, Request, Scope, Who, present,
};
use crate::progress::{Event, Progress};
use crate::tools::WorkspaceTools;
use crate::tools::policy::{Decision, Object, Policy};
pub type Command = (Vec<String>, PathBuf, Vec<(String, String)>);
pub const AGENT_CALL: &str = "agent_call";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionOption {
pub id: String,
pub kind: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Verdict {
Allow(Option<Object>),
Deny(String),
Stop,
}
pub struct Governor {
root: PathBuf,
tools: WorkspaceTools,
policy: Policy,
approver: Option<Arc<dyn Approver>>,
present: PresentContext,
task: String,
task_title: String,
run: Option<String>,
frozen_additions: bool,
progress: Option<(String, Arc<dyn Progress>)>,
copied: Vec<String>,
agent: String,
asking: std::sync::atomic::AtomicU64,
asking_since: Mutex<Option<std::time::Instant>>,
state: Mutex<State>,
}
impl std::fmt::Debug for Governor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Governor")
.field("root", &self.root)
.field("task", &self.task)
.finish_non_exhaustive()
}
}
#[derive(Debug, Default)]
struct State {
once: Vec<Object>,
remembered: Vec<(Object, &'static str)>,
log: Vec<Value>,
pending: Option<Value>,
said: String,
stopped: bool,
asked: u32,
decided: std::collections::HashSet<String>,
allowed: std::collections::HashSet<String>,
request: Option<String>,
unasked: u32,
}
pub struct Setup {
pub root: PathBuf,
pub tools: WorkspaceTools,
pub policy: Policy,
pub approver: Option<Arc<dyn Approver>>,
pub present: PresentContext,
pub task: String,
pub task_title: String,
pub run: Option<String>,
pub frozen_additions: bool,
pub run_approved: Vec<Object>,
pub approved_once: Vec<Object>,
pub progress: Option<(String, Arc<dyn Progress>)>,
pub copied: Vec<String>,
pub agent: String,
}
#[must_use]
pub fn argv_of_line(line: &str) -> Vec<String> {
const SHELL: &[char] = &[
'|', '&', ';', '<', '>', '(', ')', '$', '`', '\\', '"', '\'', '*', '?', '[', ']', '{', '}',
'~', '!', '#', '\n', '=',
];
let line = line.trim_matches(|c: char| c.is_ascii_whitespace());
let odd_space = line
.chars()
.any(|c| c.is_whitespace() && !c.is_ascii_whitespace());
if line.contains(SHELL)
|| odd_space
|| line
.chars()
.any(|c| c.is_control() && !c.is_ascii_whitespace())
{
return vec!["sh".to_owned(), "-c".to_owned(), line.to_owned()];
}
line.split_ascii_whitespace().map(str::to_owned).collect()
}
#[must_use]
pub fn commands_of_line(line: &str) -> Option<Vec<Vec<String>>> {
let mut parts = Vec::new();
let mut rest = line.trim();
while !rest.is_empty() {
let at = ["&&", "||", ";", "\n"]
.iter()
.filter_map(|sep| rest.find(sep).map(|i| (i, sep.len())))
.min();
let (part, next) = match at {
Some((i, n)) => (&rest[..i], &rest[i + n..]),
None => (rest, ""),
};
let argv = argv_of_line(part);
if argv.is_empty() {
return None;
}
if argv.first().is_some_and(|p| p == "sh") && argv.get(1).is_some_and(|f| f == "-c") {
return None;
}
parts.push(argv);
rest = next.trim();
}
(!parts.is_empty()).then_some(parts)
}
fn shell_line(argv: &[String]) -> Option<&str> {
let [shell, flag, line] = argv else {
return None;
};
let name = shell.rsplit('/').next().unwrap_or(shell);
(matches!(name, "sh" | "bash" | "zsh" | "dash")
&& matches!(flag.as_str(), "-c" | "-lc" | "-ic"))
.then_some(line.as_str())
}
const READS: &[(&str, &[&str])] = &[
("cat", &["-n"]),
("head", &["-n N", "-c N"]),
("tail", &["-n N", "-c N"]),
("wc", &["-l", "-w", "-c", "-m"]),
];
const COMMAND_KEYS: &[&str] = &[
"command", "commands", "cmd", "argv", "args", "script", "shell", "bash",
];
const PATH_KEYS: &[&str] = &[
"path",
"paths",
"file_path",
"filePath",
"fileName",
"file",
"files",
"notebook_path",
"destination",
"new_path",
"newPath",
"old_path",
"source",
"target",
"cwd",
"workdir",
"directory",
"dir",
];
fn known_keys(kind: &str) -> Option<&'static [&'static str]> {
Some(match kind {
"read" => &[
"path",
"paths",
"file_path",
"filePath",
"fileName",
"line",
"limit",
"offset",
"start_line",
"end_line",
],
"search" => &[
"pattern",
"query",
"path",
"paths",
"glob",
"include",
"file_path",
],
"edit" => &[
"path",
"file_path",
"filePath",
"fileName",
"old_string",
"new_string",
"replace_all",
"content",
"file_text",
"diff",
"edits",
],
"delete" => &["path", "paths", "file_path", "filePath", "fileName"],
"move" => &[
"path",
"file_path",
"source",
"destination",
"new_path",
"newPath",
"target",
],
"think" => &[
"thought",
"thoughts",
"todos",
"plan",
"text",
"description",
],
_ => return None,
})
}
const EDIT_KEYS: &[&str] = &[
"path",
"file_path",
"old_string",
"new_string",
"replace_all",
];
const SECRET_NAMES: &[&str] = &[
"**/.git-credentials",
"**/*.tfstate*",
"**/*credentials*",
"**/*.p12",
"**/*.pfx",
"**/*.kdbx",
"**/id_*",
"**/.netrc",
"**/.pgpass",
"**/.npmrc",
"**/.pypirc",
"**/.dockercfg",
"**/.docker/config.json",
"**/kubeconfig",
"**/.kube/config",
"**/.htpasswd",
"**/*secret*",
"**/token",
"**/*.token",
"**/token.txt",
"**/*.jks",
"**/*.keystore",
"**/deploy_key*",
];
fn secret_name(rel: &str) -> bool {
static SET: std::sync::OnceLock<Option<globset::GlobSet>> = std::sync::OnceLock::new();
SET.get_or_init(|| {
let mut b = globset::GlobSetBuilder::new();
for g in SECRET_NAMES {
if let Ok(glob) = globset::GlobBuilder::new(g).case_insensitive(true).build() {
b.add(glob);
}
}
b.build().ok()
})
.as_ref()
.is_none_or(|s| s.is_match(rel))
}
#[must_use]
pub fn plain_read(argv: &[String]) -> Option<String> {
let (program, args) = argv.split_first()?;
let (_, options) = READS.iter().find(|(p, _)| p == program)?;
let mut file = None;
let mut words = args.iter();
while let Some(a) = words.next() {
if let Some(flag) = a.strip_prefix('-') {
if options.contains(&a.as_str()) {
continue;
}
let mut chars = flag.chars();
let letter = chars.next()?;
let rest: String = chars.collect();
if !options.contains(&format!("-{letter} N").as_str()) {
return None;
}
let number = if rest.is_empty() {
words.next()?.clone()
} else {
rest
};
if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit()) {
return None;
}
continue;
}
if file.replace(a.clone()).is_some() {
return None;
}
}
file
}
pub const READS_GRANT: &str = "reads";
fn canonical(v: &Value) -> String {
fn sorted(v: &Value) -> Value {
match v {
Value::Object(o) => {
let mut keys: Vec<&String> = o.keys().collect();
keys.sort();
let mut out = serde_json::Map::new();
for k in keys {
out.insert(k.clone(), sorted(&o[k]));
}
Value::Object(out)
}
Value::Array(a) => Value::Array(a.iter().map(sorted).collect()),
other => other.clone(),
}
}
sorted(v).to_string()
}
fn execute_keeps_to(raw: &Value, argv: &[String]) -> bool {
let Some(o) = raw.as_object() else {
return false;
};
let line = argv.join(" ");
o.iter().all(|(k, v)| match k.as_str() {
"command" => true,
"commands" => v.as_array().is_some_and(|a| {
a.len() == 1
&& a[0].as_str().is_some_and(|c| {
c.split_ascii_whitespace().collect::<Vec<_>>().join(" ") == line
})
}),
"cwd" | "working_directory" | "workdir" => {
v.as_str().is_some_and(|c| c.is_empty() || c == ".")
}
"timeout" => v.is_u64(),
"description" => v.is_string(),
_ => false,
})
}
#[must_use]
pub fn holds_a_credential(text: &str) -> bool {
static PATTERNS: std::sync::OnceLock<Option<regex::RegexSet>> = std::sync::OnceLock::new();
PATTERNS
.get_or_init(|| {
regex::RegexSet::new([
r"-----BEGIN [A-Z ]*PRIVATE KEY-----",
r"[a-zA-Z][a-zA-Z0-9+.-]*://[^\s/:@]+:[^\s/@]+@",
r"\bAKIA[0-9A-Z]{16}\b",
r"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}",
r"\bgithub_pat_[A-Za-z0-9_]{20,}",
r"\bxox[abprs]-[A-Za-z0-9-]{10,}",
r"\bsk-[A-Za-z0-9_-]{16,}",
r#"(?i)(api[_-]?key|secret|passw(or)?d|token|private[_-]?key)["']?\s*[:=]\s*["']?[^\s"']{4,}"#,
])
.ok()
})
.as_ref()
.is_none_or(|set| set.is_match(text))
}
pub const RUNS_ITSELF: &str = "the agent runs it itself, outside speccoder's sandbox";
#[derive(Debug, Clone, PartialEq)]
enum Judged {
Granted(String, &'static str),
Think,
Deny(String),
Person(String, Object),
}
fn strings_at(v: &Value, keys: &[&str], out: &mut Vec<Value>) {
match v {
Value::Object(o) => {
for (k, x) in o {
if keys.contains(&k.as_str()) {
out.push(x.clone());
}
strings_at(x, keys, out);
}
}
Value::Array(a) => a.iter().for_each(|x| strings_at(x, keys, out)),
_ => {}
}
}
fn command_of(v: &Value) -> Vec<Vec<String>> {
match v {
Value::String(line) => vec![argv_of_line(line)],
Value::Array(a) if a.iter().all(Value::is_string) => {
vec![
a.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect(),
]
}
Value::Array(a) => a.iter().flat_map(command_of).collect(),
other => vec![vec!["sh".to_owned(), "-c".to_owned(), other.to_string()]],
}
}
fn keeps_to(kind: &str, raw: &Value) -> bool {
let Some(keys) = known_keys(kind) else {
return false;
};
let Some(o) = raw.as_object() else {
return raw.is_null();
};
o.iter().all(|(k, v)| {
keys.contains(&k.as_str())
&& match k.as_str() {
"line" | "limit" | "offset" | "start_line" | "end_line" => v.is_u64(),
"replace_all" => v.is_boolean(),
"paths" => v.as_array().is_some_and(|a| a.iter().all(Value::is_string)),
"todos" => v.is_array() || v.is_string(),
"edits" => v.as_array().is_some_and(|a| {
a.iter().all(|e| {
e.as_object().is_some_and(|e| {
e.iter().all(|(k, v)| {
EDIT_KEYS.contains(&k.as_str())
&& (v.is_string() || (k == "replace_all" && v.is_boolean()))
})
})
})
}),
_ => v.is_string(),
}
})
}
impl Governor {
#[must_use]
pub fn new(s: Setup) -> Self {
let remembered = s
.run_approved
.into_iter()
.map(|o| (o, "run-grant"))
.collect();
Self {
root: s.root,
tools: s.tools,
policy: s.policy,
approver: s.approver,
present: s.present,
task: s.task,
task_title: s.task_title,
run: s.run,
frozen_additions: s.frozen_additions,
progress: s.progress,
copied: s.copied,
agent: s.agent,
asking: std::sync::atomic::AtomicU64::new(0),
asking_since: Mutex::new(None),
state: Mutex::new(State {
once: s.approved_once,
remembered,
..State::default()
}),
}
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn log(&self) -> Vec<Value> {
self.state.lock().map(|s| s.log.clone()).unwrap_or_default()
}
#[must_use]
pub fn pending(&self) -> Option<Value> {
self.state.lock().ok().and_then(|s| s.pending.clone())
}
#[must_use]
pub fn stopped(&self) -> bool {
self.state.lock().map_or(true, |s| s.stopped)
}
#[must_use]
pub fn asked(&self) -> u32 {
self.state.lock().map_or(0, |s| s.asked)
}
#[must_use]
pub fn asking(&self) -> std::time::Duration {
let done =
std::time::Duration::from_millis(self.asking.load(std::sync::atomic::Ordering::SeqCst));
let now = self
.asking_since
.lock()
.ok()
.and_then(|s| s.map(|t| t.elapsed()))
.unwrap_or_default();
done + now
}
pub fn said(&self, text: &str) {
if let Ok(mut s) = self.state.lock() {
s.said.push_str(text);
}
}
pub fn reported(&self, call: &ToolRequest) {
let mut arguments = call.raw_input.clone().unwrap_or(Value::Null);
cap(&mut arguments);
crate::secrets::mask_json(&mut arguments);
let ok = call.status.as_deref() == Some("completed");
let mut held = Vec::new();
strings_at(
call.raw_input.as_ref().unwrap_or(&Value::Null),
COMMAND_KEYS,
&mut held,
);
let effect = matches!(
call.kind.as_deref(),
Some("edit" | "delete" | "move" | "execute")
) || !held.is_empty();
let entry = json!({
"tool": format!("agent:{}", call.kind.as_deref().unwrap_or("other")),
"arguments": arguments,
"title": crate::secrets::mask(&call.shown_title()),
"decision": "reported",
"ok": ok,
"by": "agent",
"call": call.id,
});
self.emit(&entry);
let unasked = if let Ok(mut s) = self.state.lock() {
s.said.clear();
let failed = call.status.as_deref() == Some("failed");
let new = effect
&& ((ok && !s.allowed.contains(&call.id))
|| (failed && !s.decided.contains(&call.id)));
if new {
s.unasked += 1;
}
s.log.push(entry);
new.then_some(s.unasked)
} else {
None
};
if let Some(calls) = unasked {
self.event(Event::AgentUnasked {
task: self.task_id(),
calls,
});
}
}
#[must_use]
pub fn unasked(&self) -> u32 {
self.state.lock().map_or(0, |s| s.unasked)
}
pub fn stop(&self) {
if let Ok(mut s) = self.state.lock() {
s.stopped = true;
}
}
fn rel(&self, path: &Path) -> Result<String, String> {
let full = if path.is_relative() {
self.root.join(path)
} else {
path.to_path_buf()
};
let mut base = full.clone();
let mut rest = Vec::new();
while std::fs::symlink_metadata(&base).is_err() {
match (
base.file_name().map(std::ffi::OsStr::to_os_string),
base.parent(),
) {
(Some(name), Some(parent)) => {
rest.push(name);
base = parent.to_path_buf();
}
_ => break,
}
}
let outside = || {
format!(
"`{}` is outside the worktree; an attempt works only inside it",
path.display()
)
};
let mut canonical = std::fs::canonicalize(&base).map_err(|_| outside())?;
for name in rest.iter().rev() {
if name == ".." {
return Err(outside());
}
canonical.push(name);
}
let r = canonical
.strip_prefix(&self.root)
.map_err(|_| outside())?
.to_string_lossy()
.replace('\\', "/");
Ok(if r.is_empty() { ".".to_owned() } else { r })
}
fn plain_file(&self, rel: &str) -> bool {
use std::os::unix::fs::MetadataExt as _;
let folded = rel.to_lowercase();
if self.policy.protected().is_secret(rel)
|| self.policy.protected().is_secret(&folded)
|| secret_name(rel)
|| self.copied.iter().any(|c| c.eq_ignore_ascii_case(rel))
|| folded
.split('/')
.any(|part| part == ".git" || part.starts_with(".speccoder"))
{
return false;
}
let mut at = self.root.clone();
for part in Path::new(rel).components() {
at.push(part);
match std::fs::symlink_metadata(&at) {
Ok(m) if !m.file_type().is_symlink() => {}
_ => return false,
}
}
let Ok(m) = std::fs::symlink_metadata(&at) else {
return false;
};
if !m.is_file() || m.nlink() != 1 || m.len() > crate::tools::READ_CAP as u64 {
return false;
}
let not_ignored = std::process::Command::new("git")
.args(["check-ignore", "-q", "--"])
.arg(rel)
.current_dir(&self.root)
.status()
.is_ok_and(|s| s.code() == Some(1));
if !not_ignored {
return false;
}
std::fs::read(&at).is_ok_and(|b| {
let text = String::from_utf8_lossy(&b);
crate::secrets::mask(&text) == text && !holds_a_credential(&text)
})
}
fn judge(&self, call: &ToolRequest) -> Judged {
let raw = call.raw_input.clone().unwrap_or(Value::Null);
let kind = call.kind.as_deref().unwrap_or("other");
let mut held = Vec::new();
strings_at(&raw, COMMAND_KEYS, &mut held);
let mut argvs: Vec<Vec<String>> = Vec::new();
for v in &held {
for argv in command_of(v) {
match shell_line(&argv).and_then(commands_of_line) {
Some(parts) => argvs.extend(parts),
None => argvs.push(argv),
}
}
}
argvs.retain(|a| !a.is_empty());
let mut named = Vec::new();
strings_at(&raw, PATH_KEYS, &mut named);
let mut paths: Vec<PathBuf> = call.locations.clone();
for v in &named {
match v {
Value::String(p) => paths.push(PathBuf::from(p)),
Value::Array(a) => {
paths.extend(a.iter().filter_map(Value::as_str).map(PathBuf::from))
}
_ => {}
}
}
if let Some(changes) = raw.get("changes").and_then(Value::as_object) {
paths.extend(changes.keys().map(PathBuf::from));
}
let mut rels = Vec::new();
for p in &paths {
match self.rel(p) {
Ok(r) => rels.push(r),
Err(e) => return Judged::Deny(e),
}
}
let reading = argvs.len() == 1 && plain_read(&argvs[0]).is_some();
if reading && let Some(file) = plain_read(&argvs[0]) {
match self.rel(Path::new(&file)) {
Ok(r) => rels.push(r),
Err(e) => return Judged::Deny(e),
}
}
rels.sort();
rels.dedup();
let writes = matches!(kind, "edit" | "delete" | "move");
for r in &rels {
if self.policy.protected().is_secret(r)
|| self.policy.protected().is_secret(&r.to_lowercase())
{
return Judged::Deny(format!("`{r}` looks like a credential"));
}
if writes && let Some(class) = self.policy.write_protected(r) {
return Judged::Deny(format!(
"`{r}` is protected: {}",
self.policy.protected().reason(class)
));
}
}
let exact = Object::Agent {
agent: self.agent.clone(),
call: kind.to_owned(),
argvs: argvs.clone(),
paths: rels.clone(),
input: crate::integrity::sha256_hex(
canonical(&json!([kind, raw, call.locations])).as_bytes(),
),
};
if kind == "fetch" {
return Judged::Deny(
"fetching from the network: an attempt's commands run without it".to_owned(),
);
}
if kind == "switch_mode" {
return Judged::Deny(
"the agent is kept in the mode that asks before acting".to_owned(),
);
}
if kind == "think" && argvs.is_empty() && keeps_to("think", &raw) {
return Judged::Think;
}
let read_shaped = match kind {
"read" => argvs.is_empty() && keeps_to("read", &raw) && rels.len() == 1,
"execute" => reading && rels.len() == 1 && execute_keeps_to(&raw, &argvs[0]),
_ => false,
};
if read_shaped && self.plain_file(&rels[0]) {
let reads = self.reads_object();
if let Some(scope) = self.granted(&reads) {
return Judged::Granted(rels[0].clone(), scope);
}
return Judged::Person(format!("the agent reads `{}` itself", rels[0]), reads);
}
let why = if !argvs.is_empty() || kind == "execute" {
RUNS_ITSELF.to_owned()
} else if !keeps_to(kind, &raw) {
"speccoder does not know everything this call carries".to_owned()
} else {
match kind {
"read" | "search" => "the agent reads files itself".to_owned(),
"edit" | "delete" | "move" => format!("the agent changes files itself ({kind})"),
_ => "no rule can tell what this call does".to_owned(),
}
};
Judged::Person(why, exact)
}
fn reads_object(&self) -> Object {
Object::Agent {
agent: self.agent.clone(),
call: READS_GRANT.to_owned(),
argvs: Vec::new(),
paths: Vec::new(),
input: String::new(),
}
}
pub fn permission(&self, call: &ToolRequest, options: &[PermissionOption]) -> Option<String> {
let pick = |kinds: &[&str]| {
kinds
.iter()
.find_map(|k| options.iter().find(|o| o.kind == *k))
.map(|o| o.id.clone())
};
let reject = || pick(&["reject_once", "reject_always"]);
if let Ok(mut s) = self.state.lock() {
s.decided.insert(call.id.clone());
s.request = Some(call.id.clone());
}
let args = call_args(call);
let verdict = if self.stopped() {
Verdict::Stop
} else {
match self.judge(call) {
Judged::Granted(rel, scope) => {
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" }));
Verdict::Allow(None)
}
Judged::Think => {
self.record(json!({ "tool": AGENT_CALL, "arguments": args, "decision": "allow", "by": "rule", "rule": "thinking" }));
Verdict::Allow(None)
}
Judged::Deny(reason) => {
self.record(json!({ "tool": AGENT_CALL, "arguments": args, "decision": "deny", "reason": reason, "by": "rule", "rule": "agent call" }));
Verdict::Deny(reason)
}
Judged::Person(reason, object) => self.person(&args, &reason, object),
}
};
let answer = match &verdict {
Verdict::Allow(_) => pick(&["allow_once"]).or_else(|| {
self.record(json!({ "tool": AGENT_CALL, "arguments": args, "decision": "deny", "reason": "the agent offered no allow-once option", "by": "rule", "rule": "agent options" }));
reject()
}),
Verdict::Deny(_) => reject(),
Verdict::Stop => None,
};
if answer.is_some()
&& answer == pick(&["allow_once"])
&& let Ok(mut s) = self.state.lock()
{
s.allowed.insert(call.id.clone());
}
self.settle_request(
call,
match (&verdict, &answer) {
(Verdict::Allow(_), Some(a)) if Some(a) == pick(&["allow_once"]).as_ref() => {
"allow"
}
(Verdict::Stop, _) => "approval",
_ => "deny",
},
);
answer
}
fn settle_request(&self, call: &ToolRequest, decision: &str) {
let request = self.state.lock().ok().and_then(|mut s| s.request.take());
if request.is_some() {
let target = crate::secrets::mask(&call.shown_title());
self.emit(&json!({
"tool": format!("agent:{}", call.kind.as_deref().unwrap_or("other")),
"arguments": {},
"title": target,
"decision": decision,
"ok": decision == "allow",
}));
}
}
pub fn abandon(&self, call: &ToolRequest) {
self.record(json!({ "tool": AGENT_CALL, "arguments": call_args(call), "decision": "deny", "reason": "the decision failed", "by": "rule", "rule": "failure" }));
self.settle_request(call, "deny");
}
fn person(&self, args: &Value, reason: &str, object: Object) -> Verdict {
if let Some(r) = self.policy.rejection(&object) {
let why = format!("a person rejected this: {}", r.reason);
self.record(json!({ "tool": AGENT_CALL, "arguments": args, "decision": "deny", "reason": why, "object": object, "by": "person", "scope": "task-grant" }));
return Verdict::Deny(why);
}
if let Some(scope) = self.granted(&object) {
self.record(json!({ "tool": AGENT_CALL, "arguments": args, "decision": "allow", "ok": true, "object": object, "by": "person", "approved_by": "person", "scope": scope }));
return Verdict::Allow(Some(object));
}
let mut request = self.request(AGENT_CALL, args, reason, Some(&object));
if matches!(&object, Object::Agent { call, .. } if call == READS_GRANT) {
request.kind = READS_GRANT.to_owned();
}
self.ask(AGENT_CALL, args, Some(object), reason, request)
}
fn granted(&self, object: &Object) -> Option<&'static str> {
if self.policy.approved().iter().any(|a| a == object) {
return Some("task-grant");
}
let mut s = self.state.lock().ok()?;
if let Some(at) = s.once.iter().position(|r| r == object) {
s.once.remove(at);
return Some("once-grant");
}
s.remembered
.iter()
.find(|(r, _)| r == object)
.map(|(_, scope)| *scope)
}
pub fn decide(&self, tool: &str, args: &Value, call: Option<&ToolRequest>) -> Verdict {
let _ = call;
self.decide_as(tool, args)
}
fn decide_as(&self, tool: &str, args: &Value) -> Verdict {
if self.stopped() {
return Verdict::Deny("the attempt is ending".to_owned());
}
let mut decision = self.policy.base(tool, args);
let mut additions_only = false;
if self.frozen_additions
&& tool == "write_file"
&& matches!(&decision, Decision::Approval(r) if r.contains("frozen test"))
&& let (Some(path), Some(new)) = (args["path"].as_str(), args["content"].as_str())
&& let Ok(old) = std::fs::read_to_string(self.root.join(path))
&& crate::integrity::only_additions(&old, new)
{
decision = Decision::Allow;
additions_only = true;
}
let mut object: Option<Object> = None;
if matches!(decision, Decision::Approval(_)) {
let resolved = self.tools.resolve(tool, args);
if resolved["ok"] == true {
object = serde_json::from_value(resolved["object"].clone()).ok();
decision = self.policy.classify(tool, args, object.as_ref());
} else {
decision = Decision::Deny(
resolved["error"]
.as_str()
.unwrap_or("the call's object cannot be resolved")
.to_owned(),
);
}
}
let mut person: Option<Value> = None;
if decision == Decision::Allow && object.is_some() {
person = Some(
json!({ "approved_by": "person", "scope": "task-grant", "grant": { "scope": "task", "run": self.run } }),
);
}
if matches!(decision, Decision::Approval(_))
&& let Some(o) = &object
&& let Ok(mut s) = self.state.lock()
{
if let Some(at) = s.once.iter().position(|r| r == o) {
s.once.remove(at);
person = Some(
json!({ "approved_by": "person", "scope": "once-grant", "grant": { "scope": "once", "run": self.run } }),
);
decision = Decision::Allow;
} else if let Some((_, scope)) = s.remembered.iter().find(|(r, _)| r == o) {
person = Some(
json!({ "approved_by": "person", "scope": scope, "grant": { "scope": scope.trim_end_matches("-grant"), "run": self.run } }),
);
decision = Decision::Allow;
}
}
decision = self.policy.settle(decision);
match decision {
Decision::Allow => {
let mut entry =
json!({ "tool": tool, "arguments": args, "decision": "allow", "ok": true });
if tool == "run_command" {
entry["executed_by"] = json!("speccoder");
entry["sandboxed"] = json!(true);
}
self.decided_by(&mut entry, object.as_ref(), person.as_ref(), tool, args);
if additions_only {
entry["frozen_additions"] = json!(true);
}
self.record(entry);
Verdict::Allow(object)
}
Decision::Deny(reason) => {
let mut entry = json!({ "tool": tool, "arguments": args, "decision": "deny", "reason": reason });
self.decided_by(&mut entry, object.as_ref(), person.as_ref(), tool, args);
self.record(entry);
Verdict::Deny(reason)
}
Decision::Approval(reason) => {
let request = self.request(tool, args, &reason, object.as_ref());
self.ask(tool, args, object, &reason, request)
}
}
}
fn decided_by(
&self,
entry: &mut Value,
object: Option<&Object>,
person: Option<&Value>,
tool: &str,
args: &Value,
) {
if let Some(o) = object {
entry["object"] = json!(o);
}
match person.and_then(Value::as_object) {
Some(p) => {
entry["by"] = json!("person");
if let Some(e) = entry.as_object_mut() {
e.extend(p.clone());
}
}
None => {
entry["by"] = json!("rule");
entry["rule"] = json!(self.policy.rule(tool, args, object));
}
}
}
fn request(&self, tool: &str, args: &Value, reason: &str, object: Option<&Object>) -> Request {
let kind = crate::approvals::kind_of(tool, args);
let risk = crate::approvals::risk(tool, reason);
let said = self
.state
.lock()
.map(|s| s.said.clone())
.unwrap_or_default();
let changed = object.and_then(|o| {
self.policy
.approved()
.iter()
.find_map(|earlier| o.changed_from(earlier))
});
Request {
task: self.task.clone(),
task_title: self.task_title.clone(),
turn: 0,
tool: tool.to_owned(),
arguments: args.clone(),
kind: kind.to_owned(),
reason: reason.to_owned(),
object: object.cloned(),
changed,
risk: risk.to_owned(),
claim: crate::approvals::claim(&crate::secrets::mask(&said)),
options: crate::approvals::options(risk, object.is_some(), false, kind, risk),
run: self.run.clone(),
..Request::default()
}
}
fn ask(
&self,
tool: &str,
args: &Value,
object: Option<Object>,
reason: &str,
mut request: Request,
) -> Verdict {
self.event(Event::ApprovalRequested {
task: self.task_id(),
call: String::new(),
tool: tool.to_owned(),
what: format!("{tool} {}", crate::progress::target(tool, args))
.chars()
.filter(|c| !c.is_control())
.take(60)
.collect(),
class: object
.as_ref()
.map(|o| o.class().as_str().to_owned())
.unwrap_or_default(),
object: object.as_ref().map(Object::short).unwrap_or_default(),
});
let Some(approver) = &self.approver else {
return self.wait(tool, args, object, reason, request);
};
if let Ok(mut s) = self.state.lock() {
s.asked += 1;
request.asked = s.asked;
}
let context = PresentContext {
root: Some(self.root.clone()),
..self.present.clone()
};
let began = std::time::Instant::now();
if let Ok(mut s) = self.asking_since.lock() {
*s = Some(began);
}
let answer: Answer = approver.ask(&present(&request, &context));
self.asking.fetch_add(
u64::try_from(began.elapsed().as_millis()).unwrap_or(u64::MAX),
std::sync::atomic::Ordering::SeqCst,
);
if let Ok(mut s) = self.asking_since.lock() {
*s = None;
}
let answer = if self.stopped() && answer.decision == Said::Approve {
Answer::reject("the attempt ended while you decided", answer.by)
} else {
answer
};
let by = answer.by.clone();
let approver_v = serde_json::to_value(&by).unwrap_or(Value::Null);
let operator = by.as_ref().map(Who::view);
self.event(Event::ApprovalDecided {
task: self.task_id(),
call: String::new(),
decision: if answer.decision == Said::Approve {
"approve"
} else {
"reject"
}
.to_owned(),
scope: (answer.decision == Said::Approve).then(|| answer.scope.name().to_owned()),
by: by
.as_ref()
.map_or_else(|| "unknown".to_owned(), |w| w.name.clone()),
how: by
.as_ref()
.map_or_else(|| "unknown".to_owned(), |w| w.basis.clone()),
class: object
.as_ref()
.map(|o| o.class().as_str().to_owned())
.unwrap_or_default(),
object: object.as_ref().map(Object::short).unwrap_or_default(),
});
let mut entry =
json!({ "tool": tool, "arguments": args, "reason": reason, "by": "person" });
if let Some(o) = &object {
entry["object"] = json!(o);
}
match answer.decision {
Said::Approve => {
entry["decision"] = json!("allow");
entry["ok"] = json!(true);
entry["approved_by"] = json!("person");
entry["scope"] = json!(answer.scope.name());
entry["note"] = json!(answer.note);
entry["approver"] = approver_v;
entry["operator"] = json!(operator);
if answer.always {
entry["always"] = json!(true);
}
if let (Some(o), Scope::Task | Scope::Run) = (&object, answer.scope) {
entry["grant"] = json!({ "scope": answer.scope.name(), "run": self.run });
if let Ok(mut s) = self.state.lock() {
s.remembered.push((
o.clone(),
if answer.scope == Scope::Run {
"run-grant"
} else {
"task-grant"
},
));
}
}
self.record(entry);
Verdict::Allow(object)
}
Said::Reject => {
let why = answer
.reason
.unwrap_or_else(|| "no reason given".to_owned());
entry["decision"] = json!("deny");
entry["rejected_by"] = json!("person");
entry["approver"] = approver_v;
entry["operator"] = json!(operator);
entry["rejection"] = json!(why);
self.record(entry);
Verdict::Deny(format!("a person rejected this: {why}"))
}
Said::Stop => self.wait(tool, args, object, reason, request),
}
}
fn wait(
&self,
tool: &str,
args: &Value,
object: Option<Object>,
reason: &str,
request: Request,
) -> Verdict {
let mut entry =
json!({ "tool": tool, "arguments": args, "decision": "approval", "reason": reason });
if let Some(o) = &object {
entry["object"] = json!(o);
}
self.record(entry);
if let Ok(mut s) = self.state.lock() {
if s.pending.is_none() {
s.pending = Some(json!({
"tool": tool,
"arguments": args,
"reason": reason,
"object": object,
"run": self.run,
"request": request,
}));
}
s.stopped = true;
}
Verdict::Stop
}
pub async fn read_text_file(
&self,
path: &Path,
line: Option<u32>,
limit: Option<u32>,
) -> Result<String, String> {
let rel = self.rel(path)?;
let start = line.map(|l| l.max(1) as usize);
let end = limit.map(|n| start.unwrap_or(1) + (n as usize).saturating_sub(1));
let args = json!({ "path": rel, "start_line": start, "end_line": end });
match self.decide("read_file", &json!({ "path": rel }), None) {
Verdict::Allow(_) => {}
Verdict::Deny(r) => return Err(format!("denied: {r}")),
Verdict::Stop => return Err("the attempt is ending".to_owned()),
}
let out = self.tools.run("read_file", &args).await;
if out["ok"] != true {
return Err(out["error"].as_str().unwrap_or("not readable").to_owned());
}
if out["binary"] == true {
return Err(format!("`{rel}` is not a text file"));
}
let mut content = out["content"].as_str().unwrap_or_default().to_owned();
if out["end_line"] == out["total_lines"]
&& out["truncated"] != true
&& std::fs::read(self.root.join(&rel)).is_ok_and(|b| b.ends_with(b"\n"))
{
content.push('\n');
}
Ok(content)
}
pub async fn write_text_file(&self, path: &Path, content: &str) -> Result<(), String> {
let rel = self.rel(path)?;
let args = json!({ "path": rel, "content": content });
let object = match self.decide("write_file", &args, None) {
Verdict::Allow(o) => o,
Verdict::Deny(r) => return Err(format!("denied: {r}")),
Verdict::Stop => {
return Err("the attempt is ending: this write waits for a person".to_owned());
}
};
let mut call = args;
if let Some(o) = object {
call[crate::tools::EXPECT] = json!(o);
}
let out = self.tools.run("write_file", &call).await;
if out["ok"] == true {
Ok(())
} else {
Err(out["error"].as_str().unwrap_or("not written").to_owned())
}
}
pub fn command(&self, argv: Vec<String>, cwd: Option<&Path>) -> Result<Command, String> {
let mut args = json!({ "argv": argv });
if let Some(c) = cwd {
args["cwd"] = json!(self.rel(c)?);
}
let object = match self.decide("run_command", &args, None) {
Verdict::Allow(o) => o,
Verdict::Deny(r) => return Err(format!("denied: {r}")),
Verdict::Stop => {
return Err("the attempt is ending: this command waits for a person".to_owned());
}
};
let dir = self.tools.command_cwd(args["cwd"].as_str()).map_err(|e| {
e["error"]
.as_str()
.unwrap_or("no such directory")
.to_owned()
})?;
if let Some(Object::Command { executable, .. }) = object {
let now = self.tools.resolve("run_command", &args);
if now["object"]["executable"] != json!(executable) {
return Err(format!(
"the object changed since it was approved: {executable} → {}",
now["object"]["executable"]
));
}
}
Ok((
crate::tools::policy::argv_of(&args),
dir,
self.tools.command_env(None),
))
}
fn task_id(&self) -> String {
self.task
.rsplit('/')
.next()
.unwrap_or(&self.task)
.to_owned()
}
fn event(&self, e: Event) {
if let Some((_, p)) = &self.progress {
p.event(&e);
}
}
fn emit(&self, entry: &Value) {
let mut entry = entry.clone();
cap(&mut entry);
crate::secrets::mask_json(&mut entry);
let entry = &entry;
if let Some((task, p)) = &self.progress {
let tool = entry["tool"].as_str().unwrap_or_default();
p.event(&Event::ToolCall {
task: task.clone(),
turn: 0,
tool: tool.to_owned(),
target: match entry["title"].as_str() {
Some(t) => crate::progress::target("", &json!({ "path": t })),
None => crate::progress::target(tool, &entry["arguments"]),
},
decision: entry["decision"].as_str().unwrap_or_default().to_owned(),
ok: entry["ok"].as_bool(),
tail: Vec::new(),
contained: false,
});
}
}
fn record(&self, mut entry: Value) {
entry["via"] = json!("acp");
if entry["object"]["kind"] == "agent" {
entry["executed_by"] = json!("agent");
entry["sandboxed"] = json!(false);
}
cap(&mut entry);
crate::secrets::mask_json(&mut entry);
let Ok(mut s) = self.state.lock() else { return };
match &s.request {
Some(id) => entry["request"] = json!(id),
None => self.emit(&entry),
}
s.log.push(entry);
}
}
fn call_args(call: &ToolRequest) -> Value {
let mut raw = call.raw_input.clone().unwrap_or(Value::Null);
cap(&mut raw);
let mut v = json!({
"title": call.shown_title(),
"kind": call.kind,
"rawInput": raw,
"locations": call.locations,
});
crate::secrets::mask_json(&mut v);
v
}
const KEPT: usize = 4096;
fn cap(v: &mut Value) {
match v {
Value::String(s) if s.len() > KEPT => {
let mut at = KEPT;
while !s.is_char_boundary(at) {
at -= 1;
}
let n = s.len();
s.truncate(at);
s.push_str(&format!("… [{n} bytes]"));
}
Value::Array(a) => a.iter_mut().for_each(cap),
Value::Object(o) => o.values_mut().for_each(cap),
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Tree {
_d: tempfile::TempDir,
_o: tempfile::TempDir,
root: PathBuf,
out: PathBuf,
}
fn tree() -> Tree {
let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let o = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let root = std::fs::canonicalize(d.path()).unwrap_or_else(|e| panic!("{e}"));
let out = std::fs::canonicalize(o.path()).unwrap_or_else(|e| panic!("{e}"));
let w = |p: &str, t: &str| std::fs::write(root.join(p), t).unwrap_or_default();
std::fs::create_dir_all(root.join("src")).unwrap_or_default();
w("src/lib.rs", "x\n");
w(".env", "TOKEN=abc\n");
w("settings.local", "TOKEN=abc\n");
w(".git-credentials", "https://u:p@example.com\n");
w("terraform.tfstate", "{}\n");
std::fs::write(out.join("credentials"), "secret\n").unwrap_or_default();
std::fs::hard_link(out.join("credentials"), root.join("hard.txt")).unwrap_or_default();
std::os::unix::fs::symlink(out.join("credentials"), root.join("notes.txt"))
.unwrap_or_default();
std::os::unix::fs::symlink(&out, root.join("docs")).unwrap_or_default();
let _ = std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output();
Tree {
_d: d,
_o: o,
root,
out,
}
}
fn governor_with(root: &Path, run_approved: Vec<Object>) -> Governor {
let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
let policy = Policy::new(
&[argv("cargo test"), argv("git diff")],
&[argv("make deploy")],
);
let tools = WorkspaceTools::new(root, policy.clone()).unwrap_or_else(|e| panic!("{e}"));
Governor::new(Setup {
root: root.to_path_buf(),
tools,
policy,
approver: None,
present: PresentContext::default(),
task: "f/T1".to_owned(),
task_title: "t".to_owned(),
run: None,
frozen_additions: false,
run_approved,
approved_once: Vec::new(),
progress: None,
copied: vec!["settings.local".to_owned()],
agent: "copilot".to_owned(),
})
}
const OPTIONS: [(&str, &str); 2] = [("allow", "allow_once"), ("reject", "reject_once")];
fn options() -> Vec<PermissionOption> {
OPTIONS
.iter()
.map(|(id, kind)| PermissionOption {
id: (*id).to_owned(),
kind: (*kind).to_owned(),
})
.collect()
}
fn call(kind: &str, raw: Value, locations: Vec<PathBuf>) -> ToolRequest {
ToolRequest {
id: "c1".to_owned(),
title: Some("Read file".to_owned()),
kind: Some(kind.to_owned()),
raw_input: Some(raw),
locations,
status: None,
}
}
fn answer(t: &Tree, kind: &str, raw: Value, locations: Vec<PathBuf>) -> Option<String> {
governor_with(&t.root, Vec::new()).permission(&call(kind, raw, locations), &options())
}
fn allow() -> Option<String> {
Some("allow".to_owned())
}
fn reads(t: &Tree) -> Governor {
let g = governor_with(&t.root, Vec::new());
let r = g.reads_object();
governor_with(&t.root, vec![r])
}
const PLAIN_READS: [(&str, &str); 5] = [
("execute", r#"{ "command": "cat src/lib.rs" }"#),
("execute", r#"{ "command": "cat\tsrc/lib.rs" }"#),
("execute", r#"{ "command": "head -n 5 src/lib.rs" }"#),
(
"execute",
r#"{ "command": ["/bin/zsh", "-lc", "cat src/lib.rs"] }"#,
),
("read", r#"{ "path": "src/lib.rs", "line": 1, "limit": 5 }"#),
];
#[test]
fn nothing_the_agent_does_itself_is_allowed_without_a_person() {
let t = tree();
for (kind, raw) in PLAIN_READS {
let raw: Value = serde_json::from_str(raw).unwrap_or_default();
assert_eq!(answer(&t, kind, raw.clone(), vec![]), None, "{raw}");
}
}
#[test]
fn a_persons_reads_for_the_task_cover_plain_reads_of_plain_files_only() {
let t = tree();
for (kind, raw) in PLAIN_READS {
let raw: Value = serde_json::from_str(raw).unwrap_or_default();
assert_eq!(
reads(&t).permission(&call(kind, raw.clone(), vec![]), &options()),
allow(),
"{raw}"
);
}
let w = |p: &str, text: &str| {
if let Some(dir) = Path::new(p).parent() {
std::fs::create_dir_all(t.root.join(dir)).unwrap_or_default();
}
std::fs::write(t.root.join(p), text).unwrap_or_default();
};
w("deploy_key", "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n");
w(
"notes/key.pem.txt",
"-----BEGIN RSA PRIVATE KEY-----\nabc\n",
);
w("config.ini", "password: hunter2hunter2\n");
w(
".git/config",
"[remote]\nurl = https://user:pass@example.com/r.git\n",
);
w(
"conf/remote.txt",
"url = https://user:pass@example.com/r.git\n",
);
w("secrets.yaml", "x: 1\n");
w(".htpasswd", "admin:$apr1$abc$def\n");
w("token.txt", "abcdefabcdefabcdef\n");
w("ci.txt", "GH=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345\n");
w(".speccoder/x", "x\n");
for file in [
"deploy_key",
"notes/key.pem.txt",
"config.ini",
".git/config",
"conf/remote.txt",
"secrets.yaml",
".htpasswd",
"token.txt",
"ci.txt",
".speccoder/x",
"settings.local",
"hard.txt",
"terraform.tfstate",
] {
let raw = json!({ "command": format!("cat {file}") });
assert_ne!(
reads(&t).permission(&call("execute", raw, vec![]), &options()),
allow(),
"{file}"
);
}
for (kind, raw) in [
("edit", json!({ "command": "cat src/lib.rs" })),
("delete", json!({ "command": "cat src/lib.rs" })),
("move", json!({ "command": "cat src/lib.rs" })),
("other", json!({ "command": "cat src/lib.rs" })),
("think", json!({ "command": "cat src/lib.rs" })),
(
"switch_mode",
json!({ "command": "cat src/lib.rs", "mode": "bypassPermissions" }),
),
("fetch", json!({ "command": "cat src/lib.rs" })),
("read", json!({ "command": "cat src/lib.rs" })),
(
"execute",
json!({ "command": "cat lib.rs", "working_directory": "src" }),
),
(
"execute",
json!({ "command": "cat src/lib.rs", "env": { "LD_PRELOAD": "x" } }),
),
(
"execute",
json!({ "command": "cat src/lib.rs", "stdin": "x" }),
),
] {
assert_ne!(
reads(&t).permission(&call(kind, raw.clone(), vec![]), &options()),
allow(),
"{kind} {raw}"
);
}
}
#[derive(Debug)]
struct Answers(Mutex<Vec<Answer>>);
impl Approver for Answers {
fn ask(&self, _: &crate::approvals::Presentation) -> Answer {
let mut a = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if a.is_empty() {
Answer::reject("no", None)
} else {
a.remove(0)
}
}
}
#[test]
fn for_this_task_covers_exactly_that_request_again() {
let t = tree();
for (granted, same, other) in [
(
json!({ "command": "make a && make b" }),
json!({ "command": "make a && make b" }),
json!({ "command": "make a; make b" }),
),
(
json!({ "command": "make" }),
json!({ "command": "make" }),
json!({ "command": "make", "cwd": "src" }),
),
(
json!({ "command": "make" }),
json!({ "command": "make" }),
json!({ "command": "make", "env": { "A": "1" } }),
),
(
json!({ "path": "src/a", "destination": "src/b" }),
json!({ "path": "src/a", "destination": "src/b" }),
json!({ "path": "src/b", "destination": "src/a" }),
),
(
json!({ "file_path": "src/lib.rs", "content": "a" }),
json!({ "file_path": "src/lib.rs", "content": "a" }),
json!({ "file_path": "src/lib.rs", "content": "b" }),
),
] {
let kind = if granted.get("command").is_some() {
"execute"
} else if granted.get("destination").is_some() {
"move"
} else {
"edit"
};
let mut g = governor_with(&t.root, Vec::new());
g.approver = Some(Arc::new(Answers(Mutex::new(vec![Answer::approve(
Scope::Task,
None,
None,
)]))));
assert_eq!(
g.permission(&call(kind, granted.clone(), vec![]), &options()),
allow()
);
assert_eq!(
g.permission(&call(kind, same, vec![]), &options()),
allow(),
"{granted}"
);
assert_eq!(
g.permission(&call(kind, other.clone(), vec![]), &options()),
Some("reject".to_owned()),
"{other}"
);
}
}
#[test]
fn a_request_naming_anything_outside_or_a_credential_is_refused() {
let t = tree();
let src = || vec![PathBuf::from("src/lib.rs")];
let refused = Some("reject".to_owned());
for (kind, raw, loc) in [
("read", json!({ "path": t.out.join("credentials") }), src()),
(
"edit",
json!({ "file_path": t.out.join("x"), "content": "x" }),
src(),
),
(
"move",
json!({ "path": "src/lib.rs", "destination": t.out.join("lib.rs") }),
vec![],
),
(
"edit",
json!({ "edits": [{ "path": t.out.join("x"), "new_string": "x" }] }),
src(),
),
(
"edit",
json!({ "command": "ls", "file_path": t.out.join("x") }),
vec![],
),
("edit", json!({ "file_path": "notes.txt" }), vec![]),
("read", json!({ "path": "notes.txt" }), vec![]),
("execute", json!({ "command": "cat notes.txt" }), vec![]),
("execute", json!({ "command": "cat .env" }), vec![]),
("execute", json!({ "command": "cat .ENV" }), vec![]),
(
"execute",
json!({ "command": "cat src/lib.rs", "cwd": t.out }),
vec![],
),
] {
assert_eq!(answer(&t, kind, raw.clone(), loc), refused, "{kind} {raw}");
}
}
#[test]
fn a_grant_of_speccoders_own_calls_never_covers_the_agents_and_back() {
let t = tree();
let tools =
WorkspaceTools::new(&t.root, Policy::default()).unwrap_or_else(|e| panic!("{e}"));
let native: Object = serde_json::from_value(
tools.resolve("run_command", &json!({ "argv": ["make", "deploy"] }))["object"].clone(),
)
.unwrap_or_else(|e| panic!("{e}"));
let g = governor_with(&t.root, vec![native]);
assert_eq!(
g.permission(
&call("execute", json!({ "command": "make deploy" }), vec![]),
&options()
),
None
);
let mut g = governor_with(&t.root, Vec::new());
g.approver = Some(Arc::new(Answers(Mutex::new(vec![Answer::approve(
Scope::Task,
None,
None,
)]))));
let lint = call("execute", json!({ "command": "make lint" }), vec![]);
assert_eq!(g.permission(&lint, &options()), allow());
assert_eq!(g.permission(&lint, &options()), allow());
assert!(
g.command(vec!["make".to_owned(), "lint".to_owned()], None)
.is_err()
);
}
#[test]
fn a_refused_or_unasked_change_the_agent_made_counts_as_deciding_alone() {
let t = tree();
let g = governor_with(&t.root, Vec::new());
let mut refused = call(
"edit",
json!({ "file_path": t.out.join("x"), "content": "x" }),
vec![],
);
refused.id = "d1".to_owned();
assert_eq!(
g.permission(&refused, &options()),
Some("reject".to_owned())
);
refused.status = Some("completed".to_owned());
g.reported(&refused);
let mut other = call("other", json!({ "command": "rm -rf x" }), vec![]);
other.id = "u1".to_owned();
other.status = Some("completed".to_owned());
g.reported(&other);
let mut failed = call("execute", json!({ "command": "rm -rf x" }), vec![]);
failed.id = "u2".to_owned();
failed.status = Some("failed".to_owned());
g.reported(&failed);
assert_eq!(g.unasked(), 3);
let g = reads(&t);
let mut read = call("read", json!({ "path": "src/lib.rs" }), vec![]);
read.id = "r1".to_owned();
assert_eq!(g.permission(&read, &options()), allow());
read.status = Some("completed".to_owned());
g.reported(&read);
refused.id = "d2".to_owned();
refused.status = None;
let _ = g.permission(&refused, &options());
refused.status = Some("failed".to_owned());
g.reported(&refused);
assert_eq!(g.unasked(), 0);
}
#[derive(Debug)]
struct YesTooLate(Arc<std::sync::OnceLock<Arc<Governor>>>);
impl Approver for YesTooLate {
fn ask(&self, _: &crate::approvals::Presentation) -> Answer {
if let Some(g) = self.0.get() {
g.stop();
}
Answer::approve(Scope::Task, None, None)
}
}
#[test]
fn a_yes_after_the_attempt_ended_allows_nothing() {
let t = tree();
let cell = Arc::new(std::sync::OnceLock::new());
let mut g = governor_with(&t.root, Vec::new());
g.approver = Some(Arc::new(YesTooLate(Arc::clone(&cell))));
let g = Arc::new(g);
let _ = cell.set(Arc::clone(&g));
let c = call("execute", json!({ "command": "make lint" }), vec![]);
assert_eq!(g.permission(&c, &options()), Some("reject".to_owned()));
}
#[test]
fn what_the_governor_records_is_masked_and_cut() {
let t = tree();
crate::secrets::register("GOV_TOKEN", "tok-Zx81-secret-value-gov");
let g = governor_with(&t.root, Vec::new());
let big = format!("tok-Zx81-secret-value-gov{}", "x".repeat(10_000));
let mut c = call(
"edit",
json!({ "file_path": "src/lib.rs", "content": big }),
vec![],
);
c.title = Some("Write tok-Zx81-secret-value-gov".to_owned());
let _ = g.permission(&c, &options());
let log = serde_json::to_string(&g.log()).unwrap_or_default();
assert!(!log.contains("tok-Zx81-secret-value-gov"), "{log}");
assert!(log.len() < 9000, "{}", log.len());
}
#[test]
fn a_plain_command_is_split_and_a_shell_line_is_left_to_sh() {
assert_eq!(
argv_of_line("cargo test -p core"),
["cargo", "test", "-p", "core"]
);
assert_eq!(argv_of_line("ls | grep x"), ["sh", "-c", "ls | grep x"]);
assert_eq!(
argv_of_line("git commit -m \"x\""),
["sh", "-c", "git commit -m \"x\""]
);
assert_eq!(argv_of_line("A=1 make"), ["sh", "-c", "A=1 make"]);
assert_eq!(
commands_of_line("cat src/lib.rs; ls && cargo test"),
Some(vec![
vec!["cat".to_owned(), "src/lib.rs".to_owned()],
vec!["ls".to_owned()],
vec!["cargo".to_owned(), "test".to_owned()],
])
);
assert_eq!(commands_of_line("cat a | grep b"), None);
assert_eq!(commands_of_line("echo $(id)"), None);
}
}