Skip to main content

newt_core/
role_profile.rs

1//! `RoleProfile` — promotes a persona from a prompt-only overlay into a
2//! "role profile" that binds a system-prompt overlay together with a toolset,
3//! a capability (caveat) profile, and backend/router policy.
4//!
5//! ## Why this exists
6//!
7//! A persona today is just a system-prompt overlay loaded from a `.md` file
8//! (see `newt-tui`'s `Persona`). The one-airframe-many-roles topology — a
9//! single newt binary that can embody a `dragon-rider` (orchestrator), a
10//! `wing-commander` (arbiter/judge), or a `worker` — needs each role to carry
11//! *more* than prose: which tools it may call, what authority (caveats) it
12//! holds, and which model/tier it should route to. This module is that seam.
13//!
14//! ## File format (backward compatible)
15//!
16//! A role profile is still a plain `.md` file. The only addition is an
17//! **optional** TOML front-matter block fenced by `+++` lines at the very top
18//! of the file:
19//!
20//! ```text
21//! +++
22//! role = "worker"
23//! tools = ["read_file", "write_file", "run_command"]
24//! model = "qwen2.5-coder:14b"
25//! tier = "STANDARD"
26//!
27//! [caveats]
28//! fs_read = "all"
29//! fs_write = ["src/"]
30//! exec = ["cargo"]
31//! net = "none"
32//! max_calls = 40
33//! +++
34//!
35//! # Worker
36//!
37//! You edit files and run builds...
38//! ```
39//!
40//! A file with **no** `+++` front-matter parses into a `RoleProfile` whose
41//! every field except `prompt` is `None` — i.e. exactly today's prompt-only
42//! persona. This is the load-bearing backward-compat guarantee.
43//!
44//! ## What this slice wires vs. follow-ups
45//!
46//! This module only *parses and represents* a role profile and converts the
47//! caveat shape into the canonical [`Caveats`]. It does **not** enforce the
48//! toolset or caveats — enforcement (an agent-bridle registry at the tool
49//! dispatch site, worker/MCP entry points reading the profile) is a deliberate
50//! follow-up. See `docs/design/role-profiles.md`.
51
52use serde::{Deserialize, Serialize};
53
54use crate::router::Tier;
55use crate::{Caveats, CountBound, Scope};
56
57/// A parsed role profile: a system-prompt overlay plus the optional toolset /
58/// capability / router policy declared in TOML front-matter.
59///
60/// `prompt` is always the markdown body (front-matter stripped). Every other
61/// field is `Some` only when the front-matter declared it; a profile with no
62/// front-matter has all-`None` non-prompt fields and behaves exactly like
63/// today's prompt-only persona.
64#[derive(Debug, Clone, PartialEq, Eq, Default)]
65pub struct RoleProfile {
66    /// The markdown body (front-matter removed). Injected as the persona
67    /// overlay exactly as today.
68    pub prompt: String,
69    /// Declared role name (e.g. `"dragon-rider"`, `"worker"`). Independent of
70    /// the persona file's stem, so a file can name the role it embodies.
71    pub role: Option<String>,
72    /// Allow-list of tool names this role may use. `None` = unconstrained
73    /// (today's behavior); enforcement is a follow-up.
74    pub tools: Option<Vec<String>>,
75    /// Capability profile (agent-bridle caveats) this role carries.
76    pub caveats: Option<CaveatProfile>,
77    /// Preferred backend model id (router policy hint).
78    pub model: Option<String>,
79    /// Preferred router tier (router policy hint).
80    pub tier: Option<Tier>,
81}
82
83/// The TOML front-matter shape. Kept separate from [`RoleProfile`] so the
84/// `prompt` (markdown body) is never part of the serde surface.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
86struct FrontMatter {
87    #[serde(default)]
88    role: Option<String>,
89    #[serde(default)]
90    tools: Option<Vec<String>>,
91    #[serde(default)]
92    caveats: Option<CaveatProfile>,
93    #[serde(default)]
94    model: Option<String>,
95    #[serde(default)]
96    tier: Option<Tier>,
97}
98
99/// A small, human-friendly serde shape for an agent-bridle capability profile.
100///
101/// Each filesystem/exec/net axis is a [`ScopeSpec`] that maps onto the
102/// canonical [`Scope<String>`]; `max_calls` maps onto [`CountBound`]. Omitting
103/// an axis defaults it to the top of that axis (unrestricted) via
104/// [`ScopeSpec::default`], matching `Caveats::top()` so a sparse profile
105/// attenuates only the axes it names. Convert with [`CaveatProfile::to_caveats`].
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
107pub struct CaveatProfile {
108    #[serde(default)]
109    pub fs_read: ScopeSpec,
110    #[serde(default)]
111    pub fs_write: ScopeSpec,
112    #[serde(default)]
113    pub exec: ScopeSpec,
114    #[serde(default)]
115    pub net: ScopeSpec,
116    /// Upper bound on tool calls. `None`/omitted = unlimited.
117    #[serde(default)]
118    pub max_calls: Option<u64>,
119}
120
121/// A human-friendly serde form of a single [`Scope<String>`] axis.
122///
123/// Accepts either the bare string `"all"`/`"none"` or a list of allowed items:
124///
125/// ```text
126/// fs_read  = "all"            # Scope::All
127/// net      = "none"           # Scope::Only({})  — authorizes nothing
128/// fs_write = ["src/", "Cargo.toml"]   # Scope::Only({...})
129/// ```
130///
131/// Defaults to [`ScopeSpec::All`] (unrestricted) so an omitted axis is the top
132/// of that axis, matching `Caveats::top()`.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(untagged)]
135pub enum ScopeSpec {
136    /// The keyword `"all"` or `"none"`.
137    Keyword(ScopeKeyword),
138    /// An explicit allow-list of items.
139    Items(Vec<String>),
140}
141
142impl Default for ScopeSpec {
143    fn default() -> Self {
144        Self::Keyword(ScopeKeyword::All)
145    }
146}
147
148/// The `"all"` / `"none"` keywords for a [`ScopeSpec`].
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "lowercase")]
151pub enum ScopeKeyword {
152    /// Unrestricted (top of the axis).
153    All,
154    /// Authorizes nothing.
155    None,
156}
157
158impl ScopeSpec {
159    /// Convert to the canonical lattice [`Scope<String>`].
160    #[must_use]
161    pub fn to_scope(&self) -> Scope<String> {
162        match self {
163            Self::Keyword(ScopeKeyword::All) => Scope::All,
164            Self::Keyword(ScopeKeyword::None) => Scope::none(),
165            Self::Items(items) => Scope::only(items.iter().cloned()),
166        }
167    }
168
169    /// One-line human summary of this axis (`"all"`, `"none"`, or `"[a, b]"`),
170    /// used by `/persona show` reporting.
171    #[must_use]
172    pub fn summary(&self) -> String {
173        match self {
174            Self::Keyword(ScopeKeyword::All) => "all".to_string(),
175            Self::Keyword(ScopeKeyword::None) => "none".to_string(),
176            Self::Items(items) => format!("[{}]", items.join(", ")),
177        }
178    }
179}
180
181impl CaveatProfile {
182    /// Convert this profile into the canonical [`Caveats`] lattice element.
183    ///
184    /// Omitted axes default to the top of their axis (unrestricted),
185    /// `valid_for_generation` is always `Scope::All` here (the profile does not
186    /// express generation bounds — that is minted at dispatch time), and
187    /// `max_calls` becomes [`CountBound::AtMost`] when set, else
188    /// [`CountBound::Unlimited`].
189    #[must_use]
190    pub fn to_caveats(&self) -> Caveats {
191        Caveats {
192            fs_read: self.fs_read.to_scope(),
193            fs_write: self.fs_write.to_scope(),
194            exec: self.exec.to_scope(),
195            net: self.net.to_scope(),
196            max_calls: match self.max_calls {
197                Some(n) => CountBound::AtMost(n),
198                None => CountBound::Unlimited,
199            },
200            // DEFERRED (issue #755 / epic #749): `valid_for_generation` is a
201            // causal-generation-window axis, not a filesystem/exec/net
202            // capability a preset or role profile clamps — it is minted at
203            // dispatch time. Narrowing it here is out of scope for the M6
204            // grammar-gap fix; left at `Scope::All` as a follow-up.
205            valid_for_generation: Scope::All,
206        }
207    }
208
209    /// One-line human summary of this capability profile for `/persona show`,
210    /// e.g. `fs_read=all fs_write=none exec=[cargo] net=all max_calls=40`.
211    #[must_use]
212    pub fn summary(&self) -> String {
213        let calls = match self.max_calls {
214            Some(n) => n.to_string(),
215            None => "unlimited".to_string(),
216        };
217        format!(
218            "fs_read={} fs_write={} exec={} net={} max_calls={}",
219            self.fs_read.summary(),
220            self.fs_write.summary(),
221            self.exec.summary(),
222            self.net.summary(),
223            calls,
224        )
225    }
226}
227
228/// A **named permission preset** (issue #307): a config-declared authority
229/// *clamp* that maps onto the same [`CaveatProfile`] / [`Caveats`] lattice a
230/// role profile uses. It is deliberately a thin, human-friendly surface over
231/// [`CaveatProfile`] — the existing caveat-profile mechanism — rather than a
232/// parallel type, so the same `to_scope` / `to_caveats` lowering is reused.
233///
234/// ## Config shape (`[permission_presets.<name>]`)
235///
236/// ```toml
237/// [permission_presets.readonly-triage]
238/// readonly   = true            # read anything; deny all writes
239/// exec_allow = ["git", "gh"]   # a small exec allowlist
240/// deny       = ["*"]           # everything else (net) denied
241/// max_calls  = 40              # optional tool-call ceiling
242/// # fs_read  = ["src/"]        # optional: confine reads (omit ⇒ read all)
243/// ```
244///
245/// ## Semantics — a clamp, never a grant
246///
247/// The clamp is consumed via [`NamedPermissionPreset::clamp`], which lowers to a
248/// [`Caveats`] **ceiling**. The session's effective authority is the
249/// *intersection* (`meet`) of the session's base caveats and this ceiling — a
250/// preset can only ever **attenuate**, never widen. This is the load-bearing
251/// property: the ceiling wins over `--disable-ocap` / `--yolo` and over
252/// interactive session-grants, because it is `meet`-ed into the authority the
253/// enforcement site consults (and re-`meet`-ed over any re-minted grant).
254///
255/// Each field maps onto a [`CaveatProfile`] axis:
256/// - `fs_read = [..]` / `"none"` ⇒ clamp `fs_read = Only({..})` / `none`.
257///   **Optional** — omitted (`None`) leaves `fs_read` unrestricted (`all`),
258///   the back-compat default. Before this field a preset structurally could
259///   *not* narrow reads (the M6 grammar gap, issue #755) even though
260///   [`CaveatProfile`] / [`Caveats`] can express a read clamp. A read-only
261///   triage mode still reads everything unless it sets this deliberately.
262/// - `readonly = true` ⇒ `fs_write = none`, `exec = none` (the floor a triage
263///   mode wants). `false`/omitted leaves those axes unconstrained (`all`).
264/// - `exec_allow = [..]` ⇒ `exec = Only({..})`. Overrides `readonly`'s
265///   exec clamp with the explicit allowlist (so a triage mode can permit a few
266///   read-only commands).
267/// - `deny = ["*"]` ⇒ clamp `net` to `none` (the remaining "everything else"
268///   axis once fs/exec are pinned). A non-`*` `deny` list is recorded but does
269///   not subtract from an allowlist here — the lattice is allow-list shaped, so
270///   "deny everything else" is the expressible clamp.
271/// - `max_calls` ⇒ the [`CountBound`] ceiling, as on a role profile.
272///
273/// A preset with no fields set is the identity clamp (`Caveats::top()`), so an
274/// empty `[permission_presets.x]` block leaves authority unchanged.
275///
276/// **Footgun (review #312):** omitted axes stay at `TOP`. A preset that sets
277/// only `exec_allow = ["git"]` clamps *exec* but leaves `fs_write` and `net`
278/// wide open — it is NOT "locked down" despite reading that way. For a true
279/// read-only triage clamp, set `readonly = true` (denies writes + exec) and
280/// add `exec_allow` only for the few programs the mode genuinely needs.
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
282pub struct NamedPermissionPreset {
283    /// Optional `fs_read` clamp (issue #755 — the M6 grammar-gap fix).
284    /// `None`/omitted ⇒ the `fs_read` axis stays at the top of its axis
285    /// (`all`), exactly the pre-#755 behavior where a preset structurally
286    /// could not narrow reads. `Some(spec)` narrows reads (e.g.
287    /// `Only({..})` to confine a triage mode to a subtree). Serde-defaults
288    /// to `None`, so every existing preset is byte-for-byte unchanged.
289    #[serde(default)]
290    pub fs_read: Option<ScopeSpec>,
291    /// `true` ⇒ deny all writes and (unless `exec_allow` overrides) all exec.
292    #[serde(default)]
293    pub readonly: bool,
294    /// Explicit exec allowlist. Non-empty ⇒ `exec = Only({..})`, overriding the
295    /// `readonly` exec clamp. Empty + `readonly` ⇒ `exec = none`.
296    #[serde(default)]
297    pub exec_allow: Vec<String>,
298    /// "Deny everything else" marker. `["*"]` clamps the `net` axis to `none`
299    /// (writes/exec are pinned by `readonly`/`exec_allow`). Other entries are
300    /// accepted for forward-compat but the allow-list lattice expresses denial
301    /// as "not in the allowlist", so they do not subtract here.
302    #[serde(default)]
303    pub deny: Vec<String>,
304    /// Optional tool-call ceiling. `None` ⇒ no extra bound.
305    #[serde(default)]
306    pub max_calls: Option<u64>,
307}
308
309impl NamedPermissionPreset {
310    /// `true` when `deny` asks to deny everything else (`["*"]`).
311    fn denies_all(&self) -> bool {
312        self.deny.iter().any(|d| d == "*")
313    }
314
315    /// Lower this named preset into the equivalent [`CaveatProfile`] — reusing
316    /// the SAME serde-shaped caveat mechanism a role profile carries, rather
317    /// than a parallel lowering. Axes the preset does not constrain stay at the
318    /// top of their axis (`all`) so `clamp` is the identity on them.
319    #[must_use]
320    pub fn to_caveat_profile(&self) -> CaveatProfile {
321        // fs_write: readonly pins it to none; otherwise unconstrained.
322        let fs_write = if self.readonly {
323            ScopeSpec::Keyword(ScopeKeyword::None)
324        } else {
325            ScopeSpec::default()
326        };
327        // exec: an explicit allowlist wins; else readonly pins it to none; else
328        // unconstrained.
329        let exec = if !self.exec_allow.is_empty() {
330            ScopeSpec::Items(self.exec_allow.clone())
331        } else if self.readonly {
332            ScopeSpec::Keyword(ScopeKeyword::None)
333        } else {
334            ScopeSpec::default()
335        };
336        // net: `deny = ["*"]` clamps the remaining axis to none.
337        let net = if self.denies_all() {
338            ScopeSpec::Keyword(ScopeKeyword::None)
339        } else {
340            ScopeSpec::default()
341        };
342        // fs_read: an explicit clamp narrows reads (issue #755 — the M6
343        // grammar-gap fix). `None`/omitted ⇒ the top of the axis (`all`), the
344        // historical behavior: before #755 this was hardcoded to
345        // `ScopeSpec::default()`, so a preset structurally could not clamp
346        // reads even though `CaveatProfile`/`Caveats` can. A read-only triage
347        // mode still reads everything unless a preset deliberately narrows it.
348        let fs_read = self.fs_read.clone().unwrap_or_default();
349        // DEFERRED (default-deny is step 8's job, not here): the "empty/absent
350        // preset ⇒ identity clamp (`Caveats::top()`)" default — each unset axis
351        // staying at the top of its axis — is the CORRECT meet-identity
352        // semantics for a *clamp*: an empty clamp adds no restriction and is
353        // applied via `meet`, so an absent axis must be the top. The
354        // "default-deny for un-annotated subtasks" (an absent subtask `kind` ⇒
355        // the most-restrictive preset) belongs in step 8's subtask-clamp
356        // derivation, NOT in this general lowering. Flipping the general default
357        // to deny here would break back-compat for every preset consumer
358        // (steps 2-7), so step 8 owns that default-deny.
359        CaveatProfile {
360            fs_read,
361            fs_write,
362            exec,
363            net,
364            max_calls: self.max_calls,
365        }
366    }
367
368    /// The authority **ceiling** this preset imposes, as a [`Caveats`]. The
369    /// session's effective authority is `base.meet(&preset.clamp())` — the
370    /// clamp can only attenuate, never widen. Reuses [`CaveatProfile::to_caveats`].
371    #[must_use]
372    pub fn clamp(&self) -> Caveats {
373        self.to_caveat_profile().to_caveats()
374    }
375
376    /// One-line human summary for `/permissions` and `/mode` reporting, e.g.
377    /// `readonly exec=[git, gh] deny=* max_calls=40`.
378    #[must_use]
379    pub fn summary(&self) -> String {
380        let mut parts: Vec<String> = Vec::new();
381        if self.readonly {
382            parts.push("readonly".to_string());
383        }
384        if !self.exec_allow.is_empty() {
385            parts.push(format!("exec=[{}]", self.exec_allow.join(", ")));
386        } else if self.readonly {
387            parts.push("exec=none".to_string());
388        }
389        if self.denies_all() {
390            parts.push("deny=*".to_string());
391        }
392        if let Some(n) = self.max_calls {
393            parts.push(format!("max_calls={n}"));
394        }
395        if parts.is_empty() {
396            "unconstrained".to_string()
397        } else {
398            parts.join(" ")
399        }
400    }
401}
402
403/// The fence marker for TOML front-matter (must be on its own line at the very
404/// top of the file).
405const FENCE: &str = "+++";
406
407impl RoleProfile {
408    /// Parse a role-profile `.md` file's full text into a [`RoleProfile`].
409    ///
410    /// If the text begins with a `+++` fence line, everything up to the closing
411    /// `+++` line is parsed as TOML front-matter and the remainder is the
412    /// prompt body. Otherwise the whole text is the prompt and every other
413    /// field is `None` (prompt-only — today's behavior).
414    ///
415    /// # Errors
416    ///
417    /// Returns an error if a front-matter fence is opened but never closed, or
418    /// if the front-matter is not valid TOML for the expected shape.
419    pub fn parse(text: &str) -> anyhow::Result<Self> {
420        let (front_matter, body) = split_front_matter(text)?;
421        let body = body.trim().to_string();
422        let Some(fm_text) = front_matter else {
423            // No front-matter: prompt-only, exactly today's persona.
424            return Ok(Self {
425                prompt: body,
426                ..Self::default()
427            });
428        };
429        let fm: FrontMatter = toml::from_str(fm_text)
430            .map_err(|e| anyhow::anyhow!("invalid role-profile front-matter: {e}"))?;
431        Ok(Self {
432            prompt: body,
433            role: fm.role,
434            tools: fm.tools,
435            caveats: fm.caveats,
436            model: fm.model,
437            tier: fm.tier,
438        })
439    }
440
441    /// `true` when this profile declares more than a prompt (i.e. front-matter
442    /// bound a role, tools, caveats, model, or tier). A prompt-only persona
443    /// returns `false`.
444    #[must_use]
445    pub fn is_role_bound(&self) -> bool {
446        self.role.is_some()
447            || self.tools.is_some()
448            || self.caveats.is_some()
449            || self.model.is_some()
450            || self.tier.is_some()
451    }
452}
453
454/// Split optional `+++`-fenced TOML front-matter from a markdown body.
455///
456/// Returns `(Some(front_matter_text), body)` when a fence is present, or
457/// `(None, whole_text)` otherwise.
458fn split_front_matter(text: &str) -> anyhow::Result<(Option<&str>, &str)> {
459    // Front-matter must be the very first line (allowing a leading BOM /
460    // whitespace-free start). We intentionally do not skip blank lines before
461    // the fence: a leading blank line means "no front-matter".
462    let trimmed_start = text.strip_prefix('\u{feff}').unwrap_or(text);
463    let Some(rest) = trimmed_start.strip_prefix(FENCE) else {
464        return Ok((None, text));
465    };
466    // The opening fence must be its own line: the char right after `+++` must
467    // be a newline (or the string ends).
468    let rest = match rest.strip_prefix('\n') {
469        Some(r) => r,
470        None => match rest.strip_prefix("\r\n") {
471            Some(r) => r,
472            // `+++foo` on the first line is not a fence — treat as body.
473            None if rest.is_empty() => "",
474            None => return Ok((None, text)),
475        },
476    };
477    // Find the closing fence: a line that is exactly `+++`.
478    for (idx, line) in LineOffsets::new(rest) {
479        if line.trim_end_matches(['\r', '\n']).trim() == FENCE {
480            let fm = &rest[..idx];
481            let after = &rest[idx + line.len()..];
482            return Ok((Some(fm), after));
483        }
484    }
485    anyhow::bail!("role-profile front-matter opened with `+++` but never closed")
486}
487
488/// Iterator over `(byte_offset, line_with_terminator)` pairs of a string.
489struct LineOffsets<'a> {
490    rest: &'a str,
491    offset: usize,
492}
493
494impl<'a> LineOffsets<'a> {
495    fn new(s: &'a str) -> Self {
496        Self { rest: s, offset: 0 }
497    }
498}
499
500impl<'a> Iterator for LineOffsets<'a> {
501    type Item = (usize, &'a str);
502
503    fn next(&mut self) -> Option<Self::Item> {
504        if self.rest.is_empty() {
505            return None;
506        }
507        let end = match self.rest.find('\n') {
508            Some(i) => i + 1,
509            None => self.rest.len(),
510        };
511        let line = &self.rest[..end];
512        let start = self.offset;
513        self.offset += end;
514        self.rest = &self.rest[end..];
515        Some((start, line))
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn no_front_matter_is_prompt_only() {
525        let text = "# Coder\n\nYou write code.\n";
526        let rp = RoleProfile::parse(text).unwrap();
527        assert_eq!(rp.prompt, "# Coder\n\nYou write code.");
528        assert_eq!(rp.role, None);
529        assert_eq!(rp.tools, None);
530        assert_eq!(rp.caveats, None);
531        assert_eq!(rp.model, None);
532        assert_eq!(rp.tier, None);
533        assert!(!rp.is_role_bound());
534    }
535
536    #[test]
537    fn parses_full_front_matter() {
538        let text = "\
539+++
540role = \"worker\"
541tools = [\"read_file\", \"write_file\", \"run_command\"]
542model = \"qwen2.5-coder:14b\"
543tier = \"STANDARD\"
544
545[caveats]
546fs_read = \"all\"
547fs_write = [\"src/\"]
548exec = [\"cargo\"]
549net = \"none\"
550max_calls = 40
551+++
552
553# Worker
554
555You edit files and run builds.
556";
557        let rp = RoleProfile::parse(text).unwrap();
558        assert_eq!(rp.prompt, "# Worker\n\nYou edit files and run builds.");
559        assert_eq!(rp.role.as_deref(), Some("worker"));
560        assert_eq!(
561            rp.tools,
562            Some(vec![
563                "read_file".to_string(),
564                "write_file".to_string(),
565                "run_command".to_string(),
566            ])
567        );
568        assert_eq!(rp.model.as_deref(), Some("qwen2.5-coder:14b"));
569        assert_eq!(rp.tier, Some(Tier::Standard));
570        assert!(rp.is_role_bound());
571
572        let caveats = rp.caveats.unwrap().to_caveats();
573        assert_eq!(caveats.fs_read, Scope::All);
574        assert_eq!(caveats.fs_write, Scope::only(["src/".to_string()]));
575        assert_eq!(caveats.exec, Scope::only(["cargo".to_string()]));
576        assert_eq!(caveats.net, Scope::none());
577        assert_eq!(caveats.max_calls, CountBound::AtMost(40));
578    }
579
580    #[test]
581    fn sparse_caveats_default_to_top() {
582        // Only fs_write named; every other axis defaults to unrestricted.
583        let text = "\
584+++
585role = \"reviewer\"
586[caveats]
587fs_write = \"none\"
588+++
589
590# Reviewer
591Grade diffs.
592";
593        let rp = RoleProfile::parse(text).unwrap();
594        let c = rp.caveats.unwrap().to_caveats();
595        assert_eq!(c.fs_read, Scope::All);
596        assert_eq!(c.fs_write, Scope::none());
597        assert_eq!(c.exec, Scope::All);
598        assert_eq!(c.net, Scope::All);
599        assert_eq!(c.max_calls, CountBound::Unlimited);
600    }
601
602    #[test]
603    fn malformed_front_matter_errors_clearly() {
604        let text = "\
605+++
606role = \"oops
607+++
608
609body
610";
611        let err = RoleProfile::parse(text).unwrap_err().to_string();
612        assert!(
613            err.contains("front-matter"),
614            "error should mention front-matter, got: {err}"
615        );
616    }
617
618    #[test]
619    fn unclosed_front_matter_errors() {
620        let text = "+++\nrole = \"worker\"\n\n# body never fenced\n";
621        let err = RoleProfile::parse(text).unwrap_err().to_string();
622        assert!(
623            err.contains("never closed"),
624            "error should mention unclosed fence, got: {err}"
625        );
626    }
627
628    #[test]
629    fn empty_front_matter_block_is_valid() {
630        // A `+++ +++` with nothing between is valid empty TOML → all None.
631        let text = "+++\n+++\n\n# Just a prompt\n";
632        let rp = RoleProfile::parse(text).unwrap();
633        assert_eq!(rp.prompt, "# Just a prompt");
634        assert!(!rp.is_role_bound());
635    }
636
637    #[test]
638    fn leading_plus_text_is_not_a_fence() {
639        // `+++foo` on the first line is body text, not a fence opener.
640        let text = "+++foo bar\nstill body\n";
641        let rp = RoleProfile::parse(text).unwrap();
642        assert_eq!(rp.prompt, "+++foo bar\nstill body");
643        assert!(!rp.is_role_bound());
644    }
645
646    #[test]
647    fn scope_spec_items_round_trip() {
648        let spec = ScopeSpec::Items(vec!["a".to_string(), "b".to_string()]);
649        assert_eq!(
650            spec.to_scope(),
651            Scope::only(["a".to_string(), "b".to_string()])
652        );
653    }
654
655    #[test]
656    fn caveat_profile_default_is_top() {
657        let c = CaveatProfile::default().to_caveats();
658        assert_eq!(c, Caveats::top());
659    }
660
661    #[test]
662    fn scope_spec_summary_renders_each_variant() {
663        assert_eq!(ScopeSpec::Keyword(ScopeKeyword::All).summary(), "all");
664        assert_eq!(ScopeSpec::Keyword(ScopeKeyword::None).summary(), "none");
665        assert_eq!(
666            ScopeSpec::Items(vec!["a".to_string(), "b".to_string()]).summary(),
667            "[a, b]"
668        );
669    }
670
671    #[test]
672    fn caveat_profile_summary_is_one_line_per_axis() {
673        let text = "\
674+++
675role = \"worker\"
676[caveats]
677fs_read = \"all\"
678fs_write = \"none\"
679exec = [\"cargo\"]
680max_calls = 40
681+++
682
683# Worker
684body
685";
686        let rp = RoleProfile::parse(text).unwrap();
687        let summary = rp.caveats.unwrap().summary();
688        assert_eq!(
689            summary,
690            "fs_read=all fs_write=none exec=[cargo] net=all max_calls=40"
691        );
692    }
693
694    #[test]
695    fn caveat_profile_summary_unlimited_when_no_max_calls() {
696        let summary = CaveatProfile::default().summary();
697        assert!(
698            summary.ends_with("max_calls=unlimited"),
699            "omitted max_calls renders as unlimited, got: {summary}"
700        );
701    }
702
703    // --- #307 named permission presets ----------------------------------
704
705    #[test]
706    fn empty_preset_is_identity_clamp() {
707        // An empty `[permission_presets.x]` block leaves authority unchanged:
708        // its clamp is `top()`, so `base.meet(clamp) == base`.
709        let p = NamedPermissionPreset::default();
710        assert_eq!(p.clamp(), Caveats::top());
711    }
712
713    #[test]
714    fn readonly_preset_denies_writes_and_exec() {
715        // `readonly = true` ⇒ fs_write=none, exec=none; fs_read/net untouched.
716        let p = NamedPermissionPreset {
717            readonly: true,
718            ..NamedPermissionPreset::default()
719        };
720        let c = p.clamp();
721        assert_eq!(c.fs_read, Scope::All, "read-only still reads everything");
722        assert_eq!(c.fs_write, Scope::none());
723        assert_eq!(c.exec, Scope::none());
724        assert_eq!(c.net, Scope::All, "net untouched without deny=*");
725    }
726
727    #[test]
728    fn exec_allow_overrides_readonly_exec_clamp() {
729        // A readonly triage mode may still permit a few read-only commands.
730        let p = NamedPermissionPreset {
731            readonly: true,
732            exec_allow: vec!["git".to_string(), "gh".to_string()],
733            ..NamedPermissionPreset::default()
734        };
735        let c = p.clamp();
736        assert_eq!(c.fs_write, Scope::none(), "readonly still pins writes");
737        assert_eq!(
738            c.exec,
739            Scope::only(["git".to_string(), "gh".to_string()]),
740            "explicit allowlist wins over readonly's exec=none"
741        );
742    }
743
744    #[test]
745    fn deny_star_clamps_net_to_none() {
746        let p = NamedPermissionPreset {
747            fs_read: None,
748            readonly: true,
749            exec_allow: vec!["git".to_string()],
750            deny: vec!["*".to_string()],
751            max_calls: Some(40),
752        };
753        let c = p.clamp();
754        assert_eq!(c.net, Scope::none(), "deny=* clamps the net axis");
755        assert_eq!(c.max_calls, CountBound::AtMost(40));
756    }
757
758    #[test]
759    fn preset_clamp_only_attenuates_a_full_base() {
760        // The load-bearing property: meeting a fully-authorized base with a
761        // readonly clamp yields exactly the clamp's restrictions — never wider.
762        let p = NamedPermissionPreset {
763            readonly: true,
764            exec_allow: vec!["git".to_string()],
765            deny: vec!["*".to_string()],
766            ..NamedPermissionPreset::default()
767        };
768        let clamp = p.clamp();
769        let effective = Caveats::top().meet(&clamp);
770        assert_eq!(effective, clamp, "meet(top, clamp) == clamp");
771        assert!(effective.leq(&Caveats::top()), "clamp ⊑ top (attenuation)");
772    }
773
774    #[test]
775    fn preset_parses_from_config_toml_shape() {
776        // The exact `[permission_presets.<name>]` shape from the issue.
777        let toml = "\
778readonly = true
779exec_allow = [\"git\", \"gh\"]
780deny = [\"*\"]
781max_calls = 40
782";
783        let p: NamedPermissionPreset = toml::from_str(toml).unwrap();
784        assert!(p.readonly);
785        assert_eq!(p.exec_allow, vec!["git".to_string(), "gh".to_string()]);
786        assert_eq!(p.deny, vec!["*".to_string()]);
787        assert_eq!(p.max_calls, Some(40));
788    }
789
790    #[test]
791    fn preset_summary_renders_each_clamp() {
792        let p = NamedPermissionPreset {
793            fs_read: None,
794            readonly: true,
795            exec_allow: vec!["git".to_string(), "gh".to_string()],
796            deny: vec!["*".to_string()],
797            max_calls: Some(40),
798        };
799        assert_eq!(p.summary(), "readonly exec=[git, gh] deny=* max_calls=40");
800        assert_eq!(NamedPermissionPreset::default().summary(), "unconstrained");
801    }
802
803    // --- #755 M6 grammar gap: a preset can clamp fs_read -----------------
804
805    #[test]
806    fn fs_read_clamp_narrows_reads() {
807        // M6 grammar-gap fix (issue #755): a preset CAN now clamp `fs_read`.
808        //
809        // RED on pre-#755 code: `to_caveat_profile` hardcoded
810        // `fs_read: ScopeSpec::default()` (= `Scope::All`), so the `fs_read`
811        // axis was *always* unrestricted regardless of the preset — this
812        // assertion would have read `Scope::All`. GREEN once the optional
813        // `fs_read` clamp is honored.
814        let p = NamedPermissionPreset {
815            fs_read: Some(ScopeSpec::Items(vec!["src/".to_string()])),
816            ..NamedPermissionPreset::default()
817        };
818        let c = p.clamp();
819        assert_eq!(
820            c.fs_read,
821            Scope::only(["src/".to_string()]),
822            "an fs_read clamp must narrow the read axis"
823        );
824        // The other axes are untouched by an fs_read-only clamp.
825        assert_eq!(c.fs_write, Scope::All);
826        assert_eq!(c.exec, Scope::All);
827        assert_eq!(c.net, Scope::All);
828    }
829
830    #[test]
831    fn omitted_fs_read_clamp_reads_all() {
832        // Back-compat: a preset that does not set `fs_read` leaves the read
833        // axis at the top (`all`), exactly the pre-#755 behavior. A `None`
834        // serde-default lowers to `ScopeSpec::default()` ⇒ `Scope::All`.
835        let p = NamedPermissionPreset {
836            readonly: true,
837            ..NamedPermissionPreset::default()
838        };
839        assert_eq!(
840            p.clamp().fs_read,
841            Scope::All,
842            "an unspecified fs_read clamp must leave reads unrestricted"
843        );
844        // The default preset declares no fs_read clamp.
845        assert_eq!(NamedPermissionPreset::default().fs_read, None);
846    }
847
848    #[test]
849    fn fs_read_clamp_parses_from_config_toml() {
850        // The `[permission_presets.<name>]` config shape accepts an optional
851        // `fs_read` clamp as a keyword or an allow-list, like the other axes.
852        let toml = "\
853readonly = true
854fs_read = [\"src/\", \"docs/\"]
855";
856        let p: NamedPermissionPreset = toml::from_str(toml).unwrap();
857        assert!(p.readonly);
858        assert_eq!(
859            p.fs_read,
860            Some(ScopeSpec::Items(vec![
861                "src/".to_string(),
862                "docs/".to_string()
863            ]))
864        );
865        assert_eq!(
866            p.clamp().fs_read,
867            Scope::only(["src/".to_string(), "docs/".to_string()])
868        );
869    }
870}