use super::limits::{FULL_AUTO_MAX_TOOL_CALLS, NORMAL_MAX_TOOL_CALLS};
pub const READ_ONLY_TOOLS: &[&str] = &["ls", "view", "grep"];
const READ_WRITE_TOOLS: &[&str] = &["edit", "bash", "consult", "project_knowledge"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tier {
Default,
Auto,
FullAuto,
}
#[derive(Debug, Clone)]
pub struct Policy {
tier: Tier,
max_tool_calls: u32,
timeout: Option<u64>,
}
impl Policy {
#[must_use]
pub fn new(tier: Tier, max_tool_calls: u32, timeout: Option<u64>) -> Self {
Self {
tier,
max_tool_calls,
timeout,
}
}
#[must_use]
pub fn tier(&self) -> Tier {
self.tier
}
#[must_use]
pub fn max_tool_calls(&self) -> u32 {
self.max_tool_calls
}
#[must_use]
pub fn timeout(&self) -> Option<u64> {
self.timeout
}
#[must_use]
pub fn approves(&self, tool_name: &str) -> bool {
let is_read_only = READ_ONLY_TOOLS.contains(&tool_name);
match self.tier {
Tier::Default => is_read_only,
Tier::Auto | Tier::FullAuto => is_read_only || READ_WRITE_TOOLS.contains(&tool_name),
}
}
#[must_use]
pub fn warnings(&self) -> Vec<String> {
match self.tier {
Tier::FullAuto => vec![format!(
"WARNING: --full-auto is active — max_tool_calls is elevated \
to {FULL_AUTO_MAX_TOOL_CALLS} (from the normal \
{NORMAL_MAX_TOOL_CALLS}), and the repetitive-call soft guard \
is silenced. Hard barriers (bash allowlist, metacharacter \
ban, PathGuard) remain fully enforced in every tier."
)],
Tier::Default | Tier::Auto => Vec::new(),
}
}
#[must_use]
pub fn silences_soft_guards(&self) -> bool {
matches!(self.tier, Tier::FullAuto)
}
}
#[doc(hidden)]
pub fn fuzz_policy_entrypoint(data: &[u8]) {
let (&tier_byte, name_bytes) = data.split_first().unwrap_or((&0, &[]));
let tier = match tier_byte {
0 => Tier::Default,
1 => Tier::Auto,
_ => Tier::FullAuto,
};
let max_tool_calls = u32::try_from(name_bytes.len()).unwrap_or(u32::MAX);
let timeout = name_bytes.first().map(|&b| u64::from(b));
let name = String::from_utf8_lossy(name_bytes);
let policy = Policy::new(tier, max_tool_calls, timeout);
let approved = policy.approves(&name);
let _ = policy.silences_soft_guards();
let _ = policy.warnings();
let _ = policy.tier();
let _ = policy.max_tool_calls();
let _ = policy.timeout();
let name_ref: &str = name.as_ref();
let is_known = READ_ONLY_TOOLS.contains(&name_ref) || READ_WRITE_TOOLS.contains(&name_ref);
debug_assert!(
!approved || is_known,
"fail-closed violated: approved unknown tool name {name_ref:?} in tier {tier:?}"
);
}
#[cfg(test)]
mod tests {
use super::*;
const REAL_REGISTERED_TOOL_NAMES: &[&str] = &[
"ls",
"view",
"edit",
"grep",
"bash",
"consult",
"project_knowledge",
];
fn policy(tier: Tier) -> Policy {
Policy::new(tier, NORMAL_MAX_TOOL_CALLS, None)
}
#[test]
fn test_tier_approval_matrix_is_exhaustive_and_fail_closed() {
let default = policy(Tier::Default);
for ro in READ_ONLY_TOOLS {
assert!(
default.approves(ro),
"{ro} debe auto-aprobarse en default (read-only)"
);
}
for rw in READ_WRITE_TOOLS {
assert!(!default.approves(rw), "{rw} NO debe aprobarse en default");
}
assert!(
!default.approves("tool_que_no_existe"),
"fail-closed: un tool desconocido nunca se aprueba en default"
);
for tier in [Tier::Auto, Tier::FullAuto] {
let p = policy(tier);
for known in READ_ONLY_TOOLS.iter().chain(READ_WRITE_TOOLS.iter()) {
assert!(
p.approves(known),
"{known} debe aprobarse en {tier:?} (todos los registrados)"
);
}
assert!(
!p.approves("tool_que_no_existe"),
"fail-closed: un tool desconocido nunca se aprueba, ni en {tier:?}"
);
}
}
#[test]
fn test_known_tool_set_matches_real_tool_registry() {
let mut known: Vec<&str> = READ_ONLY_TOOLS
.iter()
.copied()
.chain(READ_WRITE_TOOLS.iter().copied())
.collect();
known.sort_unstable();
let mut real: Vec<&str> = REAL_REGISTERED_TOOL_NAMES.to_vec();
real.sort_unstable();
assert_eq!(
known, real,
"READ_ONLY_TOOLS + READ_WRITE_TOOLS debe coincidir con el registro \
real de main.rs — actualizar ambas listas al registrar un tool nuevo"
);
}
#[test]
fn test_silences_soft_guards_true_only_for_full_auto() {
assert!(!policy(Tier::Default).silences_soft_guards());
assert!(!policy(Tier::Auto).silences_soft_guards());
assert!(policy(Tier::FullAuto).silences_soft_guards());
}
#[test]
fn test_warnings_nonempty_only_for_full_auto_and_mentions_elevation() {
assert!(policy(Tier::Default).warnings().is_empty());
assert!(policy(Tier::Auto).warnings().is_empty());
let warnings = policy(Tier::FullAuto).warnings();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("--full-auto"));
assert!(warnings[0].contains(&FULL_AUTO_MAX_TOOL_CALLS.to_string()));
assert!(warnings[0].contains("soft guard"));
}
#[test]
fn test_approves_denies_empty_tool_name_in_every_tier() {
for tier in [Tier::Default, Tier::Auto, Tier::FullAuto] {
assert!(!policy(tier).approves(""));
}
}
#[test]
fn test_new_accessors_expose_constructor_values_unmodified() {
let p = Policy::new(Tier::Auto, 42, Some(900));
assert_eq!(p.tier(), Tier::Auto);
assert_eq!(p.max_tool_calls(), 42);
assert_eq!(p.timeout(), Some(900));
let no_timeout = Policy::new(Tier::Default, 15, None);
assert_eq!(no_timeout.timeout(), None);
}
#[test]
fn test_fuzz_policy_entrypoint_never_panics_on_arbitrary_input() {
let cases: &[&[u8]] = &[
b"",
b"\x00",
b"\x01",
b"\x00ls",
b"\x01bash",
b"\xffedit",
b"\x00tool_que_no_existe",
&[0x02, 0xff, 0xfe, 0xfd],
];
for case in cases {
fuzz_policy_entrypoint(case);
}
}
}