| 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 |