fix(auto-review): let Auto-Review sessions ask the user questions

Auto-Review withheld `request_user_input` from the catalog, answered any
question call with a synthetic "decide autonomously" result, and
cancelled a stale question modal in the TUI. That tied questions to
approval posture: in Auto-Review the model had to guess about real
ambiguity (which design, which account, whether to delete). Auto-Review
now only auto-reviews tool approvals, as in codex `auto_review` and Claude
Code auto mode, which keeps AskUserQuestion.

Removed: `permission_posture_allows_questions`, the posture parameter and
question filter on `ToolSurfacePolicy::new`, the `questions_allowed`
plumbing and synthetic reply in the turn loop,
`should_suppress_user_input_prompt` with the stale-prompt cancel in the
event loop, and the now-unused AutoReviewQuestionSkipped message in all 15
locales. A question moves no data and changes no state. Headless exec
still withholds the tool through `exec_disallowed_tools` (no responder),
and interactive waits still time out. docs/AUTHORIZATION_ORDER.md says so.

Touches core/engine/approval.rs and tui/ui/event_loop.rs only at lines
outside PR #6570's hunks (approval.rs test fixtures; event_loop 2031 and
3790), so either lands on the other without conflict.

Tests: focused codewhale-tui --lib run 166 passed, 0 failed, 1 ignored,
including auto_review_asks_the_user_and_returns_the_answer (inverted from
auto_review_auto_resolves_hallucinated_question_without_prompting: the
question now emits UserInputRequired and the submitted answer reaches the
model) and question_tool_survives_the_tool_surface_in_every_posture
(replaces the posture-hiding and legacy full-access tests; also checks the
headless deny list). Broad core::engine / tui::ui::tests / exec_agent run
inside the 3022-passed batch; its only core::engine failures were two
mcp_boot_* tests that need a working local node. codewhale-localization
50 passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-25 12:10:02 -07:00
1 parent 90bb4cdddf
commit 2b440fa056
27 files changed
+82 -266

No files matched your search

-1
View File
@@ -1141,7 +1141,6 @@
"PendingApprovalInAgent": "Cal una aprovació a {agent} — /agents",
"ApprovalTruncationHint": " … truncat · prem {details} per a tots els detalls",
"ApprovalFullAccessPolicyBlocked": "{tool} blocat: Full Access no pot evitar aquesta política",
"AutoReviewQuestionSkipped": "Auto-Review ha omès una pregunta de l'usuari i ha continuat autònomament",
"ApprovalChooseHint": "Tria: ",
"ApprovalChooseAction": "Enter per a l'opció seleccionada, o prem y/a/d directament",
"ApprovalIntentLabel": "Intenció: ",
-1
View File
@@ -1141,7 +1141,6 @@
"PendingApprovalInAgent": "Freigabe nötig in {agent} — /agents",
"ApprovalTruncationHint": " … gekürzt · {details} für volle Details",
"ApprovalFullAccessPolicyBlocked": "{tool} blockiert: Full Access kann diese Policy nicht umgehen",
"AutoReviewQuestionSkipped": "Auto-Review hat eine Nutzerfrage übersprungen und autonom fortgefahren",
"ApprovalChooseHint": "Wahl: ",
"ApprovalChooseAction": "Enter wählt die Option, oder direkt y/a/d drücken",
"ApprovalIntentLabel": "Absicht: ",
-1
View File
@@ -1164,7 +1164,6 @@
"PendingApprovalInAgent": "Approval needed in {agent} — /agents",
"ApprovalTruncationHint": " … truncated · press {details} for full details",
"ApprovalFullAccessPolicyBlocked": "Blocked {tool}: Full Access cannot bypass this policy",
"AutoReviewQuestionSkipped": "Auto-Review skipped a user question and continued autonomously",
"ApprovalChooseHint": "Choose: ",
"ApprovalChooseAction": "Enter selected option, or press y/a/d directly",
"ApprovalIntentLabel": "Intent: ",
-1
View File
@@ -1162,7 +1162,6 @@
"PendingApprovalInAgent": "Se necesita aprobación en {agent} — /agents",
"ApprovalTruncationHint": " … truncado · presiona {details} para ver todos los detalles",
"ApprovalFullAccessPolicyBlocked": "Bloqueado {tool}: Full Access no puede omitir esta política",
"AutoReviewQuestionSkipped": "Auto-Review omitió una pregunta y continuó de forma autónoma",
"ApprovalChooseHint": "Elegir: ",
"ApprovalChooseAction": "Enter para seleccionar, o presione y/a/d directamente",
"ApprovalIntentLabel": "Intención: ",
-1
View File
@@ -1141,7 +1141,6 @@
"PendingApprovalInAgent": "Approbation requise dans {agent} — /agents",
"ApprovalTruncationHint": " … tronqué · appuyez sur {details} pour tous les détails",
"ApprovalFullAccessPolicyBlocked": "{tool} bloqué : Full Access ne peut pas contourner cette politique",
"AutoReviewQuestionSkipped": "Auto-Review a ignoré une question de l'utilisateur et a continué de façon autonome",
"ApprovalChooseHint": "Choix : ",
"ApprovalChooseAction": "Enter pour l'option sélectionnée, ou appuyez directement sur y/a/d",
"ApprovalIntentLabel": "Intention : ",
-1
View File
@@ -1141,7 +1141,6 @@
"PendingApprovalInAgent": "{agent} में स्वीकृति चाहिए — /agents",
"ApprovalTruncationHint": " … छाँटा गया · पूरा विवरण के लिए {details} दबाएँ",
"ApprovalFullAccessPolicyBlocked": "{tool} अवरुद्ध: Full Access इस नीति को दरकिनार नहीं कर सकता",
"AutoReviewQuestionSkipped": "Auto-Review ने उपयोगकर्ता प्रश्न छोड़कर स्वायत्त रूप से जारी रखा",
"ApprovalChooseHint": "चुनें: ",
"ApprovalChooseAction": "चुना विकल्प भेजें, या सीधे y/a/d दबाएँ",
"ApprovalIntentLabel": "इरादा: ",
-1
View File
@@ -1141,7 +1141,6 @@
"PendingApprovalInAgent": "Persetujuan diperlukan di {agent} — /agents",
"ApprovalTruncationHint": " … terpotong · tekan {details} untuk detail lengkap",
"ApprovalFullAccessPolicyBlocked": "Diblokir {tool}: Full Access tidak dapat melewati kebijakan ini",
"AutoReviewQuestionSkipped": "Auto-Review melewati pertanyaan pengguna dan lanjut secara mandiri",
"ApprovalChooseHint": "Pilih: ",
"ApprovalChooseAction": "Enter untuk opsi terpilih, atau tekan y/a/d langsung",
"ApprovalIntentLabel": "Maksud: ",
-1
View File
@@ -1162,7 +1162,6 @@
"PendingApprovalInAgent": "{agent} で承認が必要です — /agents",
"ApprovalTruncationHint": " … 省略 · {details} で詳細を表示",
"ApprovalFullAccessPolicyBlocked": "{tool} をブロック: Full Access ではこのポリシーを回避できません",
"AutoReviewQuestionSkipped": "Auto-Review は質問をスキップし、自律的に続行しました",
"ApprovalChooseHint": "選択:",
"ApprovalChooseAction": "Enterで選択、または y/a/d を直接入力",
"ApprovalIntentLabel": "意図:",
-1
View File
@@ -1164,7 +1164,6 @@
"PendingApprovalInAgent": "{agent}에서 승인이 필요합니다 — /agents",
"ApprovalTruncationHint": " … 잘림 · 전체 상세는 {details}",
"ApprovalFullAccessPolicyBlocked": "{tool} 차단됨: Full Access는 이 정책을 우회할 수 없습니다",
"AutoReviewQuestionSkipped": "Auto-Review가 사용자 질문을 건너뛰고 자율적으로 계속했습니다",
"ApprovalChooseHint": "선택: ",
"ApprovalChooseAction": "Enter로 선택 항목 적용, 또는 y/a/d를 바로 누르세요",
"ApprovalIntentLabel": "의도: ",
-1
View File
@@ -1162,7 +1162,6 @@
"PendingApprovalInAgent": "Aprovação necessária em {agent} — /agents",
"ApprovalTruncationHint": " … truncado · pressione {details} para ver todos os detalhes",
"ApprovalFullAccessPolicyBlocked": "{tool} bloqueado: Full Access não pode ignorar esta política",
"AutoReviewQuestionSkipped": "Auto-Review ignorou uma pergunta e continuou de forma autônoma",
"ApprovalChooseHint": "Escolha: ",
"ApprovalChooseAction": "Enter para selecionar, ou pressione y/a/d diretamente",
"ApprovalIntentLabel": "Intenção: ",
-1
View File
@@ -1141,7 +1141,6 @@
"PendingApprovalInAgent": "Нужно одобрение в {agent} — /agents",
"ApprovalTruncationHint": " … обрезано · нажмите {details} для полных деталей",
"ApprovalFullAccessPolicyBlocked": "{tool} заблокирован: Full Access не может обойти эту политику",
"AutoReviewQuestionSkipped": "Auto-Review пропустил вопрос пользователю и продолжил автономно",
"ApprovalChooseHint": "Выбор: ",
"ApprovalChooseAction": "Enter — выбранный вариант, или нажмите y/a/d напрямую",
"ApprovalIntentLabel": "Намерение: ",
-1
View File
@@ -1141,7 +1141,6 @@
"PendingApprovalInAgent": "Потрібне схвалення в {agent} — /agents",
"ApprovalTruncationHint": " … обрізано · натисніть {details} для повних деталей",
"ApprovalFullAccessPolicyBlocked": "Заблоковано {tool}: Full Access не може обійти цю політику",
"AutoReviewQuestionSkipped": "Auto-Review пропустив запитання користувача й продовжив автономно",
"ApprovalChooseHint": "Виберіть: ",
"ApprovalChooseAction": "Enter — вибрати опцію, або натисніть y/a/d напряму",
"ApprovalIntentLabel": "Намір: ",
-1
View File
@@ -1162,7 +1162,6 @@
"PendingApprovalInAgent": "Cần phê duyệt trong {agent} — /agents",
"ApprovalTruncationHint": " … đã rút gọn · nhấn {details} để xem đầy đủ",
"ApprovalFullAccessPolicyBlocked": "Đã chặn {tool}: Full Access không thể bỏ qua chính sách này",
"AutoReviewQuestionSkipped": "Auto-Review đã bỏ qua một câu hỏi và tiếp tục tự động",
"ApprovalChooseHint": "Chọn: ",
"ApprovalChooseAction": "Enter để chọn, hoặc nhấn y/a/d trực tiếp",
"ApprovalIntentLabel": "Ý định: ",
-1
View File
@@ -1162,7 +1162,6 @@
"PendingApprovalInAgent": "{agent} 需要批准 — /agents",
"ApprovalTruncationHint": " … 已截断 · 按 {details} 查看完整内容",
"ApprovalFullAccessPolicyBlocked": "已阻止 {tool}:Full Access 无法绕过此策略",
"AutoReviewQuestionSkipped": "Auto-Review 已跳过用户问题并自主继续",
"ApprovalChooseHint": "选择:",
"ApprovalChooseAction": "Enter 执行选中项,或直接按 y/a/d",
"ApprovalIntentLabel": "意图:",
-1
View File
@@ -201,7 +201,6 @@
"ApprovalEffectReadsOnly": "唯讀",
"ApprovalSaveAskRuleHint": " s 僅允許一次 + 一律詢問精確規則",
"ApprovalTruncationHint": " … 已截斷 · 按 {details} 查看完整內容",
"AutoReviewQuestionSkipped": "Auto-Review 已略過使用者問題並自主繼續",
"AutoRouteSelectedToast": "Auto: {provider} / {model}({source})· Ctrl+O:路由詳情",
"BehavioralTipBackgroundReceipt": "回執在 Work 面板中 — 按 {key} 開啟檢查器",
"BehavioralTipClearedInput": "已清空 · 按 {chord} 還原",
-2
View File
@@ -1408,7 +1408,6 @@ pub enum MessageId {
PendingApprovalInAgent,
ApprovalTruncationHint,
ApprovalFullAccessPolicyBlocked,
AutoReviewQuestionSkipped,
ApprovalChooseHint,
ApprovalChooseAction,
ApprovalIntentLabel,
@@ -3835,7 +3834,6 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::PendingApprovalInAgent,
MessageId::ApprovalTruncationHint,
MessageId::ApprovalFullAccessPolicyBlocked,
MessageId::AutoReviewQuestionSkipped,
MessageId::ApprovalChooseHint,
MessageId::ApprovalChooseAction,
MessageId::ApprovalIntentLabel,
-9
View File
@@ -304,15 +304,6 @@ pub(crate) fn provenance_can_inherit_standing_auto_authority(
)
}
/// Whether the active permission posture may pause the turn for a user
/// decision. Auto-Review is the fully autonomous posture: it must decide from
/// available context and keep moving. Tool approval and user-question policy
/// stay deliberately separate in every other posture.
#[must_use]
pub(crate) fn permission_posture_allows_questions(approval_mode: ApprovalMode) -> bool {
approval_mode != ApprovalMode::Auto
}
#[must_use]
pub(crate) fn agent_approval_mode_for_turn(
auto_approve: bool,
+3 -5
View File
@@ -4696,7 +4696,6 @@ impl Engine {
Some(Vec::new()),
None,
Some(0),
input_policy.approval_mode_for_session(),
tool_catalog::ToolMode::Direct,
),
mcp_tool_names: Vec::new(),
@@ -4952,7 +4951,6 @@ impl Engine {
allowed_tools,
self.config.disallowed_tools.clone(),
self.config.max_tool_calls,
input_policy.approval_mode_for_session(),
// Model metadata wins once wired; today the hint is always None
// and the [features] flags decide (model_registry follow-up).
tool_catalog::requested_tool_mode(None, &self.config.features),
@@ -7499,9 +7497,9 @@ pub(crate) fn auto_review_plan_decision_for_context(
let plan_decision = if context.approval_mode == ApprovalMode::Auto
&& context.tool_name == REQUEST_USER_INPUT_NAME
{
// This synthetic tool does not execute user work. Let the turn loop
// return its ordinary autonomous guidance result instead of treating
// a hallucinated question as an unknown external action.
// A question executes no user work and changes no state. Auto-Review
// reviews tool approvals only, so let the turn loop ask the user
// instead of treating the question as an unknown external action.
AutoReviewPlanDecision::Allow
} else {
match decision.action {
-2
View File
@@ -541,7 +541,6 @@ mod tests {
None,
None,
Some(4),
engine.session.approval_mode,
crate::core::engine::tool_catalog::ToolMode::Direct,
);
@@ -709,7 +708,6 @@ mod tests {
None,
None,
Some(4),
engine.session.approval_mode,
crate::core::engine::tool_catalog::ToolMode::Direct,
);
let events = handle.rx_event.clone();
+45 -96
View File
@@ -5173,7 +5173,6 @@ fn shell_denial_filters_search_catalog_without_expanding_allow_grants() {
raw_names.into_iter().map(catalog_tool).collect(),
None,
Some(vec![rule.into()]),
ApprovalMode::Suggest,
);
for name in raw_names {
assert!(surface.denies_call(name, &json!({})), "{rule}: {name}");
@@ -5187,7 +5186,6 @@ fn shell_denial_filters_search_catalog_without_expanding_allow_grants() {
raw_names.into_iter().map(catalog_tool).collect(),
Some(vec!["Bash".into()]),
None,
ApprovalMode::Suggest,
);
for name in raw_names.into_iter().skip(3) {
assert!(
@@ -5211,7 +5209,6 @@ fn shell_denial_preserves_task_reads_and_bounded_verification_actions() {
],
None,
Some(vec!["Bash".into()]),
ApprovalMode::Suggest,
);
let tasks = surface
.catalog
@@ -5286,7 +5283,6 @@ fn policy_for_catalog(
catalog: Vec<Tool>,
allowed_tools: Option<Vec<String>>,
disallowed_tools: Option<Vec<String>>,
approval_mode: ApprovalMode,
) -> ToolSurfacePolicy {
ToolSurfacePolicy::new(
crate::tools::ToolRegistry::new(crate::tools::ToolContext::new(PathBuf::from("."))),
@@ -5298,7 +5294,6 @@ fn policy_for_catalog(
allowed_tools,
disallowed_tools,
None,
approval_mode,
crate::core::engine::tool_catalog::ToolMode::Direct,
)
}
@@ -5319,7 +5314,6 @@ fn tool_catalog_scenario() {
catalog,
Some(vec!["read_file".to_string(), "exec_shell".to_string()]),
Some(vec!["exec_shell".to_string()]),
ApprovalMode::Suggest,
);
let names: Vec<&str> = surface.catalog.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, ["read_file"]);
@@ -5330,7 +5324,6 @@ fn tool_catalog_scenario() {
vec![catalog_tool("read_file"), catalog_tool("exec_shell")],
None,
None,
ApprovalMode::Suggest,
);
assert!(surface.catalog.iter().any(|tool| tool.name == "read_file"));
assert!(surface.catalog.iter().any(|tool| tool.name == "exec_shell"));
@@ -5355,12 +5348,7 @@ fn tool_catalog_shell_only_benchmark_surface_hides_native_tools() {
"exec_shell_interact".to_string(),
];
let surface = policy_for_catalog(
catalog,
Some(shell_only.to_vec()),
None,
ApprovalMode::Suggest,
);
let surface = policy_for_catalog(catalog, Some(shell_only.to_vec()), None);
let names: Vec<&str> = surface.catalog.iter().map(|t| t.name.as_str()).collect();
assert_eq!(
@@ -5388,7 +5376,6 @@ fn tool_surface_policy_never_reintroduces_denied_synthetic_tools() {
JS_EXECUTION_TOOL_NAME.to_string(),
]),
Some(denied),
ApprovalMode::Suggest,
);
for denied_name in [
@@ -5433,7 +5420,6 @@ async fn denied_synthetic_tool_is_blocked_by_the_same_turn_policy_at_execution()
vec![catalog_tool("read_file")],
Some(vec![TOOL_SEARCH_NAME.to_string()]),
Some(vec![TOOL_SEARCH_NAME.to_string()]),
ApprovalMode::Suggest,
);
assert!(!policy.allows_tool(TOOL_SEARCH_NAME));
let mut turn = crate::core::turn::TurnContext::new(4);
@@ -6007,12 +5993,7 @@ async fn named_file_write_scope_denies_mutation_outside_the_named_files() {
#[test]
fn empty_allowed_tools_surface_is_empty_and_sends_no_tools_field() {
let surface = policy_for_catalog(
vec![catalog_tool("read_file")],
Some(Vec::new()),
None,
ApprovalMode::Suggest,
);
let surface = policy_for_catalog(vec![catalog_tool("read_file")], Some(Vec::new()), None);
assert!(surface.catalog.is_empty());
assert!(surface.active_names.is_empty());
@@ -6450,7 +6431,6 @@ fn test_tool_surface(
engine.config.allowed_tools.clone(),
engine.config.disallowed_tools.clone(),
engine.config.max_tool_calls,
engine.session.approval_mode,
crate::core::engine::tool_catalog::ToolMode::Direct,
)
}
@@ -12356,60 +12336,35 @@ fn request_user_input_stays_deferred_but_can_be_dynamically_activated() {
}
#[test]
fn auto_review_hides_question_tool_while_other_postures_keep_it() {
use ApprovalMode;
for (posture, expected) in [
(ApprovalMode::Suggest, true),
(ApprovalMode::Auto, false),
(ApprovalMode::Bypass, true),
(ApprovalMode::Never, true),
] {
let surface = policy_for_catalog(
vec![api_tool("read_file"), api_tool(REQUEST_USER_INPUT_NAME)],
None,
None,
posture,
);
assert_eq!(
surface
.catalog
.iter()
.any(|tool| tool.name == REQUEST_USER_INPUT_NAME),
expected,
"{posture:?}"
);
assert_eq!(surface.allows_questions(), expected, "{posture:?}");
assert!(surface.catalog.iter().any(|tool| tool.name == "read_file"));
}
}
#[test]
fn legacy_full_access_bit_keeps_question_tool_as_effective_full_access() {
let authority = crate::core::authority::effective_input_policy(
UserInputProvenance::ExternalUser,
AppMode::Agent,
"continue",
true,
true,
true,
ApprovalMode::Auto,
);
assert_eq!(authority.approval_mode_for_session(), ApprovalMode::Bypass);
fn question_tool_survives_the_tool_surface_in_every_posture() {
// Questions are separate from approval posture: the surface takes no
// posture input, so Auto-Review offers `request_user_input` exactly like
// Suggest, Never and Full Access. Only an explicit deny (headless exec's
// default) withholds it.
let surface = policy_for_catalog(
vec![api_tool("read_file"), api_tool(REQUEST_USER_INPUT_NAME)],
None,
None,
authority.approval_mode_for_session(),
);
assert!(
surface
.catalog
.iter()
.any(|tool| tool.name == REQUEST_USER_INPUT_NAME),
"effective Full Access must keep the question tool"
.any(|tool| tool.name == REQUEST_USER_INPUT_NAME)
);
let headless = policy_for_catalog(
vec![api_tool("read_file"), api_tool(REQUEST_USER_INPUT_NAME)],
None,
crate::exec_agent::exec_disallowed_tools(None),
);
assert!(
!headless
.catalog
.iter()
.any(|tool| tool.name == REQUEST_USER_INPUT_NAME)
);
assert!(headless.catalog.iter().any(|tool| tool.name == "read_file"));
}
#[test]
@@ -13938,7 +13893,7 @@ async fn full_access_permission_allow_cannot_bypass_repo_law() {
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn auto_review_auto_resolves_hallucinated_question_without_prompting() {
async fn auto_review_asks_the_user_and_returns_the_answer() {
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -13988,9 +13943,7 @@ async fn auto_review_auto_resolves_hallucinated_question_without_prompting() {
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.and(body_string_contains(
"Auto-Review does not pause for user questions",
))
.and(body_string_contains("take-path-b"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
@@ -14058,6 +14011,7 @@ async fn auto_review_auto_resolves_hallucinated_question_without_prompting() {
.await
.expect("send Auto-Review model turn");
let mut saw_question = false;
let mut saw_tool_result = false;
let mut saw_turn_complete = false;
let mut rx = handle.rx_event.write().await;
@@ -14066,35 +14020,31 @@ async fn auto_review_auto_resolves_hallucinated_question_without_prompting() {
.expect("timed out waiting for Auto-Review question event")
{
match event {
Event::UserInputRequired { .. } => {
panic!("Auto-Review must not emit a user question")
Event::UserInputRequired { id, .. } => {
// Auto-Review reviews tool approvals only; a question still
// reaches the user.
handle
.submit_user_input(
id,
crate::tools::user_input::UserInputResponse {
answers: vec![crate::tools::user_input::UserInputAnswer {
id: "choice".to_string(),
label: "B".to_string(),
value: "take-path-b".to_string(),
}],
},
)
.await
.expect("submit answer");
saw_question = true;
}
Event::ApprovalRequired { .. } => {
panic!("Auto-Review question guard must not become an approval")
panic!("an Auto-Review question must not become an approval")
}
Event::ToolCallComplete { name, result, .. } if name == REQUEST_USER_INPUT_NAME => {
let result = result.expect("question should auto-resolve successfully");
let result = result.expect("the answered question succeeds");
assert!(result.success, "{result:?}");
assert!(
result.content.contains("continue autonomously"),
"{result:?}"
);
assert_eq!(
result
.metadata
.as_ref()
.and_then(|value| value.get("auto_resolved"))
.and_then(serde_json::Value::as_bool),
Some(true)
);
assert_eq!(
result
.metadata
.as_ref()
.and_then(|value| value.get("permission_posture"))
.and_then(serde_json::Value::as_str),
Some("auto-review")
);
assert!(result.content.contains("take-path-b"), "{result:?}");
saw_tool_result = true;
}
Event::TurnComplete { status, .. } => {
@@ -14109,6 +14059,7 @@ async fn auto_review_auto_resolves_hallucinated_question_without_prompting() {
handle.send(Op::Shutdown).await.expect("shutdown engine");
run_task.await.expect("engine task");
assert!(saw_question);
assert!(saw_tool_result);
assert!(saw_turn_complete);
}
@@ -17873,7 +17824,6 @@ async fn preflight_guard_measures_honest_input_against_the_input_ceiling() {
None,
None,
Some(4),
engine.session.approval_mode,
crate::core::engine::tool_catalog::ToolMode::Direct,
);
let (status, error) = engine
@@ -23402,7 +23352,6 @@ readline.createInterface({ input: process.stdin }).on('line', async line => {
]),
Some(vec!["mcp_slow_denied".into()]),
None,
ApprovalMode::Suggest,
crate::core::engine::tool_catalog::ToolMode::Direct,
);
let mut catalog = policy.catalog.clone();
@@ -16,7 +16,6 @@ use crate::mcp::McpPool;
use crate::model_profile::ToolSurfaceBudget;
use crate::tools::spec::{ToolError, ToolResult, optional_str, optional_u64, required_str};
use codewhale_config::AppMode;
use codewhale_execpolicy::ApprovalMode;
use codewhale_models::Tool;
use crate::core::session::ToolActivationCache;
@@ -525,7 +524,6 @@ pub(super) struct ToolSurfacePolicy {
/// this limit into its own admission counter at turn start; `None` means
/// unlimited (the default), which keeps the admission gate inert.
pub(super) max_tool_calls: Option<u32>,
questions_allowed: bool,
}
impl ToolSurfacePolicy {
@@ -540,7 +538,6 @@ impl ToolSurfacePolicy {
allowed_tools: Option<Vec<String>>,
disallowed_tools: Option<Vec<String>>,
max_tool_calls: Option<u32>,
approval_mode: ApprovalMode,
tool_mode: ToolMode,
) -> Self {
let mut catalog = tools.unwrap_or_default();
@@ -576,12 +573,6 @@ impl ToolSurfacePolicy {
.and_then(Value::as_array)
.is_none_or(|actions| !actions.is_empty())
});
let questions_allowed =
super::super::authority::permission_posture_allows_questions(approval_mode);
if !questions_allowed {
catalog.retain(|tool| tool.name != REQUEST_USER_INPUT_NAME);
}
let mut active_names = initial_active_tools(&catalog);
active_names.extend(dynamic_active_tools.iter().map(|name| (*name).to_string()));
active_names.retain(|name| catalog.iter().any(|tool| tool.name == *name));
@@ -597,7 +588,6 @@ impl ToolSurfacePolicy {
allowed_tools,
disallowed_tools,
max_tool_calls,
questions_allowed,
}
}
@@ -617,10 +607,6 @@ impl ToolSurfacePolicy {
pub(super) fn denies_call(&self, name: &str, input: &Value) -> bool {
tool_call_denied(self.disallowed_tools.as_deref(), name, input)
}
pub(super) fn allows_questions(&self) -> bool {
self.questions_allowed
}
}
pub(super) fn tool_allowed(allowed_tools: Option<&[String]>, tool_name: &str) -> bool {
@@ -170,7 +170,6 @@ fn eager_workflow_still_respects_command_allow_and_deny_gates() {
allow.map(|name| vec![name.to_string()]),
deny.map(|name| vec![name.to_string()]),
None,
codewhale_execpolicy::ApprovalMode::Suggest,
ToolMode::Direct,
);
assert_eq!(policy.allows_tool("workflow"), expected);
+11 -42
View File
@@ -723,7 +723,6 @@ impl Engine {
let mut image_rejection_recovered = false;
let mut tool_policy = tool_policy;
let mut mode = tool_policy.mode;
let mut questions_allowed = tool_policy.allows_questions();
let strict_tool_mode = tool_policy.strict_tool_mode;
let mut tool_catalog = std::mem::take(&mut tool_policy.catalog);
let mut active_tool_names = std::mem::take(&mut tool_policy.active_names);
@@ -827,9 +826,6 @@ impl Engine {
.permission_denial_rounds_without_progress = 0;
}
mode = self.current_mode;
questions_allowed = crate::core::authority::permission_posture_allows_questions(
self.session.approval_mode,
);
}
let mut accepted_steer = false;
@@ -2784,9 +2780,6 @@ impl Engine {
.permission_denial_rounds_without_progress = 0;
}
mode = self.current_mode;
questions_allowed = crate::core::authority::permission_posture_allows_questions(
self.session.approval_mode,
);
}
// Execute tools
@@ -2865,7 +2858,6 @@ impl Engine {
mcp_pool,
&batch_sandbox_policy,
&mut mode,
&mut questions_allowed,
)
.await;
@@ -3694,7 +3686,6 @@ impl Engine {
mcp_pool: Option<Arc<AsyncMutex<McpPool>>>,
batch_sandbox_policy: &crate::sandbox::SandboxPolicy,
mode: &mut AppMode,
questions_allowed: &mut bool,
) -> (Vec<Option<ToolExecOutcome>>, bool) {
let mut authority_changed = false;
// Every plan below was classified under this posture. A narrowing
@@ -3777,9 +3768,6 @@ impl Engine {
if changed_now || self.applied_runtime_authority().narrows(&planned_posture) {
authority_changed = true;
*mode = self.current_mode;
*questions_allowed = crate::core::authority::permission_posture_allows_questions(
self.session.approval_mode,
);
for plan in plans {
let result = Err(ToolError::permission_denied(
"Permissions changed while this tool call was being planned; retry it with the current permissions."
@@ -4204,28 +4192,17 @@ impl Engine {
if tool_name == REQUEST_USER_INPUT_NAME {
let started_at = Instant::now();
let result = if *questions_allowed {
match UserInputRequest::from_value_with_limits(
&tool_input,
self.config.user_input_limits,
) {
Ok(request) => self
.await_user_input(&tool_id, request)
.await
.and_then(|response| {
ToolResult::json(&response)
.map_err(|e| ToolError::execution_failed(e.to_string()))
}),
Err(err) => Err(err),
}
} else {
Ok(ToolResult::success(
"Auto-Review does not pause for user questions. Decide from the available context and continue autonomously.",
)
.with_metadata(json!({
"auto_resolved": true,
"permission_posture": "auto-review",
})))
let result = match UserInputRequest::from_value_with_limits(
&tool_input,
self.config.user_input_limits,
) {
Ok(request) => self.await_user_input(&tool_id, request).await.and_then(
|response| {
ToolResult::json(&response)
.map_err(|e| ToolError::execution_failed(e.to_string()))
},
),
Err(err) => Err(err),
};
let _ = self
@@ -4384,10 +4361,6 @@ impl Engine {
let mut result_override = if self.apply_pending_runtime_authority().await {
authority_changed = true;
*mode = self.current_mode;
*questions_allowed =
crate::core::authority::permission_posture_allows_questions(
self.session.approval_mode,
);
let approval_survives = approval_stamp.is_some()
&& !self
.applied_runtime_authority()
@@ -4431,10 +4404,6 @@ impl Engine {
if self.apply_pending_runtime_authority().await {
authority_changed = true;
*mode = self.current_mode;
*questions_allowed =
crate::core::authority::permission_posture_allows_questions(
self.session.approval_mode,
);
if approval_stamp.is_none()
|| self
.applied_runtime_authority()
-12
View File
@@ -209,15 +209,3 @@ pub(super) async fn resolve_stale_parent_request(
_ => false,
}
}
pub(super) fn should_suppress_user_input_prompt(app: &App) -> bool {
// Legacy hosts may still report Yolo/auto-approve with a stale `Auto`
// enum. Canonicalize that shape to Full Access before applying the one
// posture that suppresses questions: genuine Auto-Review.
let effective_posture = if app_auto_approve_enabled(app) {
ApprovalMode::Bypass
} else {
app.approval_mode
};
!crate::core::authority::permission_posture_allows_questions(effective_posture)
}
+19 -43
View File
@@ -2028,9 +2028,6 @@ pub(crate) async fn run_event_loop(
| EngineEvent::AgentComplete {
owner_session_id, ..
} => event_owner_is_active(app.current_session_id.as_deref(), owner_session_id),
EngineEvent::UserInputRequired { .. } => {
!should_suppress_user_input_prompt(app)
}
EngineEvent::ApprovalRequired {
tool_name,
approval_grouping_key,
@@ -3791,48 +3788,27 @@ pub(crate) async fn run_event_loop(
.await;
}
EngineEvent::UserInputRequired { id, request } => {
if should_suppress_user_input_prompt(app) {
// A question may have been planned just before the
// user switched to Auto-Review. Cancel the stale
// request instead of opening a modal under an Auto
// header; the tool result tells the model to keep
// moving without inventing a user choice.
log_sensitive_event(
"tool.user_input.auto_cancelled_auto_review",
serde_json::json!({
"tool_id": id.clone(),
"session_id": app.current_session_id,
}),
);
let _ = engine_handle.cancel_user_input(id).await;
app.pending_user_input_prompt = None;
let notice = app.tr(MessageId::AutoReviewQuestionSkipped).into_owned();
app.push_status_toast(notice, StatusToastLevel::Info, Some(6_000));
} else {
app.pending_user_input_prompt = Some((id.clone(), request.clone()));
app.view_stack.push(UserInputView::new(id.clone(), request));
let payload = notifications::input_needed_payload(app.ui_locale);
if let Some((method, _, _)) =
crate::tui::notifications::settings(config)
{
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
crate::tui::notifications::notify_done(
method,
in_tmux,
&payload,
Duration::ZERO,
Duration::ZERO,
);
}
app.push_status_toast_record(
StatusToast::new(
payload.headline(),
StatusToastLevel::Warning,
Some(12_000),
)
.for_action(id.clone()),
app.pending_user_input_prompt = Some((id.clone(), request.clone()));
app.view_stack.push(UserInputView::new(id.clone(), request));
let payload = notifications::input_needed_payload(app.ui_locale);
if let Some((method, _, _)) = crate::tui::notifications::settings(config) {
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
crate::tui::notifications::notify_done(
method,
in_tmux,
&payload,
Duration::ZERO,
Duration::ZERO,
);
}
app.push_status_toast_record(
StatusToast::new(
payload.headline(),
StatusToastLevel::Warning,
Some(12_000),
)
.for_action(id.clone()),
);
}
EngineEvent::ElevationRequired {
tool_id,
-23
View File
@@ -7960,29 +7960,6 @@ fn app_auto_approval_helper_covers_bypass_only() {
assert!(app_auto_approve_enabled(&app));
}
#[test]
fn auto_review_suppresses_stale_question_prompts_while_other_postures_allow_them() {
let mut app = create_test_app();
for (posture, expected) in [
(ApprovalMode::Suggest, false),
(ApprovalMode::Auto, true),
(ApprovalMode::Bypass, false),
(ApprovalMode::Never, false),
] {
app.approval_mode = posture;
assert_eq!(
should_suppress_user_input_prompt(&app),
expected,
"{posture:?}"
);
}
// Full Access keeps questions valid, same as Auto-Review suppresses
// them only for its own stale-prompt cleanup.
app.approval_mode = ApprovalMode::Bypass;
assert!(!should_suppress_user_input_prompt(&app));
}
fn create_test_options() -> TuiOptions {
TuiOptions {
// Keep UI tests independent from the developer's saved
+4 -2
View File
@@ -78,8 +78,10 @@ typed deny that wins for any individual segment blocks the full invocation.
The execution-policy result is combined with the registered-tool baseline; the
two should not be interpreted independently.
- Ask and Auto-Review may surface tool safety approvals. Auto-Review does not
pause for model-authored user questions, which are a separate channel.
- Ask and Auto-Review may surface tool safety approvals. Model-authored user
questions (`request_user_input`) are a separate channel and reach the user in
every interactive posture, Auto-Review included; only headless runs, which
have no responder, withhold the tool.
- Full Access and YOLO-compatible auto-approval paths do not let a typed `ask`
downgrade the session into prompting. Non-bypassable registered holds
auto-approve in Full Access. Typed deny, catastrophic background/headless