use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::paths::{Protected, normalise};
pub const MCP_PREFIX: &str = "mcp__";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
Allow,
Approval(String),
Deny(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Class {
Write,
Command,
Network,
Mcp,
Agent,
}
impl Class {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Write => "write",
Self::Command => "command",
Self::Network => "network",
Self::Mcp => "mcp",
Self::Agent => "agent",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Object {
Path {
canonical: String,
asked: String,
},
Command {
argv: Vec<String>,
argv_sha256: String,
cwd: String,
executable: String,
network: bool,
},
Tool {
name: String,
digest: String,
},
Agent {
agent: String,
call: String,
argvs: Vec<Vec<String>>,
paths: Vec<String>,
input: String,
},
}
pub const NOT_FOUND: &str = "not found";
pub const NOT_ALLOWED: &str = "not a configured check or allowed command";
#[must_use]
pub fn unasked(pending: &Value, decide: &str) -> String {
let tool = pending["tool"].as_str().unwrap_or_default();
let reason = pending["reason"].as_str().unwrap_or_default();
let arguments = &pending["arguments"];
if tool == "run_command" {
let argv = argv_of(arguments);
let shown = crate::approvals::present::shell_quote(&argv);
if reason == NOT_ALLOWED {
let entry = argv
.iter()
.map(|a| serde_json::to_string(a).unwrap_or_default())
.collect::<Vec<_>>()
.join(", ");
return format!(
"`{shown}` needs approval: it is {NOT_ALLOWED} — allow exactly it in speccoder.toml (`[commands] allow = [[{entry}]]`), or {decide}"
);
}
return format!("`{shown}` needs approval: {reason} — {decide}");
}
if let Ok(o @ Object::Agent { .. }) =
serde_json::from_value::<Object>(pending["object"].clone())
{
return format!("{} needs approval: {reason} — {decide}", o.describe());
}
let object = arguments["path"]
.as_str()
.map(|p| format!(" `{p}`"))
.unwrap_or_default();
format!("`{tool}`{object} needs approval: {reason} — {decide}")
}
impl PartialEq for Object {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Path { canonical: a, .. }, Self::Path { canonical: b, .. }) => a == b,
(
Self::Command {
argv: a1,
argv_sha256: a2,
cwd: a3,
executable: a4,
network: a5,
},
Self::Command {
argv: b1,
argv_sha256: b2,
cwd: b3,
executable: b4,
network: b5,
},
) => a1 == b1 && a2 == b2 && a3 == b3 && a4 == b4 && a5 == b5,
(Self::Tool { name: a, digest: x }, Self::Tool { name: b, digest: y }) => {
a == b && x == y
}
(
Self::Agent {
agent: a1,
call: a2,
argvs: a3,
paths: a4,
input: a5,
},
Self::Agent {
agent: b1,
call: b2,
argvs: b3,
paths: b4,
input: b5,
},
) => a1 == b1 && a2 == b2 && a3 == b3 && a4 == b4 && a5 == b5,
_ => false,
}
}
}
impl Eq for Object {}
#[must_use]
pub fn argv_sha256(argv: &[String]) -> String {
use sha2::{Digest as _, Sha256};
let json = serde_json::to_string(argv).unwrap_or_default();
Sha256::digest(json.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
fn first12(hex: &str) -> &str {
hex.get(..12).unwrap_or(hex)
}
impl Object {
#[must_use]
pub fn command(argv: Vec<String>, cwd: String, executable: String, network: bool) -> Self {
Self::Command {
argv_sha256: argv_sha256(&argv),
argv,
cwd,
executable,
network,
}
}
#[must_use]
pub const fn class(&self) -> Class {
match self {
Self::Path { .. } => Class::Write,
Self::Command { network: true, .. } => Class::Network,
Self::Command { .. } => Class::Command,
Self::Tool { .. } => Class::Mcp,
Self::Agent { .. } => Class::Agent,
}
}
#[must_use]
pub fn describe(&self) -> String {
match self {
Self::Path { canonical, .. } => format!("write `{canonical}`"),
Self::Command {
argv, cwd, network, ..
} => {
let place = if cwd == "." {
String::new()
} else {
format!(" in `{cwd}`")
};
let net = if *network { " with the network" } else { "" };
format!("run `{}`{place}{net}", argv.join(" "))
}
Self::Tool { name, digest } => format!("use `{name}`@{}", first12(digest)),
Self::Agent {
agent,
call: kind,
argvs,
paths,
..
} => {
if kind == "reads" {
return format!("{agent}'s plain reads of one file each, for the task");
}
let what: Vec<String> = argvs
.iter()
.map(|a| format!("`{}`", a.join(" ")))
.chain(paths.iter().map(|p| format!("`{p}`")))
.collect();
format!("{agent} itself: {kind} {}", what.join(", "))
}
}
}
#[must_use]
pub fn grant_id(&self) -> String {
use sha2::{Digest as _, Sha256};
let identity = match self {
Self::Path { canonical, .. } => serde_json::json!({ "path": canonical }),
Self::Command {
argv,
argv_sha256,
cwd,
executable,
network,
} => serde_json::json!({
"argv": argv, "argv_sha256": argv_sha256, "cwd": cwd,
"executable": executable, "network": network,
}),
Self::Tool { name, digest } => serde_json::json!({ "tool": name, "digest": digest }),
Self::Agent {
agent,
call,
argvs,
paths,
input,
} => {
serde_json::json!({ "agent": agent, "call": call, "argvs": argvs, "paths": paths, "input": input })
}
};
let hex: String = Sha256::digest(identity.to_string().as_bytes())
.iter()
.take(8)
.map(|b| format!("{b:02x}"))
.collect();
format!("g-{hex}")
}
#[must_use]
pub fn short(&self) -> String {
match self {
Self::Path { canonical, .. } => canonical.clone(),
Self::Command { argv_sha256, .. } => format!("argv#{}", first12(argv_sha256)),
Self::Tool { name, digest } => format!("{name}@{}", first12(digest)),
Self::Agent { agent, call, .. } => {
format!("{agent}:{call}#{}", first12(&self.grant_id()))
}
}
}
#[must_use]
pub fn same_name(&self, other: &Self) -> bool {
match (self, other) {
(Self::Path { asked: a, .. }, Self::Path { asked: b, .. }) => a == b,
(
Self::Command {
argv: a,
network: x,
..
},
Self::Command {
argv: b,
network: y,
..
},
) => a == b && x == y,
(Self::Tool { name: a, .. }, Self::Tool { name: b, .. }) => a == b,
_ => false,
}
}
#[must_use]
pub fn changed_from(&self, earlier: &Self) -> Option<String> {
if self == earlier || !self.same_name(earlier) {
return None;
}
let mut what = Vec::new();
match (earlier, self) {
(Self::Path { canonical: a, .. }, Self::Path { canonical: b, .. }) => {
what.push(format!("resolves to {b}, was {a}"));
}
(
Self::Command {
cwd: c1,
executable: e1,
..
},
Self::Command {
cwd: c2,
executable: e2,
..
},
) => {
if e1 != e2 {
what.push(format!("executable {e1} → {e2}"));
}
if c1 != c2 {
what.push(format!("directory {c1} → {c2}"));
}
}
(Self::Tool { digest: a, .. }, Self::Tool { digest: b, .. }) => {
what.push(format!(
"definition @{} → @{} (the tool's definition changed since it was approved)",
first12(a),
first12(b)
));
}
_ => {}
}
(!what.is_empty()).then(|| what.join("; "))
}
#[must_use]
pub fn tool_name(&self) -> Option<&str> {
match self {
Self::Tool { name, .. } => Some(name),
_ => None,
}
}
#[must_use]
pub fn path(&self) -> Option<&str> {
match self {
Self::Path { canonical, .. } => Some(canonical),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Rejection {
pub object: Object,
pub reason: String,
}
#[derive(Debug, Clone, Default)]
pub struct Policy {
checks: Vec<Vec<String>>,
exact: std::collections::BTreeSet<Vec<String>>,
prefix: Vec<Vec<String>>,
protected: Protected,
frozen: std::collections::BTreeSet<String>,
approved: Vec<Object>,
rejected: Vec<Rejection>,
mcp: std::collections::BTreeMap<String, String>,
authoring: bool,
planning: Option<String>,
network_exact: std::collections::BTreeSet<Vec<String>>,
network_prefix: Vec<Vec<String>>,
in_place: bool,
hidden: Vec<String>,
autopilot: bool,
exploring: Option<Exploring>,
}
#[derive(Debug, Clone, Copy)]
pub struct Exploring {
pub paths: fn(&str) -> bool,
pub commands: &'static [&'static [&'static str]],
}
pub const EXPLORING_WRITE: &str = "exploring writes nothing — the brief is made when the person chooses, and *Start spec* writes the spec";
pub const AUTOPILOT_DENIED: &str = "autopilot: not asked — listed for you";
#[must_use]
pub fn prefix_matches(prefix: &[String], argv: &[String]) -> bool {
prefix.len() >= 2 && argv.starts_with(prefix)
}
#[must_use]
pub fn prefix_refusal(prefix: &[String]) -> Option<&'static str> {
(prefix.len() < 2).then_some(
"a bare program allows any argument; add the exact command to [commands] allow instead",
)
}
const SHELLS: &[&str] = &[
"sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh", "pwsh",
];
const PRIVILEGE: &[&str] = &["sudo", "su", "doas", "pkexec"];
const NETWORK: &[&str] = &[
"curl", "wget", "nc", "ncat", "ssh", "scp", "sftp", "rsync", "ftp", "telnet",
];
const GIT_REMOTE: &[&str] = &[
"push",
"fetch",
"pull",
"clone",
"remote",
"ls-remote",
"submodule",
];
const GIT_IN_PLACE: &[&str] = &[
"checkout",
"switch",
"reset",
"commit",
"merge",
"rebase",
"cherry-pick",
"revert",
"stash",
"branch",
"tag",
"update-ref",
"worktree",
"am",
];
const RUNNERS: &[&[&str]] = &[
&["uv", "run"],
&["uv", "tool", "run"],
&["uvx"],
&["poetry", "run"],
&["pipx", "run"],
&["pdm", "run"],
&["hatch", "run"],
];
impl Policy {
#[must_use]
pub fn new(checks: &[Vec<String>], exact: &[Vec<String>]) -> Self {
let mut allowed: Vec<Vec<String>> = checks
.iter()
.map(|argv| {
let prefix: Vec<String> = argv
.iter()
.take_while(|a| !a.starts_with('-'))
.cloned()
.collect();
if (prefix.len() == 1
|| RUNNERS.iter().any(|r| {
prefix
.iter()
.map(String::as_str)
.take(r.len())
.eq(r.iter().copied())
}))
&& argv.len() > prefix.len()
{
argv.clone()
} else {
prefix
}
})
.filter(|p: &Vec<String>| !p.is_empty())
.collect();
allowed.sort();
allowed.dedup();
Self {
checks: allowed,
exact: exact.iter().filter(|p| !p.is_empty()).cloned().collect(),
..Self::default()
}
}
#[must_use]
pub fn with_prefixes(mut self, prefixes: Vec<Vec<String>>) -> Self {
self.prefix = prefixes.into_iter().filter(|p| p.len() >= 2).collect();
self
}
#[must_use]
pub fn check_prefixes(&self) -> &[Vec<String>] {
&self.checks
}
#[must_use]
pub fn with_hidden(mut self, hidden: impl IntoIterator<Item = String>) -> Self {
self.hidden = hidden.into_iter().collect();
self
}
fn is_hidden(&self, rel: &str) -> bool {
self.protected.write_class(rel).is_none()
&& !self.protected.is_context(rel)
&& self.hidden.iter().any(|h| match h.strip_suffix('/') {
Some(dir) => rel == dir || rel.starts_with(h.as_str()),
None => rel == h,
})
}
#[must_use]
pub fn in_place(mut self) -> Self {
self.in_place = true;
self
}
#[must_use]
pub const fn autopilot(mut self) -> Self {
self.autopilot = true;
self
}
#[must_use]
pub const fn is_autopilot(&self) -> bool {
self.autopilot
}
#[must_use]
pub fn settle(&self, decision: Decision) -> Decision {
match decision {
Decision::Approval(_) if self.autopilot => Decision::Deny(AUTOPILOT_DENIED.to_owned()),
d => d,
}
}
#[must_use]
pub const fn exploring(mut self, e: Exploring) -> Self {
self.exploring = Some(e);
self
}
#[must_use]
pub fn authoring(mut self) -> Self {
self.authoring = true;
self
}
#[must_use]
pub fn planning(mut self, dir: &str) -> Self {
let dir = dir.trim_matches('/');
self.planning = (!dir.is_empty()).then(|| dir.to_owned());
self
}
#[must_use]
pub fn is_planning_write(&self, rel: &str) -> bool {
let Some(dir) = &self.planning else {
return false;
};
rel.strip_prefix(dir.as_str())
.and_then(|r| r.strip_prefix('/'))
.is_some_and(|r| {
!r.is_empty()
&& !r.split('/').any(|s| s == ".." || s == ".")
&& !(self.protected.rules().judged)(r.trim_end_matches('/'))
&& self.protected.is_design_doc(rel)
&& !self.protected.is_review(rel)
})
}
#[must_use]
pub fn rules(mut self, rules: &'static crate::format::Rules) -> Self {
self.protected = Protected::new(rules);
self
}
#[must_use]
pub fn with_network(mut self, exact: Vec<Vec<String>>) -> Self {
self.network_exact = exact.into_iter().filter(|p| !p.is_empty()).collect();
self
}
#[must_use]
pub fn with_network_prefixes(mut self, prefixes: Vec<Vec<String>>) -> Self {
self.network_prefix = prefixes.into_iter().filter(|p| p.len() >= 2).collect();
self
}
#[must_use]
pub fn network_allowed(&self, argv: &[String]) -> bool {
self.network_exact.contains(argv)
|| self.network_prefix.iter().any(|p| prefix_matches(p, argv))
}
#[must_use]
pub fn with_mcp(mut self, tools: impl IntoIterator<Item = (String, String)>) -> Self {
self.mcp = tools.into_iter().collect();
self
}
#[must_use]
pub fn with_decisions(mut self, approved: Vec<Object>, rejected: Vec<Rejection>) -> Self {
self.approved = approved;
self.rejected = rejected;
self
}
#[must_use]
pub fn with_frozen(mut self, frozen: impl IntoIterator<Item = String>) -> Self {
self.frozen = frozen.into_iter().collect();
self
}
#[must_use]
pub fn protected(&self) -> &Protected {
&self.protected
}
#[must_use]
pub fn write_protected(&self, rel: &str) -> Option<super::paths::Class> {
if self.is_planning_write(rel) {
return None;
}
if self.authoring
&& (self.protected.rules().authoring_path)(rel)
&& !self.protected.is_secret(rel)
&& !self.protected.is_review(rel)
{
return None;
}
self.protected.write_class(rel)
}
#[must_use]
pub fn classify(&self, tool: &str, args: &Value, object: Option<&Object>) -> Decision {
let decision = self.base(tool, args);
if !matches!(decision, Decision::Approval(_)) {
return decision;
}
let Some(object) = object else {
return decision;
};
if let Some(r) = self.rejected.iter().find(|r| r.object == *object) {
return Decision::Deny(format!("a person rejected this: {}", r.reason));
}
if self.approved.iter().any(|a| a == object) {
return Decision::Allow;
}
decision
}
#[must_use]
pub fn rejection(&self, object: &Object) -> Option<&Rejection> {
self.rejected.iter().find(|r| r.object == *object)
}
#[must_use]
pub fn approved(&self) -> &[Object] {
&self.approved
}
#[must_use]
pub fn rule(&self, tool: &str, args: &Value, object: Option<&Object>) -> String {
if self.autopilot && matches!(self.classify(tool, args, object), Decision::Approval(_)) {
return "autopilot".to_owned();
}
if let Some(object) = object
&& matches!(self.base(tool, args), Decision::Approval(_))
{
if self.rejected.iter().any(|r| r.object == *object) {
return format!("rejection of {} for the task", object.describe());
}
if self.approved.iter().any(|a| a == object) {
return format!("grant {} for the task", object.describe());
}
}
if tool.starts_with(MCP_PREFIX) {
let entry = self.mcp.get(tool).map_or("unlisted", String::as_str);
return format!("[mcp] {entry}");
}
match tool {
"read_file" | "list_files" | "search_code" => "workspace read".to_owned(),
"write_file" | "edit_file" => "workspace write".to_owned(),
"git_status" | "git_diff" => "read-only git".to_owned(),
"run_command" => {
let argv = argv_of(args);
if self.exact.contains(&argv) {
return format!("exact command `{}`", argv.join(" "));
}
if let Some(p) = self
.checks
.iter()
.find(|p| argv.len() >= p.len() && argv[..p.len()] == p[..])
{
return format!("check prefix `{}`", p.join(" "));
}
match self.prefix.iter().find(|p| prefix_matches(p, &argv)) {
Some(p) => format!("allowed prefix `{}`", p.join(" ")),
None => "command policy".to_owned(),
}
}
_ => "tool policy".to_owned(),
}
}
#[must_use]
pub fn base(&self, tool: &str, args: &Value) -> Decision {
self.base_of(tool, args)
}
fn base_of(&self, tool: &str, args: &Value) -> Decision {
if let Some(e) = self.exploring {
return self.explore_of(e, tool, args);
}
if tool.starts_with(MCP_PREFIX) {
return match self.mcp.get(tool).map(String::as_str) {
Some("allow") => Decision::Allow,
Some("deny") => Decision::Deny(format!("`{tool}` is denied in [mcp]")),
Some(_) => Decision::Approval(format!(
"`{tool}` is an MCP tool; speccoder cannot know what it does"
)),
None => Decision::Deny(format!("unknown tool `{tool}`")),
};
}
match tool {
"read_file" | "list_files" | "search_code" => self.read(args),
"write_file" | "edit_file" => self.write(args),
"run_command" => self.with_network_rule(args, self.command(args)),
"git_status" | "git_diff" => Decision::Allow,
"git_log" | "git_show" | "journal_account" | "task_evidence" | "report" | "inbox"
| "explain" => Decision::Allow,
other => Decision::Deny(format!("unknown tool `{other}`")),
}
}
fn explore_of(&self, e: Exploring, tool: &str, args: &Value) -> Decision {
match tool {
"read_file" | "list_files" | "search_code" => self.read(args),
"git_status" | "git_diff" => Decision::Allow,
"write_file" | "edit_file" => {
let rel = args["path"]
.as_str()
.and_then(|p| super::paths::normalise(p).ok());
match rel {
Some(rel)
if (e.paths)(&rel)
&& !self.protected.is_secret(&rel)
&& !self.protected.is_review(&rel) =>
{
Decision::Allow
}
_ => Decision::Deny(EXPLORING_WRITE.to_owned()),
}
}
"run_command" => {
let argv = argv_of(args);
if args.get("network").and_then(Value::as_bool) == Some(true) {
return Decision::Deny("exploring has no network".to_owned());
}
if e.commands.iter().any(|p| {
argv.len() >= p.len() && argv.iter().zip(p.iter()).all(|(a, b)| a == b)
}) {
Decision::Allow
} else {
Decision::Deny(format!(
"exploring runs only the format's read-only commands; `{}` is not one",
argv.join(" ")
))
}
}
other => Decision::Deny(format!("`{other}` is not used while exploring")),
}
}
fn with_network_rule(&self, args: &Value, decision: Decision) -> Decision {
if args.get("network").and_then(Value::as_bool) != Some(true)
|| matches!(decision, Decision::Deny(_))
{
return decision;
}
let argv = argv_of(args);
if self.network_allowed(&argv) {
return decision;
}
Decision::Approval(format!(
"`{}` asks for the network; commands run without it",
argv.join(" ")
))
}
fn read(&self, args: &Value) -> Decision {
let Some(path) = super::below_root(args.get("path").and_then(Value::as_str)) else {
return Decision::Allow; };
match normalise(path) {
Err(e) => Decision::Deny(e.0),
Ok(p) if self.protected.is_secret(&p) => {
Decision::Deny(format!("`{p}` looks like a credential"))
}
Ok(p) if self.is_hidden(&p) => Decision::Deny(format!(
"`{p}` is ignored by git in this checkout; an in-place run works only with what a worktree would hold"
)),
Ok(_) => Decision::Allow,
}
}
fn write(&self, args: &Value) -> Decision {
let Some(path) = args.get("path").and_then(Value::as_str) else {
return Decision::Deny("no `path`".to_owned());
};
let p = match normalise(path) {
Ok(p) => p,
Err(e) => return Decision::Deny(e.0),
};
if (self.authoring
&& (self.protected.rules().authoring_path)(&p)
&& !self.protected.is_secret(&p)
&& !self.protected.is_review(&p))
|| self.is_planning_write(&p)
{
return Decision::Allow;
}
if self.is_hidden(&p) {
return Decision::Deny(format!(
"`{p}` is ignored by git in this checkout; an in-place run does not write it"
));
}
if self.protected.is_review(&p) {
return Decision::Deny(format!(
"`{p}` is protected: it records a person's review of the spec; only the person writes it (`speccoder review`)"
));
}
if let Some(class) = self.protected.write_class(&p) {
return Decision::Deny(format!(
"`{p}` is protected: {}",
self.protected.reason(class)
));
}
if self.authoring {
return Decision::Approval((self.protected.rules().authoring_refusal)(&p));
}
if self.protected.is_context(&p) {
return Decision::Approval(format!(
"`{p}` is an agent context file: every later agent session reads it"
));
}
if self
.frozen
.iter()
.any(|f| f.to_lowercase() == p.to_lowercase())
{
return Decision::Approval(format!(
"`{p}` is a frozen test (it existed when the attempt started)"
));
}
if self.protected.is_manifest(&p) {
return Decision::Approval(format!("`{p}` is a dependency manifest"));
}
Decision::Allow
}
fn command(&self, args: &Value) -> Decision {
let argv = argv_of(args);
match (self.protected.rules().command)(&argv, self.authoring) {
Some(Ok(())) => return Decision::Allow,
Some(Err(why)) => return Decision::Deny(why),
None => {}
}
let Some(program) = argv.first() else {
return Decision::Deny("empty `argv`".to_owned());
};
let name = program.rsplit('/').next().unwrap_or(program);
let has = |flag: &str| argv.iter().any(|a| a == flag);
if PRIVILEGE.contains(&name) {
return Decision::Deny(format!("`{name}` raises privileges"));
}
if name == "git" {
let sub = argv.get(1).map_or("", String::as_str);
if GIT_REMOTE.contains(&sub) {
return Decision::Deny(format!(
"`git {sub}` talks to a remote; speccoder never does"
));
}
let notes_action = argv[2..]
.iter()
.find(|a| !a.starts_with('-'))
.map_or("list", String::as_str);
if (sub == "notes" && !matches!(notes_action, "show" | "list"))
|| (sub == "update-ref" && argv.iter().any(|a| a.starts_with("refs/notes")))
{
return Decision::Deny(
"git notes are speccoder's attestations; only speccoder writes them".to_owned(),
);
}
let listing_branches = sub == "branch"
&& argv[2..].iter().all(|a| {
matches!(
a.as_str(),
"--show-current" | "--list" | "-l" | "-a" | "--all" | "-v" | "-vv"
)
});
if matches!(
sub,
"rev-parse" | "status" | "log" | "diff" | "show" | "ls-files" | "describe"
) || listing_branches
{
return Decision::Allow;
}
if self.in_place
&& !listing_branches
&& (GIT_IN_PLACE.contains(&sub)
|| (sub == "apply" && has("--index"))
|| (sub == "restore" && has("--staged")))
{
return Decision::Deny(format!(
"`git {sub}` would move the checkout of an in-place run; speccoder commits and switches it"
));
}
if sub == "clean" || (sub == "reset" && has("--hard")) || sub == "filter-branch" {
return Decision::Deny(format!("`git {sub}` destroys work"));
}
if matches!(
sub,
"commit"
| "checkout"
| "switch"
| "rebase"
| "merge"
| "stash"
| "branch"
| "tag"
| "config"
| "worktree"
) {
return Decision::Approval(format!("`git {sub}` changes repository state"));
}
}
if SHELLS.contains(&name) || name == "eval" || name == "env" || name == "xargs" {
return Decision::Approval(format!("`{name}` runs arbitrary commands"));
}
if NETWORK.contains(&name) {
return Decision::Approval(format!("`{name}` uses the network"));
}
if name == "rm" && argv.iter().any(|a| a.starts_with('-') && a.contains('r')) {
return Decision::Approval("recursive `rm`".to_owned());
}
if argv.len() == 2 && matches!(argv[1].as_str(), "--version" | "-V" | "version") {
return Decision::Allow;
}
if self.exact.contains(&argv)
|| self
.checks
.iter()
.any(|prefix| argv.len() >= prefix.len() && argv[..prefix.len()] == prefix[..])
|| self.prefix.iter().any(|p| prefix_matches(p, &argv))
{
return Decision::Allow;
}
Decision::Approval(NOT_ALLOWED.to_owned())
}
}
#[must_use]
pub fn argv_of(args: &Value) -> Vec<String> {
args.get("argv")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn policy() -> Policy {
let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
Policy::new(
&[
argv("cargo nextest run --workspace --profile speccoder"),
argv("cargo clippy --workspace -- -D warnings"),
],
&[],
)
.with_prefixes(vec![argv("cargo fmt"), argv("cargo check")])
}
#[test]
fn an_unasked_call_is_named_with_how_to_let_it_run() {
let decide = "run with prompts";
let refused = policy().classify("run_command", &json!({"argv": ["npm", "i"]}), None);
let Decision::Approval(reason) = refused else {
panic!("{refused:?}")
};
let m = unasked(
&json!({"tool": "run_command", "arguments": {"argv": ["make", "it's"]}, "reason": reason}),
decide,
);
assert_eq!(
m,
"`make 'it'\\''s'` needs approval: it is not a configured check or allowed command — allow exactly it in speccoder.toml (`[commands] allow = [[\"make\", \"it's\"]]`), or run with prompts"
);
let m = unasked(
&json!({"tool": "run_command", "arguments": {"argv": ["sh", "-c", "x"]}, "reason": "`sh` runs arbitrary commands"}),
decide,
);
assert_eq!(
m,
"`sh -c x` needs approval: `sh` runs arbitrary commands — run with prompts"
);
let m = unasked(
&json!({"tool": "write_file", "arguments": {"path": "Cargo.toml"}, "reason": "`Cargo.toml` is a dependency manifest"}),
decide,
);
assert_eq!(
m,
"`write_file` `Cargo.toml` needs approval: `Cargo.toml` is a dependency manifest — run with prompts"
);
}
fn path(canonical: &str, asked: &str) -> Object {
Object::Path {
canonical: canonical.to_owned(),
asked: asked.to_owned(),
}
}
fn command(s: &str) -> Object {
Object::command(
s.split(' ').map(str::to_owned).collect(),
".".to_owned(),
"/usr/bin/x".to_owned(),
false,
)
}
fn cmd(s: &str) -> Value {
json!({ "argv": s.split(' ').collect::<Vec<_>>() })
}
#[test]
fn classifies_the_table() {
use Decision::{Allow, Approval, Deny};
let p = policy();
let kind = |d: Decision| match d {
Allow => "allow",
Approval(_) => "approval",
Deny(_) => "deny",
};
let cases: &[(&str, Value, &str)] = &[
("read_file", json!({"path": "src/lib.rs"}), "allow"),
("read_file", json!({"path": ".env"}), "deny"),
("read_file", json!({"path": "../secret"}), "deny"),
("list_files", json!({}), "allow"),
("search_code", json!({"pattern": "fn main"}), "allow"),
("write_file", json!({"path": "src/new.rs"}), "allow"),
("edit_file", json!({"path": "specs/001-x/tasks.md"}), "deny"),
("write_file", json!({"path": "speccoder.toml"}), "deny"),
(
"write_file",
json!({"path": ".github/workflows/ci.yml"}),
"deny",
),
(
"write_file",
json!({"path": ".git/hooks/pre-commit"}),
"deny",
),
("edit_file", json!({"path": "Cargo.toml"}), "approval"),
("edit_file", json!({"path": "web/package.json"}), "approval"),
("run_command", cmd("cargo nextest run -p x"), "allow"),
("run_command", cmd("cargo clippy --all-targets"), "allow"),
("run_command", cmd("cargo fmt --all"), "allow"),
("run_command", cmd("cargo build"), "approval"),
("run_command", cmd("cargo --version"), "allow"),
("run_command", cmd("rustc -V"), "allow"),
("run_command", cmd("sudo --version"), "deny"),
("run_command", cmd("npm install left-pad"), "approval"),
("run_command", cmd("git push origin main"), "deny"),
("run_command", cmd("git fetch"), "deny"),
("run_command", cmd("git reset --hard HEAD"), "deny"),
("run_command", cmd("git clean -fdx"), "deny"),
("run_command", cmd("git commit -m x"), "approval"),
(
"run_command",
cmd("git rev-parse --is-inside-work-tree"),
"allow",
),
("run_command", cmd("git branch --show-current"), "allow"),
("run_command", cmd("git branch feature-x"), "approval"),
("run_command", cmd("git log --oneline -3"), "allow"),
("run_command", cmd("sh -c ls"), "approval"),
("run_command", cmd("sudo cargo test"), "deny"),
("run_command", cmd("curl https://example.com"), "approval"),
("run_command", cmd("rm -rf target"), "approval"),
("run_command", json!({"argv": []}), "deny"),
("git_status", json!({}), "allow"),
("git_diff", json!({}), "allow"),
("delete_everything", json!({}), "deny"),
];
for (tool, args, want) in cases {
assert_eq!(kind(p.classify(tool, args, None)), *want, "{tool} {args}");
}
}
#[test]
fn decisions_cover_classes_and_never_override_a_denial() {
let approved = vec![
path("Cargo.toml", "Cargo.toml"),
command("cargo build"),
path("speccoder.toml", "speccoder.toml"),
command("curl https://x"),
];
let rejected = vec![Rejection {
object: command("curl https://x"),
reason: "no network".to_owned(),
}];
let p = policy().with_decisions(approved, rejected);
assert_eq!(
p.classify(
"edit_file",
&json!({"path": "./Cargo.toml", "old_string": "a"}),
Some(&path("Cargo.toml", "./Cargo.toml"))
),
Decision::Allow
);
assert!(matches!(
p.classify("edit_file", &json!({"path": "Cargo.toml"}), None),
Decision::Approval(_)
));
assert_eq!(
p.classify(
"run_command",
&cmd("cargo build"),
Some(&command("cargo build"))
),
Decision::Allow
);
assert!(matches!(
p.classify(
"run_command",
&cmd("cargo build --release"),
Some(&command("cargo build --release"))
),
Decision::Approval(_)
));
assert_eq!(
p.classify(
"run_command",
&cmd("curl https://x"),
Some(&command("curl https://x"))
),
Decision::Deny("a person rejected this: no network".to_owned())
);
assert!(matches!(
p.classify("write_file", &json!({"path": "speccoder.toml"}), None),
Decision::Deny(_)
));
let p = Policy::new(&[], &[]);
for argv in [
vec![
"git",
"notes",
"--ref=speccoder",
"add",
"-f",
"-m",
"x",
"HEAD",
],
vec!["git", "notes", "remove", "HEAD"],
vec!["git", "update-ref", "refs/notes/speccoder", "HEAD"],
] {
assert!(
matches!(
p.classify("run_command", &json!({ "argv": argv }), None),
Decision::Deny(_)
),
"{argv:?}"
);
}
for argv in [
vec!["git", "notes", "--ref=speccoder", "show", "HEAD"],
vec!["git", "notes", "list"],
] {
assert!(
!matches!(
p.classify("run_command", &json!({ "argv": argv }), None),
Decision::Deny(_)
),
"{argv:?}"
);
}
let in_place = Policy::new(&[], &[]).in_place();
for argv in [
vec!["git", "switch", "main"],
vec!["git", "commit", "-m", "x"],
vec!["git", "reset", "HEAD~1"],
vec!["git", "stash"],
vec!["git", "branch", "x"],
vec!["git", "apply", "--index", "p.diff"],
vec!["git", "restore", "--staged", "a"],
vec!["git", "update-ref", "HEAD", "x"],
] {
assert!(
matches!(
in_place.classify("run_command", &json!({ "argv": argv }), None),
Decision::Deny(_)
),
"{argv:?}"
);
}
for argv in [
vec!["git", "status"],
vec!["git", "branch", "--show-current"],
] {
assert_eq!(
in_place.classify("run_command", &json!({ "argv": argv }), None),
Decision::Allow,
"{argv:?}"
);
}
let hidden = Policy::new(&[], &[]).in_place().with_hidden([
"target/".to_owned(),
"notes.txt".to_owned(),
"specs/".to_owned(),
]);
for (tool, path) in [
("read_file", "target/debug/x"),
("read_file", "notes.txt"),
("list_files", "target"),
("write_file", "notes.txt"),
] {
assert!(
matches!(
hidden.classify(tool, &json!({ "path": path }), None),
Decision::Deny(_)
),
"{tool} {path}"
);
}
assert_eq!(
hidden.classify("read_file", &json!({ "path": "specs/001-x/spec.md" }), None),
Decision::Allow
);
assert_eq!(
hidden.classify("read_file", &json!({ "path": "src/lib.rs" }), None),
Decision::Allow
);
assert!(matches!(
Policy::new(&[], &[]).classify(
"run_command",
&json!({ "argv": ["git", "switch", "main"] }),
None
),
Decision::Approval(_)
));
}
#[test]
fn a_check_through_a_runner_allows_only_itself() {
let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
let check = "uv run --locked --offline python -m pytest -q --junitxml=.speccoder/junit.xml";
let p = Policy::new(&[argv(check), argv("poetry run pytest -q")], &[]);
assert_eq!(
p.classify("run_command", &cmd(check), None),
Decision::Allow
);
for other in [
"uv run python -c print(1)",
"uv run --locked --offline sh",
"poetry run sh",
] {
assert!(
matches!(
p.classify("run_command", &cmd(other), None),
Decision::Approval(_)
),
"{other}"
);
}
}
#[test]
fn a_check_whose_prefix_is_its_bare_program_allows_only_itself() {
let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
let p = Policy::new(
&[argv("python3 -m pytest -q --junitxml=.speccoder/junit.xml")],
&[],
);
assert_eq!(
p.classify(
"run_command",
&cmd("python3 -m pytest -q --junitxml=.speccoder/junit.xml"),
None
),
Decision::Allow
);
assert!(matches!(
p.classify("run_command", &cmd("python3 -c print(1)"), None),
Decision::Approval(_)
));
assert!(matches!(
p.classify("run_command", &cmd("python3 -m pytest tests/x.py"), None),
Decision::Approval(_)
));
let q = Policy::new(&[], &[argv("python3 -m pytest")]);
assert!(matches!(
q.classify("run_command", &cmd("python3 -m pytest tests/x.py"), None),
Decision::Approval(_)
));
assert_eq!(
q.classify("run_command", &cmd("python3 -m pytest"), None),
Decision::Allow
);
}
#[test]
fn mcp_tools_follow_their_configured_decision_and_unknown_ones_are_denied() {
let p = Policy::new(&[], &[]).with_mcp([
("mcp__db__query".to_owned(), "allow".to_owned()),
("mcp__db__drop".to_owned(), "deny".to_owned()),
("mcp__db__insert".to_owned(), "approval".to_owned()),
]);
assert_eq!(
p.classify("mcp__db__query", &json!({}), None),
Decision::Allow
);
assert!(matches!(
p.classify("mcp__db__drop", &json!({}), None),
Decision::Deny(_)
));
assert!(matches!(
p.classify("mcp__db__insert", &json!({}), None),
Decision::Approval(_)
));
assert!(matches!(
p.classify("mcp__db__other", &json!({}), None),
Decision::Deny(_)
));
let tool = |name: &str, digest: &str| Object::Tool {
name: name.to_owned(),
digest: digest.to_owned(),
};
let q = p.clone().with_decisions(
vec![tool("mcp__db__insert", "d1"), tool("mcp__db__drop", "d1")],
Vec::new(),
);
assert_eq!(
q.classify(
"mcp__db__insert",
&json!({ "row": 1 }),
Some(&tool("mcp__db__insert", "d1"))
),
Decision::Allow
);
assert!(matches!(
q.classify(
"mcp__db__insert",
&json!({ "row": 1 }),
Some(&tool("mcp__db__insert", "d2"))
),
Decision::Approval(_)
));
assert!(matches!(
q.classify(
"mcp__db__drop",
&json!({}),
Some(&tool("mcp__db__drop", "d1"))
),
Decision::Deny(_)
));
assert_eq!(
tool("mcp__db__insert", "9f8e7d6c5b4a3210").short(),
"mcp__db__insert@9f8e7d6c5b4a"
);
assert!(
tool("mcp__db__insert", "d2")
.changed_from(&tool("mcp__db__insert", "d1"))
.is_some_and(|c| c.contains("definition changed"))
);
}
#[test]
fn a_planning_task_writes_design_docs_in_its_unit_never_a_judged_file() {
let p = Policy::new(&[], &[]).planning("specs/001-x/");
let w = |path: &str| p.classify("write_file", &json!({ "path": path }), None);
assert_eq!(w("specs/001-x/contracts/cli.md"), Decision::Allow);
assert_eq!(w("specs/001-x/data-model.md"), Decision::Allow);
assert_eq!(w("specs/001-x/plan.md"), Decision::Allow);
assert_eq!(p.write_protected("specs/001-x/contracts/cli.md"), None);
for judged in [
"specs/001-x/spec.md",
"specs/001-x/tasks.md",
"specs/001-x/review.json",
"specs/001-x/checklists/ux.md",
"specs/001-x/contracts/../spec.md",
".specify/memory/constitution.md",
"specs/002-y/contracts/cli.md",
"specs/001-x/.env",
"specs/001-x/.git/config",
"specs/001-x/speccoder.toml",
"specs/001-x/vitest.config.ts",
"specs/001-x/.mcp.json",
"specs/001-x/.claude/settings.json",
"specs/001-x/AGENTS.md",
"specs/001-x/CLAUDE.md",
"specs/001-x/package.json",
"specs/001-x/Cargo.toml",
"specs/001-x/.hidden.md",
] {
assert!(matches!(w(judged), Decision::Deny(_)), "{judged}");
assert!(!p.is_planning_write(judged), "{judged}");
}
assert!(p.write_protected("specs/001-x/spec.md").is_some());
assert_eq!(w("src/lib.rs"), Decision::Allow);
assert!(matches!(w("Cargo.toml"), Decision::Approval(_)));
let normal = Policy::new(&[], &[]);
assert!(matches!(
normal.classify(
"write_file",
&json!({ "path": "specs/001-x/contracts/cli.md" }),
None
),
Decision::Deny(_)
));
let o = Policy::new(&[], &[])
.rules(crate::format::Adapter::rules(
&crate::format::openspec::OPENSPEC,
))
.planning("openspec/changes/add-x");
assert!(o.is_planning_write("openspec/changes/add-x/design.md"));
for judged in ["proposal.md", "tasks.md", "specs/auth/spec.md"] {
assert!(
!o.is_planning_write(&format!("openspec/changes/add-x/{judged}")),
"{judged}"
);
}
}
#[test]
fn the_authoring_profile_writes_spec_kit_artifacts_and_runs_its_scripts_only() {
let p = Policy::new(&[], &[]).authoring();
let w = |path: &str| p.classify("write_file", &json!({ "path": path }), None);
assert_eq!(w("specs/002-login/spec.md"), Decision::Allow);
assert_eq!(w(".specify/feature.json"), Decision::Allow);
assert_eq!(w(".specify/memory/constitution.md"), Decision::Allow);
assert!(matches!(
w(".specify/scripts/bash/common.sh"),
Decision::Deny(_)
));
assert!(matches!(
w(".specify/templates/spec-template.md"),
Decision::Deny(_)
));
assert!(matches!(w("speccoder.toml"), Decision::Deny(_)));
assert!(matches!(w("specs/.env"), Decision::Deny(_)));
assert!(
matches!(w("specs/002-login/review.json"), Decision::Deny(ref why) if why.contains("review")),
"{:?}",
w("specs/002-login/review.json")
);
assert!(p.write_protected("specs/002-login/review.json").is_some());
assert!(matches!(w("src/lib.rs"), Decision::Approval(_)));
let run = |argv: &[&str]| p.classify("run_command", &json!({ "argv": argv }), None);
assert_eq!(
run(&[".specify/scripts/bash/setup-plan.sh", "--json"]),
Decision::Allow
);
assert_eq!(
run(&[
"bash",
"./.specify/scripts/bash/check-prerequisites.sh",
"--json"
]),
Decision::Allow
);
assert!(matches!(
run(&["bash", ".specify/scripts/bash/../../evil.sh"]),
Decision::Approval(_)
));
assert_eq!(
run(&[
"bash",
".specify/extensions/git/scripts/bash/create-new-feature-branch.sh",
"--json"
]),
Decision::Allow
);
assert!(matches!(
run(&["bash", ".specify/extensions/git/other.sh"]),
Decision::Approval(_)
));
assert!(matches!(
run(&["bash", "-c", "rm -rf /"]),
Decision::Approval(_)
));
assert!(matches!(
Policy::new(&[], &[]).classify(
"write_file",
&json!({ "path": "specs/002-login/spec.md" }),
None
),
Decision::Deny(_)
));
}
#[test]
fn asking_for_the_network_needs_a_person_unless_allowed() {
let checks = [vec!["cargo".to_owned(), "test".to_owned()]];
let p = Policy::new(&checks, &[]);
let net = json!({ "argv": ["cargo", "test"], "network": true });
assert!(matches!(
p.classify("run_command", &net, None),
Decision::Approval(_)
));
assert_eq!(
p.classify("run_command", &json!({ "argv": ["cargo", "test"] }), None),
Decision::Allow
);
assert!(matches!(
p.classify(
"run_command",
&json!({ "argv": ["git", "push"], "network": true }),
None
),
Decision::Deny(_)
));
let exact = p
.clone()
.with_network(vec![vec!["cargo".to_owned(), "test".to_owned()]]);
assert_eq!(exact.classify("run_command", &net, None), Decision::Allow);
let longer = json!({ "argv": ["cargo", "test", "--release"], "network": true });
assert!(matches!(
exact.classify("run_command", &longer, None),
Decision::Approval(_)
));
let bare = p.clone().with_network(vec![vec!["cargo".to_owned()]]);
assert!(matches!(
bare.classify("run_command", &net, None),
Decision::Approval(_)
));
let prefixed = p.clone().with_network_prefixes(vec![
vec!["cargo".to_owned()],
vec!["cargo".to_owned(), "test".to_owned()],
]);
assert_eq!(
prefixed.classify("run_command", &longer, None),
Decision::Allow
);
let object = Object::command(
vec!["cargo".to_owned(), "test".to_owned()],
".".to_owned(),
"/usr/bin/cargo".to_owned(),
true,
);
let approved = Policy::new(&checks, &[]).with_decisions(vec![object.clone()], vec![]);
assert_eq!(
approved.classify("run_command", &net, Some(&object)),
Decision::Allow
);
let shadowed = Object::command(
vec!["cargo".to_owned(), "test".to_owned()],
".".to_owned(),
"/w/bin/cargo".to_owned(),
true,
);
assert!(matches!(
approved.classify("run_command", &net, Some(&shadowed)),
Decision::Approval(_)
));
assert!(
shadowed
.changed_from(&object)
.is_some_and(|c| c == "executable /usr/bin/cargo → /w/bin/cargo")
);
}
#[test]
fn commands_allow_is_exact_and_prefixes_need_two_elements() {
let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
let p = Policy::new(&[], &[argv("npm run build")]);
assert_eq!(
p.classify("run_command", &cmd("npm run build"), None),
Decision::Allow
);
for longer in [
"npm run build --script-shell=/tmp/x",
"npm run build x",
"npm run",
] {
assert!(
matches!(
p.classify("run_command", &cmd(longer), None),
Decision::Approval(_)
),
"{longer}"
);
}
assert_eq!(
p.rule("run_command", &cmd("npm run build"), None),
"exact command `npm run build`"
);
let q = Policy::new(&[], &[]).with_prefixes(vec![argv("npm"), argv("npm run")]);
assert_eq!(
q.classify("run_command", &cmd("npm run build"), None),
Decision::Allow
);
assert_eq!(
q.rule("run_command", &cmd("npm run build"), None),
"allowed prefix `npm run`"
);
assert!(matches!(
q.classify("run_command", &cmd("npm install x"), None),
Decision::Approval(_)
));
assert!(
prefix_refusal(&argv("npm")).is_some() && prefix_refusal(&argv("npm run")).is_none()
);
let r = Policy::new(&[], &[argv("sh -c ls")]).with_prefixes(vec![argv("git push")]);
assert!(matches!(
r.classify("run_command", &cmd("sh -c ls"), None),
Decision::Approval(_)
));
assert!(matches!(
r.classify("run_command", &cmd("git push origin"), None),
Decision::Deny(_)
));
}
#[test]
fn autopilot_never_asks_never_lifts_a_denial_and_names_its_rule() {
let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
let p = Policy::new(&[], &[argv("cargo test")]).autopilot();
assert!(p.is_autopilot());
assert_eq!(
p.settle(p.classify("run_command", &cmd("cargo test"), None)),
Decision::Allow
);
assert_eq!(
p.settle(p.classify("run_command", &cmd("cargo add x"), None)),
Decision::Deny(AUTOPILOT_DENIED.to_owned())
);
assert_eq!(
p.rule("run_command", &cmd("cargo add x"), None),
"autopilot"
);
assert!(matches!(
p.settle(p.classify("run_command", &cmd("git push origin"), None)),
Decision::Deny(r) if r != AUTOPILOT_DENIED
));
let q = Policy::new(&[], &[]);
assert!(matches!(
q.settle(q.classify("run_command", &cmd("cargo add x"), None)),
Decision::Approval(_)
));
let a = command("make check");
let mut b = a.clone();
if let Object::Command { cwd, .. } = &mut b {
*cwd = "sub".to_owned();
}
assert_ne!(a.grant_id(), b.grant_id());
assert_eq!(a.grant_id(), a.clone().grant_id());
assert_eq!(
path("src/b/x.rs", "src/a/x.rs").grant_id(),
path("src/b/x.rs", "src/b/x.rs").grant_id()
);
assert!(a.grant_id().starts_with("g-") && a.grant_id().len() == 18);
}
proptest::proptest! {
#[test]
fn on_autopilot_no_call_waits_for_a_person(words in proptest::collection::vec("[a-z]{1,6}", 1..4), tool in 0usize..4) {
let p = Policy::new(&[], &[]).autopilot();
let argv: Vec<String> = words.clone();
let (name, args) = match tool {
0 => ("run_command", json!({ "argv": argv })),
1 => ("write_file", json!({ "path": format!("src/{}.rs", words[0]), "content": "" })),
2 => ("write_file", json!({ "path": format!(".github/{}.yml", words[0]), "content": "" })),
_ => ("mcp__x__y", json!({})),
};
let before = Policy::new(&[], &[]).classify(name, &args, None);
let after = p.settle(p.classify(name, &args, None));
proptest::prop_assert!(!matches!(after, Decision::Approval(_)));
if matches!(before, Decision::Deny(_)) {
proptest::prop_assert!(matches!(after, Decision::Deny(_)));
}
if before == Decision::Allow {
proptest::prop_assert_eq!(after, Decision::Allow);
}
}
}
const OPENSPEC_READS: &[&[&str]] = &[&["openspec", "list"], &["openspec", "show"]];
fn exploring() -> Policy {
Policy::new(&[vec!["cargo".to_owned(), "test".to_owned()]], &[]).exploring(Exploring {
paths: |rel| rel.starts_with(".specify/assessments/"),
commands: OPENSPEC_READS,
})
}
proptest::proptest! {
#[test]
fn exploring_denies_what_it_does_not_allow_and_never_asks(words in proptest::collection::vec("[a-z]{1,6}", 1..4), tool in 0usize..6) {
let p = exploring();
let (name, args) = match tool {
0 => ("run_command", json!({ "argv": words })),
1 => ("run_command", json!({ "argv": ["cargo", "test"] })),
2 => ("write_file", json!({ "path": format!("src/{}.rs", words[0]), "content": "" })),
3 => ("edit_file", json!({ "path": format!("specs/{}/spec.md", words[0]), "old_string": "a", "new_string": "b" })),
4 => ("run_command", json!({ "argv": ["openspec", "list", words[0].clone()], "network": true })),
_ => ("mcp__x__y", json!({})),
};
let d = p.classify(name, &args, None);
proptest::prop_assert!(matches!(d, Decision::Deny(_)), "{name} {args} → {d:?}");
}
}
#[test]
fn exploring_reads_and_runs_the_formats_read_only_commands() {
let p = exploring();
assert_eq!(
p.classify("read_file", &json!({ "path": "src/lib.rs" }), None),
Decision::Allow
);
assert_eq!(
p.classify(
"run_command",
&json!({ "argv": ["openspec", "list", "--json"] }),
None
),
Decision::Allow
);
assert_eq!(
p.classify(
"write_file",
&json!({ "path": ".specify/assessments/csv/notes.md", "content": "" }),
None
),
Decision::Allow
);
let Decision::Deny(why) = p.classify(
"write_file",
&json!({ "path": "notes.md", "content": "" }),
None,
) else {
panic!("a write outside the exploration paths is denied");
};
assert_eq!(why, EXPLORING_WRITE);
}
#[test]
fn a_path_object_is_its_canonical_file_whatever_the_spelling() {
assert_eq!(
path("src/b/x.rs", "src/a/x.rs"),
path("src/b/x.rs", "src/b/x.rs")
);
assert_ne!(
path("src/b/x.rs", "src/a/x.rs"),
path("src/a/x.rs", "src/a/x.rs")
);
assert!(
path("src/b/x.rs", "src/a/x.rs")
.changed_from(&path("src/a/x.rs", "src/a/x.rs"))
.is_some_and(|c| c == "resolves to src/b/x.rs, was src/a/x.rs")
);
let a = command("make check");
let mut b = a.clone();
if let Object::Command { cwd, .. } = &mut b {
*cwd = "sub".to_owned();
}
assert_ne!(a, b);
assert_eq!(a.class(), Class::Command);
assert!(a.short().starts_with("argv#") && a.short().len() == 17);
let back: Object = serde_json::from_value(serde_json::to_value(&a).unwrap()).unwrap();
assert_eq!(back, a);
assert!(serde_json::from_value::<Object>(json!({ "kind": "write", "path": "x" })).is_err());
assert!(
serde_json::from_value::<Rejection>(json!({ "grant": { "kind": "path", "canonical": "x", "asked": "x" }, "reason": "r" }))
.is_err()
);
}
#[test]
fn the_grant_path_has_no_pattern() {
let source = include_str!("policy.rs");
let classify = source
.split("pub fn classify(")
.nth(1)
.and_then(|s| s.split("\n }\n").next())
.unwrap_or_default();
assert!(!classify.is_empty());
for pattern in ["starts_with", "glob", "prefix"] {
assert!(!classify.contains(pattern), "{pattern} in the grant path");
}
}
}