From 61ba7eb03f6cb57efc75c92b00a41b2e4c2bf1c3 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Fri, 17 Jul 2026 20:05:25 -0700 Subject: [PATCH] fix(runtime): project permission question discipline per turn Derive Ask, Auto-Review, Full Access, and read-only question guidance from the effective runtime approval posture after provenance narrowing. This keeps tool execution authority separate from authority to invent user intent and updates the user-facing mode contract. Fixes #4511 Signed-off-by: Hunter B --- crates/tui/src/core/engine.rs | 30 +++++++++ crates/tui/src/core/engine/tests.rs | 95 +++++++++++++++++++++++++++++ docs/MODES.md | 24 ++++++-- 3 files changed, 144 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 7ade40812..c9862de3c 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -749,6 +749,27 @@ impl Engine { .trim() } + fn permission_question_discipline( + approval_mode: crate::tui::approval::ApprovalMode, + ) -> &'static str { + use crate::tui::approval::ApprovalMode; + + match approval_mode { + ApprovalMode::Suggest => { + "Tool approvals and user decisions are separate. Ask a concise question when an unresolved choice materially affects authority, cost, requested scope, or outcome; otherwise continue under the active approval policy." + } + ApprovalMode::Auto => { + "Proceed on reversible implementation details and minimize interruptions. Ask one concise question before an unresolved choice materially changes authority, cost, requested scope, or outcome; do not suppress a necessary question merely because a tool can run automatically." + } + ApprovalMode::Bypass => { + "Tool calls do not need approval, but Full Access does not authorize invented intent. Ask one concise, deliberate question when a consequential choice cannot be recovered safely from context; otherwise proceed autonomously within the current sandbox, repository, and managed-policy boundaries." + } + ApprovalMode::Never => { + "Remain read-only. Ask when a missing user decision blocks a truthful plan or investigation; do not imply that this permission boundary can be bypassed." + } + } + } + pub(super) async fn emit_compaction_started( &mut self, id: String, @@ -2270,6 +2291,15 @@ impl Engine { "Current mode policy:\n{}", Self::mode_runtime_instructions(self.current_mode) ), + format!( + "Current permission posture: {}", + self.session.approval_mode.permission_chip_label() + ), + "Current permission policy source: effective runtime authority".to_string(), + format!( + "Current question discipline: {}", + Self::permission_question_discipline(self.session.approval_mode) + ), format!("Input provenance: {}", provenance.as_str()), format!( "Input authority: {}", diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index bf7e02e6f..b7449aff0 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -6976,6 +6976,101 @@ fn turn_metadata_includes_plan_mode_policy() { ); } +#[test] +fn turn_metadata_projects_effective_permission_question_discipline() { + use crate::tui::approval::ApprovalMode; + + let cases = [ + ( + ApprovalMode::Suggest, + "Ask", + "Tool approvals and user decisions are separate", + ), + ( + ApprovalMode::Auto, + "Auto-Review", + "Proceed on reversible implementation details", + ), + ( + ApprovalMode::Bypass, + "Full Access", + "Full Access does not authorize invented intent", + ), + (ApprovalMode::Never, "Never", "Remain read-only"), + ]; + + for (approval_mode, posture, question_marker) in cases { + let tmp = tempdir().expect("tempdir"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + ..Default::default() + }; + let (mut engine, _handle) = Engine::new(config, &Config::default()); + engine.session.approval_mode = approval_mode; + + let message = engine.user_text_message_with_turn_metadata("continue".to_string()); + let ContentBlock::Text { text, .. } = message + .content + .last() + .expect("turn metadata must be present") + else { + panic!("expected text turn metadata"); + }; + + assert!( + text.contains(&format!("Current permission posture: {posture}")), + "{posture}: {text}" + ); + assert!( + text.contains("Current permission policy source: effective runtime authority"), + "{posture}: {text}" + ); + assert!(text.contains(question_marker), "{posture}: {text}"); + } +} + +#[test] +fn turn_metadata_uses_provenance_narrowed_permission_posture() { + use crate::tui::approval::ApprovalMode; + + let tmp = tempdir().expect("tempdir"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + ..Default::default() + }; + let (mut engine, _handle) = Engine::new(config, &Config::default()); + let authority = effective_input_policy( + UserInputProvenance::SubAgentHandoff, + AppMode::Yolo, + "continue from child", + true, + true, + true, + ApprovalMode::Bypass, + ); + engine.apply_runtime_mode_policy(&authority); + + let message = engine.runtime_text_message_with_turn_metadata( + "continue from child".to_string(), + UserInputProvenance::SubAgentHandoff, + ); + let ContentBlock::Text { text, .. } = message + .content + .last() + .expect("turn metadata must be present") + else { + panic!("expected text turn metadata"); + }; + + assert!(text.contains("Current mode: agent"), "{text}"); + assert!(text.contains("Current permission posture: Ask"), "{text}"); + assert!(!text.contains("Current permission posture: Full Access")); + assert!( + text.contains("Input authority: non_authoritative"), + "{text}" + ); +} + #[test] fn current_mode_field_assignment_takes_effect_synchronously() { // Basic unit-level invariant: the current_mode field mutates as expected. diff --git a/docs/MODES.md b/docs/MODES.md index 0d0c5c649..12e99d3e6 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -98,9 +98,10 @@ thread resume semantics. - Clear the current input if text is present. - Otherwise it is a no-op. -## Approval Mode +## Permission Posture -You can override approval behavior at runtime: +Permission posture controls tool approval, not whether the model may invent a +missing user decision. Cycle it with `Shift+Tab`, or edit it at runtime: ```text /config @@ -109,9 +110,22 @@ You can override approval behavior at runtime: Legacy note: `/set approval_mode ...` was retired in favor of `/config`. -- `suggest` (default): uses the per-mode rules above. -- `auto`: auto-approves all tools without changing the visible TUI mode. -- `never`: blocks any tool that isn't considered safe/read-only. +- `suggest` (**Ask**, default): tool approvals may interrupt, and Codewhale asks + when an unresolved user choice materially changes authority, cost, scope, or + outcome. +- `auto` (**Auto-Review**): reversible implementation details proceed with + fewer interruptions, while consequential missing choices still produce one + concise question. +- `bypass` (**Full Access**): tool calls do not show approval prompts, but this + never grants authority to invent intent. Codewhale asks deliberately when a + consequential choice cannot be recovered safely from context, and otherwise + proceeds autonomously within sandbox, repository, and managed-policy bounds. +- `never`: blocks any tool that is not considered safe/read-only. + +The effective posture and its question discipline are projected into every +turn from the same runtime authority that gates tools. A mode/posture change is +therefore visible to the next turn. Runtime-generated or child input is narrowed +before the metadata is built and cannot claim standing auto-approval authority. ## Small-Screen Status Behavior