Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions crates/tui/src/fleet/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 10 additions & 1 deletion crates/tui/src/fleet/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}"),
}
Expand Down
47 changes: 33 additions & 14 deletions crates/tui/src/fleet/role.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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
)
}

Expand Down
2 changes: 1 addition & 1 deletion crates/tui/src/fleet/roster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
(
Expand Down
13 changes: 11 additions & 2 deletions crates/tui/src/fleet/worker_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
))
}

Expand Down Expand Up @@ -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!(
Expand Down
11 changes: 10 additions & 1 deletion crates/tui/src/tools/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions crates/tui/src/tools/registry/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading