use serde::{Deserialize, Serialize};
use tuff_hooks_spec::CoverageLevel;
use crate::error::{Result, TuffError};
use crate::lockfile::ManagedPermission;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PolicyConfig {
#[serde(default)]
pub rules: Vec<PolicyRule>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PolicyEffect {
Deny,
Ask,
}
impl PolicyEffect {
pub const ALL: [Self; 2] = [Self::Deny, Self::Ask];
pub fn parse(text: &str) -> Option<Self> {
match text {
"deny" => Some(Self::Deny),
"ask" => Some(Self::Ask),
_ => None,
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Deny => "deny",
Self::Ask => "ask",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PolicySubjectKind {
Command,
Read,
Edit,
Mcp,
}
impl PolicySubjectKind {
pub const ALL: [Self; 4] = [Self::Command, Self::Read, Self::Edit, Self::Mcp];
pub const fn as_str(self) -> &'static str {
match self {
Self::Command => "command",
Self::Read => "read",
Self::Edit => "edit",
Self::Mcp => "mcp",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PolicyRule {
pub effect: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub read: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edit: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PolicySubject<'a> {
Command(&'a [String]),
Read(&'a [String]),
Edit(&'a [String]),
Mcp { server: &'a str, tool: &'a str },
}
impl PolicySubject<'_> {
pub const fn kind(&self) -> PolicySubjectKind {
match self {
Self::Command(_) => PolicySubjectKind::Command,
Self::Read(_) => PolicySubjectKind::Read,
Self::Edit(_) => PolicySubjectKind::Edit,
Self::Mcp { .. } => PolicySubjectKind::Mcp,
}
}
}
impl PolicyRule {
pub fn effect(&self) -> Result<PolicyEffect> {
match self.effect.as_str() {
"deny" => Ok(PolicyEffect::Deny),
"ask" => Ok(PolicyEffect::Ask),
"allow" => Err(TuffError::refused(
"a policy rule cannot allow anything: policies only narrow what an agent may do",
)
.with_hint(
"use effect = \"deny\" or \"ask\"; permissions an agent should have belong in the harness's own settings, not in a shareable policy",
)),
other => Err(TuffError::usage(format!(
"policy rule effect must be \"deny\" or \"ask\", not '{}'",
other.escape_debug()
))),
}
}
pub fn subject(&self) -> Result<PolicySubject<'_>> {
let mut present = Vec::new();
if self.command.is_some() {
present.push("command");
}
if self.read.is_some() {
present.push("read");
}
if self.edit.is_some() {
present.push("edit");
}
if self.mcp.is_some() {
present.push("mcp");
}
match present.as_slice() {
[] => {
return Err(TuffError::usage(
"policy rule needs a subject: one of command, read, edit, or mcp",
));
}
[_] => {}
several => {
return Err(TuffError::usage(format!(
"policy rule has more than one subject ({}); write one rule per subject",
several.join(", ")
)));
}
}
if let Some(command) = &self.command {
validate_command(command)?;
return Ok(PolicySubject::Command(command));
}
if let Some(read) = &self.read {
validate_paths("read", read)?;
return Ok(PolicySubject::Read(read));
}
if let Some(edit) = &self.edit {
validate_paths("edit", edit)?;
return Ok(PolicySubject::Edit(edit));
}
let mcp = self.mcp.as_deref().expect("one subject is present");
let (server, tool) = parse_mcp_pattern(mcp)?;
Ok(PolicySubject::Mcp { server, tool })
}
pub fn describe(&self) -> String {
let subject = if let Some(command) = &self.command {
format!("command \"{}\"", command.join(" "))
} else if let Some(read) = &self.read {
format!("read {}", quoted_list(read))
} else if let Some(edit) = &self.edit {
format!("edit {}", quoted_list(edit))
} else if let Some(mcp) = &self.mcp {
format!("mcp \"{mcp}\"")
} else {
"no subject".to_string()
};
format!("{} {subject}", self.effect)
}
}
fn quoted_list(items: &[String]) -> String {
items
.iter()
.map(|item| format!("\"{item}\""))
.collect::<Vec<_>>()
.join(", ")
}
fn validate_command(command: &[String]) -> Result<()> {
if command.is_empty() {
return Err(TuffError::usage(
"policy rule command must name at least the program, such as [\"git\", \"push\"]",
));
}
for token in command {
if token.contains('*') {
return Err(TuffError::usage(format!(
"policy rule command arguments are literal words, and '*' is not a pattern here: '{}'",
token.escape_debug()
))
.with_hint("a command rule already matches every command that starts with its arguments"));
}
if token.is_empty() || token.chars().any(char::is_whitespace) || token.contains('\0') {
return Err(TuffError::usage(format!(
"policy rule command arguments must be single words without spaces: '{}'",
token.escape_debug()
))
.with_hint("write each argument as its own string: [\"git\", \"push\", \"--force\"]"));
}
}
Ok(())
}
fn validate_paths(subject: &str, patterns: &[String]) -> Result<()> {
if patterns.is_empty() {
return Err(TuffError::usage(format!(
"policy rule {subject} must list at least one path pattern"
)));
}
for pattern in patterns {
let escapes = pattern.split('/').any(|segment| segment == "..");
let invalid = pattern.is_empty()
|| pattern.trim() != pattern
|| pattern.starts_with('/')
|| pattern.starts_with('~')
|| pattern.contains(['\\', '\0']);
if escapes || invalid {
return Err(TuffError::usage(format!(
"policy rule {subject} patterns are paths relative to the project root, such as \".env\" or \"secrets/**\": '{}'",
pattern.escape_debug()
))
.with_hint("a policy governs its project, so patterns cannot start with '/' or '~' or climb out with '..'"));
}
}
Ok(())
}
fn parse_mcp_pattern(pattern: &str) -> Result<(&str, &str)> {
let invalid = || {
TuffError::usage(format!(
"policy rule mcp must be \"server:tool\", where either side may use '*', such as \"github:delete_*\": '{}'",
pattern.escape_debug()
))
};
let (server, tool) = pattern.split_once(':').ok_or_else(invalid)?;
let allowed = |part: &str| {
!part.is_empty()
&& part
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '*'))
};
if !allowed(server) || !allowed(tool) {
return Err(invalid());
}
Ok((server, tool))
}
pub fn validate_policy(policy: &PolicyConfig) -> Result<()> {
if policy.rules.is_empty() {
return Err(TuffError::usage(
"a policy needs at least one [[policy.rules]] entry",
));
}
for (index, rule) in policy.rules.iter().enumerate() {
let context = |error: TuffError| {
let hint = error.hint().map(str::to_string);
let rewritten = TuffError::of(
error.kind(),
format!("policy rule {}: {}", index + 1, error.message()),
);
match hint {
Some(hint) => rewritten.with_hint(hint),
None => rewritten,
}
};
rule.effect().map_err(context)?;
rule.subject().map_err(context)?;
if let Some(reason) = &rule.reason
&& reason.trim().is_empty()
{
return Err(context(TuffError::usage(
"reason, when given, must not be empty",
)));
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PolicyCoverageEntry {
pub effect: PolicyEffect,
pub subject: PolicySubjectKind,
pub coverage: CoverageLevel,
#[serde(skip_serializing_if = "Option::is_none")]
pub mechanism: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub caveat: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
pub fn not_implemented_matrix() -> Vec<PolicyCoverageEntry> {
PolicyEffect::ALL
.into_iter()
.flat_map(|effect| {
PolicySubjectKind::ALL
.into_iter()
.map(move |subject| PolicyCoverageEntry {
effect,
subject,
coverage: CoverageLevel::Unsupported,
mechanism: None,
caveat: Some(
"Tuff does not compile policy rules for this agent yet".to_string(),
),
source: None,
})
})
.collect()
}
#[derive(Debug, Clone)]
pub struct RuleVerdict<'a> {
pub index: usize,
pub rule: &'a PolicyRule,
pub entry: PolicyCoverageEntry,
}
pub fn verdicts<'a>(
policy: &'a PolicyConfig,
matrix: &[PolicyCoverageEntry],
) -> Result<Vec<RuleVerdict<'a>>> {
policy
.rules
.iter()
.enumerate()
.map(|(index, rule)| {
let effect = rule.effect()?;
let subject = rule.subject()?.kind();
let entry = matrix
.iter()
.find(|entry| entry.effect == effect && entry.subject == subject)
.cloned()
.unwrap_or_else(|| PolicyCoverageEntry {
effect,
subject,
coverage: CoverageLevel::Unsupported,
mechanism: None,
caveat: Some("this agent declares nothing for this kind of rule".to_string()),
source: None,
});
Ok(RuleVerdict { index, rule, entry })
})
.collect()
}
pub fn merge_permissions(
settings_relpath: &str,
existing: Option<&[u8]>,
remove: &[(PolicyEffect, String)],
add: &[(PolicyEffect, String)],
) -> Result<Vec<u8>> {
let mut settings: serde_json::Value = match existing {
Some(bytes) if !bytes.is_empty() => serde_json::from_slice(bytes).map_err(|error| {
TuffError::corrupt(format!("{settings_relpath} is not valid JSON: {error}"))
})?,
_ => serde_json::json!({}),
};
let object = settings
.as_object_mut()
.ok_or_else(|| TuffError::corrupt(format!("{settings_relpath} must be a JSON object")))?;
if add.is_empty() && !object.contains_key("permissions") {
return Ok(serde_json::to_string_pretty(&settings)?.into_bytes());
}
let permissions = object
.entry("permissions")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or_else(|| {
TuffError::corrupt(format!(
"{settings_relpath} field 'permissions' must be an object"
))
})?;
let not_a_list = |effect: PolicyEffect| {
TuffError::corrupt(format!(
"{settings_relpath} field 'permissions.{}' must be an array",
effect.as_str()
))
};
for (effect, rule) in remove {
if let Some(list) = permissions.get_mut(effect.as_str()) {
let list = list.as_array_mut().ok_or_else(|| not_a_list(*effect))?;
list.retain(|entry| entry.as_str() != Some(rule.as_str()));
}
}
for (effect, rule) in add {
let list = permissions
.entry(effect.as_str())
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or_else(|| not_a_list(*effect))?;
if !list
.iter()
.any(|entry| entry.as_str() == Some(rule.as_str()))
{
list.push(serde_json::Value::String(rule.clone()));
}
}
for effect in PolicyEffect::ALL {
let emptied_here = remove.iter().any(|(removed, _)| *removed == effect)
&& permissions
.get(effect.as_str())
.and_then(serde_json::Value::as_array)
.is_some_and(Vec::is_empty);
if emptied_here {
permissions.remove(effect.as_str());
}
}
let now_empty = !remove.is_empty() && permissions.is_empty();
if now_empty {
object.remove("permissions");
}
Ok(serde_json::to_string_pretty(&settings)?.into_bytes())
}
pub fn remove_permissions(
repo_root: &std::path::Path,
managed: &[ManagedPermission],
) -> Result<()> {
let mut by_file: std::collections::BTreeMap<&str, Vec<(PolicyEffect, String)>> =
std::collections::BTreeMap::new();
for permission in managed {
if let Some(effect) = PolicyEffect::parse(&permission.list) {
by_file
.entry(permission.settings_path.as_str())
.or_default()
.push((effect, permission.rule.clone()));
}
}
for (relpath, removals) in by_file {
let path = repo_root.join(relpath);
if !path.is_file() {
continue;
}
let bytes = std::fs::read(&path)?;
let mut merged = merge_permissions(relpath, Some(&bytes), &removals, &[])?;
if merged != bytes {
merged.push(b'\n');
std::fs::write(&path, merged)?;
}
}
Ok(())
}
pub fn managed_permission_status(
repo_root: &std::path::Path,
permission: &ManagedPermission,
) -> &'static str {
let Ok(raw) = std::fs::read_to_string(repo_root.join(&permission.settings_path)) else {
return "missing";
};
let Ok(settings) = serde_json::from_str::<serde_json::Value>(&raw) else {
return "modified";
};
let present = settings
.get("permissions")
.and_then(|permissions| permissions.get(&permission.list))
.and_then(serde_json::Value::as_array)
.is_some_and(|list| {
list.iter()
.any(|entry| entry.as_str() == Some(permission.rule.as_str()))
});
if present { "clean" } else { "missing" }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ErrorKind;
fn deny(rule: &str) -> (PolicyEffect, String) {
(PolicyEffect::Deny, rule.to_string())
}
fn ask(rule: &str) -> (PolicyEffect, String) {
(PolicyEffect::Ask, rule.to_string())
}
#[test]
fn merging_permissions_keeps_the_users_rules_and_adds_each_rule_once() {
let existing = br#"{"model": "opus", "permissions": {"deny": ["Bash(curl *)"], "allow": ["Bash(npm test *)"]}}"#;
let add = [
deny("Bash(git push --force *)"),
ask("Bash(terraform apply *)"),
];
let once = merge_permissions(".claude/settings.json", Some(existing), &[], &add).unwrap();
let twice = merge_permissions(".claude/settings.json", Some(&once), &[], &add).unwrap();
assert_eq!(once, twice, "a redundant merge leaves the file unchanged");
let settings: serde_json::Value = serde_json::from_slice(&once).unwrap();
assert_eq!(settings["model"], "opus");
assert_eq!(
settings["permissions"]["deny"],
serde_json::json!(["Bash(curl *)", "Bash(git push --force *)"])
);
assert_eq!(
settings["permissions"]["ask"],
serde_json::json!(["Bash(terraform apply *)"])
);
assert_eq!(
settings["permissions"]["allow"],
serde_json::json!(["Bash(npm test *)"])
);
}
#[test]
fn removing_permissions_prunes_only_what_it_emptied() {
let existing = br#"{"permissions": {"deny": ["Bash(curl *)", "Bash(git push --force *)"], "ask": ["Bash(terraform apply *)"]}}"#;
let merged = merge_permissions(
"s.json",
Some(existing),
&[
deny("Bash(git push --force *)"),
ask("Bash(terraform apply *)"),
],
&[],
)
.unwrap();
let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
assert_eq!(
settings,
serde_json::json!({"permissions": {"deny": ["Bash(curl *)"]}})
);
let only_ours =
br#"{"model": "opus", "permissions": {"ask": ["Bash(terraform apply *)"]}}"#;
let merged = merge_permissions(
"s.json",
Some(only_ours),
&[ask("Bash(terraform apply *)")],
&[],
)
.unwrap();
let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
assert_eq!(settings, serde_json::json!({"model": "opus"}));
let untouched = br#"{"permissions": {}}"#;
let merged = merge_permissions("s.json", Some(untouched), &[], &[]).unwrap();
let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
assert_eq!(
settings,
serde_json::json!({"permissions": {}}),
"nothing removed, nothing pruned"
);
}
#[test]
fn a_corrupt_settings_file_is_refused() {
for (bytes, expected) in [
(&b"{ not json"[..], "is not valid JSON"),
(&b"[]"[..], "must be a JSON object"),
(
&br#"{"permissions": []}"#[..],
"'permissions' must be an object",
),
(
&br#"{"permissions": {"deny": "x"}}"#[..],
"'permissions.deny' must be an array",
),
] {
let error = merge_permissions(
".claude/settings.json",
Some(bytes),
&[],
&[deny("Bash(rm *)")],
)
.unwrap_err();
assert_eq!(error.kind(), ErrorKind::Corrupt, "{error}");
assert!(error.to_string().contains(expected), "{error}");
}
}
#[test]
fn recorded_permission_status_and_removal_from_disk() {
let temp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(temp.path().join(".claude")).unwrap();
let path = temp.path().join(".claude/settings.json");
std::fs::write(
&path,
r#"{"permissions": {"deny": ["Bash(curl *)", "Bash(rm *)"]}}"#,
)
.unwrap();
let ours = ManagedPermission {
settings_path: ".claude/settings.json".to_string(),
list: "deny".to_string(),
rule: "Bash(rm *)".to_string(),
};
assert_eq!(managed_permission_status(temp.path(), &ours), "clean");
remove_permissions(temp.path(), std::slice::from_ref(&ours)).unwrap();
assert_eq!(managed_permission_status(temp.path(), &ours), "missing");
let settings: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
settings,
serde_json::json!({"permissions": {"deny": ["Bash(curl *)"]}})
);
std::fs::write(&path, "{ not json").unwrap();
assert_eq!(managed_permission_status(temp.path(), &ours), "modified");
assert!(remove_permissions(temp.path(), &[ours]).is_err());
}
#[test]
fn a_command_argument_cannot_be_a_pattern() {
let policy =
parse("[[policy.rules]]\neffect = \"deny\"\ncommand = [\"git\", \"push\", \"*\"]\n");
let error = validate_policy(&policy).unwrap_err();
assert!(
error.to_string().contains("'*' is not a pattern here"),
"{error}"
);
}
fn parse(toml_body: &str) -> PolicyConfig {
#[derive(Deserialize)]
struct Wrapper {
policy: PolicyConfig,
}
toml::from_str::<Wrapper>(toml_body)
.expect("valid TOML")
.policy
}
const INFRA: &str = r#"
[[policy.rules]]
effect = "deny"
command = ["git", "push", "--force"]
reason = "Force pushes rewrite shared history."
[[policy.rules]]
effect = "deny"
read = [".env", "secrets/**"]
[[policy.rules]]
effect = "ask"
command = ["terraform", "apply"]
[[policy.rules]]
effect = "deny"
mcp = "github:delete_*"
"#;
#[test]
fn the_infrastructure_example_is_a_valid_policy() {
let policy = parse(INFRA);
validate_policy(&policy).unwrap();
let kinds: Vec<_> = policy
.rules
.iter()
.map(|rule| (rule.effect().unwrap(), rule.subject().unwrap().kind()))
.collect();
assert_eq!(
kinds,
vec![
(PolicyEffect::Deny, PolicySubjectKind::Command),
(PolicyEffect::Deny, PolicySubjectKind::Read),
(PolicyEffect::Ask, PolicySubjectKind::Command),
(PolicyEffect::Deny, PolicySubjectKind::Mcp),
]
);
assert_eq!(
policy.rules[0].describe(),
"deny command \"git push --force\""
);
assert_eq!(
policy.rules[1].describe(),
"deny read \".env\", \"secrets/**\""
);
}
#[test]
fn a_policy_cannot_allow_anything() {
let policy = parse("[[policy.rules]]\neffect = \"allow\"\ncommand = [\"rm\"]\n");
let error = validate_policy(&policy).unwrap_err();
assert_eq!(error.kind(), ErrorKind::Refused);
assert!(error.to_string().contains("policy rule 1"), "{error}");
assert!(error.to_string().contains("only narrow"), "{error}");
}
#[test]
fn each_rule_has_exactly_one_subject() {
let none = parse("[[policy.rules]]\neffect = \"deny\"\n");
assert!(
validate_policy(&none)
.unwrap_err()
.to_string()
.contains("needs a subject")
);
let two =
parse("[[policy.rules]]\neffect = \"deny\"\ncommand = [\"rm\"]\nread = [\".env\"]\n");
assert!(
validate_policy(&two)
.unwrap_err()
.to_string()
.contains("more than one subject (command, read)")
);
}
#[test]
fn malformed_rules_are_refused_with_the_rule_number() {
for (body, expected) in [
(
"effect = \"block\"\ncommand = [\"rm\"]",
"must be \"deny\" or \"ask\"",
),
("effect = \"deny\"\ncommand = []", "at least the program"),
(
"effect = \"deny\"\ncommand = [\"git push\"]",
"without spaces",
),
("effect = \"deny\"\nread = []", "at least one path pattern"),
(
"effect = \"deny\"\nread = [\"../outside\"]",
"relative to the project root",
),
(
"effect = \"deny\"\nedit = [\"/etc/passwd\"]",
"relative to the project root",
),
(
"effect = \"deny\"\nread = [\"~/.ssh/id_rsa\"]",
"relative to the project root",
),
("effect = \"deny\"\nmcp = \"github\"", "\"server:tool\""),
("effect = \"deny\"\nmcp = \"git hub:x\"", "\"server:tool\""),
("effect = \"deny\"\nmcp = \"github:\"", "\"server:tool\""),
(
"effect = \"deny\"\ncommand = [\"rm\"]\nreason = \" \"",
"must not be empty",
),
] {
let policy = parse(&format!(
"[[policy.rules]]\neffect = \"deny\"\ncommand = [\"ok\"]\n\n[[policy.rules]]\n{body}\n"
));
let error = validate_policy(&policy).unwrap_err();
let text = error.to_string();
assert!(text.contains("policy rule 2"), "{body}: {text}");
assert!(text.contains(expected), "{body}: {text}");
}
}
#[test]
fn an_empty_policy_is_refused() {
let error = validate_policy(&PolicyConfig { rules: Vec::new() }).unwrap_err();
assert!(error.to_string().contains("at least one"), "{error}");
}
#[test]
fn unknown_keys_in_a_rule_are_a_parse_error() {
#[derive(Deserialize)]
#[allow(dead_code)]
struct Wrapper {
policy: PolicyConfig,
}
let result =
toml::from_str::<Wrapper>("[[policy.rules]]\neffect = \"deny\"\npath = [\".env\"]\n");
assert!(result.is_err(), "a misspelt subject must not be ignored");
}
#[test]
fn the_not_implemented_matrix_covers_every_effect_and_subject_as_unsupported() {
let matrix = not_implemented_matrix();
assert_eq!(
matrix.len(),
PolicyEffect::ALL.len() * PolicySubjectKind::ALL.len()
);
assert!(
matrix
.iter()
.all(|entry| entry.coverage == CoverageLevel::Unsupported && entry.caveat.is_some())
);
}
#[test]
fn a_rule_the_matrix_does_not_mention_is_never_treated_as_enforced() {
let policy = parse(INFRA);
let matrix = vec![PolicyCoverageEntry {
effect: PolicyEffect::Deny,
subject: PolicySubjectKind::Command,
coverage: CoverageLevel::Partial,
mechanism: Some("native".to_string()),
caveat: None,
source: None,
}];
let verdicts = verdicts(&policy, &matrix).unwrap();
let coverage: Vec<_> = verdicts
.iter()
.map(|verdict| verdict.entry.coverage)
.collect();
assert_eq!(
coverage,
vec![
CoverageLevel::Partial,
CoverageLevel::Unsupported,
CoverageLevel::Unsupported,
CoverageLevel::Unsupported,
]
);
}
}