返回 CodeWhale
worker_profile.rs
根目录 / crates / tui / src / worker_profile.rs
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
590 lines RUST