fix(tui): make mode authority decide approval prompts

Remove the auto-review safety-floor paths that forced prompts past the selected mode, including publish-like shell and MCP action holds. Gate MCP/code-execution/hook ask approvals on auto_approve so YOLO follows the zero-prompt contract while hard deny/block rules still refuse work in every mode.

Stamp mode auto-approvals separately from user approvals so tool results no longer claim the user approved a call they were never shown.

Fixes #3790.

Tested: cargo fmt; cargo test -p codewhale-tui --bin codewhale-tui --locked yolo_mode_does_not_prompt_for_mcp_action; cargo test -p codewhale-tui --bin codewhale-tui --locked yolo_mode_; cargo test -p codewhale-tui --bin codewhale-tui --locked auto_review; cargo test -p codewhale-tui --bin codewhale-tui --locked approval
This commit is contained in:
CodeWhale Agent committed 2026-06-29 14:49:21 -07:00
1 parent 72f6cb4c6d
commit 1c68e3bb32
8 files changed
+365 -216

No files matched your search

+16 -3
View File
@@ -1145,12 +1145,17 @@ impl Engine {
.await;
match self.await_tool_approval(&tool_id).await {
Ok(ApprovalResult::Approved) => {
Ok(approval @ (ApprovalResult::Approved | ApprovalResult::ApprovedByMode)) => {
// #3790: a `!`-command can be auto-approved by the active
// mode (e.g. YOLO) rather than by an individual click;
// stamp it honestly so the model is not told the user
// approved a call they were never prompted for.
let by_mode = matches!(approval, ApprovalResult::ApprovedByMode);
emit_tool_audit(json!({
"event": "tool.approval_decision",
"tool_id": tool_id.clone(),
"tool_name": tool_name.clone(),
"decision": "approved",
"decision": if by_mode { "auto_approved_by_mode" } else { "approved" },
"source": "composer_bang",
}));
let mut result = Self::execute_tool_with_lock(
@@ -1169,7 +1174,11 @@ impl Engine {
if let Ok(tool_result) = result.as_mut() {
stamp_tool_result_approval(
tool_result,
ToolApprovalStamp::ApprovedByUser,
if by_mode {
ToolApprovalStamp::AutoApprovedByMode
} else {
ToolApprovalStamp::ApprovedByUser
},
);
}
result
@@ -3801,6 +3810,10 @@ impl MockEngineHandle {
pub(crate) async fn recv_approval_event(&mut self) -> Option<MockApprovalEvent> {
match self.rx_approval.recv().await? {
ApprovalDecision::Approved { id } => Some(MockApprovalEvent::Approved { id }),
// #3790: a mode auto-approval surfaces as Approved to the mock
// recorder; tests assert the honest "auto-approved by mode" note on
// the tool result itself rather than on this channel event.
ApprovalDecision::ApprovedByMode { id } => Some(MockApprovalEvent::Approved { id }),
ApprovalDecision::Denied { id } => Some(MockApprovalEvent::Denied { id }),
ApprovalDecision::RetryWithPolicy { id, policy } => {
Some(MockApprovalEvent::RetryWithPolicy { id, policy })
+11
View File
@@ -20,6 +20,12 @@ pub(super) enum ApprovalDecision {
Approved {
id: String,
},
/// Auto-approved on the active mode's authority (e.g. YOLO/Bypass), not by
/// an individual user decision. Stamped honestly so the model is never told
/// the user approved something they were never prompted for (#3790).
ApprovedByMode {
id: String,
},
Denied {
id: String,
},
@@ -46,6 +52,8 @@ pub(super) enum UserInputDecision {
pub(super) enum ApprovalResult {
/// User approved the tool execution.
Approved,
/// The active mode auto-approved the tool execution (no user prompt).
ApprovedByMode,
/// User denied the tool execution.
Denied,
/// User requested retry with an elevated sandbox policy.
@@ -93,6 +101,9 @@ impl Engine {
ApprovalDecision::Approved { id } if id == tool_id => {
return Ok(ApprovalResult::Approved);
}
ApprovalDecision::ApprovedByMode { id } if id == tool_id => {
return Ok(ApprovalResult::ApprovedByMode);
}
ApprovalDecision::Denied { id } if id == tool_id => {
return Ok(ApprovalResult::Denied);
}
+8
View File
@@ -76,6 +76,10 @@ pub(super) struct ParallelToolResult {
pub(super) enum ToolApprovalStamp {
ApprovedByUser,
ApprovedWithPolicy,
/// Auto-approved on the active mode's authority (e.g. YOLO), with no user
/// prompt. Kept distinct from `ApprovedByUser` so the model is never told a
/// user approved a call they were never shown (#3790).
AutoApprovedByMode,
}
impl ToolApprovalStamp {
@@ -83,6 +87,7 @@ impl ToolApprovalStamp {
match self {
Self::ApprovedByUser => "approved_by_user",
Self::ApprovedWithPolicy => "approved_with_policy",
Self::AutoApprovedByMode => "auto_approved_by_mode",
}
}
@@ -94,6 +99,9 @@ impl ToolApprovalStamp {
Self::ApprovedWithPolicy => {
"[approval] This tool call required approval and was approved by the user with an adjusted execution policy before execution."
}
Self::AutoApprovedByMode => {
"[approval] This tool call would require approval in Agent mode; the active mode (e.g. YOLO) auto-approved it and ran it without a user prompt."
}
}
}
}
+11
View File
@@ -77,6 +77,17 @@ impl EngineHandle {
Ok(())
}
/// Auto-approve a pending tool call on the active mode's authority (e.g.
/// YOLO), as opposed to an individual user decision. The result is stamped
/// as auto-approved-by-mode so the model is told the truth about why the
/// tool ran without a prompt (#3790).
pub async fn approve_tool_call_by_mode(&self, id: impl Into<String>) -> Result<()> {
self.tx_approval
.send(ApprovalDecision::ApprovedByMode { id: id.into() })
.await?;
Ok(())
}
/// Deny a pending tool call
pub async fn deny_tool_call(&self, id: impl Into<String>) -> Result<()> {
self.tx_approval
+227 -110
View File
@@ -1,10 +1,7 @@
use super::*;
use super::context::{COMPACTION_SUMMARY_MARKER, TURN_MAX_OUTPUT_TOKENS};
use super::turn_loop::{
auto_review_force_prompt_overrides_auto_approve, registered_tool_approval_required,
tool_error_degradation_runtime_hint,
};
use super::turn_loop::{registered_tool_approval_required, tool_error_degradation_runtime_hint};
use crate::config::ApiProvider;
use crate::models::{SystemBlock, Usage};
use crate::test_support::{EnvVarGuard, lock_test_env};
@@ -471,31 +468,12 @@ fn model_turn_event_timeout() -> Duration {
}
#[test]
fn auto_review_policy_forces_prompt_for_publish_like_actions() {
let (decision, audit) = auto_review_plan_decision(
&crate::tui::auto_review::AutoReviewPolicy::default(),
"git_push",
&json!({"remote": "origin", "branch": "main"}),
crate::tui::auto_review::RunOrigin::Interactive,
crate::tui::approval::ApprovalMode::Auto,
Some("push the release branch"),
true,
false,
);
assert_eq!(
decision,
AutoReviewPlanDecision::ForcePrompt(
"Auto-review policy requires approval: publish-like actions require a durable review step"
.to_string()
)
);
assert_eq!(audit["decision"], "hold_for_review");
assert_eq!(audit["action_kind"], "publish");
}
#[test]
fn auto_review_policy_forces_prompt_for_shell_git_push() {
fn auto_review_classifies_publish_but_no_longer_force_prompts_it() {
// #3790: publish-like shell is still *classified* as publish (audit only),
// but with the safety floor removed the policy no longer forces a prompt.
// The mode alone decides: Agent prompts via the normal approval path, YOLO
// runs with zero prompts. Previously this returned ForcePrompt with a
// hold_for_review audit decision and was overridden past YOLO.
let (decision, audit) = auto_review_plan_decision(
&crate::tui::auto_review::AutoReviewPolicy::default(),
"exec_shell",
@@ -507,16 +485,9 @@ fn auto_review_policy_forces_prompt_for_shell_git_push() {
false,
);
assert_eq!(
decision,
AutoReviewPlanDecision::ForcePrompt(
"Auto-review policy requires approval: publish-like actions require a durable review step"
.to_string()
)
);
assert_eq!(audit["decision"], "hold_for_review");
assert_eq!(decision, AutoReviewPlanDecision::NoChange);
assert_eq!(audit["action_kind"], "publish");
assert!(auto_review_force_prompt_overrides_auto_approve(&audit));
assert_ne!(audit["decision"], "hold_for_review");
}
#[test]
@@ -538,7 +509,11 @@ fn auto_review_policy_does_not_force_prompt_for_shell_git_tag_list_probe() {
}
#[test]
fn auto_review_policy_blocks_hold_when_approval_is_never() {
fn auto_review_policy_no_longer_holds_publish_even_when_approval_is_never() {
// #3790: the publish "durable review" floor was removed and deferred to
// 0.8.67. The auto-review policy no longer forces a hold or a Never-block
// for a publish-like action; refusal is the job of a typed deny rule or the
// approval gate (ApprovalMode::Never denies at the chokepoint), not a floor.
let (decision, audit) = auto_review_plan_decision(
&crate::tui::auto_review::AutoReviewPolicy::default(),
"github_publish_release",
@@ -550,15 +525,9 @@ fn auto_review_policy_blocks_hold_when_approval_is_never() {
false,
);
assert_eq!(
decision,
AutoReviewPlanDecision::Block(
"Auto-review policy requires approval: publish-like actions require a durable review step"
.to_string()
)
);
assert_eq!(decision, AutoReviewPlanDecision::NoChange);
assert_eq!(audit["approval_mode"], "NEVER");
assert_eq!(audit["decision"], "hold_for_review");
assert_ne!(audit["decision"], "hold_for_review");
}
#[test]
@@ -615,7 +584,11 @@ fn auto_review_run_origin_marks_detached_tools_as_background() {
}
#[test]
fn auto_review_policy_holds_background_destructive_suggest_approval() {
fn auto_review_policy_no_longer_holds_background_destructive_under_suggest() {
// #3790: the background/headless destructive floor was removed and deferred
// to 0.8.67. Agent-mode prompting for a write-capable shell still happens
// via the normal registered-tool approval path; the auto-review policy no
// longer force-prompts it.
let (decision, audit) = auto_review_plan_decision(
&crate::tui::auto_review::AutoReviewPolicy::default(),
"exec_shell",
@@ -627,15 +600,9 @@ fn auto_review_policy_holds_background_destructive_suggest_approval() {
false,
);
assert_eq!(
decision,
AutoReviewPlanDecision::ForcePrompt(
"Auto-review policy requires approval: destructive background/headless actions cannot auto-approve"
.to_string()
)
);
assert_eq!(decision, AutoReviewPlanDecision::NoChange);
assert_eq!(audit["run_origin"], "background");
assert_eq!(audit["decision"], "hold_for_review");
assert_ne!(audit["decision"], "hold_for_review");
}
#[test]
@@ -663,7 +630,10 @@ fn auto_review_policy_preserves_yolo_for_detached_destructive_tools() {
}
#[test]
fn auto_review_policy_blocks_background_hold_when_approval_is_never() {
fn auto_review_policy_no_longer_blocks_background_destructive_under_never() {
// #3790: with the background/headless destructive floor removed, the policy
// returns NoChange even under Never; Never denies at the approval gate, not
// via an auto-review hold. Deferred to 0.8.67.
let (decision, audit) = auto_review_plan_decision(
&crate::tui::auto_review::AutoReviewPolicy::default(),
"exec_shell",
@@ -675,13 +645,7 @@ fn auto_review_policy_blocks_background_hold_when_approval_is_never() {
false,
);
assert_eq!(
decision,
AutoReviewPlanDecision::Block(
"Auto-review policy requires approval: destructive background/headless actions cannot auto-approve"
.to_string()
)
);
assert_eq!(decision, AutoReviewPlanDecision::NoChange);
assert_eq!(audit["approval_mode"], "NEVER");
assert_eq!(audit["run_origin"], "background");
}
@@ -1481,6 +1445,37 @@ fn approval_stamp_preserves_existing_metadata() {
assert!(result.content.contains("adjusted execution policy"));
}
#[test]
fn auto_approved_by_mode_stamp_is_honest_and_not_attributed_to_the_user() {
// #3790: when the mode (e.g. YOLO) auto-approves a tool that would otherwise
// prompt, the model must be told the truth — auto-approved by the mode, NOT
// "approved by the user", which never happened.
let mut result = ToolResult::success("stdout");
stamp_tool_result_approval(&mut result, ToolApprovalStamp::AutoApprovedByMode);
assert!(
result.content.starts_with("[approval] "),
"{}",
result.content
);
assert!(
!result.content.contains("approved by the user"),
"must not claim the user approved it: {}",
result.content
);
assert!(
result.content.contains("auto-approved") && result.content.contains("mode"),
"should attribute the run to the mode: {}",
result.content
);
assert!(result.content.ends_with("stdout"));
let metadata = result.metadata.expect("approval metadata");
assert_eq!(metadata["approval"]["decision"], "auto_approved_by_mode");
assert_eq!(metadata["approval"]["model_visible"], true);
}
#[test]
fn core_native_tools_stay_loaded_in_yolo_mode() {
let always_load = HashSet::new();
@@ -2584,15 +2579,13 @@ async fn yolo_mode_does_not_prompt_for_model_driven_typed_ask_rule() {
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn yolo_mode_does_not_prompt_for_background_shell_safety_floor() {
// Regression: every shell command is classified RiskLevel::Destructive, and
// a background shell (input `background: true`) gets RunOrigin::Background.
// The auto_review safety_floor returns HoldForReview for Background +
// Destructive, which maps to ForcePrompt. Before the fix that ForcePrompt
// set approval_required=true WITHOUT checking auto_approve, so every
// background shell command prompted even in YOLO. Foreground shells are
// RunOrigin::Interactive and never hit that branch (which is why only
// background commands surfaced the bug). A typed Block/deny still holds.
async fn yolo_mode_does_not_prompt_for_background_shell() {
// #3790 regression guard: YOLO is the sole authority and runs every tool
// with zero prompts, including a background shell (input `background: true`,
// which gets RunOrigin::Background and is classified RiskLevel::Destructive).
// The auto-review override "safety floor" that used to force a prompt here —
// even in YOLO — was removed; the mode now decides alone. A typed Block/deny
// rule still hard-blocks regardless of mode.
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -2724,10 +2717,14 @@ async fn yolo_mode_does_not_prompt_for_background_shell_safety_floor() {
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn yolo_mode_forces_prompt_for_publish_like_shell() {
// YOLO keeps ordinary/background approvals out of the way, but publish-like
// actions are deliberately durable-review holds. They must still surface a
// forced prompt even when `auto_approve` is true.
async fn yolo_mode_runs_publish_like_shell_without_a_prompt() {
// #3790: YOLO is the sole authority and runs every tool with zero prompts —
// including publish-like shell (`git push` / `cargo publish` / `gh release`).
// The old #3735 behavior force-prompted publish past YOLO via the auto-review
// safety floor; that floor was removed and the mode now decides alone. So no
// Event::ApprovalRequired may fire, and the command must run. (Backgrounded
// so the mock's terminal response keys off the stable "Background task
// started" tool result; the publish-like classification is what matters.)
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -2738,7 +2735,7 @@ async fn yolo_mode_forces_prompt_for_publish_like_shell() {
let tool_call_sse = concat!(
"data: {\"id\":\"chatcmpl-publish\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[",
"{\"index\":0,\"id\":\"call_publish\",\"type\":\"function\",\"function\":{\"name\":\"exec_shell\",",
"\"arguments\":\"{\\\"command\\\":\\\"cargo publish --dry-run\\\"}\"}}",
"\"arguments\":\"{\\\"command\\\":\\\"git push origin main\\\",\\\"background\\\":true}\"}}",
"]},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl-publish\",\"choices\":[{\"index\":0,\"delta\":{},",
"\"finish_reason\":\"tool_calls\"}]}\n\n",
@@ -2754,7 +2751,7 @@ async fn yolo_mode_forces_prompt_for_publish_like_shell() {
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.and(body_string_contains("denied by user"))
.and(body_string_contains("Background task started"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
@@ -2792,7 +2789,6 @@ async fn yolo_mode_forces_prompt_for_publish_like_shell() {
&api_config,
);
let run_task = tokio::spawn(engine.run());
let handle_for_approval = handle.clone();
handle
.send(Op::SendMessage {
@@ -2809,7 +2805,7 @@ async fn yolo_mode_forces_prompt_for_publish_like_shell() {
allow_shell: true,
trust_mode: true,
auto_approve: true,
approval_mode: crate::tui::approval::ApprovalMode::Auto,
approval_mode: crate::tui::approval::ApprovalMode::Bypass,
translation_enabled: false,
show_thinking: true,
allowed_tools: None,
@@ -2821,43 +2817,20 @@ async fn yolo_mode_forces_prompt_for_publish_like_shell() {
.await
.expect("send model turn");
let mut saw_forced_approval = false;
let mut saw_approval_prompt = false;
let mut saw_complete = false;
let mut rx = handle.rx_event.write().await;
while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
.await
.expect("timed out waiting for engine event")
{
match event {
Event::ApprovalRequired {
id,
tool_name,
description,
input,
approval_force_prompt,
..
} => {
saw_forced_approval = true;
assert_eq!(tool_name, "exec_shell");
assert_eq!(input["command"], json!("cargo publish --dry-run"));
assert!(description.contains("publish-like"));
assert!(
approval_force_prompt,
"publish-like YOLO prompts must bypass TUI auto-approval"
);
handle_for_approval
.deny_tool_call(id)
.await
.expect("deny publish-like shell");
}
Event::ToolCallComplete { name, result, .. } if name == "exec_shell" => {
let err = result.expect_err("publish-like shell should be denied");
assert!(
err.to_string().contains("denied by user"),
"unexpected shell result: {err:?}"
);
Event::ApprovalRequired { .. } => {
saw_approval_prompt = true;
}
Event::TurnComplete { status, .. } => {
assert_eq!(status, TurnOutcomeStatus::Completed);
saw_complete = true;
break;
}
_ => {}
@@ -2867,7 +2840,151 @@ async fn yolo_mode_forces_prompt_for_publish_like_shell() {
handle.send(Op::Shutdown).await.expect("shutdown engine");
run_task.await.expect("engine task");
assert!(saw_forced_approval);
assert!(
!saw_approval_prompt,
"YOLO must run publish-like shell with zero prompts (#3790)"
);
assert!(saw_complete, "the YOLO publish-like turn should complete");
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn yolo_mode_does_not_prompt_for_mcp_action() {
// #3790: MCP mutations are governed by the selected mode, just like shell.
// YOLO must not emit an approval request for a non-read-only MCP tool; this
// fixture has no GitHub MCP server, so execution may fail after the no-prompt
// planning decision. The regression guard is the absence of ApprovalRequired.
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let _lock = lock_test_env();
let workspace = tempdir().expect("tempdir");
let server = MockServer::start().await;
let tool_call_sse = concat!(
"data: {\"id\":\"chatcmpl-mcp\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[",
"{\"index\":0,\"id\":\"call_mcp\",\"type\":\"function\",\"function\":{\"name\":\"mcp_github_create_pull_request\",",
"\"arguments\":\"{\\\"title\\\":\\\"test\\\",\\\"body\\\":\\\"body\\\"}\"}}",
"]},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl-mcp\",\"choices\":[{\"index\":0,\"delta\":{},",
"\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
);
let done_sse = concat!(
"data: {\"id\":\"chatcmpl-done\",\"choices\":[{\"index\":0,",
"\"delta\":{\"content\":\"ack\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl-done\",\"choices\":[{\"index\":0,\"delta\":{},",
"\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n",
);
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.and(body_string_contains("MCP tool failed"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(done_sse),
)
.expect(1)
.with_priority(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(tool_call_sse),
)
.expect(1)
.with_priority(2)
.mount(&server)
.await;
let api_config = Config {
api_key: Some("test-key".to_string()),
base_url: Some(server.uri()),
..Config::default()
};
let (engine, handle) = Engine::new(
EngineConfig {
model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
workspace: workspace.path().to_path_buf(),
snapshots_enabled: false,
subagents_enabled: false,
..EngineConfig::default()
},
&api_config,
);
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
content: "please open the PR".to_string(),
mode: AppMode::Yolo,
provider: None,
model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
reasoning_effort: None,
reasoning_effort_auto: false,
auto_model: false,
allow_shell: true,
trust_mode: true,
auto_approve: true,
approval_mode: crate::tui::approval::ApprovalMode::Bypass,
translation_enabled: false,
show_thinking: true,
allowed_tools: None,
dynamic_tools: Vec::new(),
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
.await
.expect("send model turn");
let mut saw_mcp_result = false;
let mut saw_complete = false;
let mut rx = handle.rx_event.write().await;
while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
.await
.expect("timed out waiting for engine event")
{
match event {
Event::ApprovalRequired { .. } => {
panic!("YOLO mode must not prompt for an MCP action");
}
Event::ToolCallComplete { name, result, .. }
if name == "mcp_github_create_pull_request" =>
{
saw_mcp_result = true;
let err = result
.expect_err("unconfigured MCP server should fail after no-prompt planning");
assert!(
err.to_string().contains("MCP tool failed"),
"unexpected MCP error: {err:?}"
);
}
Event::TurnComplete { status, .. } => {
assert_eq!(status, TurnOutcomeStatus::Completed);
saw_complete = true;
break;
}
_ => {}
}
}
drop(rx);
handle.send(Op::Shutdown).await.expect("shutdown engine");
run_task.await.expect("engine task");
assert!(
saw_mcp_result,
"the MCP tool should execute without an approval gate"
);
assert!(saw_complete, "the YOLO MCP turn should complete");
}
#[tokio::test]
+24 -36
View File
@@ -43,19 +43,6 @@ pub(super) fn registered_tool_approval_required(
!auto_approve
}
pub(super) fn auto_review_force_prompt_overrides_auto_approve(
audit_event: &serde_json::Value,
) -> bool {
audit_event
.get("decision")
.and_then(serde_json::Value::as_str)
== Some("hold_for_review")
&& audit_event
.get("action_kind")
.and_then(serde_json::Value::as_str)
== Some("publish")
}
pub(super) fn tool_error_degradation_runtime_hint(
consecutive_tool_error_steps: u32,
step_error_tool_names: &[String],
@@ -1716,7 +1703,7 @@ impl Engine {
if McpPool::is_mcp_tool(&tool_name) {
read_only = mcp_tool_is_read_only(&tool_name);
supports_parallel = mcp_tool_is_parallel_safe(&tool_name);
approval_required = !read_only;
approval_required = !read_only && !self.session.auto_approve;
approval_description = mcp_tool_approval_description(&tool_name);
} else if let Some(registry) = tool_registry
&& let Some(spec) = registry.get(&tool_name)
@@ -1731,13 +1718,13 @@ impl Engine {
read_only = spec.is_read_only_for(&tool_input);
detached_start = spec.starts_detached_for(&tool_input);
} else if tool_name == CODE_EXECUTION_TOOL_NAME {
approval_required = true;
approval_required = !self.session.auto_approve;
approval_description =
"Run model-provided Python code in local execution sandbox".to_string();
supports_parallel = false;
read_only = false;
} else if tool_name == JS_EXECUTION_TOOL_NAME {
approval_required = true;
approval_required = !self.session.auto_approve;
approval_description =
"Run model-provided JavaScript code in local Node.js execution sandbox"
.to_string();
@@ -1763,7 +1750,7 @@ impl Engine {
// for tools the registry would auto-run. Must stay after the
// registry-based computation above, which assigns rather than
// ORs `approval_required`.
if hook_requires_approval {
if hook_requires_approval && !self.session.auto_approve {
approval_required = true;
}
@@ -1787,13 +1774,10 @@ impl Engine {
if let Some(decision) = ask_rule_decision {
match decision {
ToolAskRuleDecision::Prompt(reason) => {
// YOLO mode (auto_approve) is the explicit
// "no approvals" contract: a typed ask-rule
// must not pop a modal in YOLO. The
// auto_review safety floor below still
// independently holds publish/destructive
// actions, and a typed deny rule still
// blocks hard.
// #3790: the mode is the sole authority — a typed
// ask-rule prompts in Agent/Plan but never in YOLO
// (auto_approve). A typed deny rule still blocks
// hard, in every mode.
if !self.session.auto_approve {
approval_required = true;
approval_description = reason;
@@ -1828,18 +1812,12 @@ impl Engine {
match decision {
AutoReviewPlanDecision::NoChange => {}
AutoReviewPlanDecision::ForcePrompt(reason) => {
// YOLO mode (auto_approve) skips ordinary review
// holds, including Background+Destructive shell
// holds created by the coarse shell risk fallback.
// Publish-like actions are different: the
// safety_floor marks them as durable-review holds
// regardless of mode, so they must still surface a
// forced prompt. A Block decision (typed deny
// rules / hard blocks) still holds below
// regardless of mode.
if !self.session.auto_approve
|| auto_review_force_prompt_overrides_auto_approve(&audit_event)
{
// #3790: the Tab-selected mode is the sole authority.
// YOLO (auto_approve) suppresses every review-driven
// prompt — there is no longer any "force prompt past
// YOLO" path. A Block decision (a typed deny rule)
// still hard-blocks below, in every mode.
if !self.session.auto_approve {
approval_required = true;
approval_description = reason;
approval_force_prompt = true;
@@ -2298,6 +2276,16 @@ impl Engine {
}));
(None, None, Some(ToolApprovalStamp::ApprovedByUser))
}
Ok(ApprovalResult::ApprovedByMode) => {
emit_tool_audit(json!({
"event": "tool.approval_decision",
"tool_id": tool_id.clone(),
"tool_name": tool_name.clone(),
"decision": "auto_approved_by_mode",
"caller": caller_type_for_tool_use(tool_caller.as_ref()),
}));
(None, None, Some(ToolApprovalStamp::AutoApprovedByMode))
}
Ok(ApprovalResult::Denied) => {
emit_tool_audit(json!({
"event": "tool.approval_decision",
+53 -65
View File
@@ -292,9 +292,12 @@ impl AutoReviewPolicy {
.with_rule(rule.id.clone());
}
if let Some(floor) = safety_floor(ctx) {
return floor;
}
// #3790: the Tab-selected mode is the single authority for whether a
// tool prompts. The auto-review "safety floor" that used to force
// publish / destructive / MCP holds *past* the mode was removed and
// deferred to 0.8.67 (to return together with Auto mode). Only an
// explicit Block (deny) rule above still refuses an action — a hard
// prohibition, not a prompt — and it applies in every mode.
if let Some(rule) = self
.allow_rules
@@ -327,43 +330,17 @@ impl AutoReviewPolicy {
}
}
fn safety_floor(ctx: &AutoReviewContext<'_>) -> Option<AutoReviewDecision> {
if matches!(ctx.action_kind, ToolActionKind::Publish) {
return Some(AutoReviewDecision::new(
AutoReviewAction::HoldForReview,
"publish-like actions require a durable review step",
));
}
if !matches!(ctx.approval_mode, ApprovalMode::Bypass)
&& matches!(ctx.run_origin, RunOrigin::Headless | RunOrigin::Background)
&& matches!(ctx.risk, RiskLevel::Destructive)
{
return Some(AutoReviewDecision::new(
AutoReviewAction::HoldForReview,
"destructive background/headless actions cannot auto-approve",
));
}
if !ctx.workspace_trusted && matches!(ctx.risk, RiskLevel::Destructive) {
return Some(AutoReviewDecision::new(
AutoReviewAction::AskUser,
"destructive action in an untrusted workspace requires user review",
));
}
None
}
fn deterministic_fallback(ctx: &AutoReviewContext<'_>) -> AutoReviewDecision {
match (ctx.category, ctx.risk, ctx.action_kind) {
(_, RiskLevel::Benign, _) => {
AutoReviewDecision::new(AutoReviewAction::Allow, "read-only action is allowed")
}
(_, _, ToolActionKind::McpAction) => AutoReviewDecision::new(
AutoReviewAction::HoldForReview,
"MCP actions may have remote side effects",
),
// #3790: MCP actions are governed by the mode exactly like every other
// tool — the engine prompts in Agent and auto-approves in YOLO. The old
// unconditional HoldForReview here (which could hold even outside the
// user's chosen mode) was removed and deferred to 0.8.67. A destructive
// MCP action now falls through to the generic Destructive arm below,
// identical to a destructive shell command.
(ToolCategory::Unknown, _, _) => AutoReviewDecision::new(
AutoReviewAction::AskUser,
"unknown tool category requires explicit review",
@@ -626,11 +603,14 @@ mod tests {
}
#[test]
fn headless_destructive_tool_holds_for_review_even_with_allow_rule() {
fn allow_rule_for_publish_is_honored_without_a_floor_override() {
// #3790: with the safety floor removed, a user allow_rule is
// authoritative — nothing overrides it back to a hold. Previously the
// publish / headless-destructive floor beat the allow_rule.
let policy = AutoReviewPolicy {
allow_rules: vec![
AutoReviewRule::allow("allow-shell", "trusted shell command")
.action_kind(ToolActionKind::Shell),
AutoReviewRule::allow("allow-publish", "trusted publish")
.action_kind(ToolActionKind::Publish),
],
..AutoReviewPolicy::default()
};
@@ -643,12 +623,14 @@ mod tests {
let decision = policy.evaluate(&ctx);
assert_eq!(decision.action, AutoReviewAction::HoldForReview);
assert!(decision.rule_id.is_none());
assert_eq!(decision.action, AutoReviewAction::Allow);
assert_eq!(decision.rule_id.as_deref(), Some("allow-publish"));
}
#[test]
fn mcp_read_allows_and_mcp_action_holds() {
fn mcp_read_allows_and_mcp_action_is_not_held_by_policy() {
// #3790: the policy no longer holds MCP actions. The mode governs MCP
// prompting at the engine exactly like shell — Agent prompts, YOLO runs.
let policy = AutoReviewPolicy::default();
let read_ctx = ctx_for(
"read_mcp_resource",
@@ -664,14 +646,16 @@ mod tests {
);
assert_eq!(policy.evaluate(&read_ctx).action, AutoReviewAction::Allow);
assert_eq!(
assert_ne!(
policy.evaluate(&action_ctx).action,
AutoReviewAction::HoldForReview
AutoReviewAction::HoldForReview,
"MCP actions are no longer held by the policy; the mode governs prompting"
);
}
#[test]
fn git_push_like_action_holds_for_review() {
fn git_push_tool_is_classified_publish_but_not_held() {
// #3790: still classified Publish (audit only); no longer force-held.
let policy = AutoReviewPolicy::default();
let ctx = ctx_for(
"git_push",
@@ -680,14 +664,15 @@ mod tests {
ApprovalMode::Auto,
);
let decision = policy.evaluate(&ctx);
assert_eq!(decision.action, AutoReviewAction::HoldForReview);
assert!(decision.reason.contains("publish-like"));
assert_eq!(ctx.action_kind, ToolActionKind::Publish);
assert_ne!(
policy.evaluate(&ctx).action,
AutoReviewAction::HoldForReview
);
}
#[test]
fn shell_git_push_holds_for_publish_review() {
fn shell_git_push_is_classified_publish_but_not_held() {
let policy = AutoReviewPolicy::default();
let ctx = ctx_for(
"exec_shell",
@@ -696,15 +681,15 @@ mod tests {
ApprovalMode::Auto,
);
let decision = policy.evaluate(&ctx);
assert_eq!(ctx.action_kind, ToolActionKind::Publish);
assert_eq!(decision.action, AutoReviewAction::HoldForReview);
assert!(decision.reason.contains("publish-like"));
assert_ne!(
policy.evaluate(&ctx).action,
AutoReviewAction::HoldForReview
);
}
#[test]
fn shell_chained_publish_command_holds_for_review() {
fn shell_chained_publish_is_classified_publish_but_not_held() {
let policy = AutoReviewPolicy::default();
let ctx = ctx_for(
"exec_shell",
@@ -713,10 +698,11 @@ mod tests {
ApprovalMode::Auto,
);
let decision = policy.evaluate(&ctx);
assert_eq!(ctx.action_kind, ToolActionKind::Publish);
assert_eq!(decision.action, AutoReviewAction::HoldForReview);
assert_ne!(
policy.evaluate(&ctx).action,
AutoReviewAction::HoldForReview
);
}
#[test]
@@ -744,7 +730,7 @@ mod tests {
}
#[test]
fn shell_git_tag_creation_holds_for_publish_review() {
fn shell_git_tag_creation_is_classified_publish_but_not_held() {
let policy = AutoReviewPolicy::default();
let ctx = ctx_for(
"exec_shell",
@@ -753,14 +739,15 @@ mod tests {
ApprovalMode::Auto,
);
let decision = policy.evaluate(&ctx);
assert_eq!(ctx.action_kind, ToolActionKind::Publish);
assert_eq!(decision.action, AutoReviewAction::HoldForReview);
assert_ne!(
policy.evaluate(&ctx).action,
AutoReviewAction::HoldForReview
);
}
#[test]
fn shell_git_tag_delete_holds_for_publish_review() {
fn shell_git_tag_delete_is_classified_publish_but_not_held() {
let policy = AutoReviewPolicy::default();
let ctx = ctx_for(
"exec_shell",
@@ -769,10 +756,11 @@ mod tests {
ApprovalMode::Auto,
);
let decision = policy.evaluate(&ctx);
assert_eq!(ctx.action_kind, ToolActionKind::Publish);
assert_eq!(decision.action, AutoReviewAction::HoldForReview);
assert_ne!(
policy.evaluate(&ctx).action,
AutoReviewAction::HoldForReview
);
}
#[test]
+15 -2
View File
@@ -2959,8 +2959,17 @@ async fn run_event_loop(
&approval_grouping_key,
approval_force_prompt,
) {
// #3790: distinguish a mode auto-approval (e.g. YOLO)
// from re-using an earlier in-session user approval, so
// the engine stamps the tool result honestly instead of
// always claiming the user approved it.
let by_mode = app_auto_approve_enabled(app);
log_sensitive_event(
"tool.approval.auto_approve",
if by_mode {
"tool.approval.auto_approve_mode"
} else {
"tool.approval.auto_approve_session"
},
serde_json::json!({
"tool_name": tool_name,
"approval_key": approval_key,
@@ -2968,7 +2977,11 @@ async fn run_event_loop(
"mode": app.mode.label(),
}),
);
let _ = engine_handle.approve_tool_call(id.clone()).await;
if by_mode {
let _ = engine_handle.approve_tool_call_by_mode(id.clone()).await;
} else {
let _ = engine_handle.approve_tool_call(id.clone()).await;
}
} else if app.approval_mode == ApprovalMode::Never {
log_sensitive_event(
"tool.approval.auto_deny",