use serde::{Deserialize, Serialize};
use crate::router::Tier;
use crate::{Caveats, CountBound, Scope};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RoleProfile {
pub prompt: String,
pub role: Option<String>,
pub tools: Option<Vec<String>>,
pub caveats: Option<CaveatProfile>,
pub model: Option<String>,
pub tier: Option<Tier>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
struct FrontMatter {
#[serde(default)]
role: Option<String>,
#[serde(default)]
tools: Option<Vec<String>>,
#[serde(default)]
caveats: Option<CaveatProfile>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
tier: Option<Tier>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct CaveatProfile {
#[serde(default)]
pub fs_read: ScopeSpec,
#[serde(default)]
pub fs_write: ScopeSpec,
#[serde(default)]
pub exec: ScopeSpec,
#[serde(default)]
pub net: ScopeSpec,
#[serde(default)]
pub max_calls: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ScopeSpec {
Keyword(ScopeKeyword),
Items(Vec<String>),
}
impl Default for ScopeSpec {
fn default() -> Self {
Self::Keyword(ScopeKeyword::All)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ScopeKeyword {
All,
None,
}
impl ScopeSpec {
#[must_use]
pub fn to_scope(&self) -> Scope<String> {
match self {
Self::Keyword(ScopeKeyword::All) => Scope::All,
Self::Keyword(ScopeKeyword::None) => Scope::none(),
Self::Items(items) => Scope::only(items.iter().cloned()),
}
}
#[must_use]
pub fn summary(&self) -> String {
match self {
Self::Keyword(ScopeKeyword::All) => "all".to_string(),
Self::Keyword(ScopeKeyword::None) => "none".to_string(),
Self::Items(items) => format!("[{}]", items.join(", ")),
}
}
}
impl CaveatProfile {
#[must_use]
pub fn to_caveats(&self) -> Caveats {
Caveats {
fs_read: self.fs_read.to_scope(),
fs_write: self.fs_write.to_scope(),
exec: self.exec.to_scope(),
net: self.net.to_scope(),
max_calls: match self.max_calls {
Some(n) => CountBound::AtMost(n),
None => CountBound::Unlimited,
},
valid_for_generation: Scope::All,
}
}
#[must_use]
pub fn summary(&self) -> String {
let calls = match self.max_calls {
Some(n) => n.to_string(),
None => "unlimited".to_string(),
};
format!(
"fs_read={} fs_write={} exec={} net={} max_calls={}",
self.fs_read.summary(),
self.fs_write.summary(),
self.exec.summary(),
self.net.summary(),
calls,
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct NamedPermissionPreset {
#[serde(default)]
pub fs_read: Option<ScopeSpec>,
#[serde(default)]
pub readonly: bool,
#[serde(default)]
pub exec_allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
#[serde(default)]
pub max_calls: Option<u64>,
}
impl NamedPermissionPreset {
fn denies_all(&self) -> bool {
self.deny.iter().any(|d| d == "*")
}
#[must_use]
pub fn to_caveat_profile(&self) -> CaveatProfile {
let fs_write = if self.readonly {
ScopeSpec::Keyword(ScopeKeyword::None)
} else {
ScopeSpec::default()
};
let exec = if !self.exec_allow.is_empty() {
ScopeSpec::Items(self.exec_allow.clone())
} else if self.readonly {
ScopeSpec::Keyword(ScopeKeyword::None)
} else {
ScopeSpec::default()
};
let net = if self.denies_all() {
ScopeSpec::Keyword(ScopeKeyword::None)
} else {
ScopeSpec::default()
};
let fs_read = self.fs_read.clone().unwrap_or_default();
CaveatProfile {
fs_read,
fs_write,
exec,
net,
max_calls: self.max_calls,
}
}
#[must_use]
pub fn clamp(&self) -> Caveats {
self.to_caveat_profile().to_caveats()
}
#[must_use]
pub fn summary(&self) -> String {
let mut parts: Vec<String> = Vec::new();
if self.readonly {
parts.push("readonly".to_string());
}
if !self.exec_allow.is_empty() {
parts.push(format!("exec=[{}]", self.exec_allow.join(", ")));
} else if self.readonly {
parts.push("exec=none".to_string());
}
if self.denies_all() {
parts.push("deny=*".to_string());
}
if let Some(n) = self.max_calls {
parts.push(format!("max_calls={n}"));
}
if parts.is_empty() {
"unconstrained".to_string()
} else {
parts.join(" ")
}
}
}
const FENCE: &str = "+++";
impl RoleProfile {
pub fn parse(text: &str) -> anyhow::Result<Self> {
let (front_matter, body) = split_front_matter(text)?;
let body = body.trim().to_string();
let Some(fm_text) = front_matter else {
return Ok(Self {
prompt: body,
..Self::default()
});
};
let fm: FrontMatter = toml::from_str(fm_text)
.map_err(|e| anyhow::anyhow!("invalid role-profile front-matter: {e}"))?;
Ok(Self {
prompt: body,
role: fm.role,
tools: fm.tools,
caveats: fm.caveats,
model: fm.model,
tier: fm.tier,
})
}
#[must_use]
pub fn is_role_bound(&self) -> bool {
self.role.is_some()
|| self.tools.is_some()
|| self.caveats.is_some()
|| self.model.is_some()
|| self.tier.is_some()
}
}
fn split_front_matter(text: &str) -> anyhow::Result<(Option<&str>, &str)> {
let trimmed_start = text.strip_prefix('\u{feff}').unwrap_or(text);
let Some(rest) = trimmed_start.strip_prefix(FENCE) else {
return Ok((None, text));
};
let rest = match rest.strip_prefix('\n') {
Some(r) => r,
None => match rest.strip_prefix("\r\n") {
Some(r) => r,
None if rest.is_empty() => "",
None => return Ok((None, text)),
},
};
for (idx, line) in LineOffsets::new(rest) {
if line.trim_end_matches(['\r', '\n']).trim() == FENCE {
let fm = &rest[..idx];
let after = &rest[idx + line.len()..];
return Ok((Some(fm), after));
}
}
anyhow::bail!("role-profile front-matter opened with `+++` but never closed")
}
struct LineOffsets<'a> {
rest: &'a str,
offset: usize,
}
impl<'a> LineOffsets<'a> {
fn new(s: &'a str) -> Self {
Self { rest: s, offset: 0 }
}
}
impl<'a> Iterator for LineOffsets<'a> {
type Item = (usize, &'a str);
fn next(&mut self) -> Option<Self::Item> {
if self.rest.is_empty() {
return None;
}
let end = match self.rest.find('\n') {
Some(i) => i + 1,
None => self.rest.len(),
};
let line = &self.rest[..end];
let start = self.offset;
self.offset += end;
self.rest = &self.rest[end..];
Some((start, line))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_front_matter_is_prompt_only() {
let text = "# Coder\n\nYou write code.\n";
let rp = RoleProfile::parse(text).unwrap();
assert_eq!(rp.prompt, "# Coder\n\nYou write code.");
assert_eq!(rp.role, None);
assert_eq!(rp.tools, None);
assert_eq!(rp.caveats, None);
assert_eq!(rp.model, None);
assert_eq!(rp.tier, None);
assert!(!rp.is_role_bound());
}
#[test]
fn parses_full_front_matter() {
let text = "\
+++
role = \"worker\"
tools = [\"read_file\", \"write_file\", \"run_command\"]
model = \"qwen2.5-coder:14b\"
tier = \"STANDARD\"
[caveats]
fs_read = \"all\"
fs_write = [\"src/\"]
exec = [\"cargo\"]
net = \"none\"
max_calls = 40
+++
# Worker
You edit files and run builds.
";
let rp = RoleProfile::parse(text).unwrap();
assert_eq!(rp.prompt, "# Worker\n\nYou edit files and run builds.");
assert_eq!(rp.role.as_deref(), Some("worker"));
assert_eq!(
rp.tools,
Some(vec![
"read_file".to_string(),
"write_file".to_string(),
"run_command".to_string(),
])
);
assert_eq!(rp.model.as_deref(), Some("qwen2.5-coder:14b"));
assert_eq!(rp.tier, Some(Tier::Standard));
assert!(rp.is_role_bound());
let caveats = rp.caveats.unwrap().to_caveats();
assert_eq!(caveats.fs_read, Scope::All);
assert_eq!(caveats.fs_write, Scope::only(["src/".to_string()]));
assert_eq!(caveats.exec, Scope::only(["cargo".to_string()]));
assert_eq!(caveats.net, Scope::none());
assert_eq!(caveats.max_calls, CountBound::AtMost(40));
}
#[test]
fn sparse_caveats_default_to_top() {
let text = "\
+++
role = \"reviewer\"
[caveats]
fs_write = \"none\"
+++
# Reviewer
Grade diffs.
";
let rp = RoleProfile::parse(text).unwrap();
let c = rp.caveats.unwrap().to_caveats();
assert_eq!(c.fs_read, Scope::All);
assert_eq!(c.fs_write, Scope::none());
assert_eq!(c.exec, Scope::All);
assert_eq!(c.net, Scope::All);
assert_eq!(c.max_calls, CountBound::Unlimited);
}
#[test]
fn malformed_front_matter_errors_clearly() {
let text = "\
+++
role = \"oops
+++
body
";
let err = RoleProfile::parse(text).unwrap_err().to_string();
assert!(
err.contains("front-matter"),
"error should mention front-matter, got: {err}"
);
}
#[test]
fn unclosed_front_matter_errors() {
let text = "+++\nrole = \"worker\"\n\n# body never fenced\n";
let err = RoleProfile::parse(text).unwrap_err().to_string();
assert!(
err.contains("never closed"),
"error should mention unclosed fence, got: {err}"
);
}
#[test]
fn empty_front_matter_block_is_valid() {
let text = "+++\n+++\n\n# Just a prompt\n";
let rp = RoleProfile::parse(text).unwrap();
assert_eq!(rp.prompt, "# Just a prompt");
assert!(!rp.is_role_bound());
}
#[test]
fn leading_plus_text_is_not_a_fence() {
let text = "+++foo bar\nstill body\n";
let rp = RoleProfile::parse(text).unwrap();
assert_eq!(rp.prompt, "+++foo bar\nstill body");
assert!(!rp.is_role_bound());
}
#[test]
fn scope_spec_items_round_trip() {
let spec = ScopeSpec::Items(vec!["a".to_string(), "b".to_string()]);
assert_eq!(
spec.to_scope(),
Scope::only(["a".to_string(), "b".to_string()])
);
}
#[test]
fn caveat_profile_default_is_top() {
let c = CaveatProfile::default().to_caveats();
assert_eq!(c, Caveats::top());
}
#[test]
fn scope_spec_summary_renders_each_variant() {
assert_eq!(ScopeSpec::Keyword(ScopeKeyword::All).summary(), "all");
assert_eq!(ScopeSpec::Keyword(ScopeKeyword::None).summary(), "none");
assert_eq!(
ScopeSpec::Items(vec!["a".to_string(), "b".to_string()]).summary(),
"[a, b]"
);
}
#[test]
fn caveat_profile_summary_is_one_line_per_axis() {
let text = "\
+++
role = \"worker\"
[caveats]
fs_read = \"all\"
fs_write = \"none\"
exec = [\"cargo\"]
max_calls = 40
+++
# Worker
body
";
let rp = RoleProfile::parse(text).unwrap();
let summary = rp.caveats.unwrap().summary();
assert_eq!(
summary,
"fs_read=all fs_write=none exec=[cargo] net=all max_calls=40"
);
}
#[test]
fn caveat_profile_summary_unlimited_when_no_max_calls() {
let summary = CaveatProfile::default().summary();
assert!(
summary.ends_with("max_calls=unlimited"),
"omitted max_calls renders as unlimited, got: {summary}"
);
}
#[test]
fn empty_preset_is_identity_clamp() {
let p = NamedPermissionPreset::default();
assert_eq!(p.clamp(), Caveats::top());
}
#[test]
fn readonly_preset_denies_writes_and_exec() {
let p = NamedPermissionPreset {
readonly: true,
..NamedPermissionPreset::default()
};
let c = p.clamp();
assert_eq!(c.fs_read, Scope::All, "read-only still reads everything");
assert_eq!(c.fs_write, Scope::none());
assert_eq!(c.exec, Scope::none());
assert_eq!(c.net, Scope::All, "net untouched without deny=*");
}
#[test]
fn exec_allow_overrides_readonly_exec_clamp() {
let p = NamedPermissionPreset {
readonly: true,
exec_allow: vec!["git".to_string(), "gh".to_string()],
..NamedPermissionPreset::default()
};
let c = p.clamp();
assert_eq!(c.fs_write, Scope::none(), "readonly still pins writes");
assert_eq!(
c.exec,
Scope::only(["git".to_string(), "gh".to_string()]),
"explicit allowlist wins over readonly's exec=none"
);
}
#[test]
fn deny_star_clamps_net_to_none() {
let p = NamedPermissionPreset {
fs_read: None,
readonly: true,
exec_allow: vec!["git".to_string()],
deny: vec!["*".to_string()],
max_calls: Some(40),
};
let c = p.clamp();
assert_eq!(c.net, Scope::none(), "deny=* clamps the net axis");
assert_eq!(c.max_calls, CountBound::AtMost(40));
}
#[test]
fn preset_clamp_only_attenuates_a_full_base() {
let p = NamedPermissionPreset {
readonly: true,
exec_allow: vec!["git".to_string()],
deny: vec!["*".to_string()],
..NamedPermissionPreset::default()
};
let clamp = p.clamp();
let effective = Caveats::top().meet(&clamp);
assert_eq!(effective, clamp, "meet(top, clamp) == clamp");
assert!(effective.leq(&Caveats::top()), "clamp ⊑ top (attenuation)");
}
#[test]
fn preset_parses_from_config_toml_shape() {
let toml = "\
readonly = true
exec_allow = [\"git\", \"gh\"]
deny = [\"*\"]
max_calls = 40
";
let p: NamedPermissionPreset = toml::from_str(toml).unwrap();
assert!(p.readonly);
assert_eq!(p.exec_allow, vec!["git".to_string(), "gh".to_string()]);
assert_eq!(p.deny, vec!["*".to_string()]);
assert_eq!(p.max_calls, Some(40));
}
#[test]
fn preset_summary_renders_each_clamp() {
let p = NamedPermissionPreset {
fs_read: None,
readonly: true,
exec_allow: vec!["git".to_string(), "gh".to_string()],
deny: vec!["*".to_string()],
max_calls: Some(40),
};
assert_eq!(p.summary(), "readonly exec=[git, gh] deny=* max_calls=40");
assert_eq!(NamedPermissionPreset::default().summary(), "unconstrained");
}
#[test]
fn fs_read_clamp_narrows_reads() {
let p = NamedPermissionPreset {
fs_read: Some(ScopeSpec::Items(vec!["src/".to_string()])),
..NamedPermissionPreset::default()
};
let c = p.clamp();
assert_eq!(
c.fs_read,
Scope::only(["src/".to_string()]),
"an fs_read clamp must narrow the read axis"
);
assert_eq!(c.fs_write, Scope::All);
assert_eq!(c.exec, Scope::All);
assert_eq!(c.net, Scope::All);
}
#[test]
fn omitted_fs_read_clamp_reads_all() {
let p = NamedPermissionPreset {
readonly: true,
..NamedPermissionPreset::default()
};
assert_eq!(
p.clamp().fs_read,
Scope::All,
"an unspecified fs_read clamp must leave reads unrestricted"
);
assert_eq!(NamedPermissionPreset::default().fs_read, None);
}
#[test]
fn fs_read_clamp_parses_from_config_toml() {
let toml = "\
readonly = true
fs_read = [\"src/\", \"docs/\"]
";
let p: NamedPermissionPreset = toml::from_str(toml).unwrap();
assert!(p.readonly);
assert_eq!(
p.fs_read,
Some(ScopeSpec::Items(vec![
"src/".to_string(),
"docs/".to_string()
]))
);
assert_eq!(
p.clamp().fs_read,
Scope::only(["src/".to_string(), "docs/".to_string()])
);
}
}