fix(runtime): honor named permission postures

Normalize current and legacy mode/permission wires into one runtime policy. Persist canonical thread defaults and per-turn receipts, then drive the engine's actual approval mode from that policy. Named postures are authoritative over legacy auto_approve/yolo fields, while trust_mode remains separate and invalid values fail closed.
This commit is contained in:
Hmbown
2026-07-31 22:44:38 -07:00
parent 6f3d24a8fa
commit b0e947a576
7 changed files with 446 additions and 46 deletions
+1
View File
@@ -105,6 +105,7 @@ mod route_runtime;
mod runtime_api;
mod runtime_handoff;
mod runtime_log;
mod runtime_policy;
mod runtime_threads;
mod safe_label;
mod sandbox;
+5
View File
@@ -202,6 +202,7 @@ struct StreamTurnRequest {
prompt: String,
model: Option<String>,
mode: Option<String>,
permission_posture: Option<String>,
workspace: Option<PathBuf>,
allow_shell: Option<bool>,
trust_mode: Option<bool>,
@@ -2050,6 +2051,7 @@ async fn retry_thread_turn(
input_summary: None,
model: None,
mode: None,
permission_posture: None,
allow_shell: None,
trust_mode: None,
auto_approve: None,
@@ -2363,6 +2365,7 @@ async fn stream_turn(
.clone()
.unwrap_or_else(|| state.workspace.clone());
let mode = req.mode.clone().unwrap_or_else(|| "agent".to_string());
let permission_posture = req.permission_posture.clone();
let allow_shell = req.allow_shell.unwrap_or(state.config.read().allow_shell());
let trust_mode = req.trust_mode.unwrap_or(false);
let auto_approve = req.auto_approve.unwrap_or(false);
@@ -2374,6 +2377,7 @@ async fn stream_turn(
model: Some(model.clone()),
workspace: Some(workspace.clone()),
mode: Some(mode.clone()),
permission_posture: permission_posture.clone(),
allow_shell: Some(allow_shell),
trust_mode: Some(trust_mode),
auto_approve: Some(auto_approve),
@@ -2407,6 +2411,7 @@ async fn stream_turn(
input_summary: None,
model: Some(model.clone()),
mode: Some(mode.clone()),
permission_posture,
allow_shell: Some(allow_shell),
trust_mode: Some(trust_mode),
auto_approve: Some(auto_approve),
+71 -1
View File
@@ -270,6 +270,7 @@ fn messages_from_thread_detail_batches_tool_results() {
model_provider_id: None,
workspace: PathBuf::from("."),
mode: "agent".to_string(),
permission_posture: Some("ask".to_string()),
allow_shell: false,
trust_mode: false,
auto_approve: false,
@@ -292,6 +293,7 @@ fn messages_from_thread_detail_batches_tool_results() {
ended_at: Some(now),
duration_ms: Some(0),
usage: None,
permission_posture: Some("ask".to_string()),
effective_provider: None,
effective_provider_id: None,
effective_billing_surface: None,
@@ -455,6 +457,7 @@ fn legacy_exact_thread_export_normalizes_provider_kind_and_id() {
model_provider_id: None,
workspace: PathBuf::from("."),
mode: "agent".to_string(),
permission_posture: None,
allow_shell: false,
trust_mode: false,
auto_approve: false,
@@ -4935,6 +4938,61 @@ async fn create_thread_accepts_dynamic_tools_and_environments() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn create_thread_normalizes_and_persists_named_permission_posture() -> Result<()> {
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let created: serde_json::Value = client
.post(format!("http://{addr}/v1/threads"))
.json(&json!({
"model": "test-model",
"mode": "operate",
"permission_posture": "auto-review"
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(created["mode"], "operate");
assert_eq!(created["permission_posture"], "auto_review");
assert_eq!(created["auto_approve"], false);
assert_eq!(created["trust_mode"], false);
let authoritative_ask: serde_json::Value = client
.post(format!("http://{addr}/v1/threads"))
.json(&json!({
"model": "test-model",
"mode": "yolo",
"permission_posture": "ask",
"auto_approve": true
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(authoritative_ask["mode"], "agent");
assert_eq!(authoritative_ask["permission_posture"], "ask");
assert_eq!(authoritative_ask["auto_approve"], false);
let invalid = client
.post(format!("http://{addr}/v1/threads"))
.json(&json!({
"model": "test-model",
"permission_posture": "owner"
}))
.send()
.await?;
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
handle.abort();
Ok(())
}
#[tokio::test]
async fn start_turn_accepts_dynamic_tools_and_environment_id() -> Result<()> {
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
@@ -4963,7 +5021,8 @@ async fn start_turn_accepts_dynamic_tools_and_environment_id() -> Result<()> {
"input_schema": { "type": "object" }
}
],
"environment_id": "local"
"environment_id": "local",
"permission_posture": "auto-review"
}))
.send()
.await?
@@ -4971,6 +5030,17 @@ async fn start_turn_accepts_dynamic_tools_and_environment_id() -> Result<()> {
.json()
.await?;
assert_eq!(started["turn"]["thread_id"], thread_id);
assert_eq!(started["thread"]["permission_posture"], "ask");
assert_eq!(started["turn"]["permission_posture"], "auto_review");
let stored: serde_json::Value = client
.get(format!("http://{addr}/v1/threads/{thread_id}"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(stored["turns"][0]["permission_posture"], "auto_review");
handle.abort();
Ok(())
+158
View File
@@ -0,0 +1,158 @@
//! Normalization for Runtime mode and permission-posture wires.
//!
//! Older clients can still send mode aliases and the legacy `auto_approve`
//! bit. New callers send a named posture. Keep those inputs readable, but
//! persist and execute one contract: Plan / Act / Operate plus Ask /
//! Auto-Review / Full Access.
use anyhow::{Result, bail};
use crate::tui::app::AppMode;
use crate::tui::approval::ApprovalMode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct RuntimePolicyProjection {
pub(crate) mode: AppMode,
pub(crate) permission: ApprovalMode,
}
impl RuntimePolicyProjection {
/// Read a persisted compatibility shape. Unknown historical values fail
/// closed to Act + Ask unless the old bypass bit is explicitly present.
#[must_use]
pub(crate) fn from_persisted(
mode: &str,
permission_posture: Option<&str>,
auto_approve: bool,
) -> Self {
let parsed_mode = parse_runtime_mode(mode).unwrap_or(AppMode::Agent);
let permission = permission_posture
.and_then(ApprovalMode::from_config_value)
.filter(|permission| *permission != ApprovalMode::Never)
.unwrap_or_else(|| {
if parsed_mode == AppMode::Yolo || auto_approve {
ApprovalMode::Bypass
} else {
ApprovalMode::Suggest
}
});
Self {
mode: visible_mode(parsed_mode),
permission,
}
}
/// Validate and normalize a new Runtime request. Legacy aliases are
/// accepted as one-way inputs, but only current values are persisted.
pub(crate) fn from_request(
mode: &str,
permission_posture: Option<&str>,
auto_approve: Option<bool>,
) -> Result<Self> {
let parsed_mode = parse_runtime_mode(mode).ok_or_else(|| {
anyhow::anyhow!("unsupported Runtime mode {mode:?}; expected plan, act, or operate")
})?;
let permission = match permission_posture {
Some(value) => ApprovalMode::from_config_value(value).ok_or_else(|| {
anyhow::anyhow!(
"unsupported permission posture {value:?}; expected ask, auto-review, or full-access"
)
})?,
None if parsed_mode == AppMode::Yolo || auto_approve.unwrap_or(false) => {
ApprovalMode::Bypass
}
None => ApprovalMode::Suggest,
};
if permission == ApprovalMode::Never {
bail!("permission posture 'never' is not part of the Runtime product contract");
}
Ok(Self {
mode: visible_mode(parsed_mode),
permission,
})
}
#[must_use]
pub(crate) fn mode_setting(self) -> &'static str {
self.mode.as_setting()
}
#[must_use]
pub(crate) fn permission_wire(self) -> &'static str {
match self.permission {
ApprovalMode::Suggest => "ask",
ApprovalMode::Auto => "auto_review",
ApprovalMode::Bypass => "full_access",
ApprovalMode::Never => "ask",
}
}
#[must_use]
pub(crate) fn auto_approve(self) -> bool {
self.permission == ApprovalMode::Bypass
}
}
#[must_use]
pub(crate) fn parse_runtime_mode(value: &str) -> Option<AppMode> {
match value.trim().to_ascii_lowercase().as_str() {
"normal" => Some(AppMode::Agent),
other => AppMode::parse(other),
}
}
#[must_use]
fn visible_mode(mode: AppMode) -> AppMode {
match mode {
AppMode::Auto | AppMode::Yolo => AppMode::Agent,
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_inputs_project_to_current_mode_and_permission_wires() {
for alias in ["normal", "agent", "auto"] {
let policy = RuntimePolicyProjection::from_persisted(alias, None, false);
assert_eq!(policy.mode_setting(), "agent", "{alias}");
assert_eq!(policy.permission_wire(), "ask", "{alias}");
}
for alias in ["yolo", "bypass"] {
let policy = RuntimePolicyProjection::from_persisted(alias, None, false);
assert_eq!(policy.mode_setting(), "agent", "{alias}");
assert_eq!(policy.permission_wire(), "full_access", "{alias}");
}
}
#[test]
fn named_posture_is_normalized_and_authoritative() {
let auto = RuntimePolicyProjection::from_request("operate", Some("auto-review"), None)
.expect("compat permission");
assert_eq!(auto.mode_setting(), "operate");
assert_eq!(auto.permission_wire(), "auto_review");
assert_eq!(auto.permission, ApprovalMode::Auto);
assert!(!auto.auto_approve());
let full = RuntimePolicyProjection::from_request("act", Some("full_access"), None)
.expect("current permission wire");
assert_eq!(full.permission_wire(), "full_access");
assert_eq!(full.permission, ApprovalMode::Bypass);
assert!(full.auto_approve());
let ask = RuntimePolicyProjection::from_request("yolo", Some("ask"), Some(true))
.expect("named posture overrides legacy flags");
assert_eq!(ask.mode_setting(), "agent");
assert_eq!(ask.permission_wire(), "ask");
assert_eq!(ask.permission, ApprovalMode::Suggest);
assert!(!ask.auto_approve());
}
#[test]
fn invalid_or_never_postures_are_rejected() {
assert!(RuntimePolicyProjection::from_request("act", Some("owner"), None).is_err());
assert!(RuntimePolicyProjection::from_request("act", Some("never"), None).is_err());
}
}
+129 -38
View File
@@ -46,9 +46,11 @@ use crate::route_budget::{
use crate::route_runtime::{
ResolvedRuntimeRoute, resolve_runtime_route, resolve_runtime_route_for_identity,
};
use crate::runtime_policy::RuntimePolicyProjection;
use crate::tools::plan::new_shared_plan_state;
use crate::tools::subagent::SubAgentStatus;
use crate::tools::todo::new_shared_todo_list;
#[cfg(test)]
use crate::tui::app::AppMode;
use codewhale_protocol::runtime::{
DynamicToolCallContent, DynamicToolCallParams, DynamicToolCallResult, DynamicToolSpec,
@@ -432,6 +434,10 @@ pub struct ThreadRecord {
pub model_provider_id: Option<String>,
pub workspace: PathBuf,
pub mode: String,
/// Named default permission posture for new turns. Absent on legacy
/// records, whose effective posture is derived from the old fields.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_posture: Option<String>,
pub allow_shell: bool,
pub trust_mode: bool,
pub auto_approve: bool,
@@ -467,6 +473,7 @@ fn thread_execution_state_matches(left: &ThreadRecord, right: &ThreadRecord) ->
&& left.model_provider_id == right.model_provider_id
&& left.workspace == right.workspace
&& left.mode == right.mode
&& left.permission_posture == right.permission_posture
&& left.allow_shell == right.allow_shell
&& left.trust_mode == right.trust_mode
&& left.auto_approve == right.auto_approve
@@ -495,6 +502,10 @@ pub struct TurnRecord {
pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
/// Canonical posture that governed this turn. New records always carry
/// this receipt; old records deserialize with no fabricated value.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_posture: Option<String>,
/// Concrete generic provider kind selected for this turn.
#[serde(
default,
@@ -1477,6 +1488,8 @@ pub struct CreateThreadRequest {
pub model_provider_id: Option<String>,
pub workspace: Option<PathBuf>,
pub mode: Option<String>,
#[serde(default)]
pub permission_posture: Option<String>,
pub allow_shell: Option<bool>,
pub trust_mode: Option<bool>,
pub auto_approve: Option<bool>,
@@ -1505,6 +1518,7 @@ pub struct UpdateThreadRequest {
pub auto_approve: Option<bool>,
pub model: Option<String>,
pub mode: Option<String>,
pub permission_posture: Option<String>,
pub title: Option<String>,
pub system_prompt: Option<String>,
pub workspace: Option<PathBuf>,
@@ -1517,6 +1531,8 @@ pub struct StartTurnRequest {
pub input_summary: Option<String>,
pub model: Option<String>,
pub mode: Option<String>,
#[serde(default)]
pub permission_posture: Option<String>,
pub allow_shell: Option<bool>,
pub trust_mode: Option<bool>,
pub auto_approve: Option<bool>,
@@ -3504,15 +3520,22 @@ impl RuntimeThreadManager {
.filter(|m| !m.trim().is_empty())
.unwrap_or(default_model);
let workspace = req.workspace.unwrap_or_else(|| self.workspace.clone());
let mode = req
let requested_mode = req
.mode
.filter(|m| !m.trim().is_empty())
.unwrap_or_else(|| "agent".to_string());
let policy = RuntimePolicyProjection::from_request(
&requested_mode,
req.permission_posture.as_deref(),
req.auto_approve,
)?;
let mode = policy.mode_setting().to_string();
let permission_posture = Some(policy.permission_wire().to_string());
let allow_shell = req
.allow_shell
.unwrap_or_else(|| self.read_config().allow_shell());
let trust_mode = req.trust_mode.unwrap_or(false);
let auto_approve = req.auto_approve.unwrap_or(false);
let auto_approve = policy.auto_approve();
let thread = ThreadRecord {
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
@@ -3524,6 +3547,7 @@ impl RuntimeThreadManager {
model_provider_id,
workspace,
mode,
permission_posture,
allow_shell,
trust_mode,
auto_approve,
@@ -3668,6 +3692,7 @@ impl RuntimeThreadManager {
&& req.auto_approve.is_none()
&& req.model.is_none()
&& req.mode.is_none()
&& req.permission_posture.is_none()
&& req.title.is_none()
&& req.system_prompt.is_none()
&& req.workspace.is_none()
@@ -3685,6 +3710,11 @@ impl RuntimeThreadManager {
{
bail!("mode must not be empty");
}
if let Some(permission_posture) = req.permission_posture.as_ref()
&& permission_posture.trim().is_empty()
{
bail!("permission_posture must not be empty");
}
if let Some(workspace) = req.workspace.as_ref()
&& workspace.as_os_str().is_empty()
{
@@ -3702,6 +3732,33 @@ impl RuntimeThreadManager {
.load_thread(id)
.with_context(|| format!("Thread not found: {id}"))?;
let mut changes = serde_json::Map::new();
let policy_patch = if req.mode.is_some()
|| req.permission_posture.is_some()
|| req.auto_approve.is_some()
{
let requested_mode = req.mode.as_deref().unwrap_or(&thread.mode);
let requested_permission = if req.permission_posture.is_some() {
req.permission_posture.as_deref()
} else if req.auto_approve.is_some()
|| req.mode.as_deref().is_some_and(|mode| {
matches!(
mode.trim().to_ascii_lowercase().as_str(),
"yolo" | "4" | "bypass" | "bypass-permissions" | "bypasspermissions"
)
})
{
None
} else {
thread.permission_posture.as_deref()
};
Some(RuntimePolicyProjection::from_request(
requested_mode,
requested_permission,
req.auto_approve,
)?)
} else {
None
};
if let Some(archived) = req.archived
&& thread.archived != archived
@@ -3721,23 +3778,28 @@ impl RuntimeThreadManager {
thread.trust_mode = trust_mode;
changes.insert("trust_mode".to_string(), json!(trust_mode));
}
if let Some(auto_approve) = req.auto_approve
&& thread.auto_approve != auto_approve
{
thread.auto_approve = auto_approve;
changes.insert("auto_approve".to_string(), json!(auto_approve));
}
if let Some(model) = req.model
&& thread.model != model
{
thread.model = model.clone();
changes.insert("model".to_string(), json!(model));
}
if let Some(mode) = req.mode
&& thread.mode != mode
{
thread.mode = mode.clone();
changes.insert("mode".to_string(), json!(mode));
if let Some(policy) = policy_patch {
let mode = policy.mode_setting().to_string();
let permission_posture = Some(policy.permission_wire().to_string());
let auto_approve = policy.auto_approve();
if thread.mode != mode {
thread.mode = mode.clone();
changes.insert("mode".to_string(), json!(mode));
}
if thread.permission_posture != permission_posture {
thread.permission_posture = permission_posture.clone();
changes.insert("permission_posture".to_string(), json!(permission_posture));
}
if thread.auto_approve != auto_approve {
thread.auto_approve = auto_approve;
changes.insert("auto_approve".to_string(), json!(auto_approve));
}
}
if let Some(title) = req.title {
// Empty string clears a previously-set title and reverts to derived.
@@ -4438,6 +4500,7 @@ impl RuntimeThreadManager {
ended_at: Some(now),
duration_ms: Some(0),
usage: None,
permission_posture: None,
effective_provider: None,
effective_provider_id: None,
effective_billing_surface: None,
@@ -4862,6 +4925,37 @@ impl RuntimeThreadManager {
}
let thread = self.get_thread(thread_id).await?;
let requested_mode = req.mode.as_deref().unwrap_or(&thread.mode);
let policy =
if req.mode.is_some() || req.permission_posture.is_some() || req.auto_approve.is_some()
{
let requested_permission = if req.permission_posture.is_some() {
req.permission_posture.as_deref()
} else if req.auto_approve.is_some()
|| req.mode.as_deref().is_some_and(|mode| {
matches!(
mode.trim().to_ascii_lowercase().as_str(),
"yolo" | "4" | "bypass" | "bypass-permissions" | "bypasspermissions"
)
})
{
None
} else {
thread.permission_posture.as_deref()
};
RuntimePolicyProjection::from_request(
requested_mode,
requested_permission,
req.auto_approve,
)?
} else {
RuntimePolicyProjection::from_persisted(
requested_mode,
thread.permission_posture.as_deref(),
thread.auto_approve,
)
};
let mode = policy.mode;
let engine = self.ensure_engine_loaded(&thread).await?;
let client_preflight_required = {
@@ -4880,11 +4974,6 @@ impl RuntimeThreadManager {
// Resolve the concrete provider/model before persisting a turn. Auto
// routing can fail, and such a failure must not leave a zombie
// in-progress record behind.
let mode = req
.mode
.as_deref()
.and_then(parse_mode_opt)
.unwrap_or_else(|| parse_mode(&thread.mode));
let requested_model = req.model.as_deref().unwrap_or(&thread.model).to_string();
let auto_model = requested_model.trim().eq_ignore_ascii_case("auto");
let cfg_snapshot = self.config.read().clone();
@@ -4973,6 +5062,7 @@ impl RuntimeThreadManager {
ended_at: None,
duration_ms: None,
usage: None,
permission_posture: Some(policy.permission_wire().to_string()),
effective_provider: Some(provider.as_str().to_string()),
effective_provider_id: provider_identity
.exact_id
@@ -5009,7 +5099,7 @@ impl RuntimeThreadManager {
let allow_shell = req.allow_shell.unwrap_or(thread.allow_shell);
let trust_mode = req.trust_mode.unwrap_or(thread.trust_mode);
let auto_approve = req.auto_approve.unwrap_or(thread.auto_approve);
let auto_approve = policy.auto_approve();
let op = Op::SendMessage {
content: prompt,
mode,
@@ -5028,11 +5118,7 @@ impl RuntimeThreadManager {
allowed_tools: None,
dynamic_tools: req.dynamic_tools,
hook_executor: None,
approval_mode: if auto_approve {
crate::tui::approval::ApprovalMode::Bypass
} else {
crate::tui::approval::ApprovalMode::Suggest
},
approval_mode: policy.permission,
verbosity,
provenance: crate::core::ops::UserInputProvenance::ExternalUser,
};
@@ -5307,6 +5393,15 @@ impl RuntimeThreadManager {
ended_at: None,
duration_ms: None,
usage: None,
permission_posture: Some(
RuntimePolicyProjection::from_persisted(
&thread.mode,
thread.permission_posture.as_deref(),
thread.auto_approve,
)
.permission_wire()
.to_string(),
),
effective_provider: Some(route_provider.as_str().to_string()),
effective_provider_id: route_identity
.exact_id
@@ -5663,7 +5758,12 @@ impl RuntimeThreadManager {
system_prompt_override: thread.system_prompt.is_some(),
model: route_model.clone(),
workspace: thread.workspace.clone(),
mode: parse_mode(&thread.mode),
mode: RuntimePolicyProjection::from_persisted(
&thread.mode,
thread.permission_posture.as_deref(),
thread.auto_approve,
)
.mode,
})
.await
.map_err(|e| anyhow!("Failed to sync thread session: {e}"))?;
@@ -7464,22 +7564,13 @@ fn enforce_lru_capacity(
evicted
}
/// Resolves only explicit mode tokens to an app mode. Free-form prompt text is
/// never a valid mode token: `parse_mode_opt` returns `None` unless the input is
/// exactly `agent`/`plan`/`yolo` or numeric aliases `1`/`2`/`4`. Mode
/// changes originate from the Tab cycle, `/mode`, the mode picker, or
/// config/startup defaults, not from submitted natural-language prompt text.
///
/// Textual `auto` is a legacy alias for Agent while Auto is deferred (#3733).
/// Compatibility parser retained for focused Runtime tests.
#[cfg(test)]
fn parse_mode_opt(mode: &str) -> Option<AppMode> {
match mode.trim().to_ascii_lowercase().as_str() {
"agent" | "auto" | "1" => Some(AppMode::Agent),
"plan" | "2" => Some(AppMode::Plan),
"yolo" | "4" | "bypass" | "bypass-permissions" | "bypasspermissions" => Some(AppMode::Yolo),
_ => None,
}
crate::runtime_policy::parse_runtime_mode(mode)
}
#[cfg(test)]
fn parse_mode(mode: &str) -> AppMode {
parse_mode_opt(mode).unwrap_or(AppMode::Agent)
}
+75 -6
View File
@@ -318,6 +318,7 @@ fn sample_thread(thread_id: &str) -> ThreadRecord {
model_provider_id: None,
workspace: PathBuf::from("."),
mode: AppMode::Agent.as_setting().to_string(),
permission_posture: Some("ask".to_string()),
allow_shell: false,
trust_mode: false,
auto_approve: false,
@@ -344,6 +345,7 @@ fn sample_turn(thread_id: &str, turn_id: &str, status: RuntimeTurnStatus) -> Tur
ended_at: None,
duration_ms: None,
usage: None,
permission_posture: None,
effective_provider: None,
effective_provider_id: None,
effective_billing_surface: None,
@@ -4268,7 +4270,7 @@ async fn start_turn_passes_effective_auto_approve_to_engine() -> Result<()> {
let harness = install_mock_engine(&manager, &thread.id).await;
let mut rx_op = harness.rx_op;
let _turn = manager
let turn = manager
.start_turn(
&thread.id,
StartTurnRequest {
@@ -4283,9 +4285,17 @@ async fn start_turn_passes_effective_auto_approve_to_engine() -> Result<()> {
},
)
.await?;
assert_eq!(turn.permission_posture.as_deref(), Some("full_access"));
match rx_op.recv().await {
Some(Op::SendMessage { auto_approve, .. }) => assert!(auto_approve),
Some(Op::SendMessage {
auto_approve,
approval_mode,
..
}) => {
assert!(auto_approve);
assert_eq!(approval_mode, crate::tui::approval::ApprovalMode::Bypass);
}
other => panic!("expected SendMessage op, got {other:?}"),
}
@@ -4313,7 +4323,7 @@ async fn start_turn_can_override_thread_auto_approve_to_false() -> Result<()> {
let harness = install_mock_engine(&manager, &thread.id).await;
let mut rx_op = harness.rx_op;
let _turn = manager
let turn = manager
.start_turn(
&thread.id,
StartTurnRequest {
@@ -4328,15 +4338,69 @@ async fn start_turn_can_override_thread_auto_approve_to_false() -> Result<()> {
},
)
.await?;
assert_eq!(turn.permission_posture.as_deref(), Some("ask"));
match rx_op.recv().await {
Some(Op::SendMessage { auto_approve, .. }) => assert!(!auto_approve),
Some(Op::SendMessage {
auto_approve,
approval_mode,
..
}) => {
assert!(!auto_approve);
assert_eq!(approval_mode, crate::tui::approval::ApprovalMode::Suggest);
}
other => panic!("expected SendMessage op, got {other:?}"),
}
Ok(())
}
#[tokio::test]
async fn start_turn_enforces_and_records_auto_review_without_legacy_bypass() -> Result<()> {
let manager = test_manager(test_runtime_dir())?;
let thread = manager
.create_thread(CreateThreadRequest {
permission_posture: Some("ask".to_string()),
..Default::default()
})
.await?;
let harness = install_mock_engine(&manager, &thread.id).await;
let mut rx_op = harness.rx_op;
let turn = manager
.start_turn(
&thread.id,
StartTurnRequest {
prompt: "review autonomously".to_string(),
permission_posture: Some("auto-review".to_string()),
..Default::default()
},
)
.await?;
assert_eq!(turn.permission_posture.as_deref(), Some("auto_review"));
assert_eq!(
manager
.store
.load_turn(&turn.id)?
.permission_posture
.as_deref(),
Some("auto_review")
);
match rx_op.recv().await {
Some(Op::SendMessage {
auto_approve,
approval_mode,
..
}) => {
assert!(!auto_approve);
assert_eq!(approval_mode, crate::tui::approval::ApprovalMode::Auto);
}
other => panic!("expected SendMessage op, got {other:?}"),
}
Ok(())
}
#[tokio::test]
async fn compact_thread_preserves_thread_auto_approve_policy() -> Result<()> {
let manager = test_manager(test_runtime_dir())?;
@@ -8492,6 +8556,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> {
model_provider_id: None,
workspace: PathBuf::from("."),
mode: "agent".to_string(),
permission_posture: None,
allow_shell: false,
trust_mode: false,
auto_approve: false,
@@ -8559,6 +8624,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> {
ended_at: None,
duration_ms: None,
usage: None,
permission_posture: None,
effective_provider: None,
effective_provider_id: None,
effective_billing_surface: None,
@@ -8584,6 +8650,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> {
ended_at: None,
duration_ms: None,
usage: None,
permission_posture: None,
effective_provider: None,
effective_provider_id: None,
effective_billing_surface: None,
@@ -8664,7 +8731,8 @@ fn parse_mode_opt_resolves_explicit_tokens_and_aliases() {
assert_eq!(parse_mode_opt("plan"), Some(AppMode::Plan));
assert_eq!(parse_mode_opt("2"), Some(AppMode::Plan));
assert_eq!(parse_mode_opt("auto"), Some(AppMode::Agent));
assert_eq!(parse_mode_opt("3"), None);
assert_eq!(parse_mode_opt("operate"), Some(AppMode::Operate));
assert_eq!(parse_mode_opt("3"), Some(AppMode::Operate));
assert_eq!(parse_mode_opt("yolo"), Some(AppMode::Yolo));
assert_eq!(parse_mode_opt("4"), Some(AppMode::Yolo));
assert_eq!(parse_mode_opt(" PLAN "), Some(AppMode::Plan));
@@ -8689,7 +8757,7 @@ fn parse_mode_wrapper_defaults_and_resolves_numeric_aliases() {
assert_eq!(parse_mode("auto"), AppMode::Agent);
assert_eq!(parse_mode("1"), AppMode::Agent);
assert_eq!(parse_mode("2"), AppMode::Plan);
assert_eq!(parse_mode("3"), AppMode::Agent);
assert_eq!(parse_mode("3"), AppMode::Operate);
assert_eq!(parse_mode("4"), AppMode::Yolo);
}
@@ -8839,6 +8907,7 @@ fn seed_turns_with_user_messages(
ended_at: Some(created_at),
duration_ms: Some(0),
usage: None,
permission_posture: None,
effective_provider: None,
effective_provider_id: None,
effective_billing_surface: None,
+7 -1
View File
@@ -81,7 +81,9 @@ impl ApprovalMode {
match value.trim().to_ascii_lowercase().as_str() {
"auto" | "auto-review" | "auto_review" => Some(ApprovalMode::Auto),
"bypass" | "yolo" | "dontask" | "dont_ask" | "bypass-permissions"
| "bypasspermissions" | "full-access" | "full" => Some(ApprovalMode::Bypass),
| "bypasspermissions" | "full-access" | "full_access" | "full" => {
Some(ApprovalMode::Bypass)
}
"suggest" | "suggested" | "on-request" | "untrusted" | "ask" => {
Some(ApprovalMode::Suggest)
}
@@ -4377,6 +4379,10 @@ diff --git a/src/b.rs b/src/b.rs
ApprovalMode::from_config_value("on-request"),
Some(ApprovalMode::Suggest)
);
assert_eq!(
ApprovalMode::from_config_value("full_access"),
Some(ApprovalMode::Bypass)
);
assert_eq!(
ApprovalMode::from_config_value("deny"),
Some(ApprovalMode::Never)