| 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::tools::subagent::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 | /// Recon: 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 recon needs `git`/`gh`/web reach — |
| 54 | /// the old `read_only()` default left such lanes with no way to run any |
| 55 | /// command or reach any remote, which made default scout lanes useless |
| 56 | /// for the recon they exist for. |
| 57 | pub const fn recon() -> 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 | /// How a worker's model is selected. New model-facing spawns default to the |
| 124 | /// parent/session model; a child only takes a smaller/faster family sibling when |
| 125 | /// the parent explicitly asks for that route. |
| 126 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] |
| 127 | #[serde(rename_all = "snake_case")] |
| 128 | pub enum ModelRoute { |
| 129 | /// Same model as the parent / session. |
| 130 | Inherit, |
| 131 | /// Explicitly request a smaller/faster same-family sibling when known. |
| 132 | Faster, |
| 133 | /// Legacy persisted route from the old hidden auto-router. New spawns do |
| 134 | /// not emit this; runtime treats it like `Faster` for compatibility. |
| 135 | Auto, |
| 136 | /// An explicit model id, validated against the active provider at spawn time. |
| 137 | Fixed(String), |
| 138 | } |
| 139 | |
| 140 | /// The capability contract a single worker runs under. |
| 141 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] |
| 142 | pub struct WorkerRuntimeProfile { |
| 143 | pub role: FleetRole, |
| 144 | pub permissions: PermissionSet, |
| 145 | pub shell: ShellPolicy, |
| 146 | pub tools: ToolScope, |
| 147 | pub model: ModelRoute, |
| 148 | /// Explicit provider override; `None` inherits the parent/session provider. |
| 149 | pub provider: Option<String>, |
| 150 | /// Explicit reasoning/thinking tier; `None` inherits the parent/session tier. |
| 151 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 152 | pub reasoning_effort: Option<String>, |
| 153 | /// Tool deny-list inherited from the parent session's `--disallowed-tools` |
| 154 | /// (#4042). Deny always wins over allow, even over the explicit allowlist |
| 155 | /// and the role posture. Entries support wildcard matching: an exact name |
| 156 | /// (`exec_shell`) or a `prefix*` glob (`mcp_*`), compared case-insensitively. |
| 157 | /// |
| 158 | /// A child can only ever *add* entries — `derive_child()` takes the union of |
| 159 | /// the parent's and the child's deny lists, so a descendant can never drop a |
| 160 | /// restriction an ancestor imposed. The only way to start without the |
| 161 | /// parent's list is an explicit `inherit_disallowed_tools: false` at spawn, |
| 162 | /// which clears the cloned runtime's list before the registry reads it. |
| 163 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 164 | pub denied_tools: Vec<String>, |
| 165 | /// Remaining nested-delegation budget. A worker may spawn children while |
| 166 | /// `max_spawn_depth > 0`; each level decrements it. Clamped to the workspace |
| 167 | /// ceiling. |
| 168 | pub max_spawn_depth: u32, |
| 169 | /// Finite model-turn budget for this role. |
| 170 | #[serde(default = "default_general_max_steps")] |
| 171 | pub max_steps: u32, |
| 172 | /// Whether the worker runs detached (background) or inline (foreground). |
| 173 | pub background: bool, |
| 174 | } |
| 175 | |
| 176 | impl WorkerRuntimeProfile { |
| 177 | /// Maximum model turns for read-mostly workers. |
| 178 | pub const READ_ONLY_MAX_STEPS: u32 = 60; |
| 179 | /// Maximum model turns for workers that may implement changes. |
| 180 | pub const GENERAL_MAX_STEPS: u32 = 120; |
| 181 | |
| 182 | /// Return the finite model-turn budget appropriate for this role. |
| 183 | #[must_use] |
| 184 | pub const fn default_max_steps(role: FleetRole) -> u32 { |
| 185 | match role { |
| 186 | FleetRole::Scout |
| 187 | | FleetRole::Reviewer |
| 188 | | FleetRole::Planner |
| 189 | | FleetRole::Verifier |
| 190 | | FleetRole::Consultant => Self::READ_ONLY_MAX_STEPS, |
| 191 | FleetRole::Builder | FleetRole::Worker | FleetRole::Custom => Self::GENERAL_MAX_STEPS, |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | /// The default profile for a role — the per-role posture. Mirrors the role |
| 196 | /// stances documented in `docs/SUBAGENTS.md` (explore/plan/review are |
| 197 | /// read-only; verifier runs tests; implementer/general write). |
| 198 | #[must_use] |
| 199 | pub fn for_role(role: FleetRole) -> Self { |
| 200 | let (permissions, shell) = match role { |
| 201 | // Read-only investigators. Recon posture: no workspace writes, |
| 202 | // but network reach and the bounded verification surface are |
| 203 | // granted so a default scout/reviewer lane can actually run |
| 204 | // git/gh/web recon instead of being reduced to file reads. |
| 205 | // Raw shell stays denied by the clamp (write && shell == Full |
| 206 | // is required for it), so this widens capability without |
| 207 | // widening mutation authority. |
| 208 | FleetRole::Scout | FleetRole::Reviewer => (PermissionSet::recon(), ShellPolicy::Full), |
| 209 | // Planner: analysis only, no shell. |
| 210 | FleetRole::Planner => (PermissionSet::read_only(), ShellPolicy::None), |
| 211 | // Consultant: counsel only. Reads to ground its advice; never acts on |
| 212 | // the workspace, so no shell either (#4752). |
| 213 | FleetRole::Consultant => (PermissionSet::read_only(), ShellPolicy::None), |
| 214 | // Verifier: doesn't modify code, but runs the test suite. |
| 215 | FleetRole::Verifier => (PermissionSet::read_only(), ShellPolicy::Full), |
| 216 | // Doers. |
| 217 | FleetRole::Builder | FleetRole::Worker => (PermissionSet::full(), ShellPolicy::Full), |
| 218 | // Custom starts locked down; the caller opens specific tools explicitly. |
| 219 | FleetRole::Custom => (PermissionSet::read_only(), ShellPolicy::None), |
| 220 | }; |
| 221 | Self { |
| 222 | role: role.clone(), |
| 223 | permissions, |
| 224 | shell, |
| 225 | tools: ToolScope::Inherit, |
| 226 | model: ModelRoute::Inherit, |
| 227 | provider: None, |
| 228 | // A Consultant is asked for judgement, so it defaults to the highest |
| 229 | // reasoning tier rather than inheriting the session's (#4752). |
| 230 | // Still only a default: an explicit spawn-time or profile value |
| 231 | // wins via `derive_child`, same as every other role. |
| 232 | reasoning_effort: matches!(role, FleetRole::Consultant).then(|| "high".to_string()), |
| 233 | denied_tools: Vec::new(), |
| 234 | max_spawn_depth: codewhale_config::DEFAULT_SPAWN_DEPTH, |
| 235 | max_steps: Self::default_max_steps(role.clone()), |
| 236 | background: true, |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | /// Derive a child profile from this (parent) profile and a `requested` child |
| 241 | /// profile. The result is the **intersection** of the two — it can never |
| 242 | /// grant the child something the parent lacks (#414 / #426 / #1186): |
| 243 | /// |
| 244 | /// - permissions are AND-ed, |
| 245 | /// - shell takes the more restrictive policy, |
| 246 | /// - an explicit parent tool set bounds the child's tool set, |
| 247 | /// - the spawn-depth budget decrements by one level and clamps to the ceiling, |
| 248 | /// - the tool deny-list is the **union** of the two — a child may add |
| 249 | /// restrictions but never drop one an ancestor imposed (#4042). |
| 250 | /// |
| 251 | /// The child keeps its own requested role, model route, and |
| 252 | /// foreground/background preference (these don't grant capability), but its |
| 253 | /// provider falls back to the parent's when unset. |
| 254 | #[must_use] |
| 255 | pub fn derive_child(&self, requested: &WorkerRuntimeProfile) -> WorkerRuntimeProfile { |
| 256 | let permissions = self.permissions.intersect(requested.permissions); |
| 257 | let shell = self.shell.min_with(requested.shell); |
| 258 | // Deny-lists union: a child can never drop a restriction an ancestor |
| 259 | // imposed. Wildcard entries are merged verbatim (no expansion). |
| 260 | let mut denied_tools = self.denied_tools.clone(); |
| 261 | for rule in &requested.denied_tools { |
| 262 | if !denied_tools.contains(rule) { |
| 263 | denied_tools.push(rule.clone()); |
| 264 | } |
| 265 | } |
| 266 | let tools = match (&self.tools, &requested.tools) { |
| 267 | // Parent restricts to a set → the child can only narrow within it. |
| 268 | (ToolScope::Explicit(parent), ToolScope::Explicit(child)) => ToolScope::Explicit( |
| 269 | child |
| 270 | .iter() |
| 271 | .filter(|name| parent.contains(name)) |
| 272 | .cloned() |
| 273 | .collect(), |
| 274 | ), |
| 275 | (ToolScope::Explicit(parent), ToolScope::Inherit) => { |
| 276 | ToolScope::Explicit(parent.clone()) |
| 277 | } |
| 278 | // Parent inherits the full surface → the child's request stands. |
| 279 | (ToolScope::Inherit, child) => child.clone(), |
| 280 | }; |
| 281 | // The child gets at most one level less budget than the parent, and never |
| 282 | // more than it requested, clamped to the hard ceiling. |
| 283 | let max_spawn_depth = requested |
| 284 | .max_spawn_depth |
| 285 | .min(self.max_spawn_depth.saturating_sub(1)) |
| 286 | .min(codewhale_config::MAX_SPAWN_DEPTH_CEILING); |
| 287 | WorkerRuntimeProfile { |
| 288 | role: requested.role.clone(), |
| 289 | permissions, |
| 290 | shell, |
| 291 | tools, |
| 292 | model: requested.model.clone(), |
| 293 | provider: requested.provider.clone().or_else(|| self.provider.clone()), |
| 294 | reasoning_effort: requested |
| 295 | .reasoning_effort |
| 296 | .clone() |
| 297 | .or_else(|| self.reasoning_effort.clone()), |
| 298 | denied_tools, |
| 299 | max_spawn_depth, |
| 300 | max_steps: requested.max_steps, |
| 301 | background: requested.background, |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | /// Whether this worker may still spawn a child (budget remaining). |
| 306 | #[must_use] |
| 307 | pub fn can_spawn_child(&self) -> bool { |
| 308 | self.max_spawn_depth > 0 |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | const fn default_general_max_steps() -> u32 { |
| 313 | WorkerRuntimeProfile::GENERAL_MAX_STEPS |
| 314 | } |
| 315 | |
| 316 | impl Default for WorkerRuntimeProfile { |
| 317 | fn default() -> Self { |
| 318 | Self::for_role(FleetRole::Worker) |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | /// Unified pre-launch manifest for a child agent (#414). |
| 323 | /// |
| 324 | /// Everything needed to provision, launch, and resume a child — prompt, role, |
| 325 | /// model, tools, permissions, workspace boundary, budget, and identity — comes |
| 326 | /// from this single persisted record. No field is derived ad-hoc at launch time. |
| 327 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] |
| 328 | pub struct ChildLaunchManifest { |
| 329 | pub owner_session: String, |
| 330 | pub child_id: String, |
| 331 | pub profile: WorkerRuntimeProfile, |
| 332 | pub prompt: String, |
| 333 | pub cwd: Option<String>, |
| 334 | pub worktree: bool, |
| 335 | pub writable_roots: Vec<String>, |
| 336 | #[serde(default)] |
| 337 | pub writable_files: Vec<String>, |
| 338 | #[serde(default)] |
| 339 | pub coordination_contracts: Vec<String>, |
| 340 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 341 | pub expected_artifact: Option<String>, |
| 342 | pub token_budget: Option<u64>, |
| 343 | pub resume_identity: Option<String>, |
| 344 | #[serde(default)] |
| 345 | pub generation: u32, |
| 346 | /// Agent id this child was resumed from via `resume_from`, if any. |
| 347 | /// Carries provenance across continuation chains so receipts can trace |
| 348 | /// the lineage without inspecting the transcript. |
| 349 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 350 | pub resume_from_agent_id: Option<String>, |
| 351 | } |
| 352 | |
| 353 | #[cfg(test)] |
| 354 | mod tests { |
| 355 | use super::*; |
| 356 | |
| 357 | #[test] |
| 358 | fn permission_intersection_never_escalates() { |
| 359 | let parent = PermissionSet::read_only(); |
| 360 | let greedy_child = PermissionSet::full(); |
| 361 | // Even though the child asks for everything, the read-only parent wins. |
| 362 | let got = parent.intersect(greedy_child); |
| 363 | assert_eq!(got, PermissionSet::read_only()); |
| 364 | } |
| 365 | |
| 366 | #[test] |
| 367 | fn shell_policy_min_takes_the_safer() { |
| 368 | assert_eq!( |
| 369 | ShellPolicy::ReadOnly.min_with(ShellPolicy::Full), |
| 370 | ShellPolicy::ReadOnly |
| 371 | ); |
| 372 | assert_eq!( |
| 373 | ShellPolicy::None.min_with(ShellPolicy::ReadOnly), |
| 374 | ShellPolicy::None |
| 375 | ); |
| 376 | assert_eq!( |
| 377 | ShellPolicy::Full.min_with(ShellPolicy::Full), |
| 378 | ShellPolicy::Full |
| 379 | ); |
| 380 | } |
| 381 | |
| 382 | #[test] |
| 383 | fn for_role_postures_match_role_stances() { |
| 384 | let explore = WorkerRuntimeProfile::for_role(FleetRole::Scout); |
| 385 | assert!(!explore.permissions.write, "explore must not write"); |
| 386 | assert!( |
| 387 | explore.permissions.network, |
| 388 | "explore/recon lanes keep network reach" |
| 389 | ); |
| 390 | assert_eq!( |
| 391 | explore.shell, |
| 392 | ShellPolicy::Full, |
| 393 | "explore/recon lanes hold shell authority so the bounded verification surface survives the clamp (raw shell still requires write)" |
| 394 | ); |
| 395 | assert_eq!( |
| 396 | explore.model, |
| 397 | ModelRoute::Inherit, |
| 398 | "explore should not silently downgrade the child model" |
| 399 | ); |
| 400 | |
| 401 | let implementer = WorkerRuntimeProfile::for_role(FleetRole::Builder); |
| 402 | assert!(implementer.permissions.write, "implementer writes"); |
| 403 | assert_eq!(implementer.shell, ShellPolicy::Full); |
| 404 | |
| 405 | let verifier = WorkerRuntimeProfile::for_role(FleetRole::Verifier); |
| 406 | assert!( |
| 407 | !verifier.permissions.write, |
| 408 | "verifier reports, does not patch" |
| 409 | ); |
| 410 | assert_eq!( |
| 411 | verifier.shell, |
| 412 | ShellPolicy::Full, |
| 413 | "verifier runs the test suite" |
| 414 | ); |
| 415 | } |
| 416 | |
| 417 | #[test] |
| 418 | fn role_step_budgets_are_finite_and_profile_owned() { |
| 419 | for role in [ |
| 420 | FleetRole::Scout, |
| 421 | FleetRole::Reviewer, |
| 422 | FleetRole::Planner, |
| 423 | FleetRole::Verifier, |
| 424 | FleetRole::Builder, |
| 425 | FleetRole::Worker, |
| 426 | FleetRole::Custom, |
| 427 | ] { |
| 428 | assert!(WorkerRuntimeProfile::for_role(role.clone()).max_steps > 0); |
| 429 | assert_eq!( |
| 430 | WorkerRuntimeProfile::for_role(role.clone()).max_steps, |
| 431 | WorkerRuntimeProfile::default_max_steps(role) |
| 432 | ); |
| 433 | } |
| 434 | } |
| 435 | |
| 436 | /// #4752: Consultant is counsel, not labour. Its posture has to be read-only |
| 437 | /// and shell-less by construction, not by the caller remembering to pass |
| 438 | /// `write_authority: read-only`. |
| 439 | #[test] |
| 440 | fn consultant_is_read_only_shell_less_and_high_reasoning_by_default() { |
| 441 | let consultant = WorkerRuntimeProfile::for_role(FleetRole::Consultant); |
| 442 | |
| 443 | assert!( |
| 444 | !consultant.permissions.write, |
| 445 | "a consultant advises, it never writes" |
| 446 | ); |
| 447 | assert_eq!( |
| 448 | consultant.shell, |
| 449 | ShellPolicy::None, |
| 450 | "a consultant has no reason to run commands" |
| 451 | ); |
| 452 | assert_eq!( |
| 453 | consultant.reasoning_effort.as_deref(), |
| 454 | Some("high"), |
| 455 | "the point of asking a consultant is the reasoning tier" |
| 456 | ); |
| 457 | assert_eq!( |
| 458 | consultant.model, |
| 459 | ModelRoute::Inherit, |
| 460 | "tier is a reasoning-effort default, not a hardcoded model" |
| 461 | ); |
| 462 | assert_eq!( |
| 463 | consultant.max_steps, |
| 464 | WorkerRuntimeProfile::READ_ONLY_MAX_STEPS, |
| 465 | "read-mostly budget, like the other read-only roles" |
| 466 | ); |
| 467 | } |
| 468 | |
| 469 | /// The reasoning default must not become a ceiling: an explicit request |
| 470 | /// still wins, exactly as it does for every other role. |
| 471 | #[test] |
| 472 | fn an_explicit_reasoning_tier_overrides_the_consultant_default() { |
| 473 | let parent = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 474 | let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Consultant); |
| 475 | requested.reasoning_effort = Some("max".to_string()); |
| 476 | |
| 477 | let child = parent.derive_child(&requested); |
| 478 | |
| 479 | assert_eq!(child.reasoning_effort.as_deref(), Some("max")); |
| 480 | assert!(!child.permissions.write, "still read-only"); |
| 481 | } |
| 482 | |
| 483 | #[test] |
| 484 | fn child_cannot_escalate_beyond_a_readonly_parent() { |
| 485 | // Scout now carries the recon posture: no writes, but network reach |
| 486 | // and full shell authority (bounded verification surface; raw shell |
| 487 | // still requires write at the clamp). |
| 488 | let parent = WorkerRuntimeProfile::for_role(FleetRole::Scout); // recon |
| 489 | let greedy = WorkerRuntimeProfile::for_role(FleetRole::Builder); // wants write + full shell |
| 490 | let child = parent.derive_child(&greedy); |
| 491 | assert!( |
| 492 | !child.permissions.write, |
| 493 | "a read-only parent cannot bear a writing child" |
| 494 | ); |
| 495 | assert!( |
| 496 | child.permissions.network, |
| 497 | "child inherits the recon parent's network reach" |
| 498 | ); |
| 499 | assert_eq!( |
| 500 | child.shell, |
| 501 | ShellPolicy::Full, |
| 502 | "child shell clamped to parent's recon posture" |
| 503 | ); |
| 504 | } |
| 505 | |
| 506 | #[test] |
| 507 | fn child_explicit_tools_are_bounded_by_parent() { |
| 508 | let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 509 | parent.tools = ToolScope::Explicit(vec!["read_file".into(), "grep_files".into()]); |
| 510 | let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 511 | requested.tools = ToolScope::Explicit(vec!["read_file".into(), "write_file".into()]); |
| 512 | let child = parent.derive_child(&requested); |
| 513 | match child.tools { |
| 514 | ToolScope::Explicit(names) => { |
| 515 | assert_eq!( |
| 516 | names, |
| 517 | vec!["read_file".to_string()], |
| 518 | "write_file not in parent set is dropped" |
| 519 | ); |
| 520 | } |
| 521 | ToolScope::Inherit => panic!("expected explicit tool scope"), |
| 522 | } |
| 523 | } |
| 524 | |
| 525 | #[test] |
| 526 | fn spawn_depth_decrements_and_clamps() { |
| 527 | let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 528 | parent.max_spawn_depth = 2; |
| 529 | let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 530 | requested.max_spawn_depth = 99; // tries to grab more than the parent has |
| 531 | let child = parent.derive_child(&requested); |
| 532 | assert_eq!( |
| 533 | child.max_spawn_depth, 1, |
| 534 | "child budget is at most parent-1, never the requested 99" |
| 535 | ); |
| 536 | assert!(child.can_spawn_child()); |
| 537 | |
| 538 | let mut leaf_parent = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 539 | leaf_parent.max_spawn_depth = 1; |
| 540 | let grandchild = leaf_parent.derive_child(&requested); |
| 541 | assert_eq!(grandchild.max_spawn_depth, 0); |
| 542 | assert!( |
| 543 | !grandchild.can_spawn_child(), |
| 544 | "budget exhausted at the leaf" |
| 545 | ); |
| 546 | } |
| 547 | |
| 548 | #[test] |
| 549 | fn child_provider_falls_back_to_parent() { |
| 550 | let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 551 | parent.provider = Some("moonshot".to_string()); |
| 552 | let requested = WorkerRuntimeProfile::for_role(FleetRole::Scout); // provider None |
| 553 | let child = parent.derive_child(&requested); |
| 554 | assert_eq!(child.provider.as_deref(), Some("moonshot")); |
| 555 | } |
| 556 | |
| 557 | #[test] |
| 558 | fn child_reasoning_effort_uses_requested_then_parent() { |
| 559 | let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 560 | parent.reasoning_effort = Some("low".to_string()); |
| 561 | |
| 562 | let requested = WorkerRuntimeProfile::for_role(FleetRole::Scout); |
| 563 | let inherited = parent.derive_child(&requested); |
| 564 | assert_eq!(inherited.reasoning_effort.as_deref(), Some("low")); |
| 565 | |
| 566 | let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Scout); |
| 567 | requested.reasoning_effort = Some("max".to_string()); |
| 568 | let overridden = parent.derive_child(&requested); |
| 569 | assert_eq!(overridden.reasoning_effort.as_deref(), Some("max")); |
| 570 | } |
| 571 | |
| 572 | #[test] |
| 573 | fn child_denied_tools_union_never_drops_parent_restriction() { |
| 574 | // A child may only *add* deny entries; it can never drop a restriction |
| 575 | // an ancestor imposed (#4042 non-escalation invariant). |
| 576 | let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker); |
| 577 | parent.denied_tools = vec!["exec_shell".into(), "mcp_*".into()]; |
| 578 | |
| 579 | // Child asks for its own deny list and (tryingly) tries to omit the |
| 580 | // parent's exec_shell — the union keeps both. |
| 581 | let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Builder); |
| 582 | requested.denied_tools = vec!["write_file".into()]; |
| 583 | |
| 584 | let child = parent.derive_child(&requested); |
| 585 | assert!(child.denied_tools.contains(&"exec_shell".to_string())); |
| 586 | assert!(child.denied_tools.contains(&"mcp_*".to_string())); |
| 587 | assert!(child.denied_tools.contains(&"write_file".to_string())); |
| 588 | } |
| 589 | } |
| 590 |