mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 01:49:49 -10:00
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:
1 parent
292ffa56ae
commit
966ef974e1
7 files changed
+532
-174
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`)。提示词前缀在子代理启动时自动加载;父代理的委派提示词成为第一个回合的用户消息。
|
||||
|
||||
Reference in new issue
Block a user