mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 09:59:45 -10:00
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:
1 parent
2579658d26
commit
d1655c4246
5 files changed
+282
-49
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in new issue
Block a user