fix(subagent): agents follow the session's live permission posture; Full Access judges agent calls like the parent's

User report (Linux, 0.10.0): "Even when I give maximum permissions to one of
the agents, it doesn't take them (the reviewer/guardian denies or gets
stuck)"; downgrading to 0.9.3 worked.

Root causes (both introduced 0.9.3 -> 0.10.0 by the child permission gate,
SubAgentToolRegistry::gate_held_call; 0.9.3 simply skipped approval for an
auto-approved child):

1. The posture was copied at spawn into four places (SubAgentRuntime
   .approval_mode, SubAgentToolRegistry.auto_approve, context.auto_approve /
   approval_mode, context.elevated_sandbox_policy), once per turn. Switching
   the session to Full Access never reached a running agent: an agent spawned
   under Auto-Review kept asking the guardian and being denied (fail closed,
   up to 90 s per call).
2. Every agent call was judged as RunOrigin::Background, so under Full
   Access the "destructive background/headless" safety floor denied ordinary
   foreground work the same session runs in its own turn (e.g. `rm -rf
   /abs/workspace/build`, `curl ... | sh`, `rm -rf "$DIR"`), reported as
   "Built-in safety gate requires approval".

Fix: one posture primitive, read live. The engine puts a LivePosture handle
(its existing live_runtime_authority cell - the same state the TUI decides
agent approval prompts against) on the ToolContext it builds; each agent
call re-reads it (ToolContext::with_live_posture) for approval and the
sandbox that posture implies. The copies SubAgentRuntime.approval_mode and
SubAgentToolRegistry.auto_approve are deleted; with_permission_posture no
longer takes a posture. The agent's run origin now comes from the same rule
the parent uses (auto_review_run_origin_for_plan(starts_detached)), so
destructive *detached* work still holds in every posture. Unchanged floors:
operator deny rules, role/grant/envelope, publish and detached-destructive
holds, Ask/Auto-Review approval, sandbox for non-Full-Access postures.
Narrowing (Full Access -> Ask/Auto-Review) reaches running agents too.

Evidence (macOS, focused, CARGO_BUILD_JOBS=2):
- Negative control (fix reverted in mod.rs, tests kept): 2 of 4 new/changed
  tests FAILED with the user's symptoms:
  a_posture_switch_reaches_an_already_running_agent -> "Auto-Review guardian
  unavailable ...; the call was denied (fail closed)" after switching to Full
  Access; full_access_child_runs_what_the_parent_turn_runs -> "Built-in
  safety gate requires approval: destructive background/headless action
  requires durable review".
- With fix: child_permission_gate + authority + related: 49 passed, 0 failed;
  tools::subagent + fleet:: + engine subagent/authority filters: 1221 passed,
  0 failed; core::engine (RUST_MIN_STACK=16777216, as CI): 640 passed,
  0 failed, 4 ignored.
- rustfmt --edition 2024 --check clean on touched files.
Not proven: a real Linux run of the binary (bwrap/landlock sandbox path after
an upgrade to Full Access); the new tests are platform-independent.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
This commit is contained in:
Hunter BandClaude Opus 5.5 committed 2026-09-29 17:09:27 -07:00
1 parent 2579658d26
commit d1655c4246
5 files changed
+282 -49

No files matched your search

+85 -6
View File
@@ -1150,6 +1150,81 @@ impl LiveRuntimeAuthorityState {
}
}
/// The session's live permission posture, as every agent it spawned reads it.
///
/// Agents used to copy the posture when they were spawned, so a session the
/// person switched to Full Access kept gating its running agents with the old
/// posture: an agent spawned under Auto-Review went on asking the guardian,
/// and being denied, after the person had granted Full Access. Each agent call
/// now re-reads the posture the person last chose — the same cell the TUI
/// decides that agent's approval prompts against — and the sandbox it implies.
#[derive(Clone)]
pub(crate) struct LivePosture {
state: Arc<StdMutex<LiveRuntimeAuthorityState>>,
workspace: PathBuf,
network_access: crate::core::authority::SandboxNetworkAccess,
}
impl LivePosture {
/// Project the live posture onto one agent call: approval, and the
/// sandbox that posture implies. Shell and trust stay as the agent's grant
/// narrowed them.
pub(crate) fn apply(&self, context: &mut ToolContext) {
let live = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.authority
.clone();
context.auto_approve = live.auto_approve;
context.approval_mode = live.approval_mode;
context.elevated_sandbox_policy = Some(crate::core::authority::sandbox_policy_for_turn(
live.mode,
live.approval_mode,
live.configured_sandbox_mode.as_deref(),
&self.workspace,
self.network_access,
));
}
}
#[cfg(test)]
impl LivePosture {
/// A posture cell a test switches the way the TUI does.
pub(crate) fn for_tests(workspace: &Path, approval_mode: ApprovalMode) -> Self {
let posture = Self {
state: Arc::new(StdMutex::new(LiveRuntimeAuthorityState::new(
LiveRuntimeAuthority::from_fields(
AppMode::Agent,
true,
false,
false,
ApprovalMode::Suggest,
None,
),
))),
workspace: workspace.to_path_buf(),
network_access: crate::core::authority::SandboxNetworkAccess::Restricted,
};
posture.switch_for_tests(approval_mode);
posture
}
pub(crate) fn switch_for_tests(&self, approval_mode: ApprovalMode) {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.authority = LiveRuntimeAuthority::from_fields(
AppMode::Agent,
true,
false,
false,
approval_mode,
None,
);
}
}
/// Runtime-facing view of the engine's exact live permission authority.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct RuntimePermissionAuthority {
@@ -5105,7 +5180,6 @@ impl Engine {
.with_parent_mode(input_policy.mode)
.with_approval_receipt_store(self.approval_receipt_store.clone())
.with_permission_posture(
self.session.approval_mode,
Arc::clone(&self.shared_auto_review_policy),
self.config.terminal_chrome_enabled,
);
@@ -6303,7 +6377,6 @@ impl Engine {
.with_parent_mode(mode)
.with_approval_receipt_store(self.approval_receipt_store.clone())
.with_permission_posture(
self.session.approval_mode,
Arc::clone(&self.shared_auto_review_policy),
self.config.terminal_chrome_enabled,
);
@@ -6452,14 +6525,20 @@ impl Engine {
.and_then(crate::client::ProviderNativeSearchClient::new);
}
let network_access = crate::core::authority::SandboxNetworkAccess::from_config(
self.api_config.sandbox_network_access,
);
let policy = authority.sandbox_policy(
&self.session.workspace,
self.api_config.sandbox_mode.as_deref(),
crate::core::authority::SandboxNetworkAccess::from_config(
self.api_config.sandbox_network_access,
),
network_access,
);
let mut ctx = ctx.with_elevated_sandbox_policy(policy);
ctx.live_posture = Some(LivePosture {
state: Arc::clone(&self.live_runtime_authority),
workspace: self.session.workspace.clone(),
network_access,
});
if matches!(authority.mode, AppMode::Plan) {
ctx = ctx.with_shell_network_denied_hint(PLAN_SHELL_NETWORK_DENIED_HINT);
}
@@ -7748,7 +7827,7 @@ pub(crate) enum AutoReviewPlanDecision {
ConsultReviewer(String),
}
pub(super) fn auto_review_run_origin_for_plan(
pub(crate) fn auto_review_run_origin_for_plan(
detached_start: bool,
) -> crate::tui::auto_review::RunOrigin {
if detached_start {
+30
View File
@@ -640,6 +640,36 @@ fn registry_instruction_is_in_the_initial_prompt_only_when_mcp_is_enabled() {
/// not about a second policy surface: discovery is never ordered ahead of
/// ordinary local work, and the deferred-tool cost of reaching the Registry
/// tools is stated where the model reads about them.
/// The engine hands every agent the live posture cell, not a copy: a
/// posture the person publishes after the agent's context was built is what
/// the agent's next call runs under, sandbox included.
#[test]
fn agent_tool_contexts_follow_the_published_posture() {
let (engine, handle) = Engine::new(EngineConfig::default(), &Config::default());
let context = engine.build_tool_context(AppMode::Agent, false);
let before = context.clone().with_live_posture();
assert!(!before.auto_approve);
assert_ne!(
before.elevated_sandbox_policy,
Some(crate::sandbox::SandboxPolicy::DangerFullAccess)
);
handle.publish_turn_authority(
AppMode::Agent,
true,
false,
true,
ApprovalMode::Bypass,
None,
);
let after = context.with_live_posture();
assert!(after.auto_approve);
assert_eq!(after.approval_mode, ApprovalMode::Bypass);
assert_eq!(
after.elevated_sandbox_policy,
Some(crate::sandbox::SandboxPolicy::DangerFullAccess)
);
}
#[test]
fn registry_instruction_does_not_gate_ordinary_local_work() {
let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
+13
View File
@@ -720,6 +720,9 @@ pub struct ToolExecutionState {
/// hosts without an engine turn), where code mode keeps its read-only,
/// auto-approved profile.
pub(crate) nested_call_gate: Option<crate::tools::codemode::NestedCallGate>,
/// Where the session's live permission posture lives. Set by the engine;
/// every agent call re-reads it (`None` keeps the posture above as is).
pub(crate) live_posture: Option<crate::core::engine::LivePosture>,
}
impl std::ops::Deref for ToolContext {
@@ -820,6 +823,7 @@ impl ToolContext {
provider_native_search: None,
route_capabilities: codewhale_config::route::RouteCapabilities::default(),
nested_call_gate: None,
live_posture: None,
}),
}
}
@@ -855,6 +859,15 @@ impl ToolContext {
self
}
/// Re-read the session's live permission posture (see `live_posture`).
#[must_use]
pub(crate) fn with_live_posture(mut self) -> Self {
if let Some(live) = self.live_posture.clone() {
live.apply(&mut self);
}
self
}
/// Stamp tool work with the sub-agent that owns it.
#[must_use]
pub fn with_owner_agent(
+33 -36
View File
@@ -2846,12 +2846,6 @@ pub struct SubAgentRuntime {
pub todos: SharedTodoList,
/// Session mode of the orchestrating parent at spawn time (Wave 7 M4/M5).
pub parent_mode: AppMode,
/// The session's permission posture at spawn time. Children inherit it
/// faithfully: under Auto-Review the same deterministic floor and model
/// guardian that gate the parent gate the child's held calls; under Ask a
/// held call is routed to the parent's approval UI when one exists;
/// Full Access still fails closed on the non-bypassable safety floor.
pub approval_mode: ApprovalMode,
/// The session's deterministic Auto-Review policy (configured allow/block
/// rules plus the built-in safety floor), shared with every descendant.
pub auto_review_policy: std::sync::Arc<crate::tui::auto_review::AutoReviewPolicy>,
@@ -2931,7 +2925,6 @@ impl SubAgentRuntime {
speech_output_dir: None,
todos: crate::tools::todo::new_shared_todo_list(),
parent_mode: AppMode::Agent,
approval_mode: ApprovalMode::Suggest,
auto_review_policy: std::sync::Arc::new(
crate::tui::auto_review::AutoReviewPolicy::default(),
),
@@ -2964,17 +2957,15 @@ impl SubAgentRuntime {
self
}
/// Install the session's permission posture and Auto-Review policy so
/// children are gated exactly like the parent, and say whether the host
/// can answer a prompt raised on a child's behalf.
/// Install the session's Auto-Review policy so children are gated exactly
/// like the parent, and say whether the host can answer a prompt raised on
/// a child's behalf. The posture itself travels on `context`, live.
#[must_use]
pub fn with_permission_posture(
mut self,
approval_mode: ApprovalMode,
auto_review_policy: std::sync::Arc<crate::tui::auto_review::AutoReviewPolicy>,
parent_can_prompt: bool,
) -> Self {
self.approval_mode = approval_mode;
self.auto_review_policy = auto_review_policy;
self.parent_can_prompt = parent_can_prompt;
self
@@ -3268,7 +3259,6 @@ impl SubAgentRuntime {
// LLM attempt reports 429s/successes to the adaptive scheduler.
governor: self.governor.clone(),
parent_mode: self.parent_mode,
approval_mode: self.approval_mode,
auto_review_policy: Arc::clone(&self.auto_review_policy),
parent_can_prompt: self.parent_can_prompt,
approval_receipt_store: self.approval_receipt_store.clone(),
@@ -18288,9 +18278,6 @@ struct SubAgentToolRegistry {
// 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,
// Every mutation is attributed to the child and checked against its live
@@ -18424,7 +18411,6 @@ impl SubAgentToolRegistry {
Self {
grant,
disallowed_tools: effective_profile.denied_tools.clone(),
auto_approve: runtime.context.auto_approve,
accept_edits: runtime.accept_edits,
agent_type,
owner_agent_id,
@@ -18545,16 +18531,21 @@ impl SubAgentToolRegistry {
tool_id: &str,
name: &str,
input: &Value,
posture: &ToolContext,
) -> ChildGateVerdict {
use crate::core::engine::{AutoReviewPlanDecision, auto_review_plan_decision_for_context};
use crate::core::events::{ToolGate, ToolGateVerdict};
use crate::tui::auto_review::{AutoReviewContext, RunOrigin};
let approval_mode = if self.auto_approve {
ApprovalMode::Bypass
} else {
self.gate_runtime.approval_mode
use crate::core::engine::{
AutoReviewPlanDecision, auto_review_plan_decision_for_context,
auto_review_run_origin_for_plan,
};
use crate::core::events::{ToolGate, ToolGateVerdict};
use crate::tui::auto_review::AutoReviewContext;
// Approval posture is separate from authority: it can remove a
// prompt, never restore a tool removed by role, scope, or envelope.
let approval_mode = crate::core::authority::agent_approval_mode_for_turn(
posture.auto_approve,
posture.approval_mode,
);
let workspace = self.gate_runtime.context.workspace.clone();
// Operator deny rules constrain every descendant, including Full
// Access and role-delegated reads/writes. The Config clone retains the
@@ -18581,12 +18572,16 @@ impl SubAgentToolRegistry {
return ChildGateVerdict::Deny(reason);
}
let workspace_trusted = crate::config::is_workspace_trusted(&workspace);
// Children are background workers: destructive detached work holds
// in every posture, exactly as it does for a detached parent start.
// Judged exactly like the parent's own call: destructive *detached*
// work holds in every posture, and nothing else is background.
let detached = self
.registry
.get(name)
.is_some_and(|spec| spec.starts_detached_for(input));
let review_context = AutoReviewContext::from_tool_call(
name,
input,
RunOrigin::Background,
auto_review_run_origin_for_plan(detached),
approval_mode,
workspace_trusted,
Some(&workspace),
@@ -19562,8 +19557,16 @@ impl SubAgentToolRegistry {
// Role posture and the execution envelope below stay authoritative:
// this gate can only decide whether a call the role permits also
// clears the session's approval boundary.
if let ChildGateVerdict::Deny(reason) =
self.gate_held_call(agent_id, tool_id, name, &input).await
let context = self
.registry
.context()
.clone()
.with_live_posture()
.with_owner_agent(self.owner_agent_id.clone(), self.owner_agent_name.clone())
.with_origin_tool_call_id(tool_id.to_string());
if let ChildGateVerdict::Deny(reason) = self
.gate_held_call(agent_id, tool_id, name, &input, &context)
.await
{
return Err(admission_denied(reason));
}
@@ -19656,12 +19659,6 @@ impl SubAgentToolRegistry {
}
}
}
let context = self
.registry
.context()
.clone()
.with_owner_agent(self.owner_agent_id.clone(), self.owner_agent_name.clone())
.with_origin_tool_call_id(tool_id.to_string());
let observed_paths = if scope_aware_write {
mutation_paths(name, &input)?
} else {
+121 -7
View File
@@ -15045,7 +15045,6 @@ pub(crate) fn stub_runtime() -> SubAgentRuntime {
parent_completion_tx: None,
fork_context: None,
parent_mode: AppMode::Agent,
approval_mode: ApprovalMode::Suggest,
auto_review_policy: std::sync::Arc::new(
crate::tui::auto_review::AutoReviewPolicy::default(),
),
@@ -20511,7 +20510,7 @@ async fn read_only_web_evidence_keeps_the_parent_approval_gate() {
let mut runtime =
stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options());
runtime.context = ToolContext::new(tmp.path());
runtime.approval_mode = ApprovalMode::Never;
runtime.context.approval_mode = ApprovalMode::Never;
runtime.worker_profile = WorkerRuntimeProfile::for_role(role.clone());
runtime.worker_profile.permissions.network = true;
let registry = SubAgentToolRegistry::new(
@@ -20540,7 +20539,13 @@ async fn read_only_web_evidence_keeps_the_parent_approval_gate() {
assert!(
matches!(
registry
.gate_held_call("agent_scout", "test-web", name, &input)
.gate_held_call(
"agent_scout",
"test-web",
name,
&input,
registry.registry.context(),
)
.await,
ChildGateVerdict::Deny(_)
),
@@ -23364,6 +23369,26 @@ mod child_permission_gate {
SubAgentToolRegistry,
tokio::sync::mpsc::Receiver<Event>,
SharedSubAgentManager,
) {
worker_registry_with_live_posture(
approval_mode,
auto_approve,
parent_can_prompt,
client,
None,
)
}
fn worker_registry_with_live_posture(
approval_mode: ApprovalMode,
auto_approve: bool,
parent_can_prompt: bool,
client: Option<CodewhaleClient>,
live_posture: Option<crate::core::engine::LivePosture>,
) -> (
SubAgentToolRegistry,
tokio::sync::mpsc::Receiver<Event>,
SharedSubAgentManager,
) {
let tmp = tempdir().expect("tempdir");
let (tx, rx) = tokio::sync::mpsc::channel(32);
@@ -23375,13 +23400,14 @@ mod child_permission_gate {
let session_id = format!("child_gate_{}", uuid::Uuid::new_v4().simple());
runtime.context = ToolContext::new(workspace.clone()).with_state_namespace(session_id);
runtime.context.auto_approve = auto_approve;
runtime.context.approval_mode = approval_mode;
runtime.context.live_posture = live_posture;
runtime.allow_shell = true;
runtime.event_tx = Some(tx);
runtime = runtime.with_approval_receipt_store(Ok(
crate::approval_log::ApprovalReceiptStore::new(tmp.path().join("sessions")),
));
runtime = runtime.with_permission_posture(
approval_mode,
std::sync::Arc::new(crate::tui::auto_review::AutoReviewPolicy::default()),
parent_can_prompt,
);
@@ -24605,6 +24631,90 @@ mod child_permission_gate {
assert!(receipts[0].2.is_none());
}
/// Linux report against 0.10.0: "even when I give maximum permissions to
/// one of the agents, it doesn't take them (the guardian denies)". A
/// running agent kept the posture it was spawned under, so switching the
/// session to Full Access left an Auto-Review agent asking — and, with no
/// reachable guardian, being denied by — the guardian. The agent's next
/// call now runs under the posture the person just chose, both ways.
#[tokio::test]
async fn a_posture_switch_reaches_an_already_running_agent() {
let live =
crate::core::engine::LivePosture::for_tests(&std::env::temp_dir(), ApprovalMode::Auto);
let (registry, mut rx, _) = worker_registry_with_live_posture(
ApprovalMode::Auto,
false,
true,
Some(unreachable_client()),
Some(live.clone()),
);
let call = json!({"command": "echo built | cat"});
let err = registry
.execute("agent_gate", "bash", call.clone())
.await
.expect_err("Auto-Review with no reachable guardian fails closed");
assert!(err.to_string().contains("fail closed"), "{err}");
assert_eq!(drain_gate_receipts(&mut rx).len(), 1);
live.switch_for_tests(ApprovalMode::Bypass);
let output = registry
.execute("agent_gate", "bash", call.clone())
.await
.expect("the same running agent runs the call once Full Access is granted");
assert!(output.contains("built"), "{output}");
assert!(
drain_gate_receipts(&mut rx).is_empty(),
"Full Access consults no guardian"
);
live.switch_for_tests(ApprovalMode::Auto);
registry
.execute("agent_gate", "bash", call)
.await
.expect_err("narrowing back to Auto-Review reaches the agent too");
}
/// Linux report against 0.10.0: an agent given Full Access still had its
/// calls denied by the "safety gate". Every child call was judged as
/// detached background work, so an ordinary absolute-path cleanup — which
/// the same session runs without a word in its own turn — failed closed in
/// the agent. Full Access judges the agent's call exactly like the
/// parent's: only a genuinely detached start keeps the floor.
#[tokio::test]
async fn full_access_child_runs_what_the_parent_turn_runs() {
let (registry, mut rx, _) = worker_registry(ApprovalMode::Bypass, true, true, None);
let workspace = registry.gate_runtime.context.workspace.clone();
let build = workspace.join("build");
std::fs::create_dir_all(build.join("out")).unwrap();
let command = format!("rm -rf {}", build.display());
// The parent turn's own verdict for this exact call under Full Access.
let parent_context = crate::tui::auto_review::AutoReviewContext::from_tool_call(
"bash",
&json!({"command": command}),
crate::core::engine::auto_review_run_origin_for_plan(false),
ApprovalMode::Bypass,
true,
Some(&workspace),
);
let (parent_decision, _) = crate::core::engine::auto_review_plan_decision_for_context(
&crate::tui::auto_review::AutoReviewPolicy::default(),
&parent_context,
);
assert!(
!matches!(
parent_decision,
crate::core::engine::AutoReviewPlanDecision::Block(_)
),
"precondition: the parent runs it: {parent_decision:?}"
);
registry
.execute("agent_gate", "bash", json!({"command": command}))
.await
.expect("a Full Access agent runs what its Full Access session runs");
assert!(!build.exists(), "the cleanup actually ran");
assert!(drain_gate_receipts(&mut rx).is_empty());
}
#[tokio::test]
async fn full_access_runs_ordinary_shell_but_still_hard_blocks_the_safety_floor() {
let (registry, mut rx, _) = worker_registry(ApprovalMode::Bypass, true, true, None);
@@ -24614,10 +24724,14 @@ mod child_permission_gate {
.expect("Full Access runs ordinary shell without a prompt");
assert!(output.contains("full-access"), "{output}");
assert!(drain_gate_receipts(&mut rx).is_empty());
// Destructive detached work holds in every posture (children are
// background workers), so Full Access still fails closed here.
// Destructive *detached* work holds in every posture, exactly as it
// does for a detached parent start, so Full Access fails closed here.
let err = registry
.execute("agent_gate", "bash", json!({"command": "rm -rf /usr"}))
.execute(
"agent_gate",
"Bash",
json!({"command": "rm -rf /usr", "background": true}),
)
.await
.expect_err("destructive background shell stays blocked in Full Access");
assert!(