| 1 | //! Worker runtime profile — the per-role capability contract for a CodeWhale |
| 2 | //! worker (#3217, #3211, #3213, and the child-permission-intersection issues |
| 3 | //! #414 / #426 / #1186). |
| 4 | //! |
| 5 | //! This is the **Workflow substrate**: every detached worker — whether launched |
| 6 | //! as an `agent` sub-agent or a Fleet worker — should run under a profile |
| 7 | //! that bounds what it may do (permissions, shell access, tool scope, model |
| 8 | //! route, recursion budget, foreground/background). A child profile is always |
| 9 | //! **derived** from its parent and can never escalate beyond it. |
| 10 | //! |
| 11 | //! Scope: this module defines the contract and the parent→child derivation with |
| 12 | //! tests. `agent` and Fleet worker records now build and persist these |
| 13 | //! profiles so parent-visible worker projections have a single capability |
| 14 | //! contract. Runtime enforcement of every declared field remains incremental |
| 15 | //! follow-up work (#3217). |
| 16 | |
| 17 | #![allow(dead_code)] // foundation: consumers are wired in a follow-up (#3217). |
| 18 | |
| 19 | use crate::fleet::role::FleetRole; |
| 20 | use serde::{Deserialize, Serialize}; |
| 21 | |
| 22 | /// Coarse capability classes a worker may exercise, beyond read access (reads |
| 23 | /// are always permitted). A child may only ever hold a *subset* of its parent's |
| 24 | /// capabilities. |
| 25 | #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] |
| 26 | pub struct PermissionSet { |
| 27 | /// May modify the workspace (`write_file` / `edit_file` / `apply_patch`). |
| 28 | pub write: bool, |
| 29 | /// May use network-capable tools (web search/fetch, networked MCP servers). |
| 30 | pub network: bool, |
| 31 | } |
| 32 | |
| 33 | impl PermissionSet { |
| 34 | /// Full capabilities (write + network). |
| 35 | pub const fn full() -> Self { |
| 36 | Self { |
| 37 | write: true, |
| 38 | network: true, |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | /// Read-only: no write, no network. |
| 43 | pub const fn read_only() -> Self { |
| 44 | Self { |
| 45 | write: false, |
| 46 | network: false, |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | /// Read-only inspection: read-only on the workspace, but network-capable. |
| 51 | /// |
| 52 | /// The read-only investigator posture (scout/reviewer): it must not |
| 53 | /// mutate the workspace, but real read-only inspection needs |
| 54 | /// `git`/`gh`/web reach — the old `read_only()` default left such lanes |
| 55 | /// with no way to run any command or reach any remote, which made default |
| 56 | /// scout lanes useless for the inspection they exist for. |
| 57 | pub const fn read_only_with_network() -> Self { |
| 58 | Self { |
| 59 | write: false, |
| 60 | network: true, |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | /// Intersection: a capability is granted only if **both** sets grant it. |
| 65 | /// This is the core non-escalation primitive — `parent.intersect(child)` |
| 66 | /// can never produce a capability the parent lacks. |
| 67 | #[must_use] |
| 68 | pub fn intersect(self, other: Self) -> Self { |
| 69 | Self { |
| 70 | write: self.write && other.write, |
| 71 | network: self.network && other.network, |
| 72 | } |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | /// Shell access policy — the replacement for the legacy per-worker shell boolean |
| 77 | /// (#3217). Ordered from most to least restrictive so `min` yields the safer of |
| 78 | /// two policies. |
| 79 | #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] |
| 80 | #[serde(rename_all = "snake_case")] |
| 81 | pub enum ShellPolicy { |
| 82 | /// No shell access. |
| 83 | None, |
| 84 | /// Read-only / non-mutating commands only (the policy enforcement lives in |
| 85 | /// the exec/sandbox layer; this is the declared intent). |
| 86 | ReadOnly, |
| 87 | /// Full shell access. |
| 88 | Full, |
| 89 | } |
| 90 | |
| 91 | impl ShellPolicy { |
| 92 | /// Convert the legacy top-level shell opt-in into the typed shell policy. |
| 93 | #[must_use] |
| 94 | pub const fn from_legacy_allow_shell(allow_shell: bool) -> Self { |
| 95 | if allow_shell { Self::Full } else { Self::None } |
| 96 | } |
| 97 | |
| 98 | /// Whether any shell tools should be exposed under this policy. |
| 99 | #[must_use] |
| 100 | pub const fn allows_shell(self) -> bool { |
| 101 | !matches!(self, Self::None) |
| 102 | } |
| 103 | |
| 104 | /// The more restrictive (safer) of two policies. A child can never exceed |
| 105 | /// its parent's shell policy. |
| 106 | #[must_use] |
| 107 | pub fn min_with(self, other: Self) -> Self { |
| 108 | if self <= other { self } else { other } |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | /// Which tools a worker may call. Mirrors the existing `AgentWorkerToolProfile` |
| 113 | /// (`Inherited` / `Explicit`) so the two can be reconciled when this is wired in. |
| 114 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] |
| 115 | #[serde(rename_all = "snake_case")] |
| 116 | pub enum ToolScope { |
| 117 | /// Inherit the parent's tool surface. |
| 118 | Inherit, |
| 119 | /// Only the explicitly listed tool names. |
| 120 | Explicit(Vec<String>), |
| 121 | } |
| 122 | |
| 123 | /// File-system authority axis of a [`ChildGrant`]. Ordered least to most |
| 124 | /// permissive so `Ord::min` is the non-escalation primitive. |
| 125 | /// |
| 126 | /// `None` is not "no writes" — it is "no file access of any kind", which is |
| 127 | /// what a child with an empty tool surface experiences. `Read` permits |
| 128 | /// evidence collection only; `Write` permits mutation inside the session's |
| 129 | /// write policy (workspace boundary, exact-file claims, approval posture). |
| 130 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] |
| 131 | pub enum FileGrant { |
| 132 | None, |
| 133 | Read, |
| 134 | Write, |
| 135 | } |
| 136 | |
| 137 | /// Process authority axis of a [`ChildGrant`]. Ordered least to most |
| 138 | /// permissive so `Ord::min` is the non-escalation primitive. |
| 139 | /// |
| 140 | /// `Inspect` is the read-only shell: canonical `bash` only, and only calls |
| 141 | /// the agent read-only classifier proves mutation-free (the explore / |
| 142 | /// reviewer / planner posture). `Verify` is the bounded built-in verification |
| 143 | /// surface — default workspace checks, pure test selection, and bounded Git |
| 144 | /// fetch/merge-tree — with no shell grammar (the test/verifier posture). A |
| 145 | /// child asking for a surface its parent does not hold degrades to the |
| 146 | /// narrower side (`Inspect ∩ Verify = Inspect`): bounded inspection is a |
| 147 | /// subset of bounded process authority, never a widening. |
| 148 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] |
| 149 | pub enum ShellGrant { |
| 150 | None, |
| 151 | Inspect, |
| 152 | Verify, |
| 153 | Full, |
| 154 | } |
| 155 | |
| 156 | /// The named tool surface a grant exposes, before the explicit scope narrows |
| 157 | /// it. This is the role-preset axis of `tools` — distinct from |
| 158 | /// [`ChildGrant::scope`], which is the caller's own allowlist. |
| 159 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 160 | pub enum ToolSurface { |
| 161 | /// Every registered tool the grant's other axes admit. |
| 162 | Inherited, |
| 163 | /// The read-only evidence surface — `readonly_evidence_tool` plus |
| 164 | /// `agent` (delegation). The explore / reviewer preset. |
| 165 | Evidence, |
| 166 | } |
| 167 | |
| 168 | /// The single authority object a delegated child runs under (#5633). |
| 169 | /// |
| 170 | /// Roles are *presets over this object*, never a second permission system: |
| 171 | /// [`ChildGrant::for_role`] gives the widest grant a role can hold, and |
| 172 | /// [`ChildGrant::resolve`] intersects it with the effective parent-derived |
| 173 | /// profile and the caller's explicit scope. One projection serves every |
| 174 | /// consumer — the child's tool catalog, its dispatch refusals, and its |
| 175 | /// capability envelope all read the same fields, so a tool that is visible |
| 176 | /// is callable and a tool that is denied never appears. |
| 177 | /// |
| 178 | /// This is deliberately a *projection*, not a second persisted record: |
| 179 | /// [`WorkerRuntimeProfile`] remains the wire/persistence shape and |
| 180 | /// [`ChildAuthority`](crate::fleet::role::ChildAuthority) / |
| 181 | /// [`ToolAuthorityEnvelope`](crate::tools::spec::ToolAuthorityEnvelope) |
| 182 | /// remain the transports that carry the same authority across the durable |
| 183 | /// Fleet and `codewhale exec` subprocess boundaries. What this object retires |
| 184 | /// is the *semantic* duplication — posture sentinel entries read back off a |
| 185 | /// deny list, and role-keyed re-derivation inside the registry. |
| 186 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 187 | pub struct ChildGrant { |
| 188 | /// File-system authority. |
| 189 | pub files: FileGrant, |
| 190 | /// Process authority. |
| 191 | pub shell: ShellGrant, |
| 192 | /// May reach the network through any surface. |
| 193 | pub network: bool, |
| 194 | /// May drive the operator's machine (computer-use / desktop tools). |
| 195 | /// Children never hold this today; the field is declared so every |
| 196 | /// surface reports the same denial rather than implying a grant. |
| 197 | pub desktop: bool, |
| 198 | /// The named tool surface before explicit scope narrowing. |
| 199 | pub surface: ToolSurface, |
| 200 | /// The caller's explicit allowlist, already intersected with the parent's |
| 201 | /// scope at spawn. `Some(vec![])` — the `tools = false` posture — permits |
| 202 | /// nothing at all. |
| 203 | pub scope: Option<Vec<String>>, |
| 204 | /// May delegate children of its own (remaining spawn depth). |
| 205 | pub spawn: bool, |
| 206 | } |
| 207 | |
| 208 | impl ChildGrant { |
| 209 | /// The widest grant a role can ever hold — the role preset. |
| 210 | /// |
| 211 | /// This is the role-to-authority table, expressed once: everything else |
| 212 | /// only narrows it. Read-only roles stay read-only on the workspace by |
| 213 | /// intent; network reach is a read, and a worker cut off from it for no |
| 214 | /// role reason cannot do its job. `desktop` is never in any preset. |
| 215 | #[must_use] |
| 216 | pub fn for_role(role: &FleetRole) -> Self { |
| 217 | let (files, shell, surface) = match role { |
| 218 | // Read-only investigators: evidence collection only, with the |
| 219 | // classifier-bounded inspection shell. |
| 220 | FleetRole::Scout | FleetRole::Reviewer => { |
| 221 | (FileGrant::Read, ShellGrant::Inspect, ToolSurface::Evidence) |
| 222 | } |
| 223 | // Planner: analysis only, same bounded shell, broader read |
| 224 | // surface — a plan may consult any read the session offers. |
| 225 | FleetRole::Planner => (FileGrant::Read, ShellGrant::Inspect, ToolSurface::Inherited), |
| 226 | // Counsel only: reads to ground advice, never acts — no process |
| 227 | // surface at all (#4752). |
| 228 | FleetRole::Consultant => (FileGrant::Read, ShellGrant::None, ToolSurface::Inherited), |
| 229 | // Verifier: bounded workspace checks and bounded Git reference |
| 230 | // reads, no shell grammar. |
| 231 | FleetRole::Verifier => (FileGrant::Read, ShellGrant::Verify, ToolSurface::Inherited), |
| 232 | // Doers, and Custom: the preset asks for everything; the parent |
| 233 | // profile and explicit scope do all the narrowing. |
| 234 | FleetRole::Builder | FleetRole::Worker | FleetRole::Custom => { |
| 235 | (FileGrant::Write, ShellGrant::Full, ToolSurface::Inherited) |
| 236 | } |
| 237 | }; |
| 238 | Self { |
| 239 | files, |
| 240 | shell, |
| 241 | network: true, |
| 242 | desktop: false, |
| 243 | surface, |
| 244 | scope: None, |
| 245 | spawn: true, |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | /// Resolve the grant a child actually runs under: the role preset |
| 250 | /// intersected with the effective parent-derived profile, carrying the |
| 251 | /// caller's explicit scope and the remaining delegation depth. |
| 252 | /// |
| 253 | /// Every field takes the more restrictive side, so the result can never |
| 254 | /// name authority the parent lacked. This is the in-process counterpart |
| 255 | /// of [`crate::fleet::role::ChildAuthority::clamp`]. |
| 256 | #[must_use] |
| 257 | pub fn resolve( |
| 258 | role: &FleetRole, |
| 259 | profile: &WorkerRuntimeProfile, |
| 260 | scope: Option<Vec<String>>, |
| 261 | can_spawn: bool, |
| 262 | ) -> Self { |
| 263 | let preset = Self::for_role(role); |
| 264 | let profile_shell = match profile.shell { |
| 265 | ShellPolicy::None => ShellGrant::None, |
| 266 | ShellPolicy::ReadOnly => ShellGrant::Inspect, |
| 267 | ShellPolicy::Full => ShellGrant::Full, |
| 268 | }; |
| 269 | // The posture deny lists are the transport the ceiling clamps install; |
| 270 | // the grant folds their sentinels into the semantic axes so the two |
| 271 | // can never disagree. `fetch_url` stands for "no network"; `run_tests` |
| 272 | // stands for "no shell authority at all" — it removes process-start |
| 273 | // grants (`Verify`/`Full` → `None`) while `Inspect` survives, because |
| 274 | // classifier-bounded evidence reads were never shell authority. |
| 275 | // `exec_shell` (raw shell gone) is deliberately *not* folded: a |
| 276 | // write-denied verifier installs it while keeping shell authority. |
| 277 | let denied = |name: &str| { |
| 278 | crate::core::engine::tool_catalog::tool_denied( |
| 279 | Some(profile.denied_tools.as_slice()), |
| 280 | name, |
| 281 | ) |
| 282 | }; |
| 283 | let resolved_shell = preset.shell.min(profile_shell); |
| 284 | let shell = if denied(crate::fleet::role::SHELL_AUTHORITY_SENTINEL) { |
| 285 | match resolved_shell { |
| 286 | ShellGrant::Inspect => ShellGrant::Inspect, |
| 287 | _ => ShellGrant::None, |
| 288 | } |
| 289 | } else { |
| 290 | resolved_shell |
| 291 | }; |
| 292 | let no_tools = matches!(scope.as_deref(), Some([])); |
| 293 | Self { |
| 294 | files: if no_tools { |
| 295 | FileGrant::None |
| 296 | } else { |
| 297 | preset.files.min(if profile.permissions.write { |
| 298 | FileGrant::Write |
| 299 | } else { |
| 300 | FileGrant::Read |
| 301 | }) |
| 302 | }, |
| 303 | shell, |
| 304 | network: preset.network |
| 305 | && profile.permissions.network |
| 306 | && !denied(crate::fleet::role::NETWORK_DENIAL_SENTINEL), |
| 307 | desktop: false, |
| 308 | surface: preset.surface, |
| 309 | scope, |
| 310 | spawn: can_spawn, |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | /// The transport shell policy the registered surface and `BashTool` |
| 315 | /// enforce for this grant. |
| 316 | /// |
| 317 | /// `Verify` maps to `ShellPolicy::Full`: the bounded verification surface |
| 318 | /// is a process-start authority the profile transports as Full, while the |
| 319 | /// grant itself — not the transport — decides that raw shell names never |
| 320 | /// reach the catalog. Keeping the transport at Full also preserves the |
| 321 | /// ceiling a verifier may pass to its own children. |
| 322 | #[must_use] |
| 323 | pub const fn shell_policy(&self) -> ShellPolicy { |
| 324 | match self.shell { |
| 325 | ShellGrant::None => ShellPolicy::None, |
| 326 | ShellGrant::Inspect => ShellPolicy::ReadOnly, |
| 327 | ShellGrant::Verify | ShellGrant::Full => ShellPolicy::Full, |
| 328 | } |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | /// How a worker's model is selected. New model-facing spawns default to the |
| 333 | /// parent/session model; a child only takes a smaller/faster family sibling when |
| 334 | /// the parent explicitly asks for that route. |
| 335 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] |
| 336 | #[serde(rename_all = "snake_case")] |
| 337 | pub enum ModelRoute { |
| 338 | /// Same model as the parent / session. |
| 339 | Inherit, |
| 340 | /// Explicitly request a smaller/faster same-family sibling when known. |
| 341 | Faster, |
| 342 | /// Legacy persisted route from the old hidden auto-router. New spawns do |
| 343 | /// not emit this; runtime treats it like `Faster` for compatibility. |
| 344 | Auto, |
| 345 | /// An explicit model id, validated against the active provider at spawn time. |
| 346 | Fixed(String), |
| 347 | } |
| 348 | |
| 349 | /// The capability contract a single worker runs under. |
| 350 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] |
| 351 | pub struct WorkerRuntimeProfile { |
| 352 | pub role: FleetRole, |
| 353 | pub permissions: PermissionSet, |
| 354 | pub shell: ShellPolicy, |
| 355 | pub tools: ToolScope, |
| 356 | pub model: ModelRoute, |
| 357 | /// Explicit provider override; `None` inherits the parent/session provider. |
| 358 | pub provider: Option<String>, |
| 359 | /// Explicit reasoning/thinking tier; `None` inherits the parent/session tier. |
| 360 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 361 | pub reasoning_effort: Option<String>, |
| 362 | /// Tool deny-list inherited from the parent session's `--disallowed-tools` |
| 363 | /// (#4042). Deny always wins over allow, even over the explicit allowlist |
| 364 | /// and the role posture. Entries support wildcard matching: an exact name |
| 365 | /// (`exec_shell`) or a `prefix*` glob (`mcp_*`), compared case-insensitively. |
| 366 | /// |
| 367 | /// A child can only ever *add* entries — `derive_child()` takes the union of |
| 368 | /// the parent's and the child's deny lists, so a descendant can never drop a |
| 369 | /// restriction an ancestor imposed. The legacy `inherit_disallowed_tools: |
| 370 | /// false` input remains accepted but cannot remove this ceiling. |
| 371 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 372 | pub denied_tools: Vec<String>, |
| 373 | /// Absolute recursion ceiling. Older profiles stored a remaining allowance; |
| 374 | /// interpreting that smaller value as absolute fails closed on recovery. |
| 375 | /// `spawn_depth` records this worker's position on the same axis. |
| 376 | pub max_spawn_depth: u32, |
| 377 | #[serde(default)] |
| 378 | pub spawn_depth: u32, |
| 379 | /// Whole-run wall time, including queued, model and tool work. |
| 380 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 381 | pub wall_time_secs: Option<u64>, |
| 382 | /// Persisted wall-clock deadline; continuations cannot restart the clock. |
| 383 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 384 | pub wall_deadline_ms: Option<u64>, |
| 385 | /// Optional model-turn cap. Zero means unbounded, matching the normal |
| 386 | /// Codex and GrokBuild agent loop; an operator may still set a cap. |
| 387 | #[serde(default = "default_general_max_steps")] |
| 388 | pub max_steps: u32, |
| 389 | /// Whether the worker runs detached (background) or inline (foreground). |
| 390 | pub background: bool, |
| 391 | } |
| 392 | |
| 393 | impl WorkerRuntimeProfile { |
| 394 | /// Default model turns for every role: unbounded unless explicitly capped. |
| 395 | pub const READ_ONLY_MAX_STEPS: u32 = 0; |
| 396 | pub const GENERAL_MAX_STEPS: u32 = 0; |
| 397 | |
| 398 | /// Return the default model-turn cap for this role (zero = unbounded). |
| 399 | #[must_use] |
| 400 | pub const fn default_max_steps(role: FleetRole) -> u32 { |
| 401 | match role { |
| 402 | FleetRole::Scout |
| 403 | | FleetRole::Reviewer |
| 404 | | FleetRole::Planner |
| 405 | | FleetRole::Verifier |
| 406 | | FleetRole::Consultant => Self::READ_ONLY_MAX_STEPS, |
| 407 | FleetRole::Builder | FleetRole::Worker | FleetRole::Custom => Self::GENERAL_MAX_STEPS, |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | /// The default profile for a role — the per-role posture. Mirrors the role |
| 412 | /// stances documented in `docs/SUBAGENTS.md` (explore/plan/review are |
| 413 | /// read-only; verifier runs tests; implementer/general write). |
| 414 | #[must_use] |
| 415 | pub fn for_role(role: FleetRole) -> Self { |
| 416 | // A role's default is what the role *intends*, expressed as the widest |
| 417 | // posture the role can be given; the parent's effective posture is the |
| 418 | // ceiling (`derive_child` intersects, never widens). Read-only roles |
| 419 | // stay read-only on the workspace by intent. Nothing else is taken |
| 420 | // away by default: network reach is a read, and a worker cut off from |
| 421 | // the network or from shell for no role reason cannot do its job. |
| 422 | let (permissions, shell) = match role { |
| 423 | // Read-only investigators: no workspace writes, but network reach |
| 424 | // and the bounded verification surface so a scout/reviewer lane |
| 425 | // can run git/gh/web inspection. Raw shell stays denied by the |
| 426 | // registry clamp (read-only classifier), so this widens capability |
| 427 | // without widening mutation authority. |
| 428 | FleetRole::Scout | FleetRole::Reviewer => { |
| 429 | (PermissionSet::read_only_with_network(), ShellPolicy::Full) |
| 430 | } |
| 431 | // Planner: analysis only. Reads the workspace and the web and may |
| 432 | // run read-only shell probes (`git log`, `rg`) under the read-only |
| 433 | // classifier; never mutates. |
| 434 | FleetRole::Planner => ( |
| 435 | PermissionSet::read_only_with_network(), |
| 436 | ShellPolicy::ReadOnly, |
| 437 | ), |
| 438 | // Consultant: counsel only. Reads (workspace and web) to ground |
| 439 | // its advice; never acts on the workspace, so no shell (#4752). |
| 440 | FleetRole::Consultant => (PermissionSet::read_only_with_network(), ShellPolicy::None), |
| 441 | // Verifier: doesn't modify code, but runs the bounded built-in |
| 442 | // verification surface (test/check selections) under a full shell |
| 443 | // ceiling clamped by ChildAuthority: writes are denied and |
| 444 | // unbounded shell forms are refused (#5186). The old wording |
| 445 | // promised "runs the test suite" without saying the surface is |
| 446 | // bounded. See the roster description and VERIFIER_AGENT_INTRO. |
| 447 | FleetRole::Verifier => (PermissionSet::read_only_with_network(), ShellPolicy::Full), |
| 448 | // Doers, and Custom: inherit the parent's effective posture. A |
| 449 | // custom worker is narrowed by its explicit tool list and by the |
| 450 | // spawning call, not by a silent locked-down default. |
| 451 | FleetRole::Builder | FleetRole::Worker | FleetRole::Custom => { |
| 452 | (PermissionSet::full(), ShellPolicy::Full) |
| 453 | } |
| 454 | }; |
| 455 | Self { |
| 456 | role: role.clone(), |
| 457 | permissions, |
| 458 | shell, |
| 459 | tools: ToolScope::Inherit, |
| 460 | model: ModelRoute::Inherit, |
| 461 | provider: None, |
| 462 | // A Consultant is asked for judgement, so it defaults to the highest |
| 463 | // reasoning tier rather than inheriting the session's (#4752). |
| 464 | // Still only a default: an explicit spawn-time or profile value |
| 465 | // wins via `derive_child`, same as every other role. |
| 466 | reasoning_effort: matches!(role, FleetRole::Consultant).then(|| "high".to_string()), |
| 467 | denied_tools: Vec::new(), |
| 468 | max_spawn_depth: codewhale_config::DEFAULT_SPAWN_DEPTH, |
| 469 | spawn_depth: 0, |
| 470 | wall_time_secs: None, |
| 471 | wall_deadline_ms: None, |
| 472 | max_steps: Self::default_max_steps(role.clone()), |
| 473 | background: true, |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | /// Derive a child profile from this (parent) profile and a `requested` child |
| 478 | /// profile. The result is the **intersection** of the two — it can never |
| 479 | /// grant the child something the parent lacks (#414 / #426 / #1186): |
| 480 | /// |
| 481 | /// - permissions are AND-ed, |
| 482 | /// - shell takes the more restrictive policy, |
| 483 | /// - an explicit parent tool set bounds the child's tool set, |
| 484 | /// - the spawn-depth budget decrements by one level and clamps to the ceiling, |
| 485 | /// - the tool deny-list is the **union** of the two — a child may add |
| 486 | /// restrictions but never drop one an ancestor imposed (#4042). |
| 487 | /// |
| 488 | /// The child keeps its own requested role, model route, and |
| 489 | /// foreground/background preference (these don't grant capability), but its |
| 490 | /// provider falls back to the parent's when unset. |
| 491 | #[must_use] |
| 492 | pub fn derive_child(&self, requested: &WorkerRuntimeProfile) -> WorkerRuntimeProfile { |
| 493 | let permissions = self.permissions.intersect(requested.permissions); |
| 494 | let shell = self.shell.min_with(requested.shell); |
| 495 | // Deny-lists union: a child can never drop a restriction an ancestor |
| 496 | // imposed. Wildcard entries are merged verbatim (no expansion). |
| 497 | let mut denied_tools = self.denied_tools.clone(); |
| 498 | for rule in &requested.denied_tools { |
| 499 | if !denied_tools.contains(rule) { |
| 500 | denied_tools.push(rule.clone()); |
| 501 | } |
| 502 | } |
| 503 | let tools = match (&self.tools, &requested.tools) { |
| 504 | // Parent restricts to a set → the child can only narrow within it. |
| 505 | (ToolScope::Explicit(parent), ToolScope::Explicit(child)) => ToolScope::Explicit( |
| 506 | child |
| 507 | .iter() |
| 508 | .filter(|name| parent.contains(name)) |
| 509 | .cloned() |
| 510 | .collect(), |
| 511 | ), |
| 512 | (ToolScope::Explicit(parent), ToolScope::Inherit) => { |
| 513 | ToolScope::Explicit(parent.clone()) |
| 514 | } |
| 515 | // Parent inherits the full surface → the child's request stands. |
| 516 | (ToolScope::Inherit, child) => child.clone(), |
| 517 | }; |
| 518 | // Depth stays absolute; only the current position increments. Every |
| 519 | // authority projection compares the position against this same ceiling. |
| 520 | let max_spawn_depth = requested |
| 521 | .max_spawn_depth |
| 522 | .min(self.max_spawn_depth) |
| 523 | .min(codewhale_config::MAX_SPAWN_DEPTH_CEILING); |
| 524 | WorkerRuntimeProfile { |
| 525 | role: requested.role.clone(), |
| 526 | permissions, |
| 527 | shell, |
| 528 | tools, |
| 529 | model: requested.model.clone(), |
| 530 | provider: requested.provider.clone().or_else(|| self.provider.clone()), |
| 531 | reasoning_effort: requested |
| 532 | .reasoning_effort |
| 533 | .clone() |
| 534 | .or_else(|| self.reasoning_effort.clone()), |
| 535 | denied_tools, |
| 536 | max_spawn_depth, |
| 537 | spawn_depth: self.spawn_depth.saturating_add(1), |
| 538 | wall_time_secs: narrow_optional_limit(self.wall_time_secs, requested.wall_time_secs), |
| 539 | wall_deadline_ms: narrow_optional_limit( |
| 540 | self.wall_deadline_ms, |
| 541 | requested.wall_deadline_ms, |
| 542 | ), |
| 543 | max_steps: narrow_model_steps(self.max_steps, requested.max_steps), |
| 544 | background: requested.background, |
| 545 | } |
| 546 | } |
| 547 | |
| 548 | /// Remaining generations, projected from the one absolute ceiling. |
| 549 | #[must_use] |
| 550 | pub fn remaining_spawn_depth(&self) -> u32 { |
| 551 | self.max_spawn_depth.saturating_sub(self.spawn_depth) |
| 552 | } |
| 553 | |
| 554 | #[must_use] |
| 555 | pub fn can_spawn_child(&self) -> bool { |
| 556 | self.spawn_depth < self.max_spawn_depth |
| 557 | } |
| 558 | } |
| 559 | |
| 560 | /// Omission inherits; neither a child request nor a replay can widen a cap. |
| 561 | pub(crate) fn narrow_optional_limit<T: Ord>( |
| 562 | inherited: Option<T>, |
| 563 | requested: Option<T>, |
| 564 | ) -> Option<T> { |
| 565 | match (inherited, requested) { |
| 566 | (Some(inherited), Some(requested)) => Some(inherited.min(requested)), |
| 567 | (inherited, requested) => inherited.or(requested), |
| 568 | } |
| 569 | } |
| 570 | |
| 571 | /// Zero is the operator/internal unbounded sentinel, never a widening request. |
| 572 | pub(crate) fn narrow_model_steps(inherited: u32, requested: u32) -> u32 { |
| 573 | narrow_optional_limit( |
| 574 | (inherited > 0).then_some(inherited), |
| 575 | (requested > 0).then_some(requested), |
| 576 | ) |
| 577 | .unwrap_or(0) |
| 578 | } |
| 579 | |
| 580 | const fn default_general_max_steps() -> u32 { |
| 581 | WorkerRuntimeProfile::GENERAL_MAX_STEPS |
| 582 | } |
| 583 | |
| 584 | impl Default for WorkerRuntimeProfile { |
| 585 | fn default() -> Self { |
| 586 | Self::for_role(FleetRole::Worker) |
| 587 | } |
| 588 | } |
| 589 | |
| 590 | /// Unified pre-launch manifest for a child agent (#414). |
| 591 | /// |
| 592 | /// Everything needed to provision, launch, and resume a child — prompt, role, |
| 593 | /// model, tools, permissions, workspace boundary, budget, and identity — comes |
| 594 | /// from this single persisted record. No field is derived ad-hoc at launch time. |
| 595 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] |
| 596 | pub struct ChildLaunchManifest { |
| 597 | pub owner_session: String, |
| 598 | pub child_id: String, |
| 599 | pub profile: WorkerRuntimeProfile, |
| 600 | pub prompt: String, |
| 601 | pub cwd: Option<String>, |
| 602 | pub worktree: bool, |
| 603 | pub writable_roots: Vec<String>, |
| 604 | #[serde(default)] |
| 605 | pub writable_files: Vec<String>, |
| 606 | #[serde(default)] |
| 607 | pub coordination_contracts: Vec<String>, |
| 608 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 609 | pub expected_artifact: Option<String>, |
| 610 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 611 | pub deliverables: Vec<String>, |
| 612 | pub resume_identity: Option<String>, |
| 613 | #[serde(default)] |
| 614 | pub generation: u32, |
| 615 | /// Agent id this child was resumed from via `resume_from`, if any. |
| 616 | /// Carries provenance across continuation chains so receipts can trace |
| 617 | /// the lineage without inspecting the transcript. |
| 618 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 619 | pub resume_from_agent_id: Option<String>, |
| 620 | } |
| 621 | |
| 622 | #[cfg(test)] |
| 623 | mod tests { |
| 624 | use super::*; |
| 625 | |
| 626 | #[test] |
| 627 | fn permission_intersection_never_escalates() { |
| 628 | let parent = PermissionSet::read_only(); |
| 629 | let greedy_child = PermissionSet::full(); |
| 630 | // Even though the child asks for everything, the read-only parent wins. |
| 631 | let got = parent.intersect(greedy_child); |
| 632 | assert_eq!(got, PermissionSet::read_only()); |
| 633 | } |
| 634 | |
| 635 | #[test] |
| 636 | fn shell_policy_min_takes_the_safer() { |
| 637 | assert_eq!( |
| 638 | ShellPolicy::ReadOnly.min_with(ShellPolicy::Full), |
| 639 | ShellPolicy::ReadOnly |
| 640 | ); |
| 641 | assert_eq!( |
| 642 | ShellPolicy::None.min_with(ShellPolicy::ReadOnly), |
| 643 | ShellPolicy::None |
| 644 | ); |
| 645 | assert_eq!( |
| 646 | ShellPolicy::Full.min_with(ShellPolicy::Full), |
| 647 | ShellPolicy::Full |
| 648 | ); |
| 649 | } |
| 650 | |
| 651 | #[test] |
| 652 | fn for_role_postures_match_role_stances() { |
| 653 | let explore = WorkerRuntimeProfile::for_role(FleetRole::Scout); |
| 654 | assert!(!explore.permissions.write, "explore must not write"); |
| 655 | assert!( |
| 656 | explore.permissions.network, |
| 657 | "explore/read-only inspection lanes keep network reach" |
| 658 | ); |
| 659 | assert_eq!( |
| 660 | explore.shell, |
| 661 | ShellPolicy::Full, |
| 662 | "explore/read-only inspection lanes hold shell authority so the bounded verification surface survives the clamp (raw shell still requires write)" |
| 663 | ); |
| 664 | assert_eq!( |
| 665 | explore.model, |
| 666 | ModelRoute::Inherit, |
| 667 | "explore should not silently downgrade the child model" |
| 668 | ); |
| 669 | |
| 670 | let implementer = WorkerRuntimeProfile::for_role(FleetRole::Builder); |
| 671 | assert!(implementer.permissions.write, "implementer writes"); |
| 672 | assert_eq!(implementer.shell, ShellPolicy::Full); |
| 673 | |
| 674 | let verifier = WorkerRuntimeProfile::for_role(FleetRole::Verifier); |
| 675 | assert!( |
| 676 | !verifier.permissions.write, |
| 677 | "verifier reports, does not patch" |
| 678 | ); |
| 679 | assert_eq!( |
| 680 | verifier.shell, |
| 681 | ShellPolicy::Full, |
| 682 | "verifier holds shell authority for the bounded verification surface (unbounded forms are refused by the policy seam)" |
| 683 | ); |
| 684 | } |
| 685 | |
| 686 | #[test] |
| 687 | fn role_step_budgets_are_unbounded_by_default_and_profile_owned() { |
| 688 | for role in [ |
| 689 | FleetRole::Scout, |
| 690 | FleetRole::Reviewer, |
| 691 | FleetRole::Planner, |
| 692 | FleetRole::Verifier, |
| 693 | FleetRole::Builder, |
| 694 | FleetRole::Worker, |
| 695 | FleetRole::Custom, |
| 696 | ] { |
| 697 | assert_eq!(WorkerRuntimeProfile::for_role(role.clone()).max_steps, 0); |
| 698 | assert_eq!( |
| 699 | WorkerRuntimeProfile::for_role(role.clone()).max_steps, |
| 700 | WorkerRuntimeProfile::default_max_steps(role) |
| 701 | ); |
| 702 | } |
| 703 | } |
| 704 | |
| 705 | /// #4752: Consultant is counsel, not labour. Its posture has to be read-only |
| 706 | /// and shell-less by construction, not by the caller remembering to pass |
| 707 | /// `write_authority: read-only`. |
| 708 | #[test] |
| 709 | fn consultant_is_read_only_shell_less_and_high_reasoning_by_default() { |
| 710 | let consultant = WorkerRuntimeProfile::for_role(FleetRole::Consultant); |
| 711 | |
| 712 | assert!( |
| 713 | !consultant.permissions.write, |
| 714 | "a consultant advises, it never writes" |
| 715 | ); |
| 716 | assert_eq!( |
| 717 | consultant.shell, |
| 718 | ShellPolicy::None, |
| 719 | "a consultant has no reason to run commands" |
| 720 | ); |
| 721 | assert_eq!( |
| 722 | consultant.reasoning_effort.as_deref(), |
| 723 | Some("high"), |
| 724 | "the point of asking a consultant is the reasoning tier" |
| 725 | ); |
| 726 | assert_eq!( |
| 727 | consultant.model, |
| 728 | ModelRoute::Inherit, |
| 729 | "tier is a reasoning-effort default, not a hardcoded model" |
| 730 | ); |
| 731 | assert_eq!( |
| 732 | consultant.max_steps, |
| 733 | WorkerRuntimeProfile::READ_ONLY_MAX_STEPS, |
| 734 | "consultants are unbounded by default like every other role" |
| 735 | ); |
| 736 | } |
| 737 | |
| 738 | /// The reasoning default must not become a ceiling: an explicit request |
| 739 | /// still wins, exactly as it does for every other role. |
| 740 | #[test] |
| 741 | fn an_explicit_reasoning_tier_overrides_the_consultant_default() { |
| 742 | let parent = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 743 | let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Consultant); |
| 744 | requested.reasoning_effort = Some("max".to_string()); |
| 745 | |
| 746 | let child = parent.derive_child(&requested); |
| 747 | |
| 748 | assert_eq!(child.reasoning_effort.as_deref(), Some("max")); |
| 749 | assert!(!child.permissions.write, "still read-only"); |
| 750 | } |
| 751 | |
| 752 | #[test] |
| 753 | fn child_cannot_escalate_beyond_a_readonly_parent() { |
| 754 | // Scout now carries the read-only inspection posture: no writes, but network reach |
| 755 | // and full shell authority (bounded verification surface; raw shell |
| 756 | // still requires write at the clamp). |
| 757 | let parent = WorkerRuntimeProfile::for_role(FleetRole::Scout); // read-only inspection |
| 758 | let greedy = WorkerRuntimeProfile::for_role(FleetRole::Builder); // wants write + full shell |
| 759 | let child = parent.derive_child(&greedy); |
| 760 | assert!( |
| 761 | !child.permissions.write, |
| 762 | "a read-only parent cannot bear a writing child" |
| 763 | ); |
| 764 | assert!( |
| 765 | child.permissions.network, |
| 766 | "child inherits the read-only inspection parent's network reach" |
| 767 | ); |
| 768 | assert_eq!( |
| 769 | child.shell, |
| 770 | ShellPolicy::Full, |
| 771 | "child shell clamped to parent's read-only inspection posture" |
| 772 | ); |
| 773 | } |
| 774 | |
| 775 | #[test] |
| 776 | fn child_explicit_tools_are_bounded_by_parent() { |
| 777 | let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 778 | parent.tools = ToolScope::Explicit(vec!["read_file".into(), "grep_files".into()]); |
| 779 | let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 780 | requested.tools = ToolScope::Explicit(vec!["read_file".into(), "write_file".into()]); |
| 781 | let child = parent.derive_child(&requested); |
| 782 | match child.tools { |
| 783 | ToolScope::Explicit(names) => { |
| 784 | assert_eq!( |
| 785 | names, |
| 786 | vec!["read_file".to_string()], |
| 787 | "write_file not in parent set is dropped" |
| 788 | ); |
| 789 | } |
| 790 | ToolScope::Inherit => panic!("expected explicit tool scope"), |
| 791 | } |
| 792 | } |
| 793 | |
| 794 | #[test] |
| 795 | fn absolute_spawn_depth_is_preserved_and_position_increments() { |
| 796 | let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 797 | parent.max_spawn_depth = 2; |
| 798 | let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 799 | requested.max_spawn_depth = 99; // tries to grab more than the parent has |
| 800 | let child = parent.derive_child(&requested); |
| 801 | assert_eq!( |
| 802 | child.max_spawn_depth, 2, |
| 803 | "the absolute ceiling is unchanged, never the requested 99" |
| 804 | ); |
| 805 | assert_eq!(child.spawn_depth, 1); |
| 806 | assert!(child.can_spawn_child()); |
| 807 | |
| 808 | let mut leaf_parent = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 809 | leaf_parent.max_spawn_depth = 1; |
| 810 | let grandchild = leaf_parent.derive_child(&requested); |
| 811 | assert_eq!(grandchild.max_spawn_depth, 1); |
| 812 | assert_eq!(grandchild.spawn_depth, 1); |
| 813 | assert!( |
| 814 | !grandchild.can_spawn_child(), |
| 815 | "budget exhausted at the leaf" |
| 816 | ); |
| 817 | } |
| 818 | |
| 819 | #[test] |
| 820 | fn child_provider_falls_back_to_parent() { |
| 821 | let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 822 | parent.provider = Some("moonshot".to_string()); |
| 823 | let requested = WorkerRuntimeProfile::for_role(FleetRole::Scout); // provider None |
| 824 | let child = parent.derive_child(&requested); |
| 825 | assert_eq!(child.provider.as_deref(), Some("moonshot")); |
| 826 | } |
| 827 | |
| 828 | #[test] |
| 829 | fn child_reasoning_effort_uses_requested_then_parent() { |
| 830 | let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 831 | parent.reasoning_effort = Some("low".to_string()); |
| 832 | |
| 833 | let requested = WorkerRuntimeProfile::for_role(FleetRole::Scout); |
| 834 | let inherited = parent.derive_child(&requested); |
| 835 | assert_eq!(inherited.reasoning_effort.as_deref(), Some("low")); |
| 836 | |
| 837 | let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Scout); |
| 838 | requested.reasoning_effort = Some("max".to_string()); |
| 839 | let overridden = parent.derive_child(&requested); |
| 840 | assert_eq!(overridden.reasoning_effort.as_deref(), Some("max")); |
| 841 | } |
| 842 | |
| 843 | #[test] |
| 844 | fn child_denied_tools_union_never_drops_parent_restriction() { |
| 845 | // A child may only *add* deny entries; it can never drop a restriction |
| 846 | // an ancestor imposed (#4042 non-escalation invariant). |
| 847 | let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 848 | parent.denied_tools = vec!["exec_shell".into(), "mcp_*".into()]; |
| 849 | |
| 850 | // Child asks for its own deny list and (tryingly) tries to omit the |
| 851 | // parent's exec_shell — the union keeps both. |
| 852 | let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Builder); |
| 853 | requested.denied_tools = vec!["write_file".into()]; |
| 854 | |
| 855 | let child = parent.derive_child(&requested); |
| 856 | assert!(child.denied_tools.contains(&"exec_shell".to_string())); |
| 857 | assert!(child.denied_tools.contains(&"mcp_*".to_string())); |
| 858 | assert!(child.denied_tools.contains(&"write_file".to_string())); |
| 859 | } |
| 860 | |
| 861 | /// Every built-in role keeps network reads by default: a worker cut off |
| 862 | /// from the network for no role reason cannot do its job. Only workspace |
| 863 | /// mutation is a role intent. |
| 864 | #[test] |
| 865 | fn every_role_default_keeps_network_reads_and_only_read_only_roles_withhold_writes() { |
| 866 | for role in [ |
| 867 | FleetRole::Scout, |
| 868 | FleetRole::Reviewer, |
| 869 | FleetRole::Planner, |
| 870 | FleetRole::Verifier, |
| 871 | FleetRole::Consultant, |
| 872 | FleetRole::Builder, |
| 873 | FleetRole::Worker, |
| 874 | FleetRole::Custom, |
| 875 | ] { |
| 876 | let profile = WorkerRuntimeProfile::for_role(role.clone()); |
| 877 | assert!( |
| 878 | profile.permissions.network, |
| 879 | "{role:?} must keep network reads" |
| 880 | ); |
| 881 | let read_only_by_intent = matches!( |
| 882 | role, |
| 883 | FleetRole::Scout |
| 884 | | FleetRole::Reviewer |
| 885 | | FleetRole::Planner |
| 886 | | FleetRole::Verifier |
| 887 | | FleetRole::Consultant |
| 888 | ); |
| 889 | assert_eq!( |
| 890 | profile.permissions.write, !read_only_by_intent, |
| 891 | "{role:?} write default" |
| 892 | ); |
| 893 | } |
| 894 | // Custom inherits (full ceiling); Planner probes read-only shell; |
| 895 | // Consultant never acts on the workspace. |
| 896 | assert_eq!( |
| 897 | WorkerRuntimeProfile::for_role(FleetRole::Custom).shell, |
| 898 | ShellPolicy::Full |
| 899 | ); |
| 900 | assert_eq!( |
| 901 | WorkerRuntimeProfile::for_role(FleetRole::Planner).shell, |
| 902 | ShellPolicy::ReadOnly |
| 903 | ); |
| 904 | assert_eq!( |
| 905 | WorkerRuntimeProfile::for_role(FleetRole::Consultant).shell, |
| 906 | ShellPolicy::None |
| 907 | ); |
| 908 | } |
| 909 | |
| 910 | /// The parent's effective posture is the ceiling: a full-default child role |
| 911 | /// under a read-only, no-network, read-only-shell parent inherits exactly |
| 912 | /// that, never more. |
| 913 | #[test] |
| 914 | fn derive_child_inherits_the_parent_ceiling_and_never_widens() { |
| 915 | let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 916 | parent.permissions = PermissionSet::read_only(); |
| 917 | parent.shell = ShellPolicy::ReadOnly; |
| 918 | for role in [FleetRole::Custom, FleetRole::Builder, FleetRole::Worker] { |
| 919 | let child = parent.derive_child(&WorkerRuntimeProfile::for_role(role.clone())); |
| 920 | assert!(!child.permissions.write, "{role:?} widened write"); |
| 921 | assert!(!child.permissions.network, "{role:?} widened network"); |
| 922 | assert_eq!(child.shell, ShellPolicy::ReadOnly, "{role:?} widened shell"); |
| 923 | } |
| 924 | // And a full parent hands a doer its full posture. |
| 925 | let full = WorkerRuntimeProfile::for_role(FleetRole::Worker) |
| 926 | .derive_child(&WorkerRuntimeProfile::for_role(FleetRole::Custom)); |
| 927 | assert!(full.permissions.write && full.permissions.network); |
| 928 | assert_eq!(full.shell, ShellPolicy::Full); |
| 929 | } |
| 930 | } |
| 931 |