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 <[email protected]>
This commit is contained in:
Hunter B committed 2026-07-17 20:30:20 -07:00
1 parent 7c8e7fc44c
commit 61ba7eb03f
3 files changed
+144 -5

No files matched your search

+30
View File
@@ -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: {}",
+95
View File
@@ -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.
+19 -5
View File
@@ -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