返回 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::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
931 lines RUST