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}