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    /// Skill names (matching a `SKILL.md` `name` under `skill_search_dirs`)
76    /// this role binds on activation (FR-4, #1041). `None` = no bound
77    /// skills — today's behavior, unaffected.
78    pub skills: Option<Vec<String>>,
79    /// Capability profile (agent-bridle caveats) this role carries.
80    pub caveats: Option<CaveatProfile>,
81    /// Preferred backend model id (router policy hint).
82    pub model: Option<String>,
83    /// Preferred router tier (router policy hint).
84    pub tier: Option<Tier>,
85    /// Operating ALTITUDE — act (doer, the default) vs advise (coach). Selected
86    /// in front-matter as `altitude = "coach"`. `None` = doer. Drives which base
87    /// identity the system prompt installs (FR-5, #999): a `Coach` altitude
88    /// REPLACES `DEFAULT_SOUL` with `COACH_SOUL` so a coaching persona doesn't
89    /// ship two contradictory identities (doer soul + advise overlay).
90    pub altitude: Option<Altitude>,
91}
92
93/// The persona's operating altitude (FR-5, #999). Decides whether the base
94/// identity ("soul") is the doer `DEFAULT_SOUL` or the advise-first
95/// `COACH_SOUL`. Serialized in front-matter as `altitude = "coach"` (or the
96/// synonym `"advise"`); an absent field is [`Altitude::Doer`].
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
98#[serde(rename_all = "lowercase")]
99pub enum Altitude {
100    /// Act: make the change, run the command. Today's behavior.
101    #[default]
102    Doer,
103    /// Advise: explain, present options, recommend — never mutate. Accepts the
104    /// synonym `"advise"` in front-matter.
105    #[serde(alias = "advise")]
106    Coach,
107}
108
109/// The TOML front-matter shape. Kept separate from [`RoleProfile`] so the
110/// `prompt` (markdown body) is never part of the serde surface.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
112struct FrontMatter {
113    #[serde(default)]
114    role: Option<String>,
115    #[serde(default)]
116    tools: Option<Vec<String>>,
117    #[serde(default)]
118    skills: Option<Vec<String>>,
119    #[serde(default)]
120    caveats: Option<CaveatProfile>,
121    #[serde(default)]
122    model: Option<String>,
123    #[serde(default)]
124    tier: Option<Tier>,
125    #[serde(default)]
126    altitude: Option<Altitude>,
127}
128
129/// A small, human-friendly serde shape for an agent-bridle capability profile.
130///
131/// Each filesystem/exec/net axis is a [`ScopeSpec`] that maps onto the
132/// canonical [`Scope<String>`]; `max_calls` maps onto [`CountBound`]. Omitting
133/// an axis defaults it to the top of that axis (unrestricted) via
134/// [`ScopeSpec::default`], matching `Caveats::top()` so a sparse profile
135/// attenuates only the axes it names. Convert with [`CaveatProfile::to_caveats`].
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
137pub struct CaveatProfile {
138    #[serde(default)]
139    pub fs_read: ScopeSpec,
140    #[serde(default)]
141    pub fs_write: ScopeSpec,
142    #[serde(default)]
143    pub exec: ScopeSpec,
144    #[serde(default)]
145    pub net: ScopeSpec,
146    /// Upper bound on tool calls. `None`/omitted = unlimited.
147    #[serde(default)]
148    pub max_calls: Option<u64>,
149}
150
151/// A human-friendly serde form of a single [`Scope<String>`] axis.
152///
153/// Accepts either the bare string `"all"`/`"none"` or a list of allowed items:
154///
155/// ```text
156/// fs_read  = "all"            # Scope::All
157/// net      = "none"           # Scope::Only({})  — authorizes nothing
158/// fs_write = ["src/", "Cargo.toml"]   # Scope::Only({...})
159/// ```
160///
161/// Defaults to [`ScopeSpec::All`] (unrestricted) so an omitted axis is the top
162/// of that axis, matching `Caveats::top()`.
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(untagged)]
165pub enum ScopeSpec {
166    /// The keyword `"all"` or `"none"`.
167    Keyword(ScopeKeyword),
168    /// An explicit allow-list of items.
169    Items(Vec<String>),
170}
171
172impl Default for ScopeSpec {
173    fn default() -> Self {
174        Self::Keyword(ScopeKeyword::All)
175    }
176}
177
178/// The `"all"` / `"none"` keywords for a [`ScopeSpec`].
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "lowercase")]
181pub enum ScopeKeyword {
182    /// Unrestricted (top of the axis).
183    All,
184    /// Authorizes nothing.
185    None,
186}
187
188impl ScopeSpec {
189    /// Convert to the canonical lattice [`Scope<String>`].
190    #[must_use]
191    pub fn to_scope(&self) -> Scope<String> {
192        match self {
193            Self::Keyword(ScopeKeyword::All) => Scope::All,
194            Self::Keyword(ScopeKeyword::None) => Scope::none(),
195            Self::Items(items) => Scope::only(items.iter().cloned()),
196        }
197    }
198
199    /// One-line human summary of this axis (`"all"`, `"none"`, or `"[a, b]"`),
200    /// used by `/persona show` reporting.
201    #[must_use]
202    pub fn summary(&self) -> String {
203        match self {
204            Self::Keyword(ScopeKeyword::All) => "all".to_string(),
205            Self::Keyword(ScopeKeyword::None) => "none".to_string(),
206            Self::Items(items) => format!("[{}]", items.join(", ")),
207        }
208    }
209}
210
211impl CaveatProfile {
212    /// Convert this profile into the canonical [`Caveats`] lattice element.
213    ///
214    /// Omitted axes default to the top of their axis (unrestricted),
215    /// `valid_for_generation` is always `Scope::All` here (the profile does not
216    /// express generation bounds — that is minted at dispatch time), and
217    /// `max_calls` becomes [`CountBound::AtMost`] when set, else
218    /// [`CountBound::Unlimited`].
219    #[must_use]
220    pub fn to_caveats(&self) -> Caveats {
221        Caveats {
222            fs_read: self.fs_read.to_scope(),
223            fs_write: self.fs_write.to_scope(),
224            exec: self.exec.to_scope(),
225            net: self.net.to_scope(),
226            max_calls: match self.max_calls {
227                Some(n) => CountBound::AtMost(n),
228                None => CountBound::Unlimited,
229            },
230            // DEFERRED (issue #755 / epic #749): `valid_for_generation` is a
231            // causal-generation-window axis, not a filesystem/exec/net
232            // capability a preset or role profile clamps — it is minted at
233            // dispatch time. Narrowing it here is out of scope for the M6
234            // grammar-gap fix; left at `Scope::All` as a follow-up.
235            valid_for_generation: Scope::All,
236        }
237    }
238
239    /// One-line human summary of this capability profile for `/persona show`,
240    /// e.g. `fs_read=all fs_write=none exec=[cargo] net=all max_calls=40`.
241    #[must_use]
242    pub fn summary(&self) -> String {
243        let calls = match self.max_calls {
244            Some(n) => n.to_string(),
245            None => "unlimited".to_string(),
246        };
247        format!(
248            "fs_read={} fs_write={} exec={} net={} max_calls={}",
249            self.fs_read.summary(),
250            self.fs_write.summary(),
251            self.exec.summary(),
252            self.net.summary(),
253            calls,
254        )
255    }
256}
257
258/// A **named permission preset** (issue #307): a config-declared authority
259/// *clamp* that maps onto the same [`CaveatProfile`] / [`Caveats`] lattice a
260/// role profile uses. It is deliberately a thin, human-friendly surface over
261/// [`CaveatProfile`] — the existing caveat-profile mechanism — rather than a
262/// parallel type, so the same `to_scope` / `to_caveats` lowering is reused.
263///
264/// ## Config shape (`[permission_presets.<name>]`)
265///
266/// ```toml
267/// [permission_presets.readonly-triage]
268/// readonly   = true            # read anything; deny all writes
269/// exec_allow = ["git", "gh"]   # a small exec allowlist
270/// deny       = ["*"]           # everything else (net) denied
271/// max_calls  = 40              # optional tool-call ceiling
272/// # fs_read  = ["src/"]        # optional: confine reads (omit ⇒ read all)
273/// ```
274///
275/// ## Semantics — a clamp, never a grant
276///
277/// The clamp is consumed via [`NamedPermissionPreset::clamp`], which lowers to a
278/// [`Caveats`] **ceiling**. The session's effective authority is the
279/// *intersection* (`meet`) of the session's base caveats and this ceiling — a
280/// preset can only ever **attenuate**, never widen. This is the load-bearing
281/// property: the ceiling wins over `--disable-ocap` / `--yolo` and over
282/// interactive session-grants, because it is `meet`-ed into the authority the
283/// enforcement site consults (and re-`meet`-ed over any re-minted grant).
284///
285/// Each field maps onto a [`CaveatProfile`] axis:
286/// - `fs_read = [..]` / `"none"` ⇒ clamp `fs_read = Only({..})` / `none`.
287///   **Optional** — omitted (`None`) leaves `fs_read` unrestricted (`all`),
288///   the back-compat default. Before this field a preset structurally could
289///   *not* narrow reads (the M6 grammar gap, issue #755) even though
290///   [`CaveatProfile`] / [`Caveats`] can express a read clamp. A read-only
291///   triage mode still reads everything unless it sets this deliberately.
292/// - `readonly = true` ⇒ `fs_write = none`, `exec = none` (the floor a triage
293///   mode wants). `false`/omitted leaves those axes unconstrained (`all`).
294/// - `exec_allow = [..]` ⇒ `exec = Only({..})`. Overrides `readonly`'s
295///   exec clamp with the explicit allowlist (so a triage mode can permit a few
296///   read-only commands).
297/// - `deny = ["*"]` ⇒ clamp `net` to `none` (the remaining "everything else"
298///   axis once fs/exec are pinned). A non-`*` `deny` list is recorded but does
299///   not subtract from an allowlist here — the lattice is allow-list shaped, so
300///   "deny everything else" is the expressible clamp.
301/// - `max_calls` ⇒ the [`CountBound`] ceiling, as on a role profile.
302///
303/// A preset with no fields set is the identity clamp (`Caveats::top()`), so an
304/// empty `[permission_presets.x]` block leaves authority unchanged.
305///
306/// **Footgun (review #312):** omitted axes stay at `TOP`. A preset that sets
307/// only `exec_allow = ["git"]` clamps *exec* but leaves `fs_write` and `net`
308/// wide open — it is NOT "locked down" despite reading that way. For a true
309/// read-only triage clamp, set `readonly = true` (denies writes + exec) and
310/// add `exec_allow` only for the few programs the mode genuinely needs.
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
312pub struct NamedPermissionPreset {
313    /// Optional `fs_read` clamp (issue #755 — the M6 grammar-gap fix).
314    /// `None`/omitted ⇒ the `fs_read` axis stays at the top of its axis
315    /// (`all`), exactly the pre-#755 behavior where a preset structurally
316    /// could not narrow reads. `Some(spec)` narrows reads (e.g.
317    /// `Only({..})` to confine a triage mode to a subtree). Serde-defaults
318    /// to `None`, so every existing preset is byte-for-byte unchanged.
319    #[serde(default)]
320    pub fs_read: Option<ScopeSpec>,
321    /// `true` ⇒ deny all writes and (unless `exec_allow` overrides) all exec.
322    #[serde(default)]
323    pub readonly: bool,
324    /// Explicit exec allowlist. Non-empty ⇒ `exec = Only({..})`, overriding the
325    /// `readonly` exec clamp. Empty + `readonly` ⇒ `exec = none`.
326    #[serde(default)]
327    pub exec_allow: Vec<String>,
328    /// "Deny everything else" marker. `["*"]` clamps the `net` axis to `none`
329    /// (writes/exec are pinned by `readonly`/`exec_allow`). Other entries are
330    /// accepted for forward-compat but the allow-list lattice expresses denial
331    /// as "not in the allowlist", so they do not subtract here.
332    #[serde(default)]
333    pub deny: Vec<String>,
334    /// Optional tool-call ceiling. `None` ⇒ no extra bound.
335    #[serde(default)]
336    pub max_calls: Option<u64>,
337}
338
339impl NamedPermissionPreset {
340    /// `true` when `deny` asks to deny everything else (`["*"]`).
341    fn denies_all(&self) -> bool {
342        self.deny.iter().any(|d| d == "*")
343    }
344
345    /// Lower this named preset into the equivalent [`CaveatProfile`] — reusing
346    /// the SAME serde-shaped caveat mechanism a role profile carries, rather
347    /// than a parallel lowering. Axes the preset does not constrain stay at the
348    /// top of their axis (`all`) so `clamp` is the identity on them.
349    #[must_use]
350    pub fn to_caveat_profile(&self) -> CaveatProfile {
351        // fs_write: readonly pins it to none; otherwise unconstrained.
352        let fs_write = if self.readonly {
353            ScopeSpec::Keyword(ScopeKeyword::None)
354        } else {
355            ScopeSpec::default()
356        };
357        // exec: an explicit allowlist wins; else readonly pins it to none; else
358        // unconstrained.
359        let exec = if !self.exec_allow.is_empty() {
360            ScopeSpec::Items(self.exec_allow.clone())
361        } else if self.readonly {
362            ScopeSpec::Keyword(ScopeKeyword::None)
363        } else {
364            ScopeSpec::default()
365        };
366        // net: `deny = ["*"]` clamps the remaining axis to none.
367        let net = if self.denies_all() {
368            ScopeSpec::Keyword(ScopeKeyword::None)
369        } else {
370            ScopeSpec::default()
371        };
372        // fs_read: an explicit clamp narrows reads (issue #755 — the M6
373        // grammar-gap fix). `None`/omitted ⇒ the top of the axis (`all`), the
374        // historical behavior: before #755 this was hardcoded to
375        // `ScopeSpec::default()`, so a preset structurally could not clamp
376        // reads even though `CaveatProfile`/`Caveats` can. A read-only triage
377        // mode still reads everything unless a preset deliberately narrows it.
378        let fs_read = self.fs_read.clone().unwrap_or_default();
379        // DEFERRED (default-deny is step 8's job, not here): the "empty/absent
380        // preset ⇒ identity clamp (`Caveats::top()`)" default — each unset axis
381        // staying at the top of its axis — is the CORRECT meet-identity
382        // semantics for a *clamp*: an empty clamp adds no restriction and is
383        // applied via `meet`, so an absent axis must be the top. The
384        // "default-deny for un-annotated subtasks" (an absent subtask `kind` ⇒
385        // the most-restrictive preset) belongs in step 8's subtask-clamp
386        // derivation, NOT in this general lowering. Flipping the general default
387        // to deny here would break back-compat for every preset consumer
388        // (steps 2-7), so step 8 owns that default-deny.
389        CaveatProfile {
390            fs_read,
391            fs_write,
392            exec,
393            net,
394            max_calls: self.max_calls,
395        }
396    }
397
398    /// The authority **ceiling** this preset imposes, as a [`Caveats`]. The
399    /// session's effective authority is `base.meet(&preset.clamp())` — the
400    /// clamp can only attenuate, never widen. Reuses [`CaveatProfile::to_caveats`].
401    #[must_use]
402    pub fn clamp(&self) -> Caveats {
403        self.to_caveat_profile().to_caveats()
404    }
405
406    /// One-line human summary for `/permissions` and `/mode` reporting, e.g.
407    /// `readonly exec=[git, gh] deny=* max_calls=40`.
408    #[must_use]
409    pub fn summary(&self) -> String {
410        let mut parts: Vec<String> = Vec::new();
411        if self.readonly {
412            parts.push("readonly".to_string());
413        }
414        if !self.exec_allow.is_empty() {
415            parts.push(format!("exec=[{}]", self.exec_allow.join(", ")));
416        } else if self.readonly {
417            parts.push("exec=none".to_string());
418        }
419        if self.denies_all() {
420            parts.push("deny=*".to_string());
421        }
422        if let Some(n) = self.max_calls {
423            parts.push(format!("max_calls={n}"));
424        }
425        if parts.is_empty() {
426            "unconstrained".to_string()
427        } else {
428            parts.join(" ")
429        }
430    }
431}
432
433/// The fence marker for TOML front-matter (must be on its own line at the very
434/// top of the file).
435const FENCE: &str = "+++";
436
437impl RoleProfile {
438    /// Parse a role-profile `.md` file's full text into a [`RoleProfile`].
439    ///
440    /// If the text begins with a `+++` fence line, everything up to the closing
441    /// `+++` line is parsed as TOML front-matter and the remainder is the
442    /// prompt body. Otherwise the whole text is the prompt and every other
443    /// field is `None` (prompt-only — today's behavior).
444    ///
445    /// # Errors
446    ///
447    /// Returns an error if a front-matter fence is opened but never closed, or
448    /// if the front-matter is not valid TOML for the expected shape.
449    pub fn parse(text: &str) -> anyhow::Result<Self> {
450        let (front_matter, body) = split_front_matter(text)?;
451        let body = body.trim().to_string();
452        let Some(fm_text) = front_matter else {
453            // No front-matter: prompt-only, exactly today's persona.
454            return Ok(Self {
455                prompt: body,
456                ..Self::default()
457            });
458        };
459        let fm: FrontMatter = toml::from_str(fm_text)
460            .map_err(|e| anyhow::anyhow!("invalid role-profile front-matter: {e}"))?;
461        Ok(Self {
462            prompt: body,
463            role: fm.role,
464            tools: fm.tools,
465            skills: fm.skills,
466            caveats: fm.caveats,
467            model: fm.model,
468            tier: fm.tier,
469            altitude: fm.altitude,
470        })
471    }
472
473    /// `true` when this profile declares more than a prompt (i.e. front-matter
474    /// bound a role, tools, skills, caveats, model, or tier). A prompt-only
475    /// persona returns `false`.
476    #[must_use]
477    pub fn is_role_bound(&self) -> bool {
478        self.role.is_some()
479            || self.tools.is_some()
480            || self.skills.is_some()
481            || self.caveats.is_some()
482            || self.model.is_some()
483            || self.tier.is_some()
484            || self.altitude.is_some()
485    }
486
487    /// Load a named persona `.md` file from `dir` (#1021 PR 5.2 — the
488    /// headless counterpart of `newt-tui`'s `PersonaStore::load`).
489    ///
490    /// Deliberately does **not** seed a shipped default when the file is
491    /// missing — `PersonaStore`'s per-file-idempotent seeding (FR-16, #1000)
492    /// is correct first-run UX for an interactive session, but a stdio
493    /// server silently writing files into `~/.newt/personas/` on every
494    /// startup is a footgun under CI/systemd. A headless caller with an
495    /// unresolvable `--persona <name>` should fail loudly instead.
496    ///
497    /// # Errors
498    ///
499    /// Returns an error if `name` isn't a valid persona name (letters,
500    /// numbers, `-`, `_` only — the same rule `PersonaStore` enforces, kept
501    /// in sync deliberately rather than shared, since crossing the
502    /// `newt-core`/`newt-tui` boundary for an 8-line check isn't worth the
503    /// coupling), if the file doesn't exist, or if it fails to parse.
504    pub fn load_from_dir(name: &str, dir: &std::path::Path) -> anyhow::Result<Self> {
505        let name = name.trim().to_ascii_lowercase();
506        if name.is_empty()
507            || !name
508                .chars()
509                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
510        {
511            anyhow::bail!("persona names may only contain letters, numbers, '-' and '_'");
512        }
513        let path = dir.join(format!("{name}.md"));
514        let raw = std::fs::read_to_string(&path).map_err(|e| {
515            anyhow::anyhow!("persona `{name}` not found at {}: {e}", path.display())
516        })?;
517        let profile = Self::parse(&raw).map_err(|e| anyhow::anyhow!("persona `{name}`: {e}"))?;
518        if profile.prompt.is_empty() {
519            anyhow::bail!("persona `{name}` is empty: {}", path.display());
520        }
521        Ok(profile)
522    }
523}
524
525/// Split optional `+++`-fenced TOML front-matter from a markdown body.
526///
527/// Returns `(Some(front_matter_text), body)` when a fence is present, or
528/// `(None, whole_text)` otherwise.
529fn split_front_matter(text: &str) -> anyhow::Result<(Option<&str>, &str)> {
530    // Front-matter must be the very first line (allowing a leading BOM /
531    // whitespace-free start). We intentionally do not skip blank lines before
532    // the fence: a leading blank line means "no front-matter".
533    let trimmed_start = text.strip_prefix('\u{feff}').unwrap_or(text);
534    let Some(rest) = trimmed_start.strip_prefix(FENCE) else {
535        return Ok((None, text));
536    };
537    // The opening fence must be its own line: the char right after `+++` must
538    // be a newline (or the string ends).
539    let rest = match rest.strip_prefix('\n') {
540        Some(r) => r,
541        None => match rest.strip_prefix("\r\n") {
542            Some(r) => r,
543            // `+++foo` on the first line is not a fence — treat as body.
544            None if rest.is_empty() => "",
545            None => return Ok((None, text)),
546        },
547    };
548    // Find the closing fence: a line that is exactly `+++`.
549    for (idx, line) in LineOffsets::new(rest) {
550        if line.trim_end_matches(['\r', '\n']).trim() == FENCE {
551            let fm = &rest[..idx];
552            let after = &rest[idx + line.len()..];
553            return Ok((Some(fm), after));
554        }
555    }
556    anyhow::bail!("role-profile front-matter opened with `+++` but never closed")
557}
558
559/// Iterator over `(byte_offset, line_with_terminator)` pairs of a string.
560struct LineOffsets<'a> {
561    rest: &'a str,
562    offset: usize,
563}
564
565impl<'a> LineOffsets<'a> {
566    fn new(s: &'a str) -> Self {
567        Self { rest: s, offset: 0 }
568    }
569}
570
571impl<'a> Iterator for LineOffsets<'a> {
572    type Item = (usize, &'a str);
573
574    fn next(&mut self) -> Option<Self::Item> {
575        if self.rest.is_empty() {
576            return None;
577        }
578        let end = match self.rest.find('\n') {
579            Some(i) => i + 1,
580            None => self.rest.len(),
581        };
582        let line = &self.rest[..end];
583        let start = self.offset;
584        self.offset += end;
585        self.rest = &self.rest[end..];
586        Some((start, line))
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    #[test]
595    fn no_front_matter_is_prompt_only() {
596        let text = "# Coder\n\nYou write code.\n";
597        let rp = RoleProfile::parse(text).unwrap();
598        assert_eq!(rp.prompt, "# Coder\n\nYou write code.");
599        assert_eq!(rp.role, None);
600        assert_eq!(rp.tools, None);
601        assert_eq!(rp.caveats, None);
602        assert_eq!(rp.model, None);
603        assert_eq!(rp.tier, None);
604        assert!(!rp.is_role_bound());
605    }
606
607    #[test]
608    fn parses_full_front_matter() {
609        let text = "\
610+++
611role = \"worker\"
612tools = [\"read_file\", \"write_file\", \"run_command\"]
613model = \"qwen2.5-coder:14b\"
614tier = \"STANDARD\"
615
616[caveats]
617fs_read = \"all\"
618fs_write = [\"src/\"]
619exec = [\"cargo\"]
620net = \"none\"
621max_calls = 40
622+++
623
624# Worker
625
626You edit files and run builds.
627";
628        let rp = RoleProfile::parse(text).unwrap();
629        assert_eq!(rp.prompt, "# Worker\n\nYou edit files and run builds.");
630        assert_eq!(rp.role.as_deref(), Some("worker"));
631        assert_eq!(
632            rp.tools,
633            Some(vec![
634                "read_file".to_string(),
635                "write_file".to_string(),
636                "run_command".to_string(),
637            ])
638        );
639        assert_eq!(rp.model.as_deref(), Some("qwen2.5-coder:14b"));
640        assert_eq!(rp.tier, Some(Tier::Standard));
641        assert!(rp.is_role_bound());
642
643        let caveats = rp.caveats.unwrap().to_caveats();
644        assert_eq!(caveats.fs_read, Scope::All);
645        assert_eq!(caveats.fs_write, Scope::only(["src/".to_string()]));
646        assert_eq!(caveats.exec, Scope::only(["cargo".to_string()]));
647        assert_eq!(caveats.net, Scope::none());
648        assert_eq!(caveats.max_calls, CountBound::AtMost(40));
649    }
650
651    #[test]
652    fn sparse_caveats_default_to_top() {
653        // Only fs_write named; every other axis defaults to unrestricted.
654        let text = "\
655+++
656role = \"reviewer\"
657[caveats]
658fs_write = \"none\"
659+++
660
661# Reviewer
662Grade diffs.
663";
664        let rp = RoleProfile::parse(text).unwrap();
665        let c = rp.caveats.unwrap().to_caveats();
666        assert_eq!(c.fs_read, Scope::All);
667        assert_eq!(c.fs_write, Scope::none());
668        assert_eq!(c.exec, Scope::All);
669        assert_eq!(c.net, Scope::All);
670        assert_eq!(c.max_calls, CountBound::Unlimited);
671    }
672
673    #[test]
674    fn malformed_front_matter_errors_clearly() {
675        let text = "\
676+++
677role = \"oops
678+++
679
680body
681";
682        let err = RoleProfile::parse(text).unwrap_err().to_string();
683        assert!(
684            err.contains("front-matter"),
685            "error should mention front-matter, got: {err}"
686        );
687    }
688
689    #[test]
690    fn unclosed_front_matter_errors() {
691        let text = "+++\nrole = \"worker\"\n\n# body never fenced\n";
692        let err = RoleProfile::parse(text).unwrap_err().to_string();
693        assert!(
694            err.contains("never closed"),
695            "error should mention unclosed fence, got: {err}"
696        );
697    }
698
699    #[test]
700    fn empty_front_matter_block_is_valid() {
701        // A `+++ +++` with nothing between is valid empty TOML → all None.
702        let text = "+++\n+++\n\n# Just a prompt\n";
703        let rp = RoleProfile::parse(text).unwrap();
704        assert_eq!(rp.prompt, "# Just a prompt");
705        assert!(!rp.is_role_bound());
706    }
707
708    /// FR-5 (#999): `altitude` parses (with `advise` as a synonym for `coach`),
709    /// defaults to `None` when absent, and counts as role-binding front-matter.
710    #[test]
711    fn parses_altitude_with_advise_synonym() {
712        let coach = RoleProfile::parse("+++\naltitude = \"coach\"\n+++\n\nx\n").unwrap();
713        assert_eq!(coach.altitude, Some(Altitude::Coach));
714        assert!(coach.is_role_bound(), "altitude alone binds the role");
715        assert_eq!(
716            RoleProfile::parse("+++\naltitude = \"advise\"\n+++\n\nx\n")
717                .unwrap()
718                .altitude,
719            Some(Altitude::Coach),
720            "`advise` is a synonym for coach"
721        );
722        assert_eq!(
723            RoleProfile::parse("+++\naltitude = \"doer\"\n+++\n\nx\n")
724                .unwrap()
725                .altitude,
726            Some(Altitude::Doer)
727        );
728        assert_eq!(
729            RoleProfile::parse("+++\nrole = \"w\"\n+++\n\nx\n")
730                .unwrap()
731                .altitude,
732            None,
733            "absent altitude is None (doer applies downstream)"
734        );
735    }
736
737    /// FR-4 (#1041): `skills` parses as a list of skill names, defaults to
738    /// `None` when absent, and counts as role-binding front-matter.
739    #[test]
740    fn parses_skills_field() {
741        let rp = RoleProfile::parse(
742            "+++\nskills = [\"gila-personal-assistant\", \"other-skill\"]\n+++\n\nx\n",
743        )
744        .unwrap();
745        assert_eq!(
746            rp.skills,
747            Some(vec![
748                "gila-personal-assistant".to_string(),
749                "other-skill".to_string(),
750            ])
751        );
752        assert!(rp.is_role_bound(), "skills alone binds the role");
753        assert_eq!(
754            RoleProfile::parse("+++\nrole = \"w\"\n+++\n\nx\n")
755                .unwrap()
756                .skills,
757            None,
758            "absent skills is None (no bound skills)"
759        );
760    }
761
762    #[test]
763    fn leading_plus_text_is_not_a_fence() {
764        // `+++foo` on the first line is body text, not a fence opener.
765        let text = "+++foo bar\nstill body\n";
766        let rp = RoleProfile::parse(text).unwrap();
767        assert_eq!(rp.prompt, "+++foo bar\nstill body");
768        assert!(!rp.is_role_bound());
769    }
770
771    #[test]
772    fn scope_spec_items_round_trip() {
773        let spec = ScopeSpec::Items(vec!["a".to_string(), "b".to_string()]);
774        assert_eq!(
775            spec.to_scope(),
776            Scope::only(["a".to_string(), "b".to_string()])
777        );
778    }
779
780    #[test]
781    fn caveat_profile_default_is_top() {
782        let c = CaveatProfile::default().to_caveats();
783        assert_eq!(c, Caveats::top());
784    }
785
786    #[test]
787    fn scope_spec_summary_renders_each_variant() {
788        assert_eq!(ScopeSpec::Keyword(ScopeKeyword::All).summary(), "all");
789        assert_eq!(ScopeSpec::Keyword(ScopeKeyword::None).summary(), "none");
790        assert_eq!(
791            ScopeSpec::Items(vec!["a".to_string(), "b".to_string()]).summary(),
792            "[a, b]"
793        );
794    }
795
796    #[test]
797    fn caveat_profile_summary_is_one_line_per_axis() {
798        let text = "\
799+++
800role = \"worker\"
801[caveats]
802fs_read = \"all\"
803fs_write = \"none\"
804exec = [\"cargo\"]
805max_calls = 40
806+++
807
808# Worker
809body
810";
811        let rp = RoleProfile::parse(text).unwrap();
812        let summary = rp.caveats.unwrap().summary();
813        assert_eq!(
814            summary,
815            "fs_read=all fs_write=none exec=[cargo] net=all max_calls=40"
816        );
817    }
818
819    #[test]
820    fn caveat_profile_summary_unlimited_when_no_max_calls() {
821        let summary = CaveatProfile::default().summary();
822        assert!(
823            summary.ends_with("max_calls=unlimited"),
824            "omitted max_calls renders as unlimited, got: {summary}"
825        );
826    }
827
828    // --- #307 named permission presets ----------------------------------
829
830    #[test]
831    fn empty_preset_is_identity_clamp() {
832        // An empty `[permission_presets.x]` block leaves authority unchanged:
833        // its clamp is `top()`, so `base.meet(clamp) == base`.
834        let p = NamedPermissionPreset::default();
835        assert_eq!(p.clamp(), Caveats::top());
836    }
837
838    #[test]
839    fn readonly_preset_denies_writes_and_exec() {
840        // `readonly = true` ⇒ fs_write=none, exec=none; fs_read/net untouched.
841        let p = NamedPermissionPreset {
842            readonly: true,
843            ..NamedPermissionPreset::default()
844        };
845        let c = p.clamp();
846        assert_eq!(c.fs_read, Scope::All, "read-only still reads everything");
847        assert_eq!(c.fs_write, Scope::none());
848        assert_eq!(c.exec, Scope::none());
849        assert_eq!(c.net, Scope::All, "net untouched without deny=*");
850    }
851
852    #[test]
853    fn exec_allow_overrides_readonly_exec_clamp() {
854        // A readonly triage mode may still permit a few read-only commands.
855        let p = NamedPermissionPreset {
856            readonly: true,
857            exec_allow: vec!["git".to_string(), "gh".to_string()],
858            ..NamedPermissionPreset::default()
859        };
860        let c = p.clamp();
861        assert_eq!(c.fs_write, Scope::none(), "readonly still pins writes");
862        assert_eq!(
863            c.exec,
864            Scope::only(["git".to_string(), "gh".to_string()]),
865            "explicit allowlist wins over readonly's exec=none"
866        );
867    }
868
869    #[test]
870    fn deny_star_clamps_net_to_none() {
871        let p = NamedPermissionPreset {
872            fs_read: None,
873            readonly: true,
874            exec_allow: vec!["git".to_string()],
875            deny: vec!["*".to_string()],
876            max_calls: Some(40),
877        };
878        let c = p.clamp();
879        assert_eq!(c.net, Scope::none(), "deny=* clamps the net axis");
880        assert_eq!(c.max_calls, CountBound::AtMost(40));
881    }
882
883    #[test]
884    fn preset_clamp_only_attenuates_a_full_base() {
885        // The load-bearing property: meeting a fully-authorized base with a
886        // readonly clamp yields exactly the clamp's restrictions — never wider.
887        let p = NamedPermissionPreset {
888            readonly: true,
889            exec_allow: vec!["git".to_string()],
890            deny: vec!["*".to_string()],
891            ..NamedPermissionPreset::default()
892        };
893        let clamp = p.clamp();
894        let effective = Caveats::top().meet(&clamp);
895        assert_eq!(effective, clamp, "meet(top, clamp) == clamp");
896        assert!(effective.leq(&Caveats::top()), "clamp ⊑ top (attenuation)");
897    }
898
899    #[test]
900    fn preset_parses_from_config_toml_shape() {
901        // The exact `[permission_presets.<name>]` shape from the issue.
902        let toml = "\
903readonly = true
904exec_allow = [\"git\", \"gh\"]
905deny = [\"*\"]
906max_calls = 40
907";
908        let p: NamedPermissionPreset = toml::from_str(toml).unwrap();
909        assert!(p.readonly);
910        assert_eq!(p.exec_allow, vec!["git".to_string(), "gh".to_string()]);
911        assert_eq!(p.deny, vec!["*".to_string()]);
912        assert_eq!(p.max_calls, Some(40));
913    }
914
915    #[test]
916    fn preset_summary_renders_each_clamp() {
917        let p = NamedPermissionPreset {
918            fs_read: None,
919            readonly: true,
920            exec_allow: vec!["git".to_string(), "gh".to_string()],
921            deny: vec!["*".to_string()],
922            max_calls: Some(40),
923        };
924        assert_eq!(p.summary(), "readonly exec=[git, gh] deny=* max_calls=40");
925        assert_eq!(NamedPermissionPreset::default().summary(), "unconstrained");
926    }
927
928    // --- #755 M6 grammar gap: a preset can clamp fs_read -----------------
929
930    #[test]
931    fn fs_read_clamp_narrows_reads() {
932        // M6 grammar-gap fix (issue #755): a preset CAN now clamp `fs_read`.
933        //
934        // RED on pre-#755 code: `to_caveat_profile` hardcoded
935        // `fs_read: ScopeSpec::default()` (= `Scope::All`), so the `fs_read`
936        // axis was *always* unrestricted regardless of the preset — this
937        // assertion would have read `Scope::All`. GREEN once the optional
938        // `fs_read` clamp is honored.
939        let p = NamedPermissionPreset {
940            fs_read: Some(ScopeSpec::Items(vec!["src/".to_string()])),
941            ..NamedPermissionPreset::default()
942        };
943        let c = p.clamp();
944        assert_eq!(
945            c.fs_read,
946            Scope::only(["src/".to_string()]),
947            "an fs_read clamp must narrow the read axis"
948        );
949        // The other axes are untouched by an fs_read-only clamp.
950        assert_eq!(c.fs_write, Scope::All);
951        assert_eq!(c.exec, Scope::All);
952        assert_eq!(c.net, Scope::All);
953    }
954
955    #[test]
956    fn omitted_fs_read_clamp_reads_all() {
957        // Back-compat: a preset that does not set `fs_read` leaves the read
958        // axis at the top (`all`), exactly the pre-#755 behavior. A `None`
959        // serde-default lowers to `ScopeSpec::default()` ⇒ `Scope::All`.
960        let p = NamedPermissionPreset {
961            readonly: true,
962            ..NamedPermissionPreset::default()
963        };
964        assert_eq!(
965            p.clamp().fs_read,
966            Scope::All,
967            "an unspecified fs_read clamp must leave reads unrestricted"
968        );
969        // The default preset declares no fs_read clamp.
970        assert_eq!(NamedPermissionPreset::default().fs_read, None);
971    }
972
973    #[test]
974    fn fs_read_clamp_parses_from_config_toml() {
975        // The `[permission_presets.<name>]` config shape accepts an optional
976        // `fs_read` clamp as a keyword or an allow-list, like the other axes.
977        let toml = "\
978readonly = true
979fs_read = [\"src/\", \"docs/\"]
980";
981        let p: NamedPermissionPreset = toml::from_str(toml).unwrap();
982        assert!(p.readonly);
983        assert_eq!(
984            p.fs_read,
985            Some(ScopeSpec::Items(vec![
986                "src/".to_string(),
987                "docs/".to_string()
988            ]))
989        );
990        assert_eq!(
991            p.clamp().fs_read,
992            Scope::only(["src/".to_string(), "docs/".to_string()])
993        );
994    }
995
996    // --- #1021 PR 5.2: headless `RoleProfile::load_from_dir` -------------
997
998    #[test]
999    fn load_from_dir_parses_an_existing_persona() {
1000        let tmp = tempfile::TempDir::new().unwrap();
1001        std::fs::write(
1002            tmp.path().join("personal-assistant.md"),
1003            "+++\nrole = \"personal-assistant\"\ntools = [\"a\"]\n+++\n\n# PA\nbody\n",
1004        )
1005        .unwrap();
1006        let rp = RoleProfile::load_from_dir("personal-assistant", tmp.path()).unwrap();
1007        assert_eq!(rp.role.as_deref(), Some("personal-assistant"));
1008        assert_eq!(rp.prompt, "# PA\nbody");
1009    }
1010
1011    #[test]
1012    fn load_from_dir_errors_loudly_on_a_missing_file_no_seeding() {
1013        let tmp = tempfile::TempDir::new().unwrap();
1014        let err = RoleProfile::load_from_dir("nope", tmp.path())
1015            .unwrap_err()
1016            .to_string();
1017        assert!(err.contains("not found"), "got: {err}");
1018        assert!(
1019            !tmp.path().join("nope.md").exists(),
1020            "a missing persona must NOT be silently seeded (headless callers fail loudly instead)"
1021        );
1022    }
1023
1024    #[test]
1025    fn load_from_dir_rejects_invalid_names() {
1026        let tmp = tempfile::TempDir::new().unwrap();
1027        let err = RoleProfile::load_from_dir("bad name!", tmp.path())
1028            .unwrap_err()
1029            .to_string();
1030        assert!(err.contains("letters, numbers"), "got: {err}");
1031    }
1032
1033    #[test]
1034    fn load_from_dir_rejects_an_empty_persona_file() {
1035        let tmp = tempfile::TempDir::new().unwrap();
1036        std::fs::write(tmp.path().join("blank.md"), "   \n").unwrap();
1037        let err = RoleProfile::load_from_dir("blank", tmp.path())
1038            .unwrap_err()
1039            .to_string();
1040        assert!(err.contains("is empty"), "got: {err}");
1041    }
1042
1043    #[test]
1044    fn load_from_dir_lowercases_the_name() {
1045        let tmp = tempfile::TempDir::new().unwrap();
1046        std::fs::write(tmp.path().join("coach.md"), "# Coach\nbody\n").unwrap();
1047        let rp = RoleProfile::load_from_dir("  CoAcH  ", tmp.path()).unwrap();
1048        assert_eq!(rp.prompt, "# Coach\nbody");
1049    }
1050}