mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 17:10:18 -10:00
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:
1 parent
7c8e7fc44c
commit
61ba7eb03f
3 files changed
+144
-5
No files matched your search
@@ -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: {}",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user