diff --git a/crates/tui/src/fleet/executor.rs b/crates/tui/src/fleet/executor.rs index a508e8545e..3871f1a970 100644 --- a/crates/tui/src/fleet/executor.rs +++ b/crates/tui/src/fleet/executor.rs @@ -243,8 +243,14 @@ pub(crate) fn authority_envelope_for_worker( && spec.runtime_profile.shell.allows_shell() { // Scout, Reviewer, and Planner get one classifier-bounded - // foreground shell. Verifiers keep the dedicated Run surface, - // consultants remain shell-less, and writers retain the historical + // foreground shell. On this executor path the Verifier keeps only + // the dedicated Run surface below. (The classifier-bounded `bash` + // evidence lane named by + // `SubAgentToolRegistry::allows_bounded_readonly_bash` is an + // in-session delegation surface: a fleet member spawned here has no + // in-process subagent registry at all, so it never holds that lane — + // see the fleet mutual-exclusivity note in docs/SUBAGENTS.md.) + // Consultants remain shell-less, and writers retain the historical // subprocess contract. ToolShellAuthority::ReadOnly } else { diff --git a/crates/tui/src/fleet/manager.rs b/crates/tui/src/fleet/manager.rs index ec3257002e..8a2df63982 100644 --- a/crates/tui/src/fleet/manager.rs +++ b/crates/tui/src/fleet/manager.rs @@ -4884,7 +4884,16 @@ esac !permissions.write, "verifier receipt {key} must stay read-only" ); - assert_eq!(permissions.shell, "full"); + // The verifier joined the read-only-shell roles, so the + // receipt records the narrowed effective posture (the old + // `full` label overclaimed a raw shell the clamp always + // denied). On the fleet envelope the verifier holds the + // gates with no bash surface at all — the classifier + // probe lane is in-session only; in-session, its bash is + // the classifier-bounded lane. The built-in verification + // surface stays keyed on the shell ceiling, not on this + // label. + assert_eq!(permissions.shell, "read_only"); } role => panic!("unexpected receipt role for {key}: {role:?}"), } diff --git a/crates/tui/src/fleet/role.rs b/crates/tui/src/fleet/role.rs index fe729af6b7..7839988404 100644 --- a/crates/tui/src/fleet/role.rs +++ b/crates/tui/src/fleet/role.rs @@ -77,10 +77,12 @@ pub enum FleetRole { /// posture pushes hard on landing the change cleanly with the /// minimum surrounding edit (#404). Builder, - /// Verification — focused on running the test suite or other - /// validation gates and reporting pass/fail with evidence. - /// Distinct from `Reviewer` in that Reviewer reads code and grades it; - /// Verifier *runs* tests and reports the outcome (#404). + /// Verification — runs the built-in verification gates and reports + /// pass/fail with evidence; the gates are refused when a read-only-shell + /// parent clamps the child's shell authority. In-session delegations + /// also carry the bounded read-only probe lane. Distinct from `Reviewer` + /// in that Reviewer reads code and grades it; Verifier *runs* the gates + /// and reports the outcome (#404). Verifier, /// Advisory counsel — a strong-model second opinion the operator can ask /// for guidance, judgement calls, and design critique (#4752). @@ -193,7 +195,7 @@ impl FleetRole { "Lands focused code changes cleanly with the minimum surrounding edit." } Self::Verifier => { - "Runs the test suite and validation gates, reports pass/fail with evidence." + "Runs the built-in verification gates with test-selection arguments unless a read-only-shell parent clamped its shell authority (the clamped child keeps the bounded read-only probes and loses the gates), plus — for in-session delegations — a bounded read-only shell for evidence gathering; cannot execute custom commands — use a write-capable role for those." } Self::Consultant => { "Read-only high-reasoning counsel for judgement calls and design critique." @@ -350,9 +352,11 @@ pub(crate) const MUTATING_TOOL_DENYLIST: &[&str] = &[ /// receipt says `write=false`. Denying the raw shell entries and leaving the /// bounded verification surface (`Run` / `run_tests` / `run_verifiers`) intact /// keeps the verifier able to do its job under a contract that is true. -/// Scout/reviewer read-only inspection selectively removes only canonical `Bash` from this -/// deny list after the role is known; its input-specific read-only classifier -/// remains the authority for that narrow exception. +/// Scout/reviewer/planner read-only inspection selectively removes only canonical `Bash` from this +/// deny list after the role is known (`from_runtime_role`'s retain); a verifier's +/// classifier-bounded `bash` is admitted separately by the subagent registry's +/// probe lane (`allows_bounded_readonly_bash`), not by that retain; the +/// input-specific read-only classifier remains the authority for both narrow exceptions. /// /// That surface is bounded only in its **default** form, and the distinction is /// load-bearing: `run_verifiers` accepts a `commands` array of arbitrary @@ -514,8 +518,10 @@ impl ChildAuthority { // Raw shell requires the ceiling to *say* `shell = "full"`. Any narrower // shell posture — `none` or `read_only` — loses the raw command surface // outright. `from_runtime_role` may subsequently - // retain canonical `Bash` for a named scout/reviewer, whose concrete - // calls are bounded by the strict read-only classifier. + // retain canonical `Bash` for the Scout/Reviewer/Planner trio, whose concrete + // calls are bounded by the strict read-only classifier; a Verifier's probe + // `bash` is admitted by the registry lane instead, without touching this + // deny list. // // This is deliberately keyed on the shell field rather than only on // `write`, and that is the whole repair: the execution envelope reads @@ -644,9 +650,13 @@ impl ChildAuthority { FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner ) && authority.ceiling.shell != ShellCeiling::None { - // Runtime's Scout/Reviewer policy permits classifier-bounded Bash - // inspection. Keep the canonical entry while all other shell and - // execution aliases remain denied. + // Runtime's Scout/Reviewer/Planner policy permits classifier-bounded + // Bash inspection. Keep the canonical entry while all other shell and + // execution aliases remain denied. This literal is the one role set + // that intentionally differs from `role_requires_read_only_shell` + // (which adds Verifier): a Verifier's probe `bash` is admitted over + // the deny by the registry lane, not by this retain, so changing + // either side means changing the member fingerprint or dropping a lane. authority .disallowed_tools .retain(|name| !name.eq_ignore_ascii_case("Bash")); @@ -715,11 +725,20 @@ pub(crate) fn posture_role_for(ceiling: PermissionCeiling) -> &'static str { /// Whether a Fleet role is never allowed a mutating shell, whatever its /// requested runtime profile says. Spawn narrows the child to a read-only /// shell for these roles, and every receipt must report that same posture. +/// +/// The narrowing downgrades only the raw-command policy: canonical `bash` +/// becomes the classifier-bounded probe lane, while the bounded built-in +/// verification surface keeps keying on the role's shell *ceiling* and the +/// shell-authority sentinel — the `test` role keeps both, because running the +/// workspace's own gates is its entire purpose. "Keeps both" describes the +/// in-session registry path; the fleet executor envelope gives the Verifier +/// only the bounded verification surface (`shell = none`, no probe lane) by +/// design. #[must_use] pub(crate) fn role_requires_read_only_shell(role: &FleetRole) -> bool { matches!( role, - FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner + FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner | FleetRole::Verifier ) } diff --git a/crates/tui/src/fleet/roster.rs b/crates/tui/src/fleet/roster.rs index c9ac4a2c7d..fef749f24b 100644 --- a/crates/tui/src/fleet/roster.rs +++ b/crates/tui/src/fleet/roster.rs @@ -481,7 +481,7 @@ impl FleetRoster { "verifier", FleetSlot::Verifier, FleetLoadout::Inherit, - "Runs builds and tests to verify claims; reports evidence, does not patch.", + "Runs the built-in verification gates to verify claims; reports evidence, does not patch, holds no shell.", None, ), ( diff --git a/crates/tui/src/fleet/worker_runtime.rs b/crates/tui/src/fleet/worker_runtime.rs index 6d86a1c8e9..8d862dfb6b 100644 --- a/crates/tui/src/fleet/worker_runtime.rs +++ b/crates/tui/src/fleet/worker_runtime.rs @@ -1521,11 +1521,15 @@ pub(crate) fn network_posture_warning_for_task( return None; } + // Name the shell the role actually runs under (read-only roles run + // narrowed), not the requested default — the same truth the receipts + // report. + let effective = crate::fleet::role::effective_runtime_profile_for_role(&agent_type, &runtime); Some(format!( "Fleet task `{}` mentions network-backed verification, but role `{}` has network=off and shell={}. Dispatch a `worker` role with shell `read_only` for gh/curl evidence, or revise the brief.", task.id, role.as_deref().unwrap_or("worker"), - shell_policy_label(runtime.shell), + shell_policy_label(effective.shell), )) } @@ -1600,7 +1604,12 @@ mod tests { ..WorkerRuntimeProfile::default() }; - for role in [FleetRole::Scout, FleetRole::Reviewer, FleetRole::Planner] { + for role in [ + FleetRole::Scout, + FleetRole::Reviewer, + FleetRole::Planner, + FleetRole::Verifier, + ] { let effective = role::effective_runtime_profile_for_role(&role, &requested); assert_eq!(effective.shell, ShellPolicy::ReadOnly, "{role:?}"); assert_eq!( diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index df854b680c..17130b5066 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -263,7 +263,16 @@ impl ToolRegistry { .filter(|tool| tool.model_visible()) .filter(|tool| { read_only_authority.is_none_or(|authority| { - readonly_evidence_tool(tool.as_ref()) + // The classifier-bounded probe lane is an evidence tool, + // but a worker whose envelope grants no shell authority + // refuses every bash call at dispatch + // (`enforce_tool_authority` demands + // `ToolShellAuthority::ReadOnly`); listing it would + // advertise a surface the worker can never use. + let evidence = readonly_evidence_tool(tool.as_ref()) + && !(matches!(tool.name(), "bash" | "Bash") + && authority.shell != super::spec::ToolShellAuthority::ReadOnly); + evidence || (tool.name() == "Run" && authority.verification == super::spec::ToolVerificationAuthority::Bounded) diff --git a/crates/tui/src/tools/registry/tests.rs b/crates/tui/src/tools/registry/tests.rs index a56963ee52..abd677b868 100644 --- a/crates/tui/src/tools/registry/tests.rs +++ b/crates/tui/src/tools/registry/tests.rs @@ -711,6 +711,38 @@ fn readonly_verifier_context(workspace: &std::path::Path) -> ToolContext { .expect("bounded verifier authority") } +/// A worker whose envelope grants no shell authority refuses every bash call +/// at dispatch (`enforce_tool_authority` demands `ToolShellAuthority::ReadOnly` +/// for bash/Bash), so the evidence catalog must not advertise the lane it can +/// never use — while a worker whose envelope DOES grant read-only shell keeps +/// the classifier-bounded probe lane. +#[test] +fn machine_catalog_omits_bash_without_shell_authority() { + let tmp = tempdir().expect("tempdir"); + let shell_less = ToolRegistryBuilder::new() + .with_read_only_shell_tool() + .build(readonly_verifier_context(tmp.path())); + let shell_less_tools = shell_less.to_api_tools(); + let names: Vec<&str> = shell_less_tools + .iter() + .map(|tool| tool.name.as_str()) + .collect(); + assert!( + !names.contains(&"bash") && !names.contains(&"Bash"), + "a shell-less verifier catalog must not list the bash lane: {names:?}" + ); + // The twin: a read-only-shell envelope keeps the lane. + let scoped = ToolRegistryBuilder::new() + .with_read_only_shell_tool() + .build(readonly_scout_context(tmp.path(), true)); + let scoped_tools = scoped.to_api_tools(); + let names: Vec<&str> = scoped_tools.iter().map(|tool| tool.name.as_str()).collect(); + assert!( + names.contains(&"bash"), + "a read-only-shell scout catalog keeps the classified probe lane: {names:?}" + ); +} + #[test] fn machine_verifier_catalog_and_dispatch_add_only_bounded_run() { let tmp = tempdir().expect("tempdir"); @@ -917,6 +949,30 @@ fn fleet_authority_intersects_readonly_github_bash_with_network_ceiling() { .expect_err("network denial must win") .to_string(); assert!(error.contains("does not grant network access"), "{error}"); + // The envelope admits Bash through the parallel classifier, which refuses + // compounds and globs outright, so a gh read inside one fails closed + // before the per-stage network predicate is consulted — with or without + // a network grant. Compound probes are an in-session `agent` capability, + // not a fleet one. + for command in [ + "pwd; gh repo view", + "gh pr list --search '*'", + "gh repo view | head -n 3", + ] { + let compound = json!({"action": "run", "command": command}); + for context in [&offline, &networked] { + let error = enforce_tool_authority("Bash", &compound, &shell, context) + .expect_err("a compound gh read fails closed under the fleet envelope") + .to_string(); + // The wall that fires is the parallel classifier's (compounds + // are never parallel-admitted), not the network ceiling: name it + // so a refusal from any other wall cannot green this leg. + assert!( + error.contains("outside its machine-readable authority envelope"), + "{command}: {error}" + ); + } + } } #[tokio::test] diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 3abcf7e7db..881bad346f 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -404,7 +404,7 @@ fn subagent_perf_enabled() -> bool { const VALID_SUBAGENT_TYPES: &str = "general, explore, planner, reviewer, implement, test, advisor, custom \ (legacy aliases remain accepted: worker, scout, builder, verifier, consultant, general-purpose, general_purpose, default, exploration/explorer, plan/planning/awaiter, review/code-review/code_review, implementer/implementation, verify/verification/validator/tester, oracle)"; -const SUBAGENT_TYPE_DESCRIPTION: &str = "Fleet role for this delegated worker. general: full tool access for multi-step tasks; write-capable, so it claims the parent workspace unless write_roots narrows it — parallel generals without disjoint write_roots contend, so prefer a read-only role for research fan-outs. explore: fast read-only exploration. planner: grounded strategy with read-only probes. reviewer: reads and grades code. implement: lands focused code changes; write-capable under the same workspace-claim rules as general, so parallel implements need disjoint write_roots or worktree=true. test: runs tests/validation gates and reports evidence. advisor: read-only high-reasoning counsel for judgement calls and design critique. custom: the tools listed in allowed_tools on the parent's posture; without an explicit write authority it starts read-only and never contends. Legacy aliases remain accepted at deserialization boundaries."; +const SUBAGENT_TYPE_DESCRIPTION: &str = "Fleet role for this delegated worker. general: full tool access for multi-step tasks; write-capable, so it claims the parent workspace unless write_roots narrows it — parallel generals without disjoint write_roots contend, so prefer a read-only role for research fan-outs. explore: fast read-only exploration. planner: grounded strategy with read-only probes. reviewer: reads and grades code. implement: lands focused code changes; write-capable under the same workspace-claim rules as general, so parallel implements need disjoint write_roots or worktree=true. test: runs the built-in verification gates and reports evidence, with a bounded read-only shell — for in-session delegations — for gathering it; a read-only-shell parent clamps the gates (probes remain), and custom commands are refused. advisor: read-only high-reasoning counsel for judgement calls and design critique. custom: the tools listed in allowed_tools on the parent's posture; without an explicit write authority it starts read-only and never contends. Legacy aliases remain accepted at deserialization boundaries."; // === Types === @@ -9513,7 +9513,7 @@ impl ToolSpec for AgentTool { concat!( "Start with action=start and prompt; returns a turn-owned agent_id immediately. A prompt-only start (no type/profile) launches read-only; pass a write-capable type (e.g. implement) or declare write_roots before write work. Read-only roles need no extra fields. Set detached=true only for work that must remain independently observable after the turn. ", "Use multiple starts for independent parallel tasks. ", - "type selects the Fleet role: general (full tool access), explore (fast read-only exploration), planner (grounded strategy, read-only probes), reviewer (reads and grades code), implement (lands focused code changes), test (runs tests and reports evidence), advisor (read-only design counsel), or custom (allowed_tools on the parent's posture); legacy aliases are still accepted. ", + "type selects the Fleet role: general (full tool access), explore (fast read-only exploration), planner (grounded strategy, read-only probes), reviewer (reads and grades code), implement (lands focused code changes), test (built-in verification gates; custom commands refused), advisor (read-only design counsel), or custom (allowed_tools on the parent's posture); legacy aliases are still accepted. ", "profile runs the child as a named Fleet role or an exact prompt-only profile explicitly presented by the embedding host — pass it only when the task needs that identity. The child follows the operator's per-role model configuration, else the parent's model; model or thinking overrides are not advertised (parse-accepted for compatibility). ", "Use action=roster to inspect Fleet roles and host-presented prompt-only profiles before choosing a type or profile. The host-profile listing is capped and unpaged — search a large pool with profile_query. ", "Child run budgets come from Fleet role defaults and operator [subagents] config; budget fields are not advertised (parse-accepted for compatibility). ", @@ -15560,12 +15560,14 @@ impl SubAgentToolSurface { /// Role-only approval posture; the registry allow/deny and runtime envelope /// remain the authoritative per-tool checks. /// -/// `Auto` is safe for every role. `Suggest` requires a write-capable role; -/// `Required` requires the full-shell posture. A Custom worker may describe -/// either posture, but its explicit allowlist and the runtime envelope still -/// 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. +/// Only the `Auto` and `Suggest` answers remain live: the posture gate's +/// `Required` branch keys on the held shell policy plus the verifier's +/// recorded parent-shell grant (`holds_shell_authority`), no longer on this +/// role-only table. `Suggest` still requires a write-capable role. A Custom +/// worker may describe either posture, but its explicit allowlist and the +/// runtime envelope still narrow what it can execute. Read-only bash +/// operations are classified Auto, so the read-only roles 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; @@ -15636,6 +15638,14 @@ struct SubAgentToolRegistry { accept_edits: bool, agent_type: FleetRole, runtime_profile: WorkerRuntimeProfile, + /// Whether the child's requested shell was a full shell *after the parent + /// clamp but before the role's own narrowing* — i.e. the read-only shell + /// it now runs under came from its role's own narrowing, not from a + /// parent that itself held no shell authority. Keys the Verifier's + /// verification-surface carve-out in [`Self::holds_shell_authority`] so a + /// read-only role parent cannot obtain gate execution it never had by + /// delegating to `test`. + parent_shell_authority: bool, // 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 @@ -15706,11 +15716,17 @@ 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, and read-only roles (Scout, Reviewer, Planner, + // Verifier) are narrowed to the hardened read-only classifier even + // when the parent has a full shell. let parent_shell = ShellPolicy::from_legacy_allow_shell(runtime.allow_shell); let mut child_shell = runtime.worker_profile.shell.min_with(parent_shell); + // Capture the grant before the role narrowing below: a Full shell + // here means the parent itself could run processes, so the role's + // read-only downgrade is the child's own posture. A clamped + // (read-only-parent) child must not regain authority through a + // role carve-out it merely shares the name of. + let parent_shell_authority = matches!(child_shell, ShellPolicy::Full); if crate::fleet::role::role_requires_read_only_shell(&agent_type) && child_shell.allows_shell() { @@ -15756,6 +15772,7 @@ impl SubAgentToolRegistry { accept_edits: runtime.accept_edits, agent_type, runtime_profile: effective_profile, + parent_shell_authority, can_spawn_child, owner_agent_id, owner_agent_name, @@ -15774,10 +15791,11 @@ impl SubAgentToolRegistry { /// clears the role-based delegation rules on its own. fn delegation_refusal(&self, name: &str, input: &Value) -> Option { // #5595/#5438: session approval is transport, not a second shell - // classifier. Scout/Reviewer/Planner calls already proven by the exact - // predicate used at posture, envelope, and execute boundaries are - // delegated reads even though lowercase `bash` deliberately keeps the - // stricter parent-parallel classifier as its generic approval baseline. + // classifier. Scout/Reviewer/Planner/Verifier calls already proven by + // the exact predicate used at posture, envelope, and execute + // boundaries are delegated reads even though lowercase `bash` + // deliberately keeps the stricter parent-parallel classifier as its + // generic approval baseline. if self.bounded_readonly_bash_evidence(name, input) { return None; } @@ -16373,8 +16391,18 @@ impl SubAgentToolRegistry { { return true; } - matches!(self.runtime_profile.shell, ShellPolicy::Full) - && role_posture_permits(&self.agent_type, ApprovalRequirement::Required) + // Canonical `bash` on a lane role is deliberately NOT + // covered by the authority check below: the classifier + // carve-out above is its only door, so a command the + // grammar refuses keeps the `[shell.readonly.command]` + // refusal instead of being waved through to the + // envelope. The check is case-aware because legacy + // `Bash` is the raw-shell alias — the carve-out is + // exact-name `bash` only, and the alias must not + // re-enter through this branch. + let bash_must_use_classifier_lane = name.eq_ignore_ascii_case("bash") + && self.allows_bounded_readonly_bash("bash"); + !bash_must_use_classifier_lane && self.holds_shell_authority() } }, None => true, @@ -16391,9 +16419,11 @@ impl SubAgentToolRegistry { /// Whether this child may surface and dispatch the canonical lowercase /// `bash` tool under the read-only inspection posture. /// - /// Scout, Reviewer, and Planner keep exactly one shell entry point — - /// canonical `bash` — whose concrete calls the strict read-only - /// classifier bounds. + /// Scout, Reviewer, Planner, and Verifier keep exactly one shell entry + /// point — canonical `bash` — whose concrete calls the strict read-only + /// classifier bounds. For the Verifier this is the evidence lane: running + /// the built-in gates is only half the role, and "pass/fail with + /// evidence" needs probes like `wc -c` the raw surface can never allow. /// 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. @@ -16413,10 +16443,7 @@ impl SubAgentToolRegistry { /// 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 - ) + && crate::fleet::role::role_requires_read_only_shell(&self.agent_type) && self.runtime_profile.shell != crate::worker_profile::ShellPolicy::None } @@ -16474,8 +16501,10 @@ 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. + // hardened evidence profile; Verifier may receive bounded Run and the + // same classifier-bounded canonical `bash` lane as the inspection + // roles, but not any raw or session-oriented shell path. Dispatch + // repeats this check. let lower = name.to_ascii_lowercase(); let evidence_tool = crate::tools::registry::readonly_evidence_tool_name(name) || self @@ -16485,14 +16514,19 @@ impl SubAgentToolRegistry { let bounded_inspection = matches!(&self.agent_type, FleetRole::Scout | FleetRole::Reviewer) && lower != "agent" && !evidence_tool; - let raw_shell = lower == "bash" - || lower.starts_with("exec_shell") + let raw_shell = lower.starts_with("exec_shell") || matches!( lower.as_str(), "exec_wait" | "exec_interact" | "task_shell_start" | "task_shell_wait" ) || lower.starts_with("terminal/"); - bounded_inspection || matches!(&self.agent_type, FleetRole::Verifier) && raw_shell + // For a verifier, canonical `bash` is hardened exactly when the + // bounded read-only lane admits it (`allows_bounded_readonly_bash`); + // every other raw-shell spelling — and legacy `Bash`, which fails the + // lane's exact-name check — stays unhardened and blocked. + let verifier_shell = matches!(&self.agent_type, FleetRole::Verifier) + && (raw_shell || (lower == "bash" && !self.allows_bounded_readonly_bash(name))); + bounded_inspection || verifier_shell } fn network_is_denied(&self) -> bool { @@ -16505,12 +16539,33 @@ impl SubAgentToolRegistry { !self.runtime_profile.permissions.write } + /// Shell *authority* — the right to start the processes the built-in + /// verification surface runs. A full runtime shell holds it. The one + /// narrowed exception is the Verifier whose *parent granted a full + /// shell* (`parent_shell_authority`): `role_requires_read_only_shell` + /// downgrades the Verifier's raw-command policy to the classifier-bounded + /// probe lane, and running the workspace's own gates is the role's entire + /// purpose, so that role-own downgrade must not take the gates away + /// (`reject_unbounded_verification` and the envelope still bound every + /// concrete call). The grant cannot travel through delegation: a Verifier + /// child of a read-only role parent was clamped by that parent, holds no + /// authority of its own, and stays refused — delegation moves work, never + /// authority. No other role regains authority through a narrowed shell: a + /// Builder or Custom child of a read-only parent inherits + /// `ShellPolicy::ReadOnly` and must not start the test runner its parent + /// cannot — keying this on the role's *default* posture would hand + /// exactly that to every role whose default shell is full. + fn holds_shell_authority(&self) -> bool { + matches!(self.runtime_profile.shell, ShellPolicy::Full) + || (self.parent_shell_authority + && matches!(self.agent_type, FleetRole::Verifier) + && !matches!(self.runtime_profile.shell, ShellPolicy::None)) + } + 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) + // Read-only evidence commands are Auto-classified exceptions, not a + // shell-authority grant; the explicit shell sentinel always wins. + !self.holds_shell_authority() || self.is_tool_denied(SHELL_AUTHORITY_SENTINEL) } fn execution_envelope(&self) -> crate::tools::execution_envelope::ExecutionEnvelope { @@ -16609,10 +16664,9 @@ impl SubAgentToolRegistry { // 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 - ) { + // The role set is the one `allows_bounded_readonly_bash` admits: the + // read-only-shell roles, including the verifier's evidence lane. + if !crate::fleet::role::role_requires_read_only_shell(&self.agent_type) { return None; } match name { @@ -17830,7 +17884,8 @@ const CONSULTANT_AGENT_INTRO: &str = concat!( ); const VERIFIER_AGENT_INTRO: &str = concat!( - "You are a trusted Fleet test agent (role: `test`). Your job is to run the requested gates with your bounded validation tools — the allowed test/check selections — and report results. You never write: patching the workspace is denied. Unbounded shell forms are refused; use the verification surface.\n", + "You are a trusted Fleet test agent (role: `test`). Your job is to run the requested gates with your bounded validation tools — the allowed test/check selections — and report results; the gates are yours on envelopes whose parent grants shell authority, while a child clamped by a read-only-shell parent keeps the read-only probes but has its gates refused. You never write: patching the workspace is denied. Unbounded shell forms are refused; use the verification surface.\n", + "When the session grants it — for in-session delegations, on envelopes that carry the probe lane — you also hold a bounded read-only shell, under the same classifier-gated grammar as the read-only roles, for gathering evidence with probes such as `wc`, `ls`, `cat`, and `grep` pipelines; envelopes without it omit the shell entirely and shell attempts refuse, and custom commands are refused.\n", "Report PASS/FAIL/FLAKY at the top of SUMMARY with exact command evidence.\n", "Capture failing assertion and file:line; put obvious fixes under RISKS.\n", "You may use more tool calls than quick exploration, but stop after decisive pass/fail evidence.\n", diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index e7f4ffd5fe..eee3ec97c5 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -2862,7 +2862,7 @@ fn agent_description_explains_background_child_and_transcript_handle() { "planner (grounded strategy", "reviewer (reads and grades code)", "implement (lands focused code changes)", - "test (runs tests and reports evidence)", + "test (built-in verification gates; custom commands refused)", "advisor (read-only design counsel)", "custom (allowed_tools", "legacy aliases are still accepted", @@ -7011,6 +7011,7 @@ fn every_named_role_has_one_complete_capability_based_surface() { "Web", "agent", "automation", + "bash", "diagnostics", "file_search", "finance", @@ -8071,6 +8072,497 @@ async fn read_only_inspection_roles_cannot_write_through_text_filters() { // bounded read-only probes the inspection roles use — so "with evidence" // includes evidence the child gathered itself (`wc`, `ls`, `cat`, `grep`) — // while custom commands stay refused and the workspace stays read-only. +/// The roster description must promise exactly the surface the role has: +/// built-in gates plus a bounded read-only evidence shell, no custom commands. +#[test] +fn verifier_roster_description_states_the_bounded_contract() { + let description = FleetRole::Verifier.description(); + assert!( + description.contains("built-in verification gates"), + "roster description must name the built-in gates: {description}" + ); + assert!( + description.contains("bounded read-only shell"), + "roster description must promise the bounded evidence shell: {description}" + ); + assert!( + description.contains("cannot execute custom commands"), + "roster description must refuse custom commands: {description}" + ); + assert!( + description.contains("unless a read-only-shell parent clamped its shell authority"), + "roster description must condition the gates on shell authority — a clamped child keeps probes and loses gates: {description}" + ); + // The Agent-tool `type` description carries the same truth, briefly — + // including the two clauses the model needs before picking: the gates + // clamp under a read-only-shell parent, and custom commands are refused. + assert!( + SUBAGENT_TYPE_DESCRIPTION.contains("test: runs the built-in verification gates"), + "type description must match the roster: {SUBAGENT_TYPE_DESCRIPTION}" + ); + assert!( + SUBAGENT_TYPE_DESCRIPTION.contains("custom commands are refused"), + "type description must refuse custom commands: {SUBAGENT_TYPE_DESCRIPTION}" + ); + assert!( + SUBAGENT_TYPE_DESCRIPTION + .contains("a read-only-shell parent clamps the gates (probes remain)"), + "type description must condition the gates on the parent shell: {SUBAGENT_TYPE_DESCRIPTION}" + ); + // The fleet roster slot blurb is the third roster surface a model reads + // (built-in members feed `AgentProfile.description`); it must not + // advertise builds or a shell the fleet envelope never grants. + let fleet_blurb = FleetRoster::built_in_members() + .into_iter() + .find(|member| member.id == "verifier") + .unwrap_or_else(|| panic!("built-in roster must carry the verifier slot")) + .description + .unwrap_or_else(|| panic!("built-in verifier slot must carry a description")); + assert!( + fleet_blurb.contains("built-in verification gates"), + "fleet roster blurb must name the built-in gates: {fleet_blurb}" + ); + assert!( + fleet_blurb.contains("holds no shell"), + "fleet roster blurb must disclaim the shell the fleet envelope refuses: {fleet_blurb}" + ); + assert!( + !fleet_blurb.contains("builds and tests"), + "fleet roster blurb must not re-overclaim builds: {fleet_blurb}" + ); +} +/// Acceptance for the verifier lane, sibling of +/// `read_only_inspection_roles_execute_pwd_and_absolute_git_log`: a live +/// `test` child must execute a bounded read-only probe through canonical +/// `bash` — `wc -c` on a fixture — with the raw-shell deny list still +/// installed, reaching the lane *through* it via +/// `allows_bounded_readonly_bash`. +#[cfg_attr(not(unix), ignore = "wc and unix paths")] +#[tokio::test] +async fn verifier_child_executes_a_bounded_readonly_bash_probe_for_evidence() { + let (tmp, registry) = read_only_with_shell_registry(); + let fixture = tmp.path().join("evidence.txt"); + std::fs::write(&fixture, "countable\n").expect("fixture"); + + let command = format!("wc -c {}", fixture.display()); + assert!( + !registry.role_blocks_unhardened_process_tool("bash"), + "canonical bash must reach the bounded lane, not the unhardened wall" + ); + let output = registry + .execute( + "agent_verifier_evidence", + "bash", + json!({ "command": command }), + ) + .await + .unwrap_or_else(|error| panic!("verifier probe must run `{command}`: {error}")); + // The count is wc's FIRST whitespace-separated field (`10 /path`), not a + // substring: `contains("10")` would also match line noise like `210`. + let count = output.split_ascii_whitespace().next().unwrap_or_default(); + assert_eq!( + count, "10", + "verifier probe must report the fixture's byte count as wc's first field: {output}" + ); +} +/// The walls around the lane stay up: the legacy raw `Bash` spelling, a +/// custom `Run` command, and a workspace write are all refused for a +/// verifier — at dispatch, and with the `reject_unbounded_verification` +/// contract text for the custom command. +#[tokio::test] +async fn verifier_child_still_refuses_raw_shell_custom_commands_and_writes() { + let (_tmp, registry) = read_only_with_shell_registry(); + + let raw = registry + .execute( + "agent_verifier_walls", + "Bash", + json!({"action": "run", "command": "rm -rf src"}), + ) + .await + .expect_err("legacy raw Bash must stay refused for a verifier"); + let raw = raw.to_string(); + assert!( + raw.contains("not available") || raw.contains("not allowed"), + "raw Bash refusal did not identify the wall: {raw}" + ); + // The second legacy-`Bash` wall is the posture's classifier-lane rule: a + // command the classifier cannot prove read-only stays `Required`, the + // case-insensitive lane-door check refuses the alias name itself, and + // the call is refused at posture — pinned here on this Verifier-labeled + // registry (the sibling pin covers the same wall through a Scout + // fixture). A *proven*-read-only shape clears posture's Auto + // classification for any role, so the alias-at-any-provability refusal + // is pinned below at the wall that actually owns it: the role clamp. + assert!( + !registry.posture_permits_tool("Bash", Some(&json!({"command": "rm -rf src"}))), + "the posture wall must refuse unprovable commands through the legacy `Bash` alias for a Verifier" + ); + // The exact-name asymmetry at the dispatch wall: only canonical + // lowercase `bash` reaches the lane, so the alias is refused even for a + // provably read-only shape — a case-relaxed lane check cannot re-admit + // it at any provability. + registry + .execute( + "agent_verifier_walls", + "Bash", + json!({"command": "pwd"}), + ) + .await + .expect_err("the legacy `Bash` alias must stay refused even for a provably read-only command — the lane is exact-name"); + + let custom_command = json!({ + "action": "verifiers", + "commands": [{"name": "x", "program": "bash", "args": ["-lc", "id"]}] + }); + // The contract text, at the guard that owns it: a custom command is the + // unbounded form, refused for a write-denied child with or without shell. + assert!( + super::reject_unbounded_verification("Run", &custom_command, true).is_err() + && super::reject_unbounded_verification("Run", &custom_command, false).is_err(), + "custom Run commands must be refused by the unbounded-verification guard" + ); + // …and the refusal reaches the real dispatch path too, from one of the + // two walls that own it — the approval gate (a Required call with no + // session decision) or the unbounded-verification guard — rather than + // failing for some unrelated reason. + let custom = registry + .execute("agent_verifier_walls", "Run", custom_command) + .await + .expect_err("custom Run commands are refused by design"); + let custom = custom.to_string(); + assert!( + custom.contains("requires approval") || custom.contains("operator-supplied commands"), + "custom command refusal must come from the approval or unbounded wall: {custom}" + ); + + let write = registry + .execute( + "agent_verifier_walls", + "File", + json!({"action": "write", "path": "a.rs", "content": "b"}), + ) + .await + .expect_err("a verifier must not mutate the workspace"); + assert!( + write.to_string().contains("not allowed") || write.to_string().contains("read-only"), + "workspace-write refusal did not identify the denial: {write}" + ); +} +/// A registry for a write-capable role whose parent handed it a read-only +/// shell and no writes — what `derive_child` produces for a Builder, Worker, +/// or Custom child of a Scout or Planner on the plain `agent` nesting path, +/// where no Fleet deny list is installed. +fn read_only_shell_child_registry(role: FleetRole) -> (tempfile::TempDir, SubAgentToolRegistry) { + 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; + let mut profile = WorkerRuntimeProfile::for_role(role.clone()); + profile.permissions = crate::worker_profile::PermissionSet::read_only_with_network(); + profile.shell = crate::worker_profile::ShellPolicy::ReadOnly; + runtime.worker_profile = profile; + let registry = SubAgentToolRegistry::new( + runtime, + role, + None, + Arc::new(Mutex::new(TodoList::new())), + Arc::new(Mutex::new(PlanState::default())), + ); + (tmp, registry) +} +/// Shell authority is the Verifier's carve-out alone. Every other role whose +/// runtime shell was narrowed to read-only — the Builder, Worker, or Custom +/// child of a read-only parent — holds no process authority: the built-in +/// test runner stays refused at posture and envelope and absent from the +/// catalog, exactly as before the verifier lane existed, so delegation +/// cannot widen a read-only parent into a child that runs the suite. +#[tokio::test] +async fn read_only_shell_children_of_other_roles_hold_no_shell_authority() { + let gate = json!({"action": "tests", "args": "--lib fleet::exact"}); + for role in [FleetRole::Builder, FleetRole::Worker, FleetRole::Custom] { + let (_tmp, registry) = read_only_shell_child_registry(role.clone()); + assert!( + !registry.posture_permits_tool("Run", Some(&gate)), + "{role:?} with a read-only shell must not hold the Required surface" + ); + assert!( + registry.envelope_refusal("Run", &gate).is_some(), + "{role:?} envelope must report no shell authority" + ); + let names: Vec = registry + .tools_for_model(&role) + .iter() + .map(|tool| tool.name.clone()) + .collect(); + assert!( + !names.iter().any(|name| name == "Run"), + "{role:?} catalog must not offer the verification gate: {names:?}" + ); + // The probe lane is read-only-roles-only: a widened role set must not + // silently hand the classifier-bounded shell to write-capable + // children. The catalog refusal keys on the same + // `allows_bounded_readonly_bash` predicate the production dispatch + // walls (the raw-shell deny rules' role-gated rescue) key on, so a + // widened role set flips both — and this catalog pin reddens first. + assert!( + !names.iter().any(|name| name == "bash"), + "{role:?} catalog must not offer the classifier probe lane: {names:?}" + ); + let refused = registry + .execute("agent_read_only_child", "Run", gate.clone()) + .await + .expect_err("the test runner must stay refused for a read-only-shell child"); + assert!( + refused.to_string().contains("not permitted") || refused.to_string().contains("shell"), + "{role:?}: {refused}" + ); + } + // The carve-out itself: the same narrowed shell on the Verifier keeps the + // bounded gates (pinned in detail by + // `verifier_child_keeps_the_default_and_selection_bounded_run_surface`). + let (_tmp, verifier) = read_only_with_shell_registry(); + assert!(verifier.posture_permits_tool("Run", Some(&gate))); + assert!(verifier.envelope_refusal("Run", &gate).is_none()); +} +/// The purpose survives the narrowing: default gates and test-selection +/// arguments stay admitted for the verifier at posture and envelope, and the +/// catalog still offers `Run`. +#[test] +fn verifier_child_keeps_the_default_and_selection_bounded_run_surface() { + let (_tmp, registry) = read_only_with_shell_registry(); + + for input in [ + json!({}), + json!({"action": "tests"}), + json!({"action": "tests", "args": "-p codewhale-tui"}), + json!({"action": "tests", "args": "--lib fleet::exact"}), + ] { + assert!( + registry.posture_permits_tool("Run", Some(&input)), + "verifier posture must admit the bounded gate: {input}" + ); + assert!( + registry.envelope_refusal("Run", &input).is_none(), + "verifier envelope must admit the bounded gate: {input}" + ); + } + let tools = registry.tools_for_model(&FleetRole::Verifier); + let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_str()).collect(); + assert!( + names.contains(&"Run"), + "catalog must keep offering the verification gate: {names:?}" + ); + // Catalog and dispatch must agree on the carve-out: the probe lane stays + // visible, not merely dispatchable. + assert!( + names.contains(&"bash"), + "catalog must keep offering the probe lane beside the gates: {names:?}" + ); +} +/// A Verifier child of a read-only role parent (its shell was clamped by the +/// parent, not narrowed by the Verifier role) regains nothing through the +/// carve-out: the built-in gate runner stays refused at posture and envelope +/// and off the catalog, while the classifier-bounded bash evidence lane — +/// which the read-only parent holds too — keeps working. Delegation moves +/// work, never authority. +#[tokio::test] +async fn clamped_verifier_child_of_read_only_parent_holds_no_run_surface() { + let (_tmp, registry) = read_only_shell_child_registry(FleetRole::Verifier); + for input in [ + json!({}), + json!({"action": "tests"}), + json!({"action": "tests", "args": "-p codewhale-tui"}), + ] { + assert!( + !registry.posture_permits_tool("Run", Some(&input)), + "a clamped verifier must not run gates: {input}" + ); + assert!( + registry.envelope_refusal("Run", &input).is_some(), + "the envelope must refuse the gate for a clamped verifier: {input}" + ); + } + let tools = registry.tools_for_model(&FleetRole::Verifier); + let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_str()).collect(); + assert!( + !names.contains(&"Run"), + "the clamped verifier catalog must not offer the gate: {names:?}" + ); + assert!( + names.contains(&"bash"), + "the clamped verifier keeps the shared bash evidence lane: {names:?}" + ); + assert!( + registry.posture_permits_tool("bash", Some(&json!({"command": "pwd"}))), + "the shared evidence lane admits read-only probes" + ); + assert!( + !registry.posture_permits_tool("bash", Some(&json!({"command": "rm -rf src"}))), + "the evidence lane stays classifier-bounded" + ); +} +/// The sub-subagent chain: a carve-out Verifier parent (its effective shell +/// is the role's read-only narrowing; the Run grant is a registry surface, +/// not shell authority) spawning a Builder child through the REAL spawn +/// path. Hop 2 is not hand-derived: the test starts the Builder through the +/// verifier's own `agent` tool and reads the child's recorded +/// `runtime_profile` — the profile `run_subagent` derived from the runtime +/// the registry published (`worker_profile_for_spawn` -> +/// `derive_child`). That recorded profile is the spawn ceiling the +/// carve-out parent actually published; a Builder built under it keeps the +/// no-Run, lane-less shape. This guards the propagation side of the +/// round-3 fix: if the registry ever leaked a pre-narrowing profile to its +/// spawns (`child_runtime.worker_profile` regressed to the parent-runtime +/// profile), the recorded shell below would come out `full` and this test +/// would redden. +#[tokio::test] +async fn sub_subagent_chain_of_a_carveout_verifier_holds_no_extra_authority() { + // A carve-out Verifier in a full session: the coarse session flag stays + // `allow_shell = true` while its effective runtime shell is the + // read-only narrowing. The ceiling its spawns publish must read the + // effective shell, not the flag. + let mut runtime = + stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); + runtime.allow_shell = true; + runtime.worker_profile.shell = crate::worker_profile::ShellPolicy::ReadOnly; + let ceiling = session_permission_ceiling(&runtime); + assert_eq!( + ceiling.shell, + codewhale_workflow::ShellCeiling::ReadOnly, + "the carve-out must not widen the shell ceiling a verifier publishes for its own spawns" + ); + // Hop 2 through the real spawn path: the write-capable `agent` start + // needs an auto-approved surface, so the fixture runs in its + // auto-approving variant. Everything else about the carve-out shape is + // the fixture's own. + let (verifier_tmp, verifier) = read_only_with_shell_registry_auto_approving(true); + assert_eq!( + verifier.runtime_profile.shell, + crate::worker_profile::ShellPolicy::ReadOnly, + "the carve-out fixture must narrow the verifier's own profile" + ); + let started = verifier + .execute( + "agent_chain_probe", + "agent", + json!({ + "action": "start", + "type": "builder", + "prompt": "noop chain probe", + }), + ) + .await + .expect("the carve-out verifier must be able to start a chain Builder"); + let started: Value = + serde_json::from_str(&started).expect("the agent start must answer with a JSON payload"); + let agent_id = started + .get("agent_id") + .and_then(Value::as_str) + .expect("the agent start must answer with an agent_id") + .to_string(); + // The record appears once the spawned task registers the child; the + // recorded profile is what production derived from the published + // runtime, not a field this test set. + let manager = verifier.gate_runtime.manager.clone(); + let record = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(record) = manager.read().await.get_worker_record(&agent_id) + && record.spec.runtime_profile != WorkerRuntimeProfile::default() + { + return record; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .expect("the chain Builder worker record must appear"); + assert_eq!( + record.spec.agent_type, + FleetRole::Builder, + "the spawned hop must be the requested Builder" + ); + assert_eq!( + record.spec.runtime_profile.shell, + crate::worker_profile::ShellPolicy::ReadOnly, + "the profile the registry published to its spawn must derive the read-only shell; \ + a pre-narrowing leak redds here" + ); + let child_tmp = tempfile::tempdir().expect("tempdir"); + let mut child_runtime = + stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); + child_runtime.context = ToolContext::new(child_tmp.path().to_path_buf()); + child_runtime.allow_shell = true; + child_runtime.worker_profile = record.spec.runtime_profile.clone(); + let builder = SubAgentToolRegistry::new( + child_runtime, + FleetRole::Builder, + None, + Arc::new(Mutex::new(TodoList::new())), + Arc::new(Mutex::new(PlanState::default())), + ); + // No gate surface at any wall, and no probe lane: the Builder two hops + // into a shell-granting chain holds exactly the read-only shell its + // ancestors owned. + let gate = json!({"action": "tests", "args": "--lib fleet::exact"}); + assert!( + !builder.posture_permits_tool("Run", Some(&gate)), + "a chain Builder must not hold the verification gate" + ); + assert!( + builder.envelope_refusal("Run", &gate).is_some(), + "a chain Builder envelope must refuse the verification gate" + ); + let names: Vec = builder + .tools_for_model(&FleetRole::Builder) + .iter() + .map(|tool| tool.name.clone()) + .collect(); + assert!( + !names.iter().any(|name| name == "Run"), + "a chain Builder catalog must not offer the verification gate: {names:?}" + ); + assert!( + !names.iter().any(|name| name == "bash"), + "a chain Builder catalog must not offer the classifier probe lane: {names:?}" + ); + let refused = builder + .execute("agent_chain_builder", "Run", gate.clone()) + .await + .expect_err("the test runner must stay refused one hop below the carve-out verifier"); + assert!( + refused.to_string().contains("not permitted") || refused.to_string().contains("shell"), + "chain Builder refusal did not identify the wall: {refused}" + ); + drop(verifier_tmp); +} +/// The verifier intro tells the child about its bounded read-only shell and +/// the custom-command refusal, scoped to in-session delegations the way +/// `FleetRole`'s roster description scopes the role. The gates claim is +/// conditional too: a child clamped by a read-only-shell parent keeps the +/// probes and loses the gates — the intro must not promise the gates +/// unconditionally. +#[test] +fn verifier_intro_states_the_evidence_contract() { + assert!( + VERIFIER_AGENT_INTRO.contains("bounded read-only shell"), + "verifier intro must name the bounded read-only evidence shell: {VERIFIER_AGENT_INTRO}" + ); + assert!( + VERIFIER_AGENT_INTRO.contains("custom commands are refused"), + "verifier intro must repeat the custom-command refusal: {VERIFIER_AGENT_INTRO}" + ); + assert!( + VERIFIER_AGENT_INTRO.contains("in-session delegations"), + "the verifier's bounded read-only shell must be scoped to in-session delegations: {VERIFIER_AGENT_INTRO}" + ); + assert!( + VERIFIER_AGENT_INTRO + .contains("a child clamped by a read-only-shell parent keeps the read-only probes but has its gates refused"), + "the intro's gates claim must carry the read-only-parent conditional: {VERIFIER_AGENT_INTRO}" + ); +} #[test] fn explore_catalog_inherits_web_but_hides_write_shell_and_fim_tools() { let tmp = tempdir().expect("tempdir"); @@ -8451,17 +8943,31 @@ async fn scout_shell_respects_parent_shell_and_network_ceilings() { crate::tools::todo::new_shared_todo_list(), crate::tools::plan::new_shared_plan_state(), ); - assert!( - !tool_names(registry.tools_for_model(&role)).contains("bash"), - "{role:?} must not inherit the read-only inspection bash catalog" - ); - if role == FleetRole::Verifier { + if role == FleetRole::Consultant { assert!( - registry - .execute("agent_verifier", "bash", json!({"command": "pwd"})) - .await - .is_err(), - "Verifier keeps bounded Run, never arbitrary Bash" + !tool_names(registry.tools_for_model(&role)).contains("bash"), + "{role:?} must not inherit the read-only inspection bash catalog" + ); + } else { + // The verifier shares the inspection lane: canonical lowercase + // `bash` is advertised and classifier-bounded — evidence probes + // run, arbitrary shell keeps the bounded-lane refusal. + assert!( + tool_names(registry.tools_for_model(&role)).contains("bash"), + "verifier must advertise the bounded read-only bash lane" + ); + let probe = registry + .execute("agent_verifier", "bash", json!({"command": "pwd"})) + .await + .expect("the verifier's bounded read-only probe runs"); + assert!(!probe.trim().is_empty(), "pwd must return evidence"); + let arbitrary = registry + .execute("agent_verifier", "bash", json!({"command": "rm -rf src"})) + .await + .expect_err("arbitrary shell stays refused behind the lane"); + assert!( + arbitrary.to_string().contains("[shell.readonly.command]"), + "unexpected refusal: {arbitrary}" ); } } @@ -8901,12 +9407,13 @@ fn every_fleet_role_catalog_advertises_one_executable_load_skill() { "read-only role {role:?} keeps load_skill without gaining {denied}" ); } - // Scout/reviewer/planner expose only canonical lowercase bash, - // whose concrete calls are reclassified. Verifier keeps its - // bounded Run surface but not raw bash. + // Inspection roles and the verifier expose only canonical + // lowercase bash, whose concrete calls are reclassified by the + // read-only classifier (the verifier's evidence lane). Consultant + // is shell-less and gains no bash at all. if matches!( &role, - FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner + FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner | FleetRole::Verifier ) { assert!(names.contains("bash"), "{role:?} keeps read-only bash"); for denied in ["exec_shell", "task_shell_start"] { @@ -20603,6 +21110,15 @@ fn the_session_ceiling_reflects_the_live_parent_posture() { // capability it will be refused for using. /// The child registry a Runtime verifier under a full parent actually runs with. fn read_only_with_shell_registry() -> (tempfile::TempDir, SubAgentToolRegistry) { + read_only_with_shell_registry_auto_approving(false) +} + +/// The carve-out fixture with approvals auto-accepted, for tests that drive +/// the real spawn path (a write-capable `agent` start is otherwise held for +/// approval and never reaches `run_subagent`). +fn read_only_with_shell_registry_auto_approving( + auto_approve: bool, +) -> (tempfile::TempDir, SubAgentToolRegistry) { let tmp = tempdir().expect("tempdir"); let authority = crate::fleet::role::ChildAuthority::from_runtime_role( "verifier", @@ -20621,6 +21137,7 @@ fn read_only_with_shell_registry() -> (tempfile::TempDir, SubAgentToolRegistry) let mut runtime = stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); runtime.context = ToolContext::new(tmp.path().to_path_buf()); + runtime.context.auto_approve = auto_approve; runtime.allow_shell = true; // The posture reaches the child through the Runtime authority envelope. runtime.worker_profile = WorkerRuntimeProfile::for_role(FleetRole::Verifier); @@ -20719,9 +21236,11 @@ fn a_verifier_runs_bounded_test_selections_and_nothing_else_at_dispatch() { } } /// Catalog and dispatch must agree: the verification surface the guard admits -/// is offered, and the raw shell it refuses is absent. +/// is offered, and the legacy raw-shell spellings it refuses are absent (the +/// verifier's shell surface is the bounded canonical `bash` tool pinned by +/// the read-only-shell tests, not the legacy `Bash`/`exec_shell` names). #[test] -fn the_verifier_catalog_offers_the_verification_surface_and_no_shell() { +fn the_verifier_catalog_offers_run_without_the_legacy_raw_shell_spellings() { let (_tmp, registry) = read_only_with_shell_registry(); let tools = registry.tools_for_model(&FleetRole::Verifier); let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_str()).collect(); diff --git a/crates/tui/src/worker_profile.rs b/crates/tui/src/worker_profile.rs index 9a3b8ab203..0e322ef10e 100644 --- a/crates/tui/src/worker_profile.rs +++ b/crates/tui/src/worker_profile.rs @@ -205,10 +205,11 @@ impl WorkerRuntimeProfile { // the network or from shell for no role reason cannot do its job. let (permissions, shell) = match role { // Read-only investigators: no workspace writes, but network reach - // and the bounded verification surface so a scout/reviewer lane - // can run git/gh/web inspection. Raw shell stays denied by the - // registry clamp (read-only classifier), so this widens capability - // without widening mutation authority. + // so a scout/reviewer lane can run git/gh/web inspection. Raw + // shell stays denied by the registry clamp (read-only + // classifier), so this widens capability without widening + // mutation authority; the bounded verification surface is the + // Verifier's own arm below, not a scout/reviewer tool. FleetRole::Scout | FleetRole::Reviewer => { (PermissionSet::read_only_with_network(), ShellPolicy::Full) } diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 977a403970..cf8b470e6f 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -471,7 +471,7 @@ Useful roles include: | `plan` | Design and migration planning | | `review` | Bug-focused review of an existing change | | `implementer` | A tightly specified edit | -| `verifier` | Running checks and reporting pass/fail evidence | +| `verifier` | Running the built-in verification gates and reporting pass/fail evidence | Sub-agents are most useful when work can be separated cleanly. Do not use them for tiny edits, and do not ask multiple agents to write the same files at the diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 506c0347d9..96b7ca006a 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -67,11 +67,11 @@ stewardship. | Role | Stance | Writes? | Network? | Shell posture | Typical use | |---------------|----------------------------------------|---------|----------|---------------|----------------------------------------------| | `general` | flexible; do whatever the parent says | yes | yes | yes | the default; multi-step tasks | -| `explore` | read-only; map the relevant code fast | no | yes | read-only (net + bounded verify) | "find every call site of `Foo`; check the PR with gh" | +| `explore` | read-only; map the relevant code fast | no | yes | read-only (net) | "find every call site of `Foo`; check the PR with gh" | | `planner` | analyse and produce a strategy | no | yes | read-only probes | "design the migration; don't execute" | -| `reviewer` | read-and-grade with severity scores | no | yes | read-only (net + bounded verify) | "audit this PR for bugs" | +| `reviewer` | read-and-grade with severity scores | no | yes | read-only (net) | "audit this PR for bugs" | | `implement` | land a specific change with min edit | yes | yes | yes | "rewrite `bar.rs::Foo::bar` to do X" | -| `test` | run tests / validation, report outcome | no | yes | bounded verification (no writes) | "verify the diff with the bounded test checks; report PASS/FAIL" | +| `test` | run the built-in verification gates and report outcome | no | yes | built-in gates unless parent-clamped (then probes only) + read-only probes (no custom commands) | "verify the diff with the built-in checks; report PASS/FAIL" | | `advisor` | short-lived, high-reasoning counsel | no | yes | none | "what are we missing in this design?" | | `custom` | explicit narrow tool allowlist | inherits | inherits | inherits | hand-picked tools on the parent's posture | @@ -90,12 +90,17 @@ builder) is a supported escape hatch for *work capacity* — the child brings its own model, route, and step budget — but the child's authority is clamped against the delegating parent's live posture, not the operator's: a scout's builder child lands read-only with raw shell and mutating tools denied, and -canonical `Bash` is denied to it too (only bounded-inspection roles keep the +canonical `Bash` is denied to it too (only the bounded-inspection roles and the verifier keep the classified read-only shell). Delegating to obtain shell is therefore mechanically useless — the scout's own bounded shell (`git -C … log`, `find … | head`, `npm view …`, classifier-gated) is the only shell path a -read-only parent has. Read-only is transitive through any delegation chain: -the clamp (`ChildAuthority::clamp` in `fleet/exact.rs`) intersects every +read-only parent has. The bounded grammar also admits compound probes of +those families — segments joined by `;`, `&&`, `||`, or `|`, where every +segment must individually be admitted (e.g. `wc -c f; wc -l f`, +`cd sub && wc -c notes.md`, `cat x | od -c`) — and a workspace-confined +`cd ` in any sequence position its connector always runs (joined by `;`, or first — a `cd` after `&&`/`||` may never execute, so it refuses). +Read-only is transitive through any delegation chain: +the clamp (`ChildAuthority::clamp` in `fleet/role.rs`) intersects every field with the narrower side, the deny-list union means a descendant can never drop an ancestor's restriction, and `inherit_disallowed_tools: false` cannot drop a posture denial (`is_posture_denial`). This is pinned by @@ -252,8 +257,8 @@ OUTPUT: VERDICT, EVIDENCE, GAPS, NEXT ``` `scout` briefs default to quick, read-only investigation (no writes, but -network reach and the bounded verification surface are available for real -scouting). About 3-5 tool calls +network reach and the classifier-gated read-only probe lane are available +for real scouting — no verification gates). About 3-5 tool calls is enough for quick exploration: orient, search, read the decisive lines, and return. Do not repeat `ALREADY_KNOWN` work unless evidence contradicts it. Review and verifier briefs can spend more calls, but should stop after decisive @@ -314,9 +319,20 @@ OUTPUT: VERDICT, EVIDENCE, GAPS, NEXT. - **`test`** — when the parent needs an authoritative pass/fail on the test suite or other validation. Verifiers don't fix failures; they capture the failing assertion + stack and put fix - candidates under RISKS. The verifier posture never writes, and shell - is clamped to the bounded built-in verification surface: the write - ceiling is read-only and unbounded shell forms are refused (#5186). + candidates under RISKS. The verifier posture never writes; its shell + is the bounded built-in verification surface — refused for an + in-session child only when a read-only-shell parent clamps its + authority (the clamped child keeps the probe lane and loses the + gates) — plus, on in-session delegations whose envelope grants it, + the same classifier-gated read-only probe lane the inspection roles + hold (`wc`, `ls`, `cat`, `grep` pipelines) for gathering evidence; + envelopes without the lane omit it and shell attempts refuse. On + Fleet members the envelope is stricter still: bounded verification + and Bash are mutually exclusive there, so a fleet verifier holds the + gates without the probe lane. The + write ceiling is read-only, unbounded shell forms are refused + (#5186), and custom commands are refused — use a write-capable role + for those. - **`advisor`** — when the operator wants a high-leverage second opinion before cheaper execution continues. Consultants read enough to ground a recommendation, but cannot write or run shell commands. `oracle` and diff --git a/docs/zh_hans/FLEET.md b/docs/zh_hans/FLEET.md index df9652850c..32b10f3214 100644 --- a/docs/zh_hans/FLEET.md +++ b/docs/zh_hans/FLEET.md @@ -121,7 +121,7 @@ call_reasrning = "lrw" 在运行时,它只能为已经冻结的 worker route 选择 reasoning 级别。它不能改变 worker、provider、model、role、tools 或 permissions。Router 调用本身被限制在 `off` 或 `low`;更贵的值会被拒绝。手动选择的 worker reasoning 级别不会产生 Router 调用。Route 与 reasoning 回执会指名 worker model,并在使用 Router 时给出 Router 的确切 provider/model,让操作者看到哪个 model 干了哪份活。如果相同的裸 Router 或 Fleet 名字在两个根里都存在,请用 `workspace/` 或 `codewhale_home/` 限定它,而不是依赖遮蔽(shadowing)。 -每个成员的 `permissions` 预设是一个**上限**,绝不是授权:它会与会话的实时姿态取交集,因此只读会话里的 `read_write` 成员以只读方式运行。这个交集成为子任务真实的工具包——`permissions = "none"` 让它一个工具都没有,而没有网络工具的成员会失去每一个可触达的网络表面——`web.run`、`fetch_url`、`web_search`、`github`、`mcp*` 系列以及可触达的 `rlm` 动作——而不是仅仅在调用时被拒绝。唯一刻意的例外是规范 `Web` 家族的 `search`/`fetch` 动作:它们是只读成员有权使用的只读 web 表面(与普通 scout 对等),因此网络被禁的成员恰好保留 `Web {search, fetch}`。它在事实上保持只读,而不仅仅是名义上:按 URL 寻址的 `fetch` 在派发时被拒绝,表面上没有任何其他东西被授予。不能写入的成员会失去可变的文件工具,并且——当它保留了 `shell = "full"` 以便运行检查时——也会失去原始 shell,只保留它为之而存在的有界验证工具(`run_tests`、`run_verifiers`):任意 shell 命令和 `write_file` 一样确定无疑地改变工作区,所以保留它会令 `write = false` 不成立。那个验证表面只在它的*默认*形式下有界,因此无界参数会随 shell 一起消失:被拒绝写入的成员可以运行内置 gate,但不能用显式 `commands` 数组运行 `run_verifiers`,也不能用原始 `args` 字符串运行 `run_tests`——这两者都会启动操作者提供的程序,是换了名字的原始 shell。 +每个成员的 `permissions` 预设是一个**上限**,绝不是授权:它会与会话的实时姿态取交集,因此只读会话里的 `read_write` 成员以只读方式运行。这个交集成为子任务真实的工具包——`permissions = "none"` 让它一个工具都没有,而没有网络工具的成员会失去每一个可触达的网络表面——`web.run`、`fetch_url`、`web_search`、`github`、`mcp*` 系列以及可触达的 `rlm` 动作——而不是仅仅在调用时被拒绝。唯一刻意的例外是规范 `Web` 家族的 `search`/`fetch` 动作:它们是只读成员有权使用的只读 web 表面(与普通 scout 对等),因此网络被禁的成员恰好保留 `Web {search, fetch}`。它在事实上保持只读,而不仅仅是名义上:按 URL 寻址的 `fetch` 在派发时被拒绝,表面上没有任何其他东西被授予。不能写入的成员会失去可变的文件工具,并且——当它保留了 `shell = "full"` 以便运行检查时——也会失去原始 shell,只保留它为之而存在的有界验证工具(`run_tests`、`run_verifiers`;经 `agent` 工具嵌套的 verifier 在授予该通道的会话内委派上额外持有分类器门控的只读取证探针通道,但 Fleet 成员信封把有界验证与 Bash 权限定义为互斥,因此不带该探针通道):任意 shell 命令和 `write_file` 一样确定无疑地改变工作区,所以保留它会令 `write = false` 不成立。那个验证表面只在它的*默认*形式下有界,因此无界参数会随 shell 一起消失:被拒绝写入的成员可以运行内置 gate,但不能用显式 `commands` 数组运行 `run_verifiers`,也不能用原始 `args` 字符串运行 `run_tests`——这两者都会启动操作者提供的程序,是换了名字的原始 shell。 这些拒绝中有两项比工具名更窄,因为有两个工具触及了它们名字之外的地方。`rlm` 通过在进程*内部*、以它自己的名字调用 fetch 工具来加载 `url`,而 `rlm` 的 `eval` 动作针对活内核运行 Python——套接字和文件系统都触及。因此该家族**按动作**被拒绝,而不是整体拒绝: diff --git a/docs/zh_hans/GUIDE.md b/docs/zh_hans/GUIDE.md index f6ad23ae18..0d2d9634f2 100644 --- a/docs/zh_hans/GUIDE.md +++ b/docs/zh_hans/GUIDE.md @@ -325,7 +325,7 @@ Codewhale 的工具是结构化操作。模型不只是产出文字,还能调 | `plan` | 设计与迁移规划 | | `review` | 对已有改动的 bug 聚焦审查 | | `implementer` | 规格明确的编辑 | -| `verifier` | 运行检查并报告通过/失败证据 | +| `verifier` | 运行内置验证门禁并报告通过/失败证据 | 子智能体在可以干净切分工作的时候最有用。不要为微小编辑使用它们,也不要让多个智能体同时写入相同文件。 diff --git a/docs/zh_hans/SUBAGENTS.md b/docs/zh_hans/SUBAGENTS.md index 708cc14dda..f4b4584e51 100644 --- a/docs/zh_hans/SUBAGENTS.md +++ b/docs/zh_hans/SUBAGENTS.md @@ -27,17 +27,17 @@ Fleet 角色是面向用户的委派工作词汇:父代理通过 `agent` 启 | 角色 | 姿态 | 可写? | 联网? | Shell 姿态 | 典型用途 | |---|---|---|---|---|---| | `worker` | 灵活;父代理说什么就做什么 | 是 | 是 | 是 | 默认角色;多步任务 | -| `scout` | 只读;快速摸清相关代码 | 否 | 是 | 只读(网络 + 有界验证) | "找到 `Foo` 的每个调用点;用 gh 检查这个 PR" | +| `scout` | 只读;快速摸清相关代码 | 否 | 是 | 只读(网络) | "找到 `Foo` 的每个调用点;用 gh 检查这个 PR" | | `planner` | 分析并产出策略 | 否 | 是 | 只读探针 | "设计迁移方案;不要执行" | -| `reviewer` | 带严重度评分的阅读与评审 | 否 | 是 | 只读(网络 + 有界验证) | "审计这个 PR 的 bug" | +| `reviewer` | 带严重度评分的阅读与评审 | 否 | 是 | 只读(网络) | "审计这个 PR 的 bug" | | `builder` | 以最小改动落地某个具体变更 | 是 | 是 | 是 | "把 `bar.rs::Foo::bar` 重写为做 X" | -| `verifier` | 运行测试/验证并报告结果 | 否 | 是 | 测试导向 | "运行 cargo test --workspace,并报告" | +| `verifier` | 运行内置验证门禁并报告结果(只读探针取证) | 否 | 是 | 内置门禁(被父代理钳制时仅剩探针)+ 只读探针(无自定义命令) | "用内置检查验证这个 diff,并报告 PASS/FAIL" | | `consultant` | 短期、高推理密度的咨询 | 否 | 是 | 无 | "这个设计我们漏掉了什么?" | | `custom` | 显式的窄工具 allowlist | 继承 | 继承 | 继承 | 在父代理姿态上精选的工具 | 角色的默认值就是该角色*想要*的姿态,而父代理的有效姿态永远是天花板(子代理绝不会扩得比父代理更宽)。只读角色按意图扣留**工作区写入**;默认不拿走任何其他东西——每个角色都保留网络读取,`custom` 继承父代理的写入/网络/shell 姿态,并且只被它的显式工具列表或发起调用收窄。被聚焦的 worker 的头部会依据运行时自身的权限快照声明有效姿态(`scout · read-only · network · read-only shell`)。 -**委派移动的是工作,绝不是权限**(#5426 的遏制答案)。只读角色委派给可写角色(scout → builder)是*工作容量*受支持的逃生舱——子代理自带其模型、路由和步骤预算——但子代理的权限被钳制在委派父代理的实时姿态上,而不是操作者的姿态上:scout 的 builder 子代理以只读落地,raw shell 和可变工具被拒绝,规范的 `Bash` 对它也被拒绝(只有有界检查角色保留分类的只读 shell)。因此通过委派来获得 shell 在机制上是无用的——scout 自带的受限 shell(`git -C … log`、`find … | head`、`npm view …`,分类器门控)是只读父代理唯一的 shell 路径。只读通过任何委派链都是传递的:钳制(`fleet/exact.rs` 中的 `ChildAuthority::clamp`)把每个字段与更窄的一侧求交,拒绝列表的并集意味着后代永远无法去掉祖先的限制,而 `inherit_disallowed_tools: false` 无法去掉姿态拒绝(`is_posture_denial`)。这一点由 `crates/tui/src/fleet/exact.rs` 测试中的 `a_read_only_parents_delegation_never_widens_authority` 钉死。 +**委派移动的是工作,绝不是权限**(#5426 的遏制答案)。只读角色委派给可写角色(scout → builder)是*工作容量*受支持的逃生舱——子代理自带其模型、路由和步骤预算——但子代理的权限被钳制在委派父代理的实时姿态上,而不是操作者的姿态上:scout 的 builder 子代理以只读落地,raw shell 和可变工具被拒绝,规范的 `Bash` 对它也被拒绝(只有有界检查角色与 verifier 保留分类的只读 shell)。因此通过委派来获得 shell 在机制上是无用的——scout 自带的受限 shell(`git -C … log`、`find … | head`、`npm view …`,分类器门控)是只读父代理唯一的 shell 路径。该有界语法还允许把这些已准入的探针组合成复合序列——以 `;`、`&&`、`||` 或 `|` 连接的段,且每个段都必须单独准入(例如 `wc -c f; wc -l f`、`cd sub && wc -c notes.md`、`cat x | od -c`)——以及工作区受限的 `cd <单个相对路径>`(可单独使用,或出现在连接符必定执行它的序列位置——以 `;` 连接或位于序列首位;`&&`/`||` 之后的 `cd` 可能根本不会执行,因此会被拒绝)。只读通过任何委派链都是传递的:钳制(`fleet/role.rs` 中的 `ChildAuthority::clamp`)把每个字段与更窄的一侧求交,拒绝列表的并集意味着后代永远无法去掉祖先的限制,而 `inherit_disallowed_tools: false` 无法去掉姿态拒绝(`is_posture_denial`)。这一点由 `crates/tui/src/fleet/exact.rs` 测试中的 `a_read_only_parents_delegation_never_widens_authority` 钉死。 会话的**权限姿态**在每个子代理内部的应用方式与父代理回合完全一致:在 Auto-Review 下,同一个确定性底线和一次性模型守护者决定 worker 的被扣留调用(绝不是提示词;守护者不可用时拒绝,fail closed);在 Ask 下,角色无法委派的被扣留调用会作为审批提示在父代理的 UI 中弹出,worker 可见地等待(`waiting for user`),或者在无法提示的主机上带着原因被拒绝;Full Access 仍然在不可绕过的安全底线上 fail closed。每一次没有人被提示的决策都是该 worker 转录中的一行备注(聚焦时可见)和一条审计日志记录。参见 `docs/MODES.md`。 @@ -84,7 +84,7 @@ STOP_CONDITION: OUTPUT: VERDICT, EVIDENCE, GAPS, NEXT ``` -`scout` 简报默认为快速的只读调查(不写,但网络触达和有界验证面可用于真正的侦察)。约 3-5 次工具调用足以完成快速探索:定位、搜索、读取决定性代码行,然后返回。除非证据与之矛盾,否则不要重复 `ALREADY_KNOWN` 的工作。review 和 verifier 简报可以花更多调用,但应在拿到决定性证据后停止。builder 和修复型简报应在扩展示范围之前或反复失败之后设置检查点,而不是设置一个很小的调用上限。 +`scout` 简报默认为快速的只读调查(不写,但网络触达和分类器门控的只读探针通道可用于真正的侦察——不含验证门禁)。约 3-5 次工具调用足以完成快速探索:定位、搜索、读取决定性代码行,然后返回。除非证据与之矛盾,否则不要重复 `ALREADY_KNOWN` 的工作。review 和 verifier 简报可以花更多调用,但应在拿到决定性证据后停止。builder 和修复型简报应在扩展示范围之前或反复失败之后设置检查点,而不是设置一个很小的调用上限。 好的委派提示词示例: @@ -122,7 +122,7 @@ OUTPUT: VERDICT、EVIDENCE、GAPS、NEXT。 - **`planner`** —— 当父代理有目标但没有可执行的分解。planner 写工件(`todo_write` 条目、响应体里的策略),但不执行它们。 - **`reviewer`** —— 当已经有一个变更,父代理想要它被评分。reviewer 不打补丁——他们在发现里描述修复方案,这样如果判定是"修它",父代理可以派一个 builder。 - **`builder`** —— 当变更已经被明确指定、只需要落地。builder 保持严格的范围:最小改动,不做顺手重构,交回前跑一次快速验证。 -- **`verifier`** —— 当父代理需要测试套件或其他验证上的权威通过/失败结论。verifier 不修失败;他们记录失败的断言 + 栈,把修复候选放在 RISKS 下。 +- **`verifier`** —— 当父代理需要测试套件或其他验证上的权威通过/失败结论。verifier 不修失败;他们记录失败的断言 + 栈,把修复候选放在 RISKS 下。verifier 姿态从不写入;其 shell 是有界内置验证面——对会话内子代理,只有当只读 shell 父代理钳制了其权限时才会被拒绝,被钳制的子代理保留探针通道、失去验证门禁——加上,在授予该通道的会话内委派上,与检查角色相同的分类器门控只读探针通道(`wc`、`ls`、`cat`、`grep` 管道)用于取证;未授予该通道的信封会省略它,shell 尝试一律拒绝。Fleet 成员的信封更严格:在那里有界验证与 Bash 权限互斥,因此 fleet verifier 只持有门禁、不带探针通道。写入上限是只读,无界 shell 形式被拒绝(#5186),自定义命令也被拒绝——请改用具备写能力的角色。 - **`consultant`** —— 当操作者想在更便宜的执行继续之前得到一个高杠杆的第二意见。consultant 读足够的材料来支撑一条建议,但不能写,也不能运行 shell 命令。`oracle` 和 `advisor` 只在加载更老的请求或持久化记录时被接受;新的提示词、回执和 UI 使用 `consultant`。 - **`custom`** —— 只有当父代理需要显式约束工具集时。通过 legacy/internal 子代理记录上的 `allowed_tools` 字段传 allowlist;面向模型的 `agent` 工具刻意保持公共 schema 很小。