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}