返回 CodeWhale
role.rs
根目录 / crates / tui / src / fleet / role.rs
1 //! Fleet roles — the lightweight role surface for exec and sub-agent spawning.
2 //!
3 //! Learned from the OMP sub-agent role system: an agent type is a **name**
4 //! plus a small static posture (tool allowlist, model, reasoning level), and
5 //! exec consumes exactly that — never the roster, ledger, or worker
6 //! machinery. The durable Fleet runs (manager / executor / worker_runtime /
7 //! exact workflow driver) remain the one consumer of the heavy machinery and
8 //! resolve roles through this same surface, so both paths share one posture.
9 //!
10 //! What lives here (and only here) for spawn-time decisions:
11 //! - [`FleetRole`]: the closed 8-role set, parsing, and canonical labels.
12 //! - Per-role posture: [`role_requires_read_only_shell`],
13 //! [`effective_runtime_profile_for_role`], [`fleet_effective_permissions`].
14 //! - The tool deny lists + [`ChildAuthority`]: how a
15 //! role posture becomes the concrete child surface (allowlist, deny list,
16 //! write authority, delegation budget, fingerprint).
17 //!
18 //! Deliberately dependency-light: protocol + workflow ceiling types,
19 //! [`crate::worker_profile`], std. Nothing from fleet control / store /
20 //! ledger / executor / worker_runtime / roster / profile / identity /
21 //! members, and nothing from tools.
22
23 use codewhale_protocol::fleet::FleetEffectivePermissions;
24 use codewhale_workflow::{PermissionCeiling, ShellCeiling};
25
26 use crate::worker_profile::{ShellPolicy, ToolScope, WorkerRuntimeProfile};
27
28 /// Canonical model-facing Fleet role values, in schema order. This is the
29 /// closed `enum` advertised on the Agent tool's `type` property. Legacy
30 /// aliases are accepted only at replay/deserialization boundaries
31 /// ([`migrate_legacy_role_token`]) and are never advertised to models.
32 pub(crate) const FLEET_ROLE_SCHEMA_VALUES: [&str; 8] = [
33 "general",
34 "explore",
35 "planner",
36 "reviewer",
37 "implement",
38 "test",
39 "advisor",
40 "custom",
41 ];
42
43 /// Human-readable hint listing every token [`FleetRole::from_str`] accepts,
44 /// for spawn-time error messages. Keep in sync with
45 /// [`migrate_legacy_role_token`] and the `from_str` match arms; those two are
46 /// the only role parser (#2649 was a second table in the spawn tool drifting
47 /// from this set).
48 pub(crate) const VALID_ROLE_ALIASES: &str = "general; explore; planner; reviewer; implement; test; advisor; custom \
49 (legacy aliases remain accepted: worker; scout; builder; verifier; consultant; default; general-purpose; general_purpose; exploration; explorer; plan; planning; awaiter; review; code-review; code_review; implementer; implementation; verify; verification; validator; tester; oracle)";
50
51 /// Canonical Fleet role for a delegated worker, with specialized behavior
52 /// and tool access per role.
53 ///
54 /// **Public vocabulary is Fleet roles** (`general`, `explore`, `planner`,
55 /// `reviewer`, `implement`, `test`, `advisor`, `custom`) and the variants match that
56 /// vocabulary one-to-one. Serialization, prompts, receipts, and UI always
57 /// use [`Self::as_str`]. Legacy wire spellings (`worker`, `scout`, `plan`,
58 /// `review`, `implementer`, …) are accepted only through
59 /// [`migrate_legacy_role_token`] at deserialization / parse boundaries.
60 ///
61 /// This is the closed runtime role set. It is distinct from
62 /// `codewhale_config::FleetRole`, which is the open config-side role
63 /// *declaration* (free-form name plus instruction overlay) carried by a
64 /// Fleet profile. The `FleetRole` type name remains a compatibility identifier.
65 #[derive(Debug, Clone, PartialEq, Eq, Default)]
66 pub enum FleetRole {
67 /// General-purpose worker - full tool access for multi-step tasks.
68 #[default]
69 Worker,
70 /// Fast exploration - read-only tools for codebase search.
71 Scout,
72 /// Planning — grounded strategy. Reads the workspace and the web and
73 /// may run classifier-bounded shell probes; never mutates.
74 Planner,
75 /// Code review - read + analysis tools.
76 Reviewer,
77 /// Implementation — focused on writing / patching code to satisfy
78 /// a specific change. Distinct from `Worker` in that the prompt
79 /// posture pushes hard on landing the change cleanly with the
80 /// minimum surrounding edit (#404).
81 Builder,
82 /// Verification — focused on running the test suite or other
83 /// validation gates and reporting pass/fail with evidence.
84 /// Distinct from `Reviewer` in that Reviewer reads code and grades it;
85 /// Verifier *runs* tests and reports the outcome (#404).
86 Verifier,
87 /// Advisory counsel — a strong-model second opinion the operator can ask
88 /// for guidance, judgement calls, and design critique (#4752).
89 ///
90 /// Read-only and shell-less by construction: a Consultant reasons about the
91 /// code (and may read the web to ground that counsel) and says what it
92 /// thinks. It is distinct from `Reviewer`, which grades a specific change
93 /// against a standard, and from `Planner`, which produces a plan to execute.
94 /// A Consultant answers "what should we do here, and what are we not seeing".
95 Consultant,
96 /// Custom tool access defined at spawn time. Inherits the parent's
97 /// write/network/shell ceiling and is narrowed by the explicit tool list
98 /// or an explicit write_authority, never by a silent lock-down.
99 Custom,
100 }
101
102 impl serde::Serialize for FleetRole {
103 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
104 where
105 S: serde::Serializer,
106 {
107 serializer.serialize_str(self.as_str())
108 }
109 }
110
111 impl<'de> serde::Deserialize<'de> for FleetRole {
112 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
113 where
114 D: serde::Deserializer<'de>,
115 {
116 let raw = String::deserialize(deserializer)?;
117 Self::from_str(&raw)
118 .ok_or_else(|| serde::de::Error::unknown_variant(&raw, &FLEET_ROLE_SCHEMA_VALUES))
119 }
120 }
121
122 /// Explicit boundary migration for pre-Fleet serialized role tokens.
123 ///
124 /// Call this only at load / parse edges. Runtime code must use Fleet role
125 /// names via [`FleetRole::as_str`]. Returns `None` for tokens that are
126 /// already canonical or unknown — callers should prefer [`FleetRole::from_str`]
127 /// for full acceptance (canonical + legacy).
128 #[must_use]
129 pub fn migrate_legacy_role_token(token: &str) -> Option<&'static str> {
130 match token.trim().to_ascii_lowercase().as_str() {
131 "worker" | "general-purpose" | "general_purpose" | "default" => Some("general"),
132 "scout" | "exploration" | "explorer" => Some("explore"),
133 "plan" | "planning" | "awaiter" => Some("planner"),
134 "review" | "code-review" | "code_review" => Some("reviewer"),
135 "builder" | "implementer" | "implementation" => Some("implement"),
136 "verifier" | "verify" | "verification" | "validator" | "tester" => Some("test"),
137 "consultant" | "oracle" => Some("advisor"),
138 _ => None,
139 }
140 }
141
142 impl FleetRole {
143 /// Parse a Fleet role from user input or a serialized boundary.
144 ///
145 /// Accepts Fleet role names and, at this parse boundary only, legacy
146 /// aliases (`scout` → explore, `plan` → planner, …).
147 #[must_use]
148 pub fn from_str(s: &str) -> Option<Self> {
149 let normalized = s.trim().to_ascii_lowercase();
150 // Boundary migration first, then canonical Fleet names.
151 let token = migrate_legacy_role_token(&normalized).unwrap_or(normalized.as_str());
152 match token {
153 "general" => Some(Self::Worker),
154 "explore" => Some(Self::Scout),
155 "planner" => Some(Self::Planner),
156 "reviewer" => Some(Self::Reviewer),
157 "implement" => Some(Self::Builder),
158 "test" => Some(Self::Verifier),
159 "advisor" => Some(Self::Consultant),
160 "custom" => Some(Self::Custom),
161 _ => None,
162 }
163 }
164
165 /// Canonical Fleet role label for runtime, schemas, prompts, receipts, UI.
166 #[must_use]
167 pub fn as_str(&self) -> &'static str {
168 match self {
169 Self::Worker => "general",
170 Self::Scout => "explore",
171 Self::Planner => "planner",
172 Self::Reviewer => "reviewer",
173 Self::Builder => "implement",
174 Self::Verifier => "test",
175 Self::Consultant => "advisor",
176 Self::Custom => "custom",
177 }
178 }
179
180 /// One-line model-facing description of what this role does. Backs the
181 /// role catalog returned by the agent roster action.
182 #[must_use]
183 pub fn description(&self) -> &'static str {
184 match self {
185 Self::Worker => "General-purpose worker with full tool access for multi-step tasks.",
186 Self::Scout => "Fast read-only exploration for codebase search and analysis.",
187 Self::Planner => {
188 "Grounded strategy: reads the workspace and the web, runs read-only probes, never mutates."
189 }
190 Self::Reviewer => "Reads and grades code against a standard.",
191 Self::Builder => {
192 "Lands focused code changes cleanly with the minimum surrounding edit."
193 }
194 Self::Verifier => {
195 "Runs the test suite and validation gates, reports pass/fail with evidence."
196 }
197 Self::Consultant => {
198 "Read-only high-reasoning counsel for judgement calls and design critique."
199 }
200 Self::Custom => "Custom tool access defined at spawn time by the parent's posture.",
201 }
202 }
203
204 /// All canonical roles in schema order, for catalog responses.
205 #[must_use]
206 pub fn all() -> [Self; 8] {
207 [
208 Self::Worker,
209 Self::Scout,
210 Self::Planner,
211 Self::Reviewer,
212 Self::Builder,
213 Self::Verifier,
214 Self::Consultant,
215 Self::Custom,
216 ]
217 }
218 }
219
220 /// Public label for any role token (canonical or legacy alias).
221 /// Canonical/legacy tokens collapse to the advertised name;
222 /// anything else passes through trimmed.
223 #[must_use]
224 pub fn public_role_label(token: &str) -> String {
225 FleetRole::from_str(token).map_or_else(
226 || token.trim().to_string(),
227 |role| role.as_str().to_string(),
228 )
229 }
230
231 // ── Child authority: the ceiling, as the child actually experiences it ───────
232
233 /// Tool names that give a model its own reach onto the network.
234 ///
235 /// `network_tool = false` must remove **all** of them from the child's
236 /// model-visible surface, not merely block them at call time — a model that can
237 /// see a tool will try it, and a refusal is a worse experience than an absent
238 /// capability. The child registry hides denied tools from
239 /// `tools_for_model` and refuses them in `is_tool_allowed`, so one deny list
240 /// covers both.
241 ///
242 /// The `mcp*` wildcards are load-bearing: a remote MCP server's tools arrive
243 /// under a runtime-generated name, so they cannot be enumerated here and must be
244 /// matched by prefix. `is_tool_denied` supports `prefix*` globs for exactly this.
245 pub(crate) const NETWORK_TOOL_DENYLIST: &[&str] = &[
246 // Web search / fetch / browse, and the canonical family that fronts them.
247 //
248 // The `Web` family name itself is deliberately NOT denied. Its `search`
249 // and `fetch` actions are the read-only web surface a network-denied
250 // member is entitled to (parity with an ordinary scout), and the family
251 // is classified read-only at the capability envelope, so removing the
252 // *name* from this list grants exactly those two actions and nothing
253 // else. What this list removes is every other spelling of the browsing
254 // surface: the separate `web.run` browse tool, the legacy `web_search` /
255 // `fetch_url` / `wait_for_dev_server` action aliases, and the `web_*` /
256 // `web.*` name families, so a deny list that stops at `Web` can never
257 // leave `web.run` visible and callable, which is the entire browsing
258 // capability by another spelling. The explicit names are kept because
259 // they document intent and because two of them (`fetch_url`,
260 // `wait_for_dev_server`) are not matched by either glob.
261 //
262 // The child registry's action seam (`SubAgentToolRegistry::is_action_allowed`)
263 // lets a network-denied child keep exactly `Web{search, fetch}` past the
264 // denied aliases, and the URL-input guard refuses a URL-addressed
265 // `fetch` at dispatch, so the reach stays closed.
266 "web_*",
267 "web.*",
268 "web.run",
269 "web_run",
270 "web_search",
271 "web.fetch",
272 "web_fetch",
273 "fetch_url",
274 "wait_for_dev_server",
275 "browse",
276 "browser",
277 // Networked service tools.
278 "github",
279 "finance",
280 // The RLM session family's two reaching actions.
281 //
282 // `rlm_open` accepts a `url` and fetches it by calling `FetchUrlTool`
283 // *in-process*, under its own name — so denying `fetch_url` never sees the
284 // call. `rlm_eval` runs operator-supplied Python against a live kernel,
285 // which owns a socket API no inspection of the *call* can bound.
286 //
287 // Both are denied outright rather than gated on the input. The narrower
288 // contract was considered and rejected: `rlm_open` chooses its source from
289 // *input fields* (`file_path` / `content` / `url` / `session_object`), not
290 // from the action name, and the action-policy seam
291 // ([`crate::tools::canonical_action`]) resolves names, not field shapes —
292 // it cannot prove a source is local before execution. So this fails closed.
293 // A network-denied member loses `rlm` loading and evaluation entirely,
294 // including the purely local `file_path` form, and keeps only the bounded
295 // metadata actions (`session_objects` / `configure` / `close`), which the
296 // per-action alias entries make expressible. See `docs/FLEET.md`.
297 "rlm_open",
298 "rlm_eval",
299 // Every MCP surface, including remote servers registered at runtime.
300 "mcp*",
301 "start_mcp_server",
302 "list_mcp_resources",
303 "list_mcp_resource_templates",
304 "read_mcp_resource",
305 ];
306
307 /// The deny-list entry that stands for "this child has no network".
308 ///
309 /// The deny list is how `network_tool = false` reaches a child across the
310 /// durable-Fleet and `codewhale exec` boundaries (through
311 /// `worker_profile.denied_tools` / `context.disallowed_tools`). `fetch_url` is
312 /// the sentinel because every network denial installs it and no narrower deny
313 /// list does — the `web_*` / `web.*` globs deliberately do not match it,
314 /// which is why it is spelled out above. The in-process child reads its
315 /// network axis off the resolved grant, which folds this sentinel in at
316 /// resolve time — so the transport denial and the semantic answer can never
317 /// disagree.
318 pub(crate) const NETWORK_DENIAL_SENTINEL: &str = "fetch_url";
319
320 /// Tool names that mutate the workspace directly.
321 ///
322 /// A member whose clamped ceiling says `write = false` must not merely be
323 /// *labelled* read-only — the mutating tools have to be gone from the surface
324 /// it can see and call. Only the action aliases are listed, never the `File`
325 /// family itself: denying `File` would take `read`/`list`/`search` with it, and
326 /// the registry already resolves `File{action:"write"}` through the alias table
327 /// to `write_file`, so denying the alias covers both spellings.
328 ///
329 /// `rlm_eval` is here for the same reason it is on the network list and not for
330 /// a different one: the Python it runs against a live kernel calls `open(...,
331 /// "w")` as readily as it opens a socket. It is a mutation primitive that
332 /// happens to be spelled as an analysis tool, and leaving it on a `write =
333 /// false` surface would let a read-only member rewrite the workspace while the
334 /// receipt said otherwise. The rest of the family — including the local
335 /// `file_path` load — survives a write denial, because reading a large file
336 /// into a kernel is exactly what a read-only member is for.
337 pub(crate) const MUTATING_TOOL_DENYLIST: &[&str] = &[
338 "write_file",
339 "edit_file",
340 "apply_patch",
341 "fim_edit",
342 "revert_turn",
343 "rlm_eval",
344 ];
345
346 /// The raw shell surface — arbitrary operator-supplied commands.
347 ///
348 /// A read-only member with `shell = "full"` is the honest-labelling problem
349 /// this list exists for. `full` was saved so the member could *run checks*, but
350 /// raw shell is a general mutation primitive: `rm`, `git checkout`, or a `>`
351 /// redirect writes the workspace just as surely as `write_file`, while the
352 /// receipt says `write=false`. Denying the raw shell entries and leaving the
353 /// bounded verification surface (`Run` / `run_tests` / `run_verifiers`) intact
354 /// keeps the verifier able to do its job under a contract that is true.
355 /// Scout/reviewer read-only inspection selectively removes only canonical `Bash` from this
356 /// deny list after the role is known; its input-specific read-only classifier
357 /// remains the authority for that narrow exception.
358 ///
359 /// That surface is bounded only in its **default** form, and the distinction is
360 /// load-bearing: `run_verifiers` accepts a `commands` array of arbitrary
361 /// `program` + `args` pairs, and `run_tests` accepts a raw `args` string. Either
362 /// one is a general command primitive by another name — `{"program": "bash",
363 /// "args": ["-lc", "..."]}` is precisely the raw shell this list just removed.
364 /// Denying the tools outright would take the verifier's whole purpose with
365 /// them, so the *unbounded arguments* are refused at the execution seam
366 /// instead; see `reject_unbounded_verification` in
367 /// [`crate::tools::subagent`]. The name deny list and that guard are one
368 /// contract split across the only two places that can each see half of it.
369 pub(crate) const RAW_SHELL_DENYLIST: &[&str] = &[
370 "Bash",
371 "exec_shell",
372 "exec_shell_wait",
373 "exec_wait",
374 "exec_shell_interact",
375 "exec_interact",
376 "exec_shell_cancel",
377 "task_shell_start",
378 "task_shell_wait",
379 // The persistent PTY surface registers as `terminal/run`, `terminal/send`,
380 // … — a glob, because the family is open-ended and every member of it is a
381 // raw command channel.
382 "terminal/*",
383 ];
384
385 /// The deny-list entry that stands for "this child has no raw shell".
386 ///
387 /// Same construction as [`NETWORK_DENIAL_SENTINEL`], and for the same reason:
388 /// posture is read back off the list that enforces it rather than carried as a
389 /// second field that could disagree. `exec_shell` is the sentinel because every
390 /// raw-shell denial installs it and no narrower deny list does.
391 ///
392 /// Read by the tests that assert the raw-shell denial actually landed. It is
393 /// deliberately *not* what decides a child's shell authority — that question
394 /// is answered by the resolved grant's `shell` axis, which distinguishes
395 /// "no process surface" from "bounded verification surface" by grant field
396 /// rather than by a sentinel name.
397 #[cfg_attr(not(test), expect(dead_code))]
398 pub(crate) const RAW_SHELL_SENTINEL: &str = "exec_shell";
399
400 /// The built-in verification surface: the workspace's own configured checks.
401 ///
402 /// Bounded in its arguments (see [`crate::tools::execution_envelope`]) but not
403 /// free of consequence — every entry forks a process. A member whose shell
404 /// ceiling is narrower than `full` holds no authority to start one, so this
405 /// list comes off its surface entirely. A `write = false, shell = "full"`
406 /// member keeps it, because running the checks is what that preset is for.
407 pub(crate) const VERIFICATION_SURFACE_DENYLIST: &[&str] = &["Run", "run_tests", "run_verifiers"];
408
409 /// The deny-list entry that stands for "this child holds no shell authority".
410 ///
411 /// Distinct from [`RAW_SHELL_SENTINEL`], and the distinction is the point.
412 /// `exec_shell` is installed whenever the *raw* shell is removed, which
413 /// includes the write-denied verifier that still holds shell authority — so
414 /// reading shell authority off it would take the verification surface away
415 /// from the one role that exists to use it. `run_tests` is installed only
416 /// when the shell *ceiling* itself is narrower than `full`, which is exactly
417 /// the posture that has no authority to start a process. The grant folds
418 /// this sentinel into its `shell` axis at resolve time: `Verify`/`Full`
419 /// collapse to `None`, while `Inspect` — classifier-bounded evidence reads,
420 /// not process-start authority — survives.
421 pub(crate) const SHELL_AUTHORITY_SENTINEL: &str = "run_tests";
422
423 /// Execution primitives that are **not** spelled as shell.
424 ///
425 /// Every entry runs an operator-supplied program or schedules one: `gate_run`
426 /// takes a command line, the mutating `automation` actions execute or schedule
427 /// a stored automation with its own cwd and prompt, `start_mcp_server` spawns a
428 /// process, and `pr_attempt_*` writes durable work state. They are listed here
429 /// so a write-denied child never *sees* them; the authoritative refusal is
430 /// capability-derived and lives in [`crate::tools::execution_envelope`], which
431 /// also covers the ones no list can name — repository plugin tools and MCP
432 /// server tools registered at runtime.
433 ///
434 /// Listing the per-action alias rather than the family is deliberate and is
435 /// what the canonical-action seam exists for: denying `tasks` outright would
436 /// take `list`/`read` with it, and durable-task bookkeeping is exactly what a
437 /// read-only member should keep.
438 pub(crate) const NON_SHELL_EXECUTION_DENYLIST: &[&str] = &[
439 "task_gate_run",
440 "task_create",
441 "task_cancel",
442 "pr_attempt_record",
443 "pr_attempt_preflight",
444 "automation_run",
445 "automation_create",
446 "automation_update",
447 "automation_pause",
448 "automation_resume",
449 "automation_delete",
450 "start_mcp_server",
451 ];
452
453 /// Whether a deny rule belongs to an enforced role posture. Operator and
454 /// ancestor denials are also immutable; this identifies posture rules for
455 /// authority diagnostics and tests, not a child opt-out exception.
456 #[cfg(test)]
457 #[must_use]
458 pub(crate) fn is_posture_denial(rule: &str) -> bool {
459 [
460 NETWORK_TOOL_DENYLIST,
461 MUTATING_TOOL_DENYLIST,
462 RAW_SHELL_DENYLIST,
463 VERIFICATION_SURFACE_DENYLIST,
464 NON_SHELL_EXECUTION_DENYLIST,
465 ]
466 .iter()
467 .flat_map(|list| list.iter())
468 .any(|entry| entry.eq_ignore_ascii_case(rule.trim()))
469 }
470
471 /// A Runtime role policy intersected with the live parent and translated into
472 /// the concrete knobs a child spawn actually carries.
473 #[derive(Debug, Clone, PartialEq, Eq)]
474 pub(crate) struct ChildAuthority {
475 /// The clamped ceiling. Never wider than either input.
476 pub(crate) ceiling: PermissionCeiling,
477 /// `Some(list)` narrows the child's model-visible surface to exactly
478 /// `list`. `Some(vec![])` — the `tools = false` case — means *no tools at
479 /// all*, which is what the child registry's empty-allowlist path produces.
480 /// `None` means full inheritance from the parent surface.
481 pub(crate) allowed_tools: Option<Vec<String>>,
482 /// Names/globs the child must never see or call. Deny wins over allow.
483 pub(crate) disallowed_tools: Vec<String>,
484 /// Spawn write authority implied by the clamped ceiling.
485 pub(crate) write_authority: &'static str,
486 /// Nested-delegation budget, clamped.
487 pub(crate) max_depth: u32,
488 /// Canonical posture role that governs the child's tool posture.
489 pub(crate) posture_role: &'static str,
490 }
491
492 impl ChildAuthority {
493 /// Intersect one Runtime-requested posture with the live parent posture.
494 ///
495 /// Every field takes the more restrictive side, so a child can never widen
496 /// live authority.
497 #[must_use]
498 pub(crate) fn clamp(requested: PermissionCeiling, session: PermissionCeiling) -> Self {
499 let ceiling = requested.clamp_to(session);
500
501 // `tools = false` is total: an empty allowlist leaves the child with no
502 // model-visible tools and nothing it is permitted to call.
503 let allowed_tools = (!ceiling.tools).then(Vec::new);
504
505 // The deny list expresses the effective Runtime posture. The spawn
506 // registry separately unions it with inherited parent restrictions, so
507 // a descendant can never drop something an ancestor
508 // imposed.
509 let mut disallowed_tools = Vec::new();
510 if !ceiling.network_tool {
511 disallowed_tools.extend(NETWORK_TOOL_DENYLIST.iter().map(|name| (*name).to_string()));
512 }
513 // Raw shell requires the ceiling to *say* `shell = "full"`. Any narrower
514 // shell posture — `none` or `read_only` — loses the raw command surface
515 // outright. `from_runtime_role` may subsequently
516 // retain canonical `Bash` for a named scout/reviewer, whose concrete
517 // calls are bounded by the strict read-only classifier.
518 //
519 // This is deliberately keyed on the shell field rather than only on
520 // `write`, and that is the whole repair: the execution envelope reads
521 // its `shell` bit back off this deny list
522 // ([`RAW_SHELL_SENTINEL`]), so a ceiling whose shell posture never
523 // installed a denial was invisible to it. A clamped ceiling of
524 // `write = true, shell = none` — which any write-capable member inherits
525 // inside a session that has no shell authority — therefore reached the
526 // envelope claiming full shell authority and could start a process the
527 // ceiling had refused it.
528 if !(ceiling.write && ceiling.shell == ShellCeiling::Full) {
529 disallowed_tools.extend(RAW_SHELL_DENYLIST.iter().map(|name| (*name).to_string()));
530 }
531 // Losing the *raw* shell and holding no shell authority at all are two
532 // different postures, and only the second one loses the bounded
533 // verification surface.
534 //
535 // A `verifier`/`tester` member (`write = false, shell = "full"`) is the
536 // case that separates them: the rule above takes its raw shell away as
537 // a mutation control, but the member still holds shell authority and
538 // running the workspace's own checks is its entire purpose. A ceiling
539 // whose shell posture is narrower than `full` holds no such authority,
540 // so for it the checks are just another way to start a process.
541 if ceiling.shell != ShellCeiling::Full {
542 disallowed_tools.extend(
543 VERIFICATION_SURFACE_DENYLIST
544 .iter()
545 .map(|name| (*name).to_string()),
546 );
547 }
548 if !ceiling.write {
549 // `write = false` has to be a fact about the child's tool surface,
550 // not a word on a receipt, so the mutating file tools go. The raw
551 // shell is already gone by the rule above; a Runtime scout/reviewer
552 // may regain only canonical Bash in `from_runtime_role`, behind its
553 // input-specific read-only classifier. The bounded verification
554 // surface (`Run` / `run_tests` / `run_verifiers`) is deliberately
555 // left for a full-shell verifier.
556 disallowed_tools.extend(
557 MUTATING_TOOL_DENYLIST
558 .iter()
559 .map(|name| (*name).to_string()),
560 );
561 // Removing the shell is not enough on its own. An execution
562 // primitive spelled as bookkeeping — a verification gate that takes
563 // a command line, an automation that runs one on a schedule, an MCP
564 // server that spawns a process — mutates the workspace exactly as
565 // well as the shell just removed, while the receipt says
566 // `write=false`. These names take them off the visible surface;
567 // `crate::tools::execution_envelope` refuses them by capability,
568 // including the ones no list can name.
569 disallowed_tools.extend(
570 NON_SHELL_EXECUTION_DENYLIST
571 .iter()
572 .map(|name| (*name).to_string()),
573 );
574 }
575
576 Self {
577 ceiling,
578 allowed_tools,
579 disallowed_tools,
580 write_authority: if ceiling.write {
581 "workspace_write"
582 } else {
583 "read_only"
584 },
585 max_depth: ceiling.delegation_depth,
586 posture_role: posture_role_for(ceiling),
587 }
588 }
589
590 /// A stable, content-free fingerprint of the envelope this authority
591 /// actually installs.
592 ///
593 /// This is the value that turns "the Fleet computed a ceiling" into
594 /// something a later layer can *check*. It covers every field a spawn
595 /// carries — allowlist, deny list, write authority, delegation budget, and
596 /// posture role — so a request that drifted between admission, routing, and
597 /// construction cannot pass for the one the Fleet resolved. Two authorities
598 /// with the same fingerprint install the same child surface; that is the
599 /// whole contract.
600 ///
601 /// Deliberately human-readable rather than hashed: it appears verbatim in
602 /// the fail-closed error, and an operator debugging a refused launch should
603 /// be able to see which side differs without a lookup table.
604 #[must_use]
605 pub(crate) fn fingerprint(&self) -> String {
606 let allowed = match &self.allowed_tools {
607 None => "inherit".to_string(),
608 Some(list) if list.is_empty() => "none".to_string(),
609 Some(list) => {
610 let mut list = list.clone();
611 list.sort();
612 list.join(",")
613 }
614 };
615 let mut denied = self.disallowed_tools.clone();
616 denied.sort();
617 denied.dedup();
618 format!(
619 "v1;posture={};write={};depth={};tools={};network={};shell={};allow={};deny={}",
620 self.posture_role,
621 self.write_authority,
622 self.max_depth,
623 self.ceiling.tools,
624 self.ceiling.network_tool,
625 self.ceiling.shell.as_str(),
626 allowed,
627 denied.join(","),
628 )
629 }
630
631 /// Derive authority exclusively from Runtime policy after Fleet identity
632 /// selection. Free-form semantic roles map to Runtime `custom`; neither
633 /// the Fleet definition nor its legacy `permissions` key participates.
634 #[must_use]
635 pub(crate) fn from_runtime_role(role: &str, session: PermissionCeiling) -> Self {
636 let runtime_role = runtime_role_for_member(role);
637 let requested = runtime_permission_ceiling(&runtime_role);
638 let mut authority = Self::clamp(requested, session);
639 authority.posture_role = runtime_role.as_str();
640
641 if matches!(
642 runtime_role,
643 FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner
644 ) && authority.ceiling.shell != ShellCeiling::None
645 {
646 // Runtime's Scout/Reviewer policy permits classifier-bounded Bash
647 // inspection. Keep the canonical entry while all other shell and
648 // execution aliases remain denied.
649 authority
650 .disallowed_tools
651 .retain(|name| !name.eq_ignore_ascii_case("Bash"));
652 }
653 authority
654 }
655 }
656
657 /// Org-chart aliases for Fleet **member** role labels: roster slot names and
658 /// coordination titles that select a runtime posture but are not part of the
659 /// model-facing spawn vocabulary.
660 ///
661 /// Deliberately *not* folded into [`migrate_legacy_role_token`]. That table
662 /// feeds [`FleetRole::from_str`], which is also the closed vocabulary the
663 /// `agent` tool validates its `type` against, and — decisively — the test the
664 /// spawn parser uses to decide whether a `role` string is a posture alias or a
665 /// roster profile key. Teaching `from_str` about `manager` or `smoke-runner`
666 /// would stop those names resolving as roster members there. So the aliases
667 /// live here, one step out, and reach exactly the one consumer that wants
668 /// them: [`runtime_role_for_member`].
669 ///
670 /// Every entry maps to the posture the durable Fleet driver already gave it,
671 /// so folding them in only ever *narrows* the exact driver (which previously
672 /// dropped all of these into write-capable `custom`); none of them widens
673 /// authority on either path.
674 fn member_role_alias(token: &str) -> Option<FleetRole> {
675 match token {
676 // Coordination happens through delegation, which needs the full
677 // General surface (#fleet-roster cutover (v0.8.67)). The operator is
678 // the helm of the overall work (it assigns managers to Workflows);
679 // the manager is the middle manager of one Workflow. Both coordinate,
680 // so both get the General surface — explicitly, not by fall-through.
681 "manager" | "coordinator" | "operator" => Some(FleetRole::Worker),
682 // Synthesis is read-only (planner posture: network reads and
683 // read-only probes, never workspace writes). It must never fall
684 // through to General's full-write posture (#fleet-roster cutover
685 // (v0.8.67)).
686 "synthesizer" | "summarizer" | "reducer" => Some(FleetRole::Planner),
687 // Documented Fleet task role spellings (`docs/FLEET.md`).
688 "smoke-runner" => Some(FleetRole::Verifier),
689 "read-only" => Some(FleetRole::Scout),
690 _ => None,
691 }
692 }
693
694 /// Map the Fleet's open semantic role label onto Runtime's closed role policy.
695 ///
696 /// **This is the only name → posture mapper.** Both Fleet drivers resolve
697 /// through it: the exact/named-Fleet driver via
698 /// [`ChildAuthority::from_runtime_role`], and the durable driver via
699 /// `worker_runtime::roster_member_agent_type` and the task-role call sites.
700 /// A second table anywhere is the defect this function exists to prevent —
701 /// before #5575 the durable driver carried its own alias list, so the same
702 /// string (`synthesizer`, `smoke-runner`, `read-only`) resolved to a
703 /// read-only posture on one driver and to write-capable `custom` on the other.
704 ///
705 /// Resolution order, and nothing else:
706 /// 1. an empty label means *unspecified*, which is the documented general
707 /// default a Fleet task without a `role` has always had;
708 /// 2. [`FleetRole::from_str`] — the declared closed vocabulary plus its
709 /// documented legacy aliases ([`VALID_ROLE_ALIASES`]);
710 /// 3. [`member_role_alias`] — the org-chart/slot spellings above;
711 /// 4. **fail closed.**
712 ///
713 /// Step 4 is the privilege boundary. An unrecognized label is still useful
714 /// identity (`auditor`, `release-lead`, …) and keeps its name on every receipt
715 /// via [`public_role_label`], but a name nobody declared must not be able to
716 /// hand a worker write authority. It therefore executes on the narrowest
717 /// posture that can still do useful work — `explore`: no workspace writes, no
718 /// raw shell, network reads and classifier-bounded `Bash` inspection. An
719 /// operator who genuinely wants "inherit whatever the parent has" spells that
720 /// `custom`, which is a declared role and resolves at step 2.
721 pub(crate) fn runtime_role_for_member(role: &str) -> FleetRole {
722 let token = role.trim().to_ascii_lowercase();
723 if token.is_empty() {
724 return FleetRole::Worker;
725 }
726 FleetRole::from_str(&token)
727 .or_else(|| member_role_alias(&token))
728 .unwrap_or(FleetRole::Scout)
729 }
730
731 fn runtime_permission_ceiling(role: &FleetRole) -> PermissionCeiling {
732 let profile = WorkerRuntimeProfile::for_role(role.clone());
733 let shell = match profile.shell {
734 ShellPolicy::None => ShellCeiling::None,
735 ShellPolicy::ReadOnly => ShellCeiling::ReadOnly,
736 ShellPolicy::Full => ShellCeiling::Full,
737 };
738 let tools = match profile.tools {
739 ToolScope::Inherit => true,
740 ToolScope::Explicit(ref tools) => !tools.is_empty(),
741 };
742 PermissionCeiling {
743 write: profile.permissions.write,
744 network_tool: profile.permissions.network,
745 shell,
746 delegation_depth: profile.remaining_spawn_depth(),
747 tools,
748 }
749 }
750
751 /// Intersect a worker shell policy with the session's legacy shell opt-in.
752 #[must_use]
753 pub(crate) fn session_shell_ceiling(shell: ShellPolicy, allow_shell: bool) -> ShellCeiling {
754 match shell {
755 ShellPolicy::None => ShellCeiling::None,
756 ShellPolicy::ReadOnly => ShellCeiling::ReadOnly,
757 ShellPolicy::Full if allow_shell => ShellCeiling::Full,
758 ShellPolicy::Full => ShellCeiling::None,
759 }
760 }
761
762 /// Map a permission ceiling onto the canonical posture role that governs the
763 /// child's tool surface.
764 #[must_use]
765 pub(crate) fn posture_role_for(ceiling: PermissionCeiling) -> &'static str {
766 if !ceiling.tools {
767 // No tools at all; the narrowest posture, and the allowlist is empty
768 // anyway.
769 return "explore";
770 }
771 if ceiling.write {
772 return "implement";
773 }
774 match ceiling.shell {
775 ShellCeiling::None | ShellCeiling::ReadOnly => "explore",
776 ShellCeiling::Full => "test",
777 }
778 }
779
780 /// Whether a Fleet role is never allowed a mutating shell, whatever its
781 /// requested runtime profile says. Spawn narrows the child to a read-only
782 /// shell for these roles, and every receipt must report that same posture.
783 #[must_use]
784 pub(crate) fn role_requires_read_only_shell(role: &FleetRole) -> bool {
785 matches!(
786 role,
787 FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner
788 )
789 }
790
791 /// The runtime profile a worker of `role` actually runs under: the requested
792 /// profile with the shell narrowed for read-only roles. Receipts and headers
793 /// derive from this, never from the requested profile alone (#5542 review).
794 #[must_use]
795 pub(crate) fn effective_runtime_profile_for_role(
796 role: &FleetRole,
797 requested: &WorkerRuntimeProfile,
798 ) -> WorkerRuntimeProfile {
799 let mut effective = requested.clone();
800 if role_requires_read_only_shell(role) && effective.shell.allows_shell() {
801 effective.shell = ShellPolicy::ReadOnly;
802 }
803 effective
804 }
805
806 fn shell_policy_label(shell: ShellPolicy) -> &'static str {
807 match shell {
808 ShellPolicy::None => "none",
809 ShellPolicy::ReadOnly => "read_only",
810 ShellPolicy::Full => "full",
811 }
812 }
813
814 fn tool_scope_label(tools: &ToolScope) -> &'static str {
815 match tools {
816 ToolScope::Inherit => "inherit",
817 ToolScope::Explicit(_) => "explicit",
818 }
819 }
820
821 /// Effective non-secret runtime permissions for a worker of `role` running
822 /// under `requested`. This is the single posture truth for both in-process
823 /// sub-agent snapshots and durable Fleet receipts: the requested profile with
824 /// the shell narrowed for read-only roles. `profile_id` / `profile_origin`
825 /// identify a saved Fleet member when one selected the role; `None` for
826 /// direct role dispatches.
827 #[must_use]
828 pub(crate) fn fleet_effective_permissions(
829 role: &FleetRole,
830 requested: &WorkerRuntimeProfile,
831 profile_id: Option<&str>,
832 profile_origin: Option<&str>,
833 ) -> FleetEffectivePermissions {
834 let profile = effective_runtime_profile_for_role(role, requested);
835 FleetEffectivePermissions {
836 write: profile.permissions.write,
837 network: profile.permissions.network,
838 shell: shell_policy_label(profile.shell).to_string(),
839 tool_scope: tool_scope_label(&profile.tools).to_string(),
840 tools: match &profile.tools {
841 ToolScope::Inherit => Vec::new(),
842 ToolScope::Explicit(tools) => tools.clone(),
843 },
844 background: profile.background,
845 max_spawn_depth: profile.max_spawn_depth,
846 profile_id: profile_id.map(str::to_string),
847 profile_origin: profile_origin.map(str::to_string),
848 source: "worker_runtime_profile".to_string(),
849 }
850 }
851
851 lines RUST