mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
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:
1 parent
72f6cb4c6d
commit
1c68e3bb32
8 files changed
+365
-216
No files matched your search
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user