feat(fleet): one ChildGrant authority projection (#5633)

The in-process child authority is one resolved object — ChildGrant in
crates/tui/src/worker_profile.rs: files (none|read|write), shell
(none|inspect|verify|full), network, desktop (never granted), the named
tool surface, the caller's explicit scope, and remaining spawn depth. A
role is a preset over it (ChildGrant::for_role); resolve() intersects the
preset with the parent-derived profile. The subagent registry holds the
resolved grant; posture, scope, and envelope checks read it, so catalog
visibility, dispatch refusals, and the capability envelope are one
projection. Posture sentinels fold into the axes at resolve time
(run_tests -> shell, fetch_url -> network), so the transport denial and
the semantic answer cannot disagree. Docs updated (SUBAGENTS.md en + zh).

Close-out sweep: the two surviving role-keyed predicates ask questions the
grant does not model and stay — role_can_delegate_writes is the
auto-approval policy for named implementation roles (retiring it would
widen approvals; pinned by
worker_child_inherits_workspace_write_carve_out_without_parent_auto_approve),
and role_requires_read_only_shell is the durable-fleet transport posture
that receipts report. No semantic duplicates remain.

Gates (remote CI lane, whale-h100 x86_64 — remote evidence, not local):
- cargo check -p codewhale-tui --locked --all-targets: 3m20s cold, 0.54s warm.
- cargo test -p codewhale-tui --lib --locked subagent: 800 passed; 6 failed;
  12030 filtered out; 36.42s. Failures classified against a HEAD control run
  (800 passed; 5 failed) plus narrow runs:
  * budget_handback x3 — fail under the full 806-test parallel run at HEAD
    and with this diff; pass 19/19 in isolation on both (8.08s).
    Pre-existing suite-scale flakiness.
  * resume_keeps_recorded_reasoning... — passed in one full run, failed in
    the next, same code: intermittent under load.
  * delivery_tests::enforced_readonly_python... — fails identically at HEAD;
    bwrap mounts a fresh tmpfs over /tmp so a workspace under /tmp is
    unreachable (sandbox/bwrap.rs; filed separately).
  * issue_5305_untethered... — pre-existing red at HEAD (the spawn succeeds
    where the guard expects refusal). An earlier attempt to pin glm-5.2
    could not reach that guard — model validation rejects the pin first —
    and is reverted rather than committed unverified.
- cargo fmt --all -- --check clean at this revision.

PARENT_SURFACE_BYTE_CEILING: 86_000 -> 88_000 to cover the slice's
re-measured 87,529B parent surface; both ceiling tests pass on the lane.
This commit is contained in:
CodeWhale Bot committed 2026-09-17 04:49:51 -07:00
1 parent 292ffa56ae
commit 966ef974e1
7 files changed
+532 -174

No files matched your search

+6
View File
@@ -196,6 +196,12 @@ because they did anything wrong. Treat their time as more expensive than ours.
- Prefer focused compilation, a relevant existing check, and direct product or
manual evidence. Run a broad suite only when the change creates a genuine
cross-cutting or release risk. Do not repeatedly rerun an unchanged suite.
- **Batch edits; compile once.** `cargo check` and test builds on this
workspace take minutes, so an edit→compile→edit loop spends most of its
time waiting on the linker. Read precisely, write every edit a coherent
slice needs, then compile and test once — the same errors surface either
way, just later and all at once. Reserve mid-slice compiles for genuinely
uncertain API or borrow questions where a wrong guess would cascade.
- Declared migrations are one-way. Once the repository adopts a replacement
architecture or shared spine, new work uses it and touched legacy code moves
toward it. Do not add another legacy call site for convenience. Keep a
+20 -14
View File
@@ -306,12 +306,15 @@ pub(crate) const NETWORK_TOOL_DENYLIST: &[&str] = &[
/// The deny-list entry that stands for "this child has no network".
///
/// The deny list *is* how `network_tool = false` reaches a child registry
/// (through `worker_profile.denied_tools`), so posture is read back off the
/// list rather than carried as a second field that could disagree with it.
/// `fetch_url` is the sentinel because every network denial installs it and no
/// narrower deny list does — the `web_*` / `web.*` globs deliberately do not
/// match it, which is why it is spelled out above.
/// The deny list is how `network_tool = false` reaches a child across the
/// durable-Fleet and `codewhale exec` boundaries (through
/// `worker_profile.denied_tools` / `context.disallowed_tools`). `fetch_url` is
/// the sentinel because every network denial installs it and no narrower deny
/// list does — the `web_*` / `web.*` globs deliberately do not match it,
/// which is why it is spelled out above. The in-process child reads its
/// network axis off the resolved grant, which folds this sentinel in at
/// resolve time — so the transport denial and the semantic answer can never
/// disagree.
pub(crate) const NETWORK_DENIAL_SENTINEL: &str = "fetch_url";
/// Tool names that mutate the workspace directly.
@@ -387,9 +390,10 @@ pub(crate) const RAW_SHELL_DENYLIST: &[&str] = &[
/// raw-shell denial installs it and no narrower deny list does.
///
/// Read by the tests that assert the raw-shell denial actually landed. It is
/// deliberately *not* what the execution envelope consults for shell
/// authority — see [`SHELL_AUTHORITY_SENTINEL`] for why those are two
/// different questions.
/// deliberately *not* what decides a child's shell authority — that question
/// is answered by the resolved grant's `shell` axis, which distinguishes
/// "no process surface" from "bounded verification surface" by grant field
/// rather than by a sentinel name.
#[cfg_attr(not(test), expect(dead_code))]
pub(crate) const RAW_SHELL_SENTINEL: &str = "exec_shell";
@@ -407,11 +411,13 @@ pub(crate) const VERIFICATION_SURFACE_DENYLIST: &[&str] = &["Run", "run_tests",
/// Distinct from [`RAW_SHELL_SENTINEL`], and the distinction is the point.
/// `exec_shell` is installed whenever the *raw* shell is removed, which
/// includes the write-denied verifier that still holds shell authority — so
/// reading shell authority off it reports every verifier as shell-less and
/// takes the verification surface away from the one role that exists to use
/// it. `run_tests` is installed only when the shell *ceiling* itself is
/// narrower than `full`, which is exactly the posture that has no authority to
/// start a process.
/// reading shell authority off it would take the verification surface away
/// from the one role that exists to use it. `run_tests` is installed only
/// when the shell *ceiling* itself is narrower than `full`, which is exactly
/// the posture that has no authority to start a process. The grant folds
/// this sentinel into its `shell` axis at resolve time: `Verify`/`Full`
/// collapse to `None`, while `Inspect` — classifier-bounded evidence reads,
/// not process-start authority — survives.
pub(crate) const SHELL_AUTHORITY_SENTINEL: &str = "run_tests";
/// Execution primitives that are **not** spelled as shell.
+107 -108
View File
@@ -50,8 +50,7 @@ use crate::dependencies::{ExternalTool, Git};
/// consumes. Existing `tools::subagent::FleetRole` paths keep resolving.
pub use crate::fleet::role::FleetRole;
use crate::fleet::role::{
FLEET_ROLE_SCHEMA_VALUES, NETWORK_DENIAL_SENTINEL, SHELL_AUTHORITY_SENTINEL,
VALID_ROLE_ALIASES, migrate_legacy_role_token, public_role_label,
FLEET_ROLE_SCHEMA_VALUES, VALID_ROLE_ALIASES, migrate_legacy_role_token, public_role_label,
};
use crate::llm_client::{LlmClient, LlmError};
use crate::reasoning_preference::ReasoningEffort;
@@ -9509,8 +9508,7 @@ fn parse_agent_ref(input: &Value) -> Result<Option<String>, ToolError> {
/// #5186: whether an `agent action=start` call asks for exactly a canonical
/// read-only Fleet role and nothing that could widen it. Those spawns run
/// without an approval modal in the default posture because the child's own
/// posture gates (`role_posture_permits`, `SubAgentToolRegistry`) enforce
/// read-only behavior from the inside.
/// grant (`SubAgentToolRegistry`) enforces read-only behavior from the inside.
///
/// Anything this parser cannot prove read-only stays gated: no role token
/// (defaults to `worker`), an unparseable role, a `profile`
@@ -16750,20 +16748,6 @@ impl SubAgentToolSurface {
/// narrow what it can execute. Read-only bash operations are classified Auto,
/// so Scouts and Reviewers do not gain general shell authority through this
/// branch.
fn role_posture_permits(agent_type: &FleetRole, approval: ApprovalRequirement) -> bool {
if matches!(agent_type, FleetRole::Custom) {
return true;
}
let profile = WorkerRuntimeProfile::for_role(agent_type.clone());
match approval {
ApprovalRequirement::Auto => true,
ApprovalRequirement::Suggest => profile.permissions.write,
ApprovalRequirement::Required => {
matches!(profile.shell, crate::worker_profile::ShellPolicy::Full)
}
}
}
/// Intersect an explicit parent scope with the child's requested subset.
///
/// A child can narrow an explicit parent scope, never widen it. Omitting the
@@ -16810,18 +16794,22 @@ fn policy_tool_name_matches(rule: &str, name: &str) -> bool {
}
struct SubAgentToolRegistry {
// `None` means the role-defined surface; `Some` is the already-intersected
// parent/child scope. Deny rules always win, including canonical prefixes.
allowed_tools: Option<Vec<String>>,
/// The one authority projection this child runs under (#5633): role
/// preset ∩ parent-derived profile ∩ caller scope, resolved once at
/// construction. Catalog visibility and dispatch refusals read the same
/// fields, so a tool that is visible is callable and a denied tool never
/// appears. `runtime_profile` is the persisted/transport record the grant
/// was resolved from — never a second answer.
grant: crate::worker_profile::ChildGrant,
// Operator/ancestor deny rules. Deny always wins, including canonical
// prefixes; the grant decides what the surface *is*, these names are
// removed from whatever it granted.
disallowed_tools: Vec<String>,
// Approval posture is separate from authority. Auto approval can remove a
// prompt, but cannot restore a tool removed by role, scope, or envelope.
auto_approve: bool,
accept_edits: bool,
agent_type: FleetRole,
runtime_profile: WorkerRuntimeProfile,
// Depth is the only special authority governing nested `agent` calls.
can_spawn_child: bool,
// Every mutation is attributed to the child and checked against its live
// coordination claim before the underlying registry sees the call.
owner_agent_id: String,
@@ -16882,26 +16870,34 @@ impl SubAgentToolRegistry {
let coordination_manager = Arc::clone(&runtime.manager);
let mut surface_options = runtime.agent_tool_surface_options.clone();
// Shell authority is an intersection: a parent cannot delegate more
// than it owns, and read-only inspection roles (Scout, Reviewer) are
// narrowed to the hardened read-only classifier even when the parent
// has a full shell.
// than it owns. The role preset narrows it further inside the grant
// below — read-only inspection roles get the bounded evidence shell,
// the verifier gets the bounded verification surface — rather than a
// second role-keyed clamp.
let parent_shell = if tool_denied(Some(&runtime.context.disallowed_tools), "bash") {
ShellPolicy::None
} else {
ShellPolicy::from_legacy_allow_shell(runtime.allow_shell)
};
let mut child_shell = runtime.worker_profile.shell.min_with(parent_shell);
if crate::fleet::role::role_requires_read_only_shell(&agent_type)
&& child_shell.allows_shell()
{
child_shell = ShellPolicy::ReadOnly;
}
let child_shell = runtime.worker_profile.shell.min_with(parent_shell);
let mut effective_profile = runtime.worker_profile.clone();
effective_profile.shell = child_shell;
let allowed_tools =
intersect_explicit_tool_scope(&effective_profile.tools, explicit_allowed_tools);
surface_options.shell_policy = child_shell;
let mut context = runtime.context.clone().with_shell_policy(child_shell);
let scope = intersect_explicit_tool_scope(&effective_profile.tools, explicit_allowed_tools);
// The one authority object: role preset ∩ effective profile ∩ scope.
// Everything after this line — registration policy, catalog filter,
// dispatch refusal, execution envelope — is a projection of `grant`.
let grant = crate::worker_profile::ChildGrant::resolve(
&agent_type,
&effective_profile,
scope,
can_spawn_child,
);
effective_profile.shell = grant.shell_policy();
surface_options.shell_policy = grant.shell_policy();
let mut context = runtime
.context
.clone()
.with_shell_policy(grant.shell_policy());
context.disallowed_tools = effective_profile.denied_tools.clone();
let mut child_runtime = runtime.clone();
child_runtime.parent_agent_id = Some(owner_agent_id.clone());
@@ -16940,13 +16936,11 @@ impl SubAgentToolRegistry {
}
Self {
allowed_tools,
grant,
disallowed_tools: effective_profile.denied_tools.clone(),
auto_approve: runtime.context.auto_approve,
accept_edits: runtime.accept_edits,
agent_type,
runtime_profile: effective_profile,
can_spawn_child,
owner_agent_id,
owner_agent_name,
coordination_manager,
@@ -16979,7 +16973,7 @@ impl SubAgentToolRegistry {
// accept Suggest edits for any write-capable posture. #5186:
// children inherit the session's in-workspace write carve-out
// (#5185) too.
let may_write = self.runtime_profile.permissions.write
let may_write = self.grant.files == crate::worker_profile::FileGrant::Write
&& (self.accept_edits || Self::role_can_delegate_writes(&self.agent_type));
(!may_write && !self.workspace_write_carve_out_permits(name, input)).then(|| {
format!(
@@ -17506,7 +17500,7 @@ impl SubAgentToolRegistry {
// This is a bounded convenience for write-capable children, not an
// authority escalation: every target must resolve inside the workspace
// and still pass sensitive-path, repository-law, and claim checks.
if !self.runtime_profile.permissions.write {
if self.grant.files != crate::worker_profile::FileGrant::Write {
return false;
}
crate::core::authority::paths_within_workspace_write_carve_out(
@@ -17539,8 +17533,7 @@ impl SubAgentToolRegistry {
) {
ApprovalRequirement::Auto => true,
ApprovalRequirement::Suggest => {
self.runtime_profile.permissions.write
&& role_posture_permits(&self.agent_type, ApprovalRequirement::Suggest)
self.grant.files == crate::worker_profile::FileGrant::Write
}
ApprovalRequirement::Required => {
// An outbound read needs approval because its payload can
@@ -17558,11 +17551,11 @@ impl SubAgentToolRegistry {
// #5426 acceptance point 1: the bounded read-only shell.
// `allows_bounded_readonly_bash` admits canonical `bash`
// to the inspection roles through the raw-shell deny
// to the inspection grant through the raw-shell deny
// list; a call the agent read-only classifier proves
// mutation-free is Auto-class evidence, not a held
// mutation, so the gate must not demand `ShellPolicy::
// Full` for it. Judged by the same predicate
// mutation, so the gate must not demand a full shell
// grant for it. Judged by the same predicate
// `BashTool::execute` enforces under
// `ShellPolicy::ReadOnly` (shell.rs), so this admission
// can never widen past the execute-time refusal — the
@@ -17574,8 +17567,10 @@ impl SubAgentToolRegistry {
{
return true;
}
matches!(self.runtime_profile.shell, ShellPolicy::Full)
&& role_posture_permits(&self.agent_type, ApprovalRequirement::Required)
// `Verify` holds process-start authority for the bounded
// verification surface; the envelope below still refuses
// its ExecutesCode/WritesFiles calls by capability.
self.grant.shell >= crate::worker_profile::ShellGrant::Verify
}
},
None => true,
@@ -17590,35 +17585,30 @@ impl SubAgentToolRegistry {
}
/// Whether this child may surface and dispatch the canonical lowercase
/// `bash` tool under the read-only inspection posture.
/// `bash` tool under the read-only inspection grant.
///
/// Scout, Reviewer, and Planner keep exactly one shell entry point —
/// The `Inspect` shell grant keeps exactly one shell entry point —
/// canonical `bash` — whose concrete calls the strict read-only
/// classifier bounds.
/// Catalog visibility and dispatch authorization consult this same
/// predicate, so a tool that appears on the wire can always be called and
/// one that is denied never appears.
///
/// The spawn clamp keeps the raw-shell deny list (legacy `Bash` /
/// `exec_shell` rules and the [`RAW_SHELL_SENTINEL`]) installed, because
/// removing it would read as "this child has raw shell". Instead the
/// catalog and dispatch admit canonical `bash` here, behind the same
/// input-specific read-only classifier the legacy carve-out used; every
/// other role still loses bash to the raw-shell rules.
/// An `Inspect` grant keeps the raw-shell deny entries (legacy `Bash` /
/// `exec_shell` rules) installed, because removing them would read as
/// "this child has raw shell". Instead the catalog and dispatch admit
/// canonical `bash` here, behind the same input-specific read-only
/// classifier the legacy carve-out used; every narrower or wider shell
/// grant still loses bash to the raw-shell rules.
///
/// The name match is **exact**, not case-insensitive. Legacy `Bash` is a
/// hidden execution-compatibility alias for saved transcripts, and it is
/// exactly what the raw-shell deny list names; admitting it here through a
/// case-insensitive compare would hand these roles back the raw shell this
/// case-insensitive compare would hand the child back the raw shell this
/// carve-out exists to withhold. Only the canonical lowercase tool the
/// first-turn catalog actually offers is bounded by the classifier.
fn allows_bounded_readonly_bash(&self, name: &str) -> bool {
name == "bash"
&& matches!(
&self.agent_type,
FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner
)
&& self.runtime_profile.shell != crate::worker_profile::ShellPolicy::None
name == "bash" && self.grant.shell == crate::worker_profile::ShellGrant::Inspect
}
fn legacy_action_alias(family: &str, action: &str) -> Option<&'static str> {
@@ -17646,7 +17636,7 @@ impl SubAgentToolRegistry {
{
return false;
}
match &self.allowed_tools {
match &self.grant.scope {
None => true,
Some(list) => {
list.iter().any(|name| name.eq_ignore_ascii_case(family))
@@ -17656,13 +17646,13 @@ impl SubAgentToolRegistry {
}
fn is_tool_allowed(&self, name: &str) -> bool {
if name == "agent" && !self.can_spawn_child {
if name == "agent" && !self.grant.spawn {
return false;
}
if self.is_tool_denied(name) && !self.allows_bounded_readonly_bash(name) {
return false;
}
match &self.allowed_tools {
match &self.grant.scope {
None => true,
Some(list) => {
explicit_scope_permits(list, name)
@@ -17673,19 +17663,31 @@ impl SubAgentToolRegistry {
}
}
fn role_blocks_unhardened_process_tool(&self, name: &str) -> bool {
// Catalog filtering is defense in depth. Scout/Reviewer see only the
// hardened evidence profile; Verifier may receive bounded Run but not
// any raw or session-oriented shell path. Dispatch repeats this check.
/// Whether the grant removes `name` from the surface entirely — the
/// catalog filter and the dispatch refusal consult this same predicate,
/// so a tool that is hidden is never callable by spelling.
fn grant_blocks_tool(&self, name: &str) -> bool {
let lower = name.to_ascii_lowercase();
// Desktop / machine-control is never in a child's grant.
if !self.grant.desktop && is_machine_control_tool(name) {
return true;
}
let evidence_tool = crate::tools::registry::readonly_evidence_tool_name(name)
|| self
.registry
.get(name)
.is_some_and(|tool| crate::tools::registry::readonly_evidence_tool(tool.as_ref()));
let bounded_inspection = matches!(&self.agent_type, FleetRole::Scout | FleetRole::Reviewer)
// The Evidence surface admits only the hardened evidence tools and
// `agent` (delegation).
if self.grant.surface == crate::worker_profile::ToolSurface::Evidence
&& lower != "agent"
&& !evidence_tool;
&& !evidence_tool
{
return true;
}
// Below a Full shell grant the raw process surface is gone — except
// canonical `bash` under Inspect, which the read-only classifier
// bounds at dispatch.
let raw_shell = lower == "bash"
|| lower.starts_with("exec_shell")
|| matches!(
@@ -17693,34 +17695,33 @@ impl SubAgentToolRegistry {
"exec_wait" | "exec_interact" | "task_shell_start" | "task_shell_wait"
)
|| lower.starts_with("terminal/");
bounded_inspection || matches!(&self.agent_type, FleetRole::Verifier) && raw_shell
raw_shell
&& self.grant.shell < crate::worker_profile::ShellGrant::Full
&& !self.allows_bounded_readonly_bash(name)
}
fn network_is_denied(&self) -> bool {
// Network denial has two sources: the resolved permission profile and
// the exact-fleet sentinel. Either one is sufficient to deny a call.
!self.runtime_profile.permissions.network || self.is_tool_denied(NETWORK_DENIAL_SENTINEL)
!self.grant.network
}
fn write_is_denied(&self) -> bool {
!self.runtime_profile.permissions.write
self.grant.files != crate::worker_profile::FileGrant::Write
}
/// Whether the grant holds process-start authority — the bounded
/// verification surface and anything stronger. `Inspect`'s read-only
/// evidence commands are Auto-classified exceptions, not shell authority.
fn shell_is_denied(&self) -> bool {
// Likewise, shell requires both a Full profile and no explicit shell
// sentinel. Read-only evidence commands are Auto-classified exceptions,
// not a Full-shell grant.
!matches!(self.runtime_profile.shell, ShellPolicy::Full)
|| self.is_tool_denied(SHELL_AUTHORITY_SENTINEL)
self.grant.shell < crate::worker_profile::ShellGrant::Verify
}
fn execution_envelope(&self) -> crate::tools::execution_envelope::ExecutionEnvelope {
// Keep the network sentinel distinct from the broader permission bit:
// read-only Web evidence remains visible when policy allows it, while
// arbitrary network-reaching inputs are rejected separately at dispatch.
// The capability envelope is the grant's own projection: catalog and
// dispatch derive it from the same fields, never from a deny-list
// sentinel or a role re-mapping that could disagree with them.
crate::tools::execution_envelope::ExecutionEnvelope {
write: !self.write_is_denied(),
network: !self.is_tool_denied(NETWORK_DENIAL_SENTINEL),
write: self.grant.files == crate::worker_profile::FileGrant::Write,
network: self.grant.network,
shell: !self.shell_is_denied(),
}
}
@@ -17806,17 +17807,15 @@ impl SubAgentToolRegistry {
if !matches!(action, "claim" | "release") {
return true;
}
!self.write_is_denied() && !self.is_tool_denied("agents/coordinate")
self.grant.files == crate::worker_profile::FileGrant::Write
&& !self.is_tool_denied("agents/coordinate")
}
fn visibility_representative_input(&self, name: &str) -> Option<Value> {
// Visibility and dispatch consult the same capability guard. These
// representative calls let a read-only bash schema survive catalog
// shaping without treating an empty input as arbitrary shell authority.
if !matches!(
&self.agent_type,
FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner
) {
if self.grant.shell != crate::worker_profile::ShellGrant::Inspect {
return None;
}
match name {
@@ -17832,7 +17831,7 @@ impl SubAgentToolRegistry {
// execute() repeats role, scope, posture, envelope, and claim checks.
let _ = agent_type;
let api_tools = self.registry.to_api_tools();
let filtered = match &self.allowed_tools {
let filtered = match &self.grant.scope {
None => api_tools,
Some(list) => api_tools
.into_iter()
@@ -17856,11 +17855,11 @@ impl SubAgentToolRegistry {
};
let mut tools = filtered
.into_iter()
.filter(|tool| tool.name != "agent" || self.can_spawn_child)
.filter(|tool| tool.name != "agent" || self.grant.spawn)
.filter(|tool| {
!self.is_tool_denied(&tool.name) || self.allows_bounded_readonly_bash(&tool.name)
})
.filter(|tool| !self.role_blocks_unhardened_process_tool(&tool.name))
.filter(|tool| !self.grant_blocks_tool(&tool.name))
.filter(|tool| {
let representative = self.visibility_representative_input(&tool.name);
tool.name == "File"
@@ -17898,13 +17897,12 @@ impl SubAgentToolRegistry {
return false;
};
let posture_allows = tool.name != "File"
|| (self.runtime_profile.permissions.write
&& role_posture_permits(&self.agent_type, ApprovalRequirement::Suggest))
|| self.grant.files == crate::worker_profile::FileGrant::Write
|| matches!(action, "read" | "list" | "search_name" | "search_content");
let evidence_action =
!matches!(&self.agent_type, FleetRole::Scout | FleetRole::Reviewer)
|| tool.name != "Web"
|| matches!(action, "search" | "fetch");
let evidence_action = self.grant.surface
!= crate::worker_profile::ToolSurface::Evidence
|| tool.name != "Web"
|| matches!(action, "search" | "fetch");
let mut representative = self
.visibility_representative_input(&tool.name)
.unwrap_or_else(|| json!({}));
@@ -17958,7 +17956,7 @@ impl SubAgentToolRegistry {
);
// A tool-free child (explicit empty scope) has nothing to discover:
// drop tool_search as well so the wire request genuinely omits tools.
let discoverable = !self.allowed_tools.as_ref().is_some_and(Vec::is_empty);
let discoverable = !self.grant.scope.as_ref().is_some_and(Vec::is_empty);
catalog.retain(|tool| {
(discoverable && tool.name == TOOL_SEARCH_NAME) || self.registry.contains(&tool.name)
});
@@ -17967,7 +17965,7 @@ impl SubAgentToolRegistry {
}
fn unavailable_allowed_tools(&self) -> Vec<String> {
match &self.allowed_tools {
match &self.grant.scope {
None => Vec::new(),
Some(list) => list
.iter()
@@ -17991,18 +17989,18 @@ impl SubAgentToolRegistry {
// message is the same for every role. The catalog removal at spawn
// makes this unreachable in practice; the executor refusal keeps
// hiding from being the only defense (#6296, #6298).
if is_machine_control_tool(name) {
if !self.grant.desktop && is_machine_control_tool(name) {
return Err(admission_denied(format!(
"[tool.family.denied] Desktop/computer-control tool `{name}` is not available to sub-agents. Run it in the parent session instead, or ask the user."
)));
}
if self.role_blocks_unhardened_process_tool(name) {
if self.grant_blocks_tool(name) {
return Err(admission_denied(format!(
"Tool {name} is not available to this read-only worker because its process path does not share the hardened evidence boundary. Use read/search, classifier-bounded bash reads, or the verifier's bounded Run tool instead."
)));
}
let action = input.get("action").and_then(Value::as_str);
if matches!(&self.agent_type, FleetRole::Scout | FleetRole::Reviewer)
if self.grant.surface == crate::worker_profile::ToolSurface::Evidence
&& name == "Web"
&& !matches!(action, Some("search" | "fetch"))
{
@@ -18031,7 +18029,8 @@ impl SubAgentToolRegistry {
} else if let Some(action) = action {
self.is_action_allowed(name, action)
} else {
self.allowed_tools
self.grant
.scope
.as_ref()
.is_none_or(|list| list.iter().any(|allowed| allowed == name))
};
+178 -52
View File
@@ -6943,65 +6943,183 @@ fn test_custom_agent_requires_allowed_tools() {
}
#[test]
fn role_posture_blocks_writes_and_shell_for_read_only_roles() {
// #3217: read-only roles may never run write/edit/patch tools, regardless
// of parent auto-approval, but can always read.
fn role_grants_block_writes_and_raw_shell_for_read_only_roles() {
// #3217/#5633: the role preset is the whole table. Read-only roles may
// never hold write authority, regardless of parent auto-approval, and the
// bounded shell grants are exactly Inspect (evidence reads) and Verify
// (the workspace's own checks).
use crate::worker_profile::{ChildGrant, FileGrant, ShellGrant, ToolSurface};
let full_parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
for role in [
FleetRole::Scout,
FleetRole::Reviewer,
FleetRole::Planner,
FleetRole::Verifier,
FleetRole::Consultant,
] {
let grant = ChildGrant::resolve(&role, &full_parent, None, true);
assert_eq!(grant.files, FileGrant::Read, "{role:?} must never write");
assert!(
!role_posture_permits(&role, ApprovalRequirement::Suggest),
"{role:?} must not run write/edit/patch tools"
);
assert!(
role_posture_permits(&role, ApprovalRequirement::Auto),
"{role:?} can still read"
grant.network,
"{role:?} keeps network reach — reads need it"
);
assert!(!grant.desktop, "{role:?} never drives the desktop");
}
// Write-capable roles keep write access.
for role in [FleetRole::Builder, FleetRole::Worker] {
assert!(
role_posture_permits(&role, ApprovalRequirement::Suggest),
"{role:?} writes"
);
// Explore/reviewer run on the evidence surface; planner on the inherited
// read surface; advisor carries no process surface at all.
for role in [FleetRole::Scout, FleetRole::Reviewer] {
let grant = ChildGrant::resolve(&role, &full_parent, None, true);
assert_eq!(grant.surface, ToolSurface::Evidence, "{role:?}");
assert_eq!(grant.shell, ShellGrant::Inspect, "{role:?}");
}
let planner = ChildGrant::resolve(&FleetRole::Planner, &full_parent, None, true);
assert_eq!(planner.surface, ToolSurface::Inherited);
assert_eq!(planner.shell, ShellGrant::Inspect);
let consultant = ChildGrant::resolve(&FleetRole::Consultant, &full_parent, None, true);
assert_eq!(consultant.shell, ShellGrant::None);
let verifier = ChildGrant::resolve(&FleetRole::Verifier, &full_parent, None, true);
assert_eq!(verifier.shell, ShellGrant::Verify);
// Write-capable roles resolve to the full grant under a full parent.
for role in [FleetRole::Builder, FleetRole::Worker, FleetRole::Custom] {
let grant = ChildGrant::resolve(&role, &full_parent, None, true);
assert_eq!(grant.files, FileGrant::Write, "{role:?} writes");
assert_eq!(grant.shell, ShellGrant::Full, "{role:?} has full shell");
}
// Only Full-shell roles may run shell (Required) tools. Scout/reviewer
// now carry the read-only inspection posture (full shell authority, bounded verification
// surface; raw shell still requires write and stays denied by the clamp),
// so they join verifier/builder/worker. Planner's declared posture is
// read-only probes (Auto-classified bash), not Required/raw shell.
for role in [
FleetRole::Verifier,
FleetRole::Builder,
FleetRole::Worker,
FleetRole::Scout,
FleetRole::Reviewer,
] {
assert!(
role_posture_permits(&role, ApprovalRequirement::Required),
"{role:?} has full shell"
);
// The grant can never exceed the parent: a read-only, shell-less parent
// clamps every role, including custom.
let read_only_parent = WorkerRuntimeProfile::for_role(FleetRole::Consultant);
for role in FleetRole::all() {
let grant = ChildGrant::resolve(&role, &read_only_parent, None, true);
assert_ne!(grant.files, FileGrant::Write, "{role:?} under read-only");
assert_eq!(grant.shell, ShellGrant::None, "{role:?} under shell-less");
}
assert!(
!role_posture_permits(&FleetRole::Planner, ApprovalRequirement::Required),
"Planner must not run raw/Required shell; read-only probes are Auto"
);
// Custom passes the role-only check; its explicit allowlist, bounded write
// authority, and parent-intersected runtime profile are enforced together.
assert!(role_posture_permits(
&FleetRole::Custom,
ApprovalRequirement::Suggest
));
assert!(role_posture_permits(
&FleetRole::Custom,
ApprovalRequirement::Required
));
// `tools = false` is total: an empty scope grants no file access at all.
let no_tools = ChildGrant::resolve(&FleetRole::Worker, &full_parent, Some(Vec::new()), true);
assert_eq!(no_tools.files, FileGrant::None);
}
/// #5633: catalog visibility and execution denial are one grant projection.
/// For every role, a name the grant removes never reaches the model-visible
/// catalog AND is refused at dispatch; a surface the grant holds stays
/// visible. Asserted against the real child registry.
#[tokio::test]
async fn issue_5633_catalog_and_dispatch_are_one_grant() {
use crate::worker_profile::{FileGrant, ShellGrant};
for role in FleetRole::all() {
let tmp = tempdir().expect("tempdir");
let mut runtime =
stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options());
runtime.context = ToolContext::new(tmp.path().to_path_buf());
runtime.allow_shell = true;
runtime.worker_profile = WorkerRuntimeProfile::for_role(FleetRole::Worker);
let registry = SubAgentToolRegistry::new(
runtime,
role.clone(),
None,
Arc::new(Mutex::new(TodoList::new())),
Arc::new(Mutex::new(PlanState::default())),
);
let catalog: std::collections::HashSet<String> = registry
.tools_for_model(&role)
.into_iter()
.map(|tool| tool.name)
.collect();
let full_shell = registry.grant.shell == ShellGrant::Full;
// Raw process surface: visible exactly under a Full grant, and a
// hidden name is refused at dispatch by the same grant — the child
// can never call what it cannot see. `Bash`/`exec_shell` are hidden
// execution-compatibility aliases, so they never appear in any
// catalog — under a non-Full grant they are still refused by name.
for name in ["terminal/run", "terminal/send", "task_shell_start"] {
assert_eq!(
catalog.contains(name),
full_shell,
"{role:?}: {name} visibility must equal the shell grant"
);
}
for name in ["Bash", "exec_shell", "terminal/run", "task_shell_start"] {
assert!(!catalog.contains(name) || full_shell, "{role:?}: {name}");
if !full_shell {
let dispatch = registry
.execute(
"child",
name,
json!({"command": "echo probe", "input": "echo probe"}),
)
.await;
assert!(
dispatch.is_err(),
"{role:?}: {name} must be refused at dispatch"
);
}
}
// The bounded inspection bash: only the Inspect and Full grants
// surface canonical `bash` — Inspect's calls are classifier-bounded
// at dispatch, and the Verify/None grants see no shell spelling at all.
assert_eq!(
catalog.contains("bash"),
matches!(registry.grant.shell, ShellGrant::Inspect | ShellGrant::Full),
"{role:?}: bash visibility must equal the Inspect/Full grant"
);
// The bounded verification surface rides on process-start
// authority: Verify and Full keep `Run`, Inspect does not.
// (`run_tests`/`run_verifiers` are hidden compat aliases governed by
// the same gate at dispatch.)
assert_eq!(
catalog.contains("Run"),
registry.grant.shell >= ShellGrant::Verify,
"{role:?}: Run visibility must equal process-start authority"
);
// File mutation actions appear in the `File` family's advertised
// enum exactly when the grant writes.
if let Some(file_tool) = registry
.tools_for_model(&role)
.into_iter()
.find(|tool| tool.name == "File")
{
let actions: Vec<String> = file_tool.input_schema["properties"]["action"]["enum"]
.as_array()
.map(|actions| {
actions
.iter()
.filter_map(|action| action.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let writes_visible = actions
.iter()
.any(|action| matches!(action.as_str(), "write" | "edit" | "patch"));
assert_eq!(
writes_visible,
registry.grant.files == FileGrant::Write,
"{role:?}: File mutation actions must match the write grant"
);
}
// A grant-blocked name is refused at dispatch with a reason that
// names an alternative — never a bare "unknown tool" that a model
// could mistake for a typo.
if !full_shell {
let error = registry
.execute("child", "terminal/run", json!({"command": "echo probe"}))
.await
.expect_err("a grant-blocked tool must fail at dispatch")
.to_string();
assert!(
error.contains("not available") || error.contains("not allowed"),
"{role:?}: dispatch refusal must explain, got: {error}"
);
}
}
}
#[test]
@@ -8720,7 +8838,7 @@ async fn read_only_roles_expose_and_dispatch_lowercase_bash_only() {
.expect_err("legacy Bash must stay denied")
.to_string();
assert!(
error.contains("not allowed"),
error.contains("hardened evidence boundary"),
"{role:?} legacy Bash refusal: {error}"
);
@@ -8758,7 +8876,7 @@ async fn read_only_roles_expose_and_dispatch_lowercase_bash_only() {
] {
assert!(registry.is_tool_allowed(name), "{role:?} must allow {name}");
assert!(
!registry.role_blocks_unhardened_process_tool(name),
!registry.grant_blocks_tool(name),
"{role:?} must not hide proven read-only tool {name}"
);
}
@@ -8768,7 +8886,7 @@ async fn read_only_roles_expose_and_dispatch_lowercase_bash_only() {
"{role:?} must allow image_ocr"
);
assert!(
!registry.role_blocks_unhardened_process_tool("image_ocr"),
!registry.grant_blocks_tool("image_ocr"),
"{role:?} must not hide proven read-only tool image_ocr"
);
}
@@ -12226,7 +12344,10 @@ async fn prompt_only_general_cannot_mutate_under_parent_auto_approve() {
)
.await
.expect_err("read-only General must not receive mutating shell");
assert!(shell_error.to_string().contains("not registered"));
assert!(
shell_error.to_string().contains("not available"),
"got: {shell_error}"
);
assert!(!tmp.path().join("forbidden.txt").exists());
assert!(!tmp.path().join("shell.txt").exists());
}
@@ -19999,7 +20120,10 @@ fn shell_denial_from_parent_is_not_a_read_only_role_exception() {
explicit_rule.is_none()
);
if explicit_rule.is_some() {
assert_eq!(registry.runtime_profile.shell, ShellPolicy::None);
assert_eq!(
registry.grant.shell,
crate::worker_profile::ShellGrant::None
);
assert!(!registry.is_tool_allowed("bash"));
}
}
@@ -20509,8 +20633,10 @@ fn the_launched_authority_is_the_one_the_spawn_boundary_accepts() {
const READ_ONLY_CHILD_ENVELOPE_BYTE_CEILING: usize = 89_000;
/// Measured 85,913B on 2026-09-15 with the always-on session recall tools
/// (#5715). Keep the next increase visible instead of adding another broad
/// margin.
const PARENT_SURFACE_BYTE_CEILING: usize = 86_000;
/// margin. Re-measured at 87,529B on 2026-09-17, with the bounded Git
/// fetch / merge_tree verify tools (b89349286f) and this slice's grant
/// text both in the shared catalog.
const PARENT_SURFACE_BYTE_CEILING: usize = 88_000;
#[tokio::test]
async fn read_only_child_envelope_stays_within_measured_ceiling() {
+209
View File
@@ -120,6 +120,215 @@ pub enum ToolScope {
Explicit(Vec<String>),
}
/// File-system authority axis of a [`ChildGrant`]. Ordered least to most
/// permissive so `Ord::min` is the non-escalation primitive.
///
/// `None` is not "no writes" — it is "no file access of any kind", which is
/// what a child with an empty tool surface experiences. `Read` permits
/// evidence collection only; `Write` permits mutation inside the session's
/// write policy (workspace boundary, exact-file claims, approval posture).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum FileGrant {
None,
Read,
Write,
}
/// Process authority axis of a [`ChildGrant`]. Ordered least to most
/// permissive so `Ord::min` is the non-escalation primitive.
///
/// `Inspect` is the read-only shell: canonical `bash` only, and only calls
/// the agent read-only classifier proves mutation-free (the explore /
/// reviewer / planner posture). `Verify` is the bounded built-in verification
/// surface — default workspace checks, pure test selection, and bounded Git
/// fetch/merge-tree — with no shell grammar (the test/verifier posture). A
/// child asking for a surface its parent does not hold degrades to the
/// narrower side (`Inspect ∩ Verify = Inspect`): bounded inspection is a
/// subset of bounded process authority, never a widening.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ShellGrant {
None,
Inspect,
Verify,
Full,
}
/// The named tool surface a grant exposes, before the explicit scope narrows
/// it. This is the role-preset axis of `tools` — distinct from
/// [`ChildGrant::scope`], which is the caller's own allowlist.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolSurface {
/// Every registered tool the grant's other axes admit.
Inherited,
/// The read-only evidence surface — `readonly_evidence_tool` plus
/// `agent` (delegation). The explore / reviewer preset.
Evidence,
}
/// The single authority object a delegated child runs under (#5633).
///
/// Roles are *presets over this object*, never a second permission system:
/// [`ChildGrant::for_role`] gives the widest grant a role can hold, and
/// [`ChildGrant::resolve`] intersects it with the effective parent-derived
/// profile and the caller's explicit scope. One projection serves every
/// consumer — the child's tool catalog, its dispatch refusals, and its
/// capability envelope all read the same fields, so a tool that is visible
/// is callable and a tool that is denied never appears.
///
/// This is deliberately a *projection*, not a second persisted record:
/// [`WorkerRuntimeProfile`] remains the wire/persistence shape and
/// [`ChildAuthority`](crate::fleet::role::ChildAuthority) /
/// [`ToolAuthorityEnvelope`](crate::tools::spec::ToolAuthorityEnvelope)
/// remain the transports that carry the same authority across the durable
/// Fleet and `codewhale exec` subprocess boundaries. What this object retires
/// is the *semantic* duplication — posture sentinel entries read back off a
/// deny list, and role-keyed re-derivation inside the registry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChildGrant {
/// File-system authority.
pub files: FileGrant,
/// Process authority.
pub shell: ShellGrant,
/// May reach the network through any surface.
pub network: bool,
/// May drive the operator's machine (computer-use / desktop tools).
/// Children never hold this today; the field is declared so every
/// surface reports the same denial rather than implying a grant.
pub desktop: bool,
/// The named tool surface before explicit scope narrowing.
pub surface: ToolSurface,
/// The caller's explicit allowlist, already intersected with the parent's
/// scope at spawn. `Some(vec![])` — the `tools = false` posture — permits
/// nothing at all.
pub scope: Option<Vec<String>>,
/// May delegate children of its own (remaining spawn depth).
pub spawn: bool,
}
impl ChildGrant {
/// The widest grant a role can ever hold — the role preset.
///
/// This is the role-to-authority table, expressed once: everything else
/// only narrows it. Read-only roles stay read-only on the workspace by
/// intent; network reach is a read, and a worker cut off from it for no
/// role reason cannot do its job. `desktop` is never in any preset.
#[must_use]
pub fn for_role(role: &FleetRole) -> Self {
let (files, shell, surface) = match role {
// Read-only investigators: evidence collection only, with the
// classifier-bounded inspection shell.
FleetRole::Scout | FleetRole::Reviewer => {
(FileGrant::Read, ShellGrant::Inspect, ToolSurface::Evidence)
}
// Planner: analysis only, same bounded shell, broader read
// surface — a plan may consult any read the session offers.
FleetRole::Planner => (FileGrant::Read, ShellGrant::Inspect, ToolSurface::Inherited),
// Counsel only: reads to ground advice, never acts — no process
// surface at all (#4752).
FleetRole::Consultant => (FileGrant::Read, ShellGrant::None, ToolSurface::Inherited),
// Verifier: bounded workspace checks and bounded Git reference
// reads, no shell grammar.
FleetRole::Verifier => (FileGrant::Read, ShellGrant::Verify, ToolSurface::Inherited),
// Doers, and Custom: the preset asks for everything; the parent
// profile and explicit scope do all the narrowing.
FleetRole::Builder | FleetRole::Worker | FleetRole::Custom => {
(FileGrant::Write, ShellGrant::Full, ToolSurface::Inherited)
}
};
Self {
files,
shell,
network: true,
desktop: false,
surface,
scope: None,
spawn: true,
}
}
/// Resolve the grant a child actually runs under: the role preset
/// intersected with the effective parent-derived profile, carrying the
/// caller's explicit scope and the remaining delegation depth.
///
/// Every field takes the more restrictive side, so the result can never
/// name authority the parent lacked. This is the in-process counterpart
/// of [`crate::fleet::role::ChildAuthority::clamp`].
#[must_use]
pub fn resolve(
role: &FleetRole,
profile: &WorkerRuntimeProfile,
scope: Option<Vec<String>>,
can_spawn: bool,
) -> Self {
let preset = Self::for_role(role);
let profile_shell = match profile.shell {
ShellPolicy::None => ShellGrant::None,
ShellPolicy::ReadOnly => ShellGrant::Inspect,
ShellPolicy::Full => ShellGrant::Full,
};
// The posture deny lists are the transport the ceiling clamps install;
// the grant folds their sentinels into the semantic axes so the two
// can never disagree. `fetch_url` stands for "no network"; `run_tests`
// stands for "no shell authority at all" — it removes process-start
// grants (`Verify`/`Full` → `None`) while `Inspect` survives, because
// classifier-bounded evidence reads were never shell authority.
// `exec_shell` (raw shell gone) is deliberately *not* folded: a
// write-denied verifier installs it while keeping shell authority.
let denied = |name: &str| {
crate::core::engine::tool_catalog::tool_denied(
Some(profile.denied_tools.as_slice()),
name,
)
};
let resolved_shell = preset.shell.min(profile_shell);
let shell = if denied(crate::fleet::role::SHELL_AUTHORITY_SENTINEL) {
match resolved_shell {
ShellGrant::Inspect => ShellGrant::Inspect,
_ => ShellGrant::None,
}
} else {
resolved_shell
};
let no_tools = matches!(scope.as_deref(), Some([]));
Self {
files: if no_tools {
FileGrant::None
} else {
preset.files.min(if profile.permissions.write {
FileGrant::Write
} else {
FileGrant::Read
})
},
shell,
network: preset.network
&& profile.permissions.network
&& !denied(crate::fleet::role::NETWORK_DENIAL_SENTINEL),
desktop: false,
surface: preset.surface,
scope,
spawn: can_spawn,
}
}
/// The transport shell policy the registered surface and `BashTool`
/// enforce for this grant.
///
/// `Verify` maps to `ShellPolicy::Full`: the bounded verification surface
/// is a process-start authority the profile transports as Full, while the
/// grant itself — not the transport — decides that raw shell names never
/// reach the catalog. Keeping the transport at Full also preserves the
/// ceiling a verifier may pass to its own children.
#[must_use]
pub const fn shell_policy(&self) -> ShellPolicy {
match self.shell {
ShellGrant::None => ShellPolicy::None,
ShellGrant::Inspect => ShellPolicy::ReadOnly,
ShellGrant::Verify | ShellGrant::Full => ShellPolicy::Full,
}
}
}
/// How a worker's model is selected. New model-facing spawns default to the
/// parent/session model; a child only takes a smaller/faster family sibling when
/// the parent explicitly asks for that route.
+10
View File
@@ -107,6 +107,16 @@ posture again. This containment is pinned by
`a_read_only_parents_delegation_never_widens_authority` in
`crates/tui/src/fleet/exact.rs` tests.
Inside the process, the resolved authority is one object —
`ChildGrant` in `crates/tui/src/worker_profile.rs`: `files`
(none/read/write), `shell` (none/inspect/verify/full), `network`, `desktop`
(never granted to a child), the named tool `surface`, the caller's explicit
`scope`, and remaining `spawn` depth. A role is a preset over that object
(`ChildGrant::for_role`); `ChildGrant::resolve` intersects it with the
parent-derived profile. The child's tool catalog, its dispatch refusals, and
its capability envelope all read the same fields — a tool that is visible is
callable, and a tool that is denied never appears.
The session's **permission posture** applies inside every child exactly as
it applies to the parent turn: under Auto-Review the same deterministic
floor and one-shot model guardian decide a worker's held calls (never a
+2
View File
@@ -41,6 +41,8 @@ Fleet 的八个规范角色名是 `general`、`explore`、`planner`、`reviewer`
**委派移动的是工作,绝不是权限。** 只读父代理可以委派给 `implement`,但子代理的写入、网络、shell 和工具权限仍受父代理实时权限限制。检查角色可以使用分类的只读 shell;平台提供原生强制隔离时,也可以使用显式的只读分析模式。角色名称或 `read_only` 标志都不能授予调用方原本没有的 shell 权限。`fleet/exact.rs` 中的 `ChildAuthority::clamp` 对每个权限字段取较窄的值,并合并拒绝列表;`inherit_disallowed_tools: false` 不能删除操作者或祖先的拒绝规则。恢复保存的 worker 时,还会再次与当前调用方权限求交。测试 `a_read_only_parents_delegation_never_widens_authority` 验证此边界。
进程内部,解析后的权限只有一个对象——`crates/tui/src/worker_profile.rs` 中的 `ChildGrant`:`files`(none/read/write)、`shell`(none/inspect/verify/full)、`network`、`desktop`(子代理一律不授予)、命名工具 `surface`、调用方的显式 `scope`,以及剩余 `spawn` 深度。角色只是该对象上的预设(`ChildGrant::for_role`);`ChildGrant::resolve` 将它与父代理派生的配置求交。子代理的工具目录、分发拒绝和能力包络读取同一份字段——可见即可调,被拒即不可见。
会话的**权限姿态**在每个子代理内部的应用方式与父代理回合完全一致:在 Auto-Review 下,同一个确定性底线和一次性模型守护者决定 worker 的被扣留调用(绝不是提示词;守护者不可用时拒绝,fail closed);在 Ask 下,角色无法委派的被扣留调用会作为审批提示在父代理的 UI 中弹出,worker 可见地等待(`waiting for user`),或者在无法提示的主机上带着原因被拒绝;Full Access 仍然在不可绕过的安全底线上 fail closed。每一次没有人被提示的决策都是该 worker 转录中的一行备注(聚焦时可见)和一条审计日志记录。参见 `docs/MODES.md`。
每个角色的完整系统提示词位于 `crates/tui/src/tools/subagent/mod.rs`(搜索 `*_AGENT_INTRO`)。提示词前缀在子代理启动时自动加载;父代理的委派提示词成为第一个回合的用户消息。