use serde::{Deserialize, Serialize};
use super::types::NESTED_WORKFLOW_TOOLS;
pub const MAX_PARALLEL_CEILING: usize = 64;
pub const MAX_AGENTS_CEILING: usize = 5000;
pub const DEFAULT_READ_TOOLS: &[&str] = &[
"read_file",
"read",
"search",
"grep",
"fs_browser",
"list_dir",
"glob",
"view_file",
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunPolicy {
pub profile: String,
pub approval: String,
pub tools: Vec<String>,
pub path_allow: Vec<String>,
pub path_deny: Vec<String>,
pub create_files: bool,
pub create_dirs: bool,
pub write_allow: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentPolicyOpts {
pub profile: Option<String>,
pub tools: Option<Vec<String>>,
pub approval: Option<String>,
pub path_allow: Option<Vec<String>>,
pub path_deny: Option<Vec<String>>,
pub create_files: Option<bool>,
pub create_dirs: Option<bool>,
pub write_allow: Option<Vec<String>>,
pub model: Option<String>,
pub max_tokens: Option<usize>,
pub label: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EffectiveAgentPolicy {
pub profile: String,
pub approval: String,
pub tools: Vec<String>,
pub path_allow: Vec<String>,
pub path_deny: Vec<String>,
pub create_files: bool,
pub create_dirs: bool,
pub write_allow: Vec<String>,
}
impl EffectiveAgentPolicy {
pub fn allows_any_write(&self) -> bool {
self.create_files || self.create_dirs || !self.write_allow.is_empty()
}
pub fn allows_write_path(&self, path: &str) -> bool {
if self.write_allow.is_empty() {
return false;
}
if self.path_deny.iter().any(|d| path_matches(d, path)) {
return false;
}
self.write_allow.iter().any(|a| path_matches(a, path))
}
pub fn allows_create_file(&self, path: &str) -> bool {
self.create_files && self.allows_write_path(path)
}
}
pub fn default_run_policy() -> RunPolicy {
RunPolicy {
profile: "explorer".into(),
approval: "read_only".into(),
tools: DEFAULT_READ_TOOLS
.iter()
.map(|s| (*s).to_string())
.collect(),
path_allow: vec!["**".into()],
path_deny: vec![".git".into(), ".git/**".into(), "**/.git/**".into()],
create_files: false,
create_dirs: false,
write_allow: vec![],
}
}
pub fn clamp_max_parallel(value: usize) -> usize {
value.clamp(1, MAX_PARALLEL_CEILING)
}
pub fn clamp_max_agents(value: usize) -> usize {
value.clamp(1, MAX_AGENTS_CEILING)
}
pub fn intersect_agent_policy(run: &RunPolicy, opts: &AgentPolicyOpts) -> EffectiveAgentPolicy {
let profile = opts.profile.clone().unwrap_or_else(|| run.profile.clone());
let base_tools = opts.tools.clone().unwrap_or_else(|| run.tools.clone());
let run_set: std::collections::BTreeSet<_> = run.tools.iter().cloned().collect();
let mut tools: Vec<String> = base_tools
.into_iter()
.filter(|t| run_set.contains(t))
.collect();
tools.retain(|t| !NESTED_WORKFLOW_TOOLS.contains(&t.as_str()));
tools.sort();
tools.dedup();
let path_allow = match &opts.path_allow {
Some(extra) if !extra.is_empty() => intersect_paths(&run.path_allow, extra),
_ => run.path_allow.clone(),
};
let mut path_deny = run.path_deny.clone();
if let Some(extra) = &opts.path_deny {
path_deny.extend(extra.iter().cloned());
}
path_deny.sort();
path_deny.dedup();
let create_files = opts.create_files.unwrap_or(run.create_files) && run.create_files;
let create_dirs = opts.create_dirs.unwrap_or(run.create_dirs) && run.create_dirs;
let write_allow = match &opts.write_allow {
Some(extra) => {
if run.write_allow.is_empty() {
Vec::new()
} else {
intersect_paths(&run.write_allow, extra)
}
}
None => {
run.write_allow.clone()
}
};
let approval = opts
.approval
.clone()
.unwrap_or_else(|| run.approval.clone());
EffectiveAgentPolicy {
profile,
approval,
tools,
path_allow,
path_deny,
create_files,
create_dirs,
write_allow,
}
}
fn intersect_paths(a: &[String], b: &[String]) -> Vec<String> {
let a_univ = a.iter().any(|p| p == "**" || p == "*" || p == ".");
let b_univ = b.iter().any(|p| p == "**" || p == "*" || p == ".");
if a_univ {
return b.to_vec();
}
if b_univ {
return a.to_vec();
}
let set: std::collections::BTreeSet<_> = a.iter().cloned().collect();
b.iter().filter(|p| set.contains(*p)).cloned().collect()
}
fn path_matches(pattern: &str, path: &str) -> bool {
if pattern == "**" || pattern == "*" {
return true;
}
if pattern.ends_with("/**") {
let prefix = &pattern[..pattern.len() - 3];
return path == prefix || path.starts_with(&format!("{prefix}/"));
}
if let Some(prefix) = pattern.strip_suffix("/**") {
return path == prefix || path.starts_with(&format!("{prefix}/"));
}
if let Some(prefix) = pattern.strip_suffix("/*") {
if path == prefix {
return true;
}
if let Some(rest) = path.strip_prefix(&format!("{prefix}/")) {
return !rest.contains('/');
}
return false;
}
pattern == path || path.starts_with(&format!("{pattern}/"))
}
#[cfg(test)]
mod policy_tests {
use super::*;
#[test]
fn p6_opts_cannot_add_tools_outside_run() {
let run = default_run_policy();
let opts = AgentPolicyOpts {
tools: Some(vec!["read_file".into(), "bash".into(), "write_file".into()]),
..Default::default()
};
let eff = intersect_agent_policy(&run, &opts);
assert!(eff.tools.contains(&"read_file".into()));
assert!(!eff.tools.iter().any(|t| t == "bash"));
assert!(!eff.tools.iter().any(|t| t == "write_file"));
}
#[test]
fn p7_implementer_empty_write_allow_no_writes() {
let mut run = default_run_policy();
run.profile = "implementer".into();
let opts = AgentPolicyOpts {
profile: Some("implementer".into()),
write_allow: Some(vec![]),
create_files: Some(true),
..Default::default()
};
let eff = intersect_agent_policy(&run, &opts);
assert!(eff.write_allow.is_empty());
assert!(!eff.create_files); assert!(!eff.allows_any_write());
}
#[test]
fn write_allow_intersection_and_path_deny() {
let mut run = default_run_policy();
run.create_files = true;
run.write_allow = vec!["src/a.rs".into(), "src/b.rs".into()];
let opts = AgentPolicyOpts {
write_allow: Some(vec!["src/a.rs".into(), "src/c.rs".into()]),
path_deny: Some(vec!["src/a.rs".into()]),
create_files: Some(true),
..Default::default()
};
let eff = intersect_agent_policy(&run, &opts);
assert_eq!(eff.write_allow, vec!["src/a.rs".to_string()]);
assert!(!eff.allows_write_path("src/a.rs")); assert!(!eff.allows_write_path("src/c.rs"));
}
#[test]
fn create_files_inherits_from_run_when_agent_omits() {
let mut run = default_run_policy();
run.create_files = true;
run.create_dirs = true;
run.write_allow = vec!["scratch/probe.txt".into()];
let opts = AgentPolicyOpts {
write_allow: Some(vec!["scratch/probe.txt".into()]),
..Default::default()
};
let eff = intersect_agent_policy(&run, &opts);
assert!(eff.create_files, "run create_files=true should inherit");
assert!(eff.create_dirs, "run create_dirs=true should inherit");
assert_eq!(eff.write_allow, vec!["scratch/probe.txt".to_string()]);
}
#[test]
fn create_files_agent_cannot_widen_past_run() {
let mut run = default_run_policy();
run.create_files = false;
run.write_allow = vec!["scratch/probe.txt".into()];
let opts = AgentPolicyOpts {
create_files: Some(true),
write_allow: Some(vec!["scratch/probe.txt".into()]),
..Default::default()
};
let eff = intersect_agent_policy(&run, &opts);
assert!(!eff.create_files, "agent must not widen create_files");
}
#[test]
fn nested_tools_always_stripped() {
let mut run = default_run_policy();
run.tools.push("subagent".into());
run.tools.push("workflow".into());
let opts = AgentPolicyOpts {
tools: Some(vec![
"read_file".into(),
"subagent".into(),
"workflow".into(),
]),
..Default::default()
};
let eff = intersect_agent_policy(&run, &opts);
assert!(!eff.tools.iter().any(|t| t == "subagent"));
assert!(!eff.tools.iter().any(|t| t == "workflow"));
}
#[test]
fn clamp_ceilings() {
assert_eq!(clamp_max_parallel(0), 1);
assert_eq!(clamp_max_parallel(16), 16);
assert_eq!(clamp_max_parallel(100), MAX_PARALLEL_CEILING);
assert_eq!(clamp_max_agents(0), 1);
assert_eq!(clamp_max_agents(1000), 1000);
assert_eq!(clamp_max_agents(99999), MAX_AGENTS_CEILING);
}
}