diff --git a/src/apps/cli/src/runtime/approval.rs b/src/apps/cli/src/runtime/approval.rs index df6170f798..97175c7153 100644 --- a/src/apps/cli/src/runtime/approval.rs +++ b/src/apps/cli/src/runtime/approval.rs @@ -1,5 +1,7 @@ +use bitfun_agent_runtime::permission::PERMISSION_MODE_CONTEXT_KEY; use bitfun_agent_runtime::sdk::{PermissionRequest, AUTO_APPROVE_ASK_CONTEXT_KEY}; use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; +use bitfun_runtime_ports::PermissionMode; use serde_json::{Map, Value}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -37,6 +39,21 @@ pub(crate) fn approval_metadata(approval_policy: CliApprovalPolicy) -> Map, + #[serde(default)] + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, + #[serde(default)] + pub include_internal: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionPermissionModeResponse { + /// The session's own selection, or `null` when it follows the default. + pub mode: Option, +} + fn deserialize_present_nullable<'de, D, T>(deserializer: D) -> Result>, D::Error> where D: serde::Deserializer<'de>, @@ -1799,6 +1824,78 @@ pub async fn update_session_model( .map_err(|error| format!("Failed to update session model: {}", error.into_message())) } +/// Sets the tool permission mode this session runs with. +/// +/// The mode is a per-session selector, so switching it in one conversation +/// leaves every other open session on its own selection. Passing no mode clears +/// the override and returns the session to the user-level default. +#[tauri::command] +pub async fn update_session_permission_mode( + runtime: State<'_, DesktopRuntimeContext>, + coordinator: State<'_, Arc>, + request: UpdateSessionPermissionModeRequest, +) -> Result { + let session_id = request.session_id.trim().to_string(); + if session_id.is_empty() { + return Err("session_id is required".to_string()); + } + let mode = match request.mode.as_deref().map(str::trim) { + None | Some("") => None, + Some(value) => Some( + PermissionMode::parse(value) + .ok_or_else(|| format!("unsupported permission mode: {value}"))?, + ), + }; + + ensure_session_loaded_for_selector_update( + runtime.inner(), + &session_id, + request.workspace_path, + request.remote_connection_id, + request.remote_ssh_host, + request.include_internal, + ) + .await?; + + coordinator + .get_session_manager() + .update_session_permission_mode(&session_id, mode) + .await + .map_err(|error| format!("Failed to update session permission mode: {error}"))?; + + Ok(SessionPermissionModeResponse { mode }) +} + +/// Reads the session's own permission mode selection. +/// +/// `null` means the session never chose one and follows the user-level default. +#[tauri::command] +pub async fn get_session_permission_mode( + runtime: State<'_, DesktopRuntimeContext>, + coordinator: State<'_, Arc>, + request: UpdateSessionPermissionModeRequest, +) -> Result { + let session_id = request.session_id.trim().to_string(); + if session_id.is_empty() { + return Err("session_id is required".to_string()); + } + ensure_session_loaded_for_selector_update( + runtime.inner(), + &session_id, + request.workspace_path, + request.remote_connection_id, + request.remote_ssh_host, + request.include_internal, + ) + .await?; + + Ok(SessionPermissionModeResponse { + mode: coordinator + .get_session_manager() + .session_permission_mode(&session_id), + }) +} + async fn ensure_session_loaded_for_selector_update( runtime: &DesktopRuntimeContext, session_id: &str, diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index cbcf7e9dfa..a4842d365c 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -721,6 +721,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::LegacyUnaudited, ), ("get_session_lineage", RemoteWorkspacePolicy::RemoteRouted), + ( + "get_session_permission_mode", + RemoteWorkspacePolicy::RemoteRouted, + ), ("get_session_files", RemoteWorkspacePolicy::LegacyUnaudited), ( "get_session_operations", @@ -1980,6 +1984,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ("update_miniapp", RemoteWorkspacePolicy::LegacyUnaudited), ("update_session_mode", RemoteWorkspacePolicy::RemoteRouted), + ( + "update_session_permission_mode", + RemoteWorkspacePolicy::RemoteRouted, + ), ( "update_session_model", RemoteWorkspacePolicy::LegacyUnaudited, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 236f6e66b6..9b566aac53 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1183,6 +1183,8 @@ pub async fn run() { api::agentic_api::create_session, api::agentic_api::update_session_mode, api::agentic_api::update_session_model, + api::agentic_api::update_session_permission_mode, + api::agentic_api::get_session_permission_mode, api::agentic_api::reload_session_context, api::agentic_api::update_session_title, api::agentic_api::ensure_coordinator_session, diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 5d2a95fbbc..9ce1ecd411 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -84,7 +84,7 @@ use bitfun_agent_runtime::deep_review::FocusedReviewAssignment; use bitfun_agent_runtime::output_surface::{ supports_inline_markdown_images_for_source, TOOL_CONTEXT_INLINE_MARKDOWN_IMAGE_DISPLAY_KEY, }; -use bitfun_agent_runtime::permission::AUTO_APPROVE_ASK_CONTEXT_KEY; +use bitfun_agent_runtime::permission::{AUTO_APPROVE_ASK_CONTEXT_KEY, PERMISSION_MODE_CONTEXT_KEY}; use bitfun_agent_runtime::remote_file_delivery::{ needs_computer_links_for_source, remote_file_delivery_reminder, TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, @@ -94,12 +94,13 @@ use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; use bitfun_events::{ToolEventData, ToolEventIdentity}; use bitfun_product_domains::external_sources::EcosystemId; use bitfun_runtime_ports::{ - agent_workspace_references_from_metadata, AgentMessageWorkspaceReferencesRequest, - AgentSessionComposerUpdate, AgentSessionWorkspaceBinding, AgentThreadGoalDeliveryKind, - AgentThreadGoalDeliveryRequest, AgentWorkspaceReference, AgentWorkspaceReferenceKind, - AgentWorkspaceReferenceSearchEntry, AgentWorkspaceReferenceSearchRequest, - AgentWorkspaceReferenceSearchResult, DelegationPolicy, PermissionDelegationContext, - PermissionRuntimeCeiling, RemoteExecPort, SessionStoragePathRequest, + agent_workspace_references_from_metadata, resolve_permission_mode, + AgentMessageWorkspaceReferencesRequest, AgentSessionComposerUpdate, + AgentSessionWorkspaceBinding, AgentThreadGoalDeliveryKind, AgentThreadGoalDeliveryRequest, + AgentWorkspaceReference, AgentWorkspaceReferenceKind, AgentWorkspaceReferenceSearchEntry, + AgentWorkspaceReferenceSearchRequest, AgentWorkspaceReferenceSearchResult, DelegationPolicy, + PermissionDelegationContext, PermissionMode, PermissionModeLayers, PermissionRuntimeCeiling, + RemoteExecPort, ResolvedPermissionMode, SessionStoragePathRequest, SessionStoragePathResolution, SessionStorePort, SubagentContextMode, TerminalPort, ThreadGoal, ThreadGoalContinuationPlan, ThreadGoalStatus, }; @@ -3646,6 +3647,22 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet child_context.insert(key.to_string(), value.to_string()); } } + // The delegated child runs under the same resolved mode as the + // submission that triggered it, so resolve the same three layers + // here instead of letting the child fall back to the global + // default. The parent runtime ceiling is applied separately and + // still bounds the child. + let delegated_permission_mode = resolve_submission_permission_mode( + permission_mode_from_metadata(Some(&user_message_metadata)), + self.session_manager + .get_session(&session_id) + .and_then(|session| session.config.permission_mode), + default_permission_mode_from_global_config().await, + ); + child_context.insert( + PERMISSION_MODE_CONTEXT_KEY.to_string(), + delegated_permission_mode.mode.as_str().to_string(), + ); let request = SubagentExecutionRequest { task_description: prompt.clone(), context_mode: SubagentContextMode::Fresh, @@ -5723,6 +5740,19 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet auto_approve_ask.to_string(), ); } + // Resolve the permission mode once per submission. Downstream rounds and + // delegated subagents read this value instead of re-resolving the layers + // with partial context, so a mid-turn configuration or session change + // cannot split one turn across two modes. + let submission_permission_mode = resolve_submission_permission_mode( + permission_mode_from_metadata(user_message_metadata.as_ref()), + session.config.permission_mode, + default_permission_mode_from_global_config().await, + ); + context_vars.insert( + PERMISSION_MODE_CONTEXT_KEY.to_string(), + submission_permission_mode.mode.as_str().to_string(), + ); if needs_computer_links_for_source(submission_policy.trigger_source) { context_vars.insert( TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY.to_string(), @@ -11744,8 +11774,11 @@ impl ConversationCoordinator { }; let profile_id = crate::agentic::agents::resolve_mode_config_profile_id(agent_type); let agent_profile = global_config.ai.agent_profiles.get(profile_id.as_ref()); + // An explicit user-authored command keeps the stored global preset; the + // session mode belongs to agent turns, not to a command the user typed. let permission_policy = resolve_effective_permission_policy( &global_config, + None, &project_rules, agent_profile, None, @@ -12563,6 +12596,47 @@ fn btw_session_memory_mode( } } +/// Reads the user-level default permission mode. +/// +/// Falling back to `Ask` on a config failure keeps an unreadable configuration +/// from silently widening permissions. +async fn default_permission_mode_from_global_config() -> PermissionMode { + match crate::service::config::get_global_config_service().await { + Ok(service) => service + .get_config(None) + .await + .map(|config: crate::service::config::types::GlobalConfig| { + PermissionMode::from_config(&config.tool_permissions) + }) + .unwrap_or(PermissionMode::Ask), + Err(_) => PermissionMode::Ask, + } +} + +/// Resolves the mode one submission runs with: `turn -> session -> global`. +/// +/// The result is written into the execution context once so every round, tool +/// call, and delegated subagent of this turn reads the same decision. +fn resolve_submission_permission_mode( + turn_override: Option, + session_mode: Option, + global_default: PermissionMode, +) -> ResolvedPermissionMode { + resolve_permission_mode( + PermissionModeLayers::new(global_default) + .with_session(session_mode) + .with_turn(turn_override), + ) +} + +/// Reads a one-off mode selection carried by a single submission. +fn permission_mode_from_metadata(metadata: Option<&serde_json::Value>) -> Option { + metadata + .and_then(|value| value.get(PERMISSION_MODE_CONTEXT_KEY)) + .and_then(serde_json::Value::as_str) + .and_then(PermissionMode::parse) +} + async fn new_session_memory_mode_from_global_config() -> SessionMemoryMode { match crate::service::config::get_global_config_service().await { Ok(service) => { @@ -12648,8 +12722,9 @@ mod tests { lineage_post_admission_cancellation_error, lineage_session_is_settling_without_active_state, logical_subagent_type_or_runtime, merge_prepended_messages_for_turn, normalize_subagent_max_concurrency, - resolve_agent_session_create_created_by, resolve_agent_submission_turn_id, - resolve_subagent_model_selection, runtime_port_error_preserving_message, + permission_mode_from_metadata, resolve_agent_session_create_created_by, + resolve_agent_submission_turn_id, resolve_subagent_model_selection, + resolve_submission_permission_mode, runtime_port_error_preserving_message, runtime_session_summary, runtime_tool_restrictions_for_session_lifetime, runtime_transcript_messages_from_turns, session_storage_workspace_locator, turn_review_manifest_for_agent, validate_required_lineage_turns_settled, @@ -12985,9 +13060,9 @@ mod tests { AgentSessionCreateRequest, AgentSessionManagementPort, AgentSessionRenameRequest, AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionSource, AgentThreadGoalGetRequest, AgentThreadGoalManagementPort, AgentUserShellCommandPort, - AgentUserShellCommandRequest, DelegationPolicy, PermissionEffect, PermissionRule, - PermissionRuntimeCeiling, PortErrorKind, SessionStoragePathRequest, SubagentContextMode, - ThreadGoal, ThreadGoalStatus, + AgentUserShellCommandRequest, DelegationPolicy, PermissionEffect, PermissionMode, + PermissionRule, PermissionRuntimeCeiling, PortErrorKind, SessionStoragePathRequest, + SubagentContextMode, ThreadGoal, ThreadGoalStatus, }; use std::collections::HashMap; use std::path::PathBuf; @@ -13372,6 +13447,61 @@ mod tests { ); } + #[test] + fn submission_permission_mode_prefers_turn_then_session_then_global() { + use bitfun_runtime_ports::PermissionModeSource; + + let global_only = resolve_submission_permission_mode(None, None, PermissionMode::Ask); + assert_eq!(global_only.mode, PermissionMode::Ask); + assert_eq!(global_only.source, PermissionModeSource::GlobalDefault); + + // A session override isolates this session from the global default. + let session_scoped = resolve_submission_permission_mode( + None, + Some(PermissionMode::FullAccess), + PermissionMode::Ask, + ); + assert_eq!(session_scoped.mode, PermissionMode::FullAccess); + assert_eq!(session_scoped.source, PermissionModeSource::Session); + + // A one-off submission selection wins over the session's own mode, + // including when it tightens the session back down. + let turn_scoped = resolve_submission_permission_mode( + Some(PermissionMode::Ask), + Some(PermissionMode::FullAccess), + PermissionMode::AutoApprove, + ); + assert_eq!(turn_scoped.mode, PermissionMode::Ask); + assert_eq!(turn_scoped.source, PermissionModeSource::Turn); + } + + #[test] + fn submission_metadata_mode_accepts_surface_aliases_and_rejects_unknown_values() { + assert_eq!( + permission_mode_from_metadata(Some(&serde_json::json!({ + "permission_mode": "auto", + }))), + Some(PermissionMode::AutoApprove) + ); + assert_eq!( + permission_mode_from_metadata(Some(&serde_json::json!({ + "permission_mode": "full_access", + }))), + Some(PermissionMode::FullAccess) + ); + assert_eq!( + permission_mode_from_metadata(Some(&serde_json::json!({ + "permission_mode": "elevated", + }))), + None + ); + assert_eq!(permission_mode_from_metadata(None), None); + assert_eq!( + permission_mode_from_metadata(Some(&serde_json::json!({ "other": "ask" }))), + None + ); + } + #[test] fn btw_session_memory_mode_requires_both_generation_switches() { assert_eq!( diff --git a/src/crates/assembly/core/src/agentic/execution/round_executor.rs b/src/crates/assembly/core/src/agentic/execution/round_executor.rs index c06191057c..31c3199d16 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -14,7 +14,9 @@ use crate::agentic::memories::{ parse_bitfun_memory_citation, parse_bitfun_memory_citation_payloads, strip_bitfun_memory_citations, }; -use crate::agentic::permission_policy::resolve_effective_permission_policy; +use crate::agentic::permission_policy::{ + permission_mode_from_context, resolve_effective_permission_policy, +}; use crate::agentic::tools::computer_use_host::ComputerUseHostRef; use crate::agentic::tools::pipeline::{ SubagentBatchExecutionPolicy as PipelineSubagentBatchExecutionPolicy, ToolExecutionContext, @@ -34,7 +36,6 @@ use crate::util::elapsed_ms_u64; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::Message as AIMessage; use crate::util::types::ToolDefinition; -use bitfun_agent_runtime::permission::AUTO_APPROVE_ASK_CONTEXT_KEY; use bitfun_agent_runtime::turn_cancellation::DialogTurnCancellationTokenStore; use bitfun_ai_adapters::{ ModelExchangeRequestTraceHandle, ModelExchangeResponseTrace, ModelExchangeTraceConfig, @@ -217,6 +218,7 @@ impl RoundExecutor { fn resolve_permission_policy( global: &crate::service::config::types::GlobalConfig, + mode: bitfun_runtime_ports::PermissionMode, project_rules: &[PermissionRule], agent_profile: Option<&AgentProfileConfig>, agent_definition_constraints: &bitfun_runtime_ports::PermissionConstraintLayer, @@ -224,6 +226,7 @@ impl RoundExecutor { ) -> bitfun_runtime_ports::ResolvedPermissionPolicy { resolve_effective_permission_policy( global, + Some(mode), project_rules, agent_profile, Some(agent_definition_constraints), @@ -232,14 +235,16 @@ impl RoundExecutor { ) } - fn resolve_auto_approve_ask( + /// The one place a running round learns its permission mode. + /// + /// Both the static preset and the interactive auto-answer preference are + /// derived from this single value, so a round can never run with a preset + /// from one mode and an approval behavior from another. + fn resolve_permission_mode( global: &crate::service::config::types::GlobalConfig, context_vars: &std::collections::HashMap, - ) -> bool { - context_vars - .get(AUTO_APPROVE_ASK_CONTEXT_KEY) - .and_then(|value| value.parse::().ok()) - .unwrap_or(global.tool_permissions.interaction.auto_approve_ask) + ) -> bitfun_runtime_ports::PermissionMode { + permission_mode_from_context(global, context_vars) } async fn sleep_with_cancellation( @@ -1056,8 +1061,9 @@ impl RoundExecutor { let subagent_batch_execution_policy = Self::map_subagent_batch_execution_policy( global_config.ai.subagent_batch_execution_policy, ); - let auto_approve_ask = - Self::resolve_auto_approve_ask(&global_config, &context.context_vars); + let permission_mode = + Self::resolve_permission_mode(&global_config, &context.context_vars); + let auto_approve_ask = permission_mode.auto_approve_ask(); let project_rules = match context.workspace.as_ref() { Some(workspace) if workspace.is_remote() => { @@ -1089,6 +1095,7 @@ impl RoundExecutor { .get(agent_profile_id.as_ref()); let permission_policy = Self::resolve_permission_policy( &global_config, + permission_mode, &project_rules, agent_profile, &context.permission_constraints, @@ -1645,7 +1652,9 @@ mod tests { use crate::service::config::types::{AgentProfileConfig, GlobalConfig}; use crate::util::errors::BitFunError; use crate::util::types::ai::GeminiUsage; - use bitfun_agent_runtime::permission::AUTO_APPROVE_ASK_CONTEXT_KEY; + use bitfun_agent_runtime::permission::{ + AUTO_APPROVE_ASK_CONTEXT_KEY, PERMISSION_MODE_CONTEXT_KEY, + }; use bitfun_agent_runtime::turn_cancellation::DialogTurnCancellationTokenStore; use bitfun_runtime_ports::{ DelegationPolicy, PermissionEffect, PermissionEvaluator, PermissionPolicyPreset, @@ -1772,6 +1781,7 @@ mod tests { let resolved = RoundExecutor::resolve_permission_policy( &global, + bitfun_runtime_ports::PermissionMode::Ask, &project_rules, Some(&agent), &Default::default(), @@ -1798,38 +1808,65 @@ mod tests { } #[test] - fn auto_approve_context_overrides_persisted_interaction_preference() { + fn permission_mode_context_overrides_persisted_default_mode() { + use bitfun_runtime_ports::PermissionMode; + let mut global = GlobalConfig::default(); global.tool_permissions.interaction.auto_approve_ask = true; let mut context_vars = std::collections::HashMap::new(); - assert!(RoundExecutor::resolve_auto_approve_ask( - &global, - &context_vars - )); + // No override: the stored configuration is the default mode. + assert_eq!( + RoundExecutor::resolve_permission_mode(&global, &context_vars), + PermissionMode::AutoApprove + ); + + // The legacy flag still speaks for the auto-approval half. context_vars.insert( AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), "false".to_string(), ); - - assert!(!RoundExecutor::resolve_auto_approve_ask( - &global, - &context_vars - )); + assert_eq!( + RoundExecutor::resolve_permission_mode(&global, &context_vars), + PermissionMode::Ask + ); context_vars.insert(AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), "true".to_string()); - assert!(RoundExecutor::resolve_auto_approve_ask( - &global, - &context_vars - )); - + assert_eq!( + RoundExecutor::resolve_permission_mode(&global, &context_vars), + PermissionMode::AutoApprove + ); context_vars.insert( AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), "invalid".to_string(), ); - assert!(RoundExecutor::resolve_auto_approve_ask( - &global, - &context_vars - )); + assert_eq!( + RoundExecutor::resolve_permission_mode(&global, &context_vars), + PermissionMode::AutoApprove + ); + + // The resolved mode key outranks the legacy flag. + context_vars.insert( + PERMISSION_MODE_CONTEXT_KEY.to_string(), + PermissionMode::FullAccess.as_str().to_string(), + ); + context_vars.insert( + AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), + "false".to_string(), + ); + assert_eq!( + RoundExecutor::resolve_permission_mode(&global, &context_vars), + PermissionMode::FullAccess + ); + + // An unparseable mode falls back instead of failing open. + context_vars.insert( + PERMISSION_MODE_CONTEXT_KEY.to_string(), + "elevated".to_string(), + ); + assert_eq!( + RoundExecutor::resolve_permission_mode(&global, &context_vars), + PermissionMode::Ask + ); } #[tokio::test] diff --git a/src/crates/assembly/core/src/agentic/fork_agent/mod.rs b/src/crates/assembly/core/src/agentic/fork_agent/mod.rs index ca33d02aeb..4ff56441b6 100644 --- a/src/crates/assembly/core/src/agentic/fork_agent/mod.rs +++ b/src/crates/assembly/core/src/agentic/fork_agent/mod.rs @@ -133,4 +133,29 @@ mod tests { ); assert_eq!(snapshot.last_submitted_agent_type.as_deref(), Some("Plan")); } + + #[test] + fn forked_session_inherits_the_parent_permission_mode_selection() { + use bitfun_runtime_ports::PermissionMode; + + let mut parent = parent_session(); + parent.config.permission_mode = Some(PermissionMode::FullAccess); + let snapshot = + ForkAgentContextSnapshot::from_parent_session(&parent, Vec::new()).expect("snapshot"); + + assert_eq!( + snapshot.build_child_session_config(None).permission_mode, + Some(PermissionMode::FullAccess) + ); + + // A parent that never chose a mode leaves the child following the + // user-level default instead of freezing a copy of it. + parent.config.permission_mode = None; + let snapshot = + ForkAgentContextSnapshot::from_parent_session(&parent, Vec::new()).expect("snapshot"); + assert_eq!( + snapshot.build_child_session_config(None).permission_mode, + None + ); + } } diff --git a/src/crates/assembly/core/src/agentic/permission_policy.rs b/src/crates/assembly/core/src/agentic/permission_policy.rs index 69b0e9057e..90a5a84be6 100644 --- a/src/crates/assembly/core/src/agentic/permission_policy.rs +++ b/src/crates/assembly/core/src/agentic/permission_policy.rs @@ -1,12 +1,44 @@ use crate::service::config::global::GlobalConfigManager; use crate::service::config::types::{AgentProfileConfig, GlobalConfig}; use crate::util::errors::BitFunResult; +use bitfun_agent_runtime::permission::{AUTO_APPROVE_ASK_CONTEXT_KEY, PERMISSION_MODE_CONTEXT_KEY}; use bitfun_runtime_ports::{ resolve_child_permission_policy, resolve_permission_policy, ChildPermissionPolicyLayers, - PermissionConstraintLayer, PermissionEffect, PermissionPolicyLayers, PermissionRule, - PermissionRuntimeCeiling, ResolvedPermissionPolicy, + PermissionConstraintLayer, PermissionEffect, PermissionMode, PermissionPolicyLayers, + PermissionRule, PermissionRuntimeCeiling, ResolvedPermissionPolicy, }; +/// Reads the effective mode a submission carried into tool execution. +/// +/// The owning surface resolves the layered selection once and writes it to the +/// execution context, so this is a lookup and not a second resolution. The +/// legacy auto-approve flag is still honored for submissions and product +/// surfaces that predate the mode key. +pub(crate) fn permission_mode_from_context( + global: &GlobalConfig, + context_vars: &std::collections::HashMap, +) -> PermissionMode { + if let Some(mode) = context_vars + .get(PERMISSION_MODE_CONTEXT_KEY) + .map(String::as_str) + .and_then(PermissionMode::parse) + { + return mode; + } + + let default_mode = PermissionMode::from_config(&global.tool_permissions); + match context_vars + .get(AUTO_APPROVE_ASK_CONTEXT_KEY) + .and_then(|value| value.parse::().ok()) + { + // A legacy flag only speaks for the auto-approval half. It must not + // downgrade a full-access selection that the same turn resolved. + Some(true) if default_mode != PermissionMode::FullAccess => PermissionMode::AutoApprove, + Some(false) if default_mode == PermissionMode::AutoApprove => PermissionMode::Ask, + _ => default_mode, + } +} + pub(crate) fn derive_parent_permission_runtime_ceiling( agent_profile: Option<&AgentProfileConfig>, agent_definition_constraints: Option<&PermissionConstraintLayer>, @@ -53,8 +85,15 @@ pub(crate) async fn load_parent_permission_runtime_ceiling( )) } +/// Resolves one turn's effective policy. +/// +/// `mode` is the already-resolved turn/session/global selection. It only +/// replaces the preset baseline; every later layer (global rules, project, +/// agent, enforced, and the constraint layers appended below) is evaluated +/// afterwards and can still tighten the result. pub(crate) fn resolve_effective_permission_policy( global: &GlobalConfig, + mode: Option, project_rules: &[PermissionRule], agent_profile: Option<&AgentProfileConfig>, agent_definition_constraints: Option<&PermissionConstraintLayer>, @@ -70,6 +109,7 @@ pub(crate) fn resolve_effective_permission_policy( resolve_child_permission_policy(ChildPermissionPolicyLayers { product_defaults: &[], global: &global.tool_permissions.policy, + mode, project: project_rules, child_agent: agent_rules, parent_runtime_ceiling, @@ -79,6 +119,7 @@ pub(crate) fn resolve_effective_permission_policy( None => resolve_permission_policy(PermissionPolicyLayers { product_defaults: &[], global: &global.tool_permissions.policy, + mode, project: project_rules, agent: agent_rules, enforced, @@ -103,6 +144,70 @@ mod tests { PermissionRule::new(action, resource, effect) } + #[test] + fn context_mode_key_outranks_the_legacy_auto_approve_flag() { + let mut global = GlobalConfig::default(); + global.tool_permissions.interaction.auto_approve_ask = true; + let mut context_vars = std::collections::HashMap::new(); + + assert_eq!( + permission_mode_from_context(&global, &context_vars), + PermissionMode::AutoApprove + ); + + context_vars.insert( + PERMISSION_MODE_CONTEXT_KEY.to_string(), + "full_access".to_string(), + ); + context_vars.insert( + AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), + "false".to_string(), + ); + assert_eq!( + permission_mode_from_context(&global, &context_vars), + PermissionMode::FullAccess + ); + } + + #[test] + fn legacy_auto_approve_flag_never_downgrades_a_full_access_default() { + let mut global = GlobalConfig::default(); + global.tool_permissions.policy.preset = PermissionPolicyPreset::FullAccess; + let mut context_vars = std::collections::HashMap::new(); + context_vars.insert(AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), "true".to_string()); + + assert_eq!( + permission_mode_from_context(&global, &context_vars), + PermissionMode::FullAccess + ); + } + + #[test] + fn session_full_access_mode_is_still_bounded_by_project_rules() { + let global = GlobalConfig::default(); + let project = vec![rule("edit", "generated/*", PermissionEffect::Deny)]; + + let resolved = resolve_effective_permission_policy( + &global, + Some(PermissionMode::FullAccess), + &project, + None, + None, + None, + &[], + ); + let evaluator = PermissionEvaluator::case_sensitive(); + + assert_eq!( + evaluator.evaluate_policy_resource("edit", "src/main.rs", &resolved), + PermissionEffect::Allow + ); + assert_eq!( + evaluator.evaluate_policy_resource("edit", "generated/api.rs", &resolved), + PermissionEffect::Deny + ); + } + #[test] fn parent_ceiling_combines_profile_and_agent_constraints_without_allows() { let profile = AgentProfileConfig { @@ -156,8 +261,15 @@ mod tests { ..AgentProfileConfig::default() }; - let resolved = - resolve_effective_permission_policy(&global, &project, Some(&profile), None, None, &[]); + let resolved = resolve_effective_permission_policy( + &global, + None, + &project, + Some(&profile), + None, + None, + &[], + ); assert_eq!( PermissionEvaluator::case_sensitive().evaluate_policy_resource( @@ -196,6 +308,7 @@ mod tests { let resolved = resolve_effective_permission_policy( &global, + None, &[], Some(&child_profile), None, diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index aba2a8b11d..4bc9acb6fd 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -51,7 +51,7 @@ use crate::util::sanitize_plain_model_output; use crate::util::timing::elapsed_ms_u64; use bitfun_core_types::SessionExecutionTarget; pub use bitfun_runtime_ports::SessionViewRestoreTiming; -use bitfun_runtime_ports::{SessionStoragePathRequest, SessionStorePort}; +use bitfun_runtime_ports::{PermissionMode, SessionStoragePathRequest, SessionStorePort}; use bitfun_services_core::session::{ apply_session_lineage, collect_hidden_subagent_cascade as collect_hidden_subagent_cascade_ids, merge_session_custom_metadata as merge_session_custom_metadata_value, @@ -4017,6 +4017,103 @@ impl SessionManager { Ok(()) } + /// Sets the session's own tool permission mode (in-memory + persistence). + /// + /// `None` clears the override so the session follows the user-level default + /// again, including later changes to that default. The value only takes + /// effect from the next submission: a running turn already resolved its + /// mode and must not change permissions halfway through. + pub async fn update_session_permission_mode( + &self, + session_id: &str, + permission_mode: Option, + ) -> BitFunResult<()> { + // Match the model-selection path: an evicted session is restored before + // the mutation permit is taken, because restore owns the same keyed lock. + if !self.sessions.contains_key(session_id) && self.config.enable_persistence { + let session_storage_path = self + .session_storage_path_index + .get(session_id) + .map(|entry| entry.value().path.clone()); + if let Some(session_storage_path) = session_storage_path { + debug!( + "Session evicted from memory, restoring for permission mode update: session_id={}", + session_id + ); + let _ = self + .restore_session_from_storage_path(&session_storage_path, session_id) + .await; + } + } + + let _mutation_guard = self.acquire_session_mutation(session_id).await?; + + let original_session = self + .sessions + .get(session_id) + .map(|session| session.clone()) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))?; + if original_session.config.permission_mode == permission_mode { + return Ok(()); + } + + let mut updated_session = original_session.clone(); + updated_session.config.permission_mode = permission_mode; + let now = SystemTime::now(); + updated_session.updated_at = now; + updated_session.last_activity_at = now; + + if self.should_persist_session_id(session_id) { + let effective_path = self.effective_session_storage_path(session_id).await; + if let Some(workspace_path) = effective_path { + if let Err(error) = self + .persistence_manager + .save_session(&workspace_path, &updated_session) + .await + { + // A selection that is not durable must not stay applied in + // memory, so restore the previous value before failing. + if let Err(rollback_error) = self + .persistence_manager + .save_session(&workspace_path, &original_session) + .await + { + return Err(BitFunError::session(format!( + "Session permission mode persistence failed and rollback did not complete: session_id={session_id}, error={error}, rollback_error={rollback_error}" + ))); + } + return Err(error); + } + } + } + + if let Some(mut session) = self.sessions.get_mut(session_id) { + session.config.permission_mode = permission_mode; + session.updated_at = now; + session.last_activity_at = now; + } else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + } + + debug!( + "Session permission mode updated: session_id={}, permission_mode={:?}", + session_id, permission_mode + ); + + Ok(()) + } + + /// Reads the session's own permission mode without falling back to the + /// user-level default. `None` means the session never chose one. + pub fn session_permission_mode(&self, session_id: &str) -> Option { + self.sessions + .get(session_id) + .and_then(|session| session.config.permission_mode) + } + /// Rebind where a session executes (in-memory + persistence). /// /// Only the workspace roots and the resolved execution target move; session @@ -8010,9 +8107,9 @@ impl SessionManager { #[cfg(test)] mod tests { use super::{ - should_auto_migrate_session_model, CoreSessionStorePort, SessionExecutionBindingError, - SessionExecutionBindingUpdate, SessionManager, SessionManagerConfig, - TEST_MODEL_RESOLUTION_AI_CONFIG, + should_auto_migrate_session_model, CoreSessionStorePort, PermissionMode, + SessionExecutionBindingError, SessionExecutionBindingUpdate, SessionManager, + SessionManagerConfig, TEST_MODEL_RESOLUTION_AI_CONFIG, }; use crate::agentic::core::{ CompressionState, Message, MessageContent, MessageRole, ProcessingPhase, Session, @@ -8965,6 +9062,74 @@ mod tests { )); } + #[tokio::test] + async fn session_permission_mode_is_per_session_and_clearable() { + let workspace = TestWorkspace::new(); + let manager = in_memory_test_manager(); + let config = SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }; + let first = manager + .create_session_with_id_and_details( + None, + "First".to_string(), + "agentic".to_string(), + config.clone(), + None, + SessionKind::Standard, + ) + .await + .expect("create first session"); + let second = manager + .create_session_with_id_and_details( + None, + "Second".to_string(), + "agentic".to_string(), + config, + None, + SessionKind::Standard, + ) + .await + .expect("create second session"); + + // A new session starts without an override and follows the default. + assert_eq!(manager.session_permission_mode(&first.session_id), None); + + manager + .update_session_permission_mode(&first.session_id, Some(PermissionMode::FullAccess)) + .await + .expect("set first session mode"); + + // The selection stays inside the session it was made in. + assert_eq!( + manager.session_permission_mode(&first.session_id), + Some(PermissionMode::FullAccess) + ); + assert_eq!(manager.session_permission_mode(&second.session_id), None); + + // Clearing returns the session to the user-level default. + manager + .update_session_permission_mode(&first.session_id, None) + .await + .expect("clear first session mode"); + assert_eq!(manager.session_permission_mode(&first.session_id), None); + } + + #[tokio::test] + async fn session_permission_mode_update_rejects_a_missing_session() { + let manager = in_memory_test_manager(); + + let error = manager + .update_session_permission_mode("missing-session", Some(PermissionMode::AutoApprove)) + .await + .expect_err("unknown session must not silently succeed"); + assert!(matches!( + error, + crate::util::errors::BitFunError::NotFound(_) + )); + } + #[tokio::test] async fn transient_session_cannot_bypass_owned_discard_through_unload() { let workspace = TestWorkspace::new(); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs index 0a244c6514..30f01a03f2 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs @@ -52,7 +52,9 @@ fn forward_subagent_invocation_context( context: &ToolUseContext, subagent_context: &mut HashMap, ) { - use bitfun_agent_runtime::permission::AUTO_APPROVE_ASK_CONTEXT_KEY; + use bitfun_agent_runtime::permission::{ + AUTO_APPROVE_ASK_CONTEXT_KEY, PERMISSION_MODE_CONTEXT_KEY, + }; use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; for key in [ @@ -69,6 +71,23 @@ fn forward_subagent_invocation_context( }; subagent_context.insert(key.to_string(), value); } + + // The child runs under the parent turn's already-resolved permission mode. + // Without this the child would fall back to the user-level default, so a + // session that chose its own mode would silently lose it at delegation. + // The parent runtime ceiling is applied separately and still bounds the + // child, so inheriting a wider mode cannot widen what the parent restricted. + if let Some(mode) = context + .custom_data + .get(PERMISSION_MODE_CONTEXT_KEY) + .and_then(Value::as_str) + .and_then(bitfun_runtime_ports::PermissionMode::parse) + { + subagent_context.insert( + PERMISSION_MODE_CONTEXT_KEY.to_string(), + mode.as_str().to_string(), + ); + } } struct BackgroundTaskStartRequest<'a> { @@ -1324,6 +1343,52 @@ mod target_context_tests { assert!(!child.contains_key(AUTO_APPROVE_ASK_CONTEXT_KEY)); } + #[test] + fn child_context_inherits_the_parent_resolved_permission_mode() { + use bitfun_agent_runtime::permission::PERMISSION_MODE_CONTEXT_KEY; + + let mut parent = parent_tool_context(); + parent.custom_data.insert( + PERMISSION_MODE_CONTEXT_KEY.to_string(), + Value::String("full_access".to_string()), + ); + let mut child = HashMap::new(); + + forward_subagent_invocation_context(&parent, &mut child); + + assert_eq!(child[PERMISSION_MODE_CONTEXT_KEY], "full_access"); + } + + #[test] + fn child_context_rejects_an_unparseable_permission_mode() { + use bitfun_agent_runtime::permission::PERMISSION_MODE_CONTEXT_KEY; + + let mut parent = parent_tool_context(); + parent.custom_data.insert( + PERMISSION_MODE_CONTEXT_KEY.to_string(), + Value::String("elevated".to_string()), + ); + let mut child = HashMap::new(); + + forward_subagent_invocation_context(&parent, &mut child); + + // Dropping it falls back to the user-level default rather than + // forwarding a value the child cannot interpret. + assert!(!child.contains_key(PERMISSION_MODE_CONTEXT_KEY)); + } + + #[test] + fn child_context_leaves_unset_permission_mode_for_global_fallback() { + use bitfun_agent_runtime::permission::PERMISSION_MODE_CONTEXT_KEY; + + let parent = parent_tool_context(); + let mut child = HashMap::new(); + + forward_subagent_invocation_context(&parent, &mut child); + + assert!(!child.contains_key(PERMISSION_MODE_CONTEXT_KEY)); + } + #[test] fn child_context_forwards_only_allowlisted_boolean_invocation_facts() { let mut parent = parent_tool_context(); diff --git a/src/crates/contracts/product-domains/src/tool_permissions.rs b/src/crates/contracts/product-domains/src/tool_permissions.rs index 7074d5c2b2..8e33e0afb4 100644 --- a/src/crates/contracts/product-domains/src/tool_permissions.rs +++ b/src/crates/contracts/product-domains/src/tool_permissions.rs @@ -235,6 +235,185 @@ pub struct PermissionInteractionConfig { pub auto_approve_ask: bool, } +/// The interaction mode a dialog turn runs with. +/// +/// This is the single user-facing selection behind the permission control. It +/// projects onto the two independent knobs the runtime already owns: the static +/// policy preset and the interactive auto-answer preference. Keeping both knobs +/// derived from one value prevents meaningless combinations such as full access +/// plus auto approval. +/// +/// A mode never widens the resolved ruleset beyond its preset baseline. Project, +/// agent, enforced, and constraint layers are evaluated after the baseline, so a +/// `FullAccess` turn is still bounded by every deny those layers own. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Hash)] +#[serde(rename_all = "snake_case")] +pub enum PermissionMode { + /// Every `ask` decision is raised to the user. + #[default] + Ask, + /// Static policy is unchanged; interactive `ask` is answered automatically. + AutoApprove, + /// The policy baseline allows everything the later layers do not deny. + FullAccess, +} + +impl PermissionMode { + pub const fn preset(self) -> PermissionPolicyPreset { + match self { + Self::Ask | Self::AutoApprove => PermissionPolicyPreset::Ask, + Self::FullAccess => PermissionPolicyPreset::FullAccess, + } + } + + pub const fn auto_approve_ask(self) -> bool { + matches!(self, Self::AutoApprove) + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::Ask => "ask", + Self::AutoApprove => "auto_approve", + Self::FullAccess => "full_access", + } + } + + /// Parses a wire value, accepting the surface aliases already used by the + /// desktop control and the CLI approval flags. + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "ask" => Some(Self::Ask), + "auto" | "auto_approve" | "autoapprove" => Some(Self::AutoApprove), + "full_access" | "fullaccess" | "full" => Some(Self::FullAccess), + _ => None, + } + } + + /// Derives the mode a stored configuration represents. + /// + /// `full_access` wins over the auto-approve preference: the preset already + /// resolves every `ask` to `allow`, so auto-answering is not observable. + pub const fn from_config(config: &ToolPermissionConfig) -> Self { + match config.policy.preset { + PermissionPolicyPreset::FullAccess => Self::FullAccess, + PermissionPolicyPreset::Ask if config.interaction.auto_approve_ask => Self::AutoApprove, + PermissionPolicyPreset::Ask => Self::Ask, + } + } +} + +impl fmt::Display for PermissionMode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Deserializes an optional mode without letting an unrecognized value fail the +/// whole record it belongs to. +/// +/// Persisted carriers must use this. A file written by a newer build can hold a +/// mode this build has never heard of, and the strict derive would turn that +/// single field into a parse failure for the entire session state — the failure +/// mode that made incompatible model settings block startup before. +/// +/// An unreadable value degrades to `None`, which means "follow the user-level +/// default". That is the same resolution a session that never chose a mode +/// gets, so the fallback lands on a value the user configured themselves rather +/// than on a mode nobody asked for. The selection is intentionally dropped +/// rather than preserved: a value this build cannot evaluate must not decide +/// how tools get authorized. +pub fn deserialize_optional_permission_mode<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(value + .as_ref() + .and_then(Value::as_str) + .and_then(PermissionMode::parse)) +} + +/// The layer that owns the effective mode of one dialog turn. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PermissionModeSource { + /// The user-level default applied to sessions that never chose a mode. + GlobalDefault, + /// A workspace-level default. Reserved: no surface writes this layer yet. + Project, + /// The session's own selection. + Session, + /// A single submission's one-off selection. + Turn, +} + +/// Ordered mode inputs. Later layers win; every layer is optional except the +/// global default. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PermissionModeLayers { + pub global_default: PermissionMode, + pub project: Option, + pub session: Option, + pub turn: Option, +} + +impl PermissionModeLayers { + pub const fn new(global_default: PermissionMode) -> Self { + Self { + global_default, + project: None, + session: None, + turn: None, + } + } + + pub const fn with_session(mut self, session: Option) -> Self { + self.session = session; + self + } + + pub const fn with_turn(mut self, turn: Option) -> Self { + self.turn = turn; + self + } +} + +/// One effective mode plus the layer that owns it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedPermissionMode { + pub mode: PermissionMode, + pub source: PermissionModeSource, +} + +/// Resolves `turn -> session -> project -> global default`. +pub const fn resolve_permission_mode(layers: PermissionModeLayers) -> ResolvedPermissionMode { + if let Some(mode) = layers.turn { + return ResolvedPermissionMode { + mode, + source: PermissionModeSource::Turn, + }; + } + if let Some(mode) = layers.session { + return ResolvedPermissionMode { + mode, + source: PermissionModeSource::Session, + }; + } + if let Some(mode) = layers.project { + return ResolvedPermissionMode { + mode, + source: PermissionModeSource::Project, + }; + } + ResolvedPermissionMode { + mode: layers.global_default, + source: PermissionModeSource::GlobalDefault, + } +} + /// Root configuration contract for the `tool_permissions` config section. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(default)] @@ -252,6 +431,10 @@ pub struct ToolPermissionConfig { pub struct PermissionPolicyLayers<'a> { pub product_defaults: &'a [PermissionRule], pub global: &'a PermissionPolicyConfig, + /// Effective interaction mode for this resolution. `None` keeps the stored + /// global preset, which is the behavior of callers that never resolved a + /// session- or turn-scoped mode. + pub mode: Option, pub project: &'a [PermissionRule], pub agent: &'a [PermissionRule], pub enforced: &'a [PermissionRule], @@ -266,6 +449,10 @@ pub struct PermissionPolicyLayers<'a> { pub struct ChildPermissionPolicyLayers<'a> { pub product_defaults: &'a [PermissionRule], pub global: &'a PermissionPolicyConfig, + /// Effective interaction mode inherited from the delegating turn. The + /// parent runtime ceiling is still applied on top, so an inherited + /// `FullAccess` cannot widen what the parent restricted. + pub mode: Option, pub project: &'a [PermissionRule], pub child_agent: &'a [PermissionRule], pub parent_runtime_ceiling: &'a PermissionRuntimeCeiling, @@ -275,7 +462,7 @@ pub struct ChildPermissionPolicyLayers<'a> { /// Expands the configured preset and merges every static rule layer in its /// security-significant evaluation order. pub fn resolve_permission_policy(layers: PermissionPolicyLayers<'_>) -> ResolvedPermissionPolicy { - let baseline = layers.global.preset.baseline_rules(); + let baseline = effective_preset(layers.mode, layers.global).baseline_rules(); ResolvedPermissionPolicy::new( merge_permission_rule_layers(&[ layers.product_defaults, @@ -294,7 +481,7 @@ pub fn resolve_permission_policy(layers: PermissionPolicyLayers<'_>) -> Resolved pub fn resolve_child_permission_policy( layers: ChildPermissionPolicyLayers<'_>, ) -> ResolvedPermissionPolicy { - let baseline = layers.global.preset.baseline_rules(); + let baseline = effective_preset(layers.mode, layers.global).baseline_rules(); ResolvedPermissionPolicy::new( merge_permission_rule_layers(&[ layers.product_defaults, @@ -627,6 +814,14 @@ impl Default for PermissionEvaluator { } } +/// Picks the preset baseline a resolution should expand. +fn effective_preset( + mode: Option, + global: &PermissionPolicyConfig, +) -> PermissionPolicyPreset { + mode.map_or(global.preset, PermissionMode::preset) +} + /// Merges global, project, and agent rule layers without changing their order. pub fn merge_permission_rule_layers(layers: &[&[PermissionRule]]) -> PermissionRuleset { let capacity = layers.iter().map(|layer| layer.len()).sum(); diff --git a/src/crates/contracts/product-domains/tests/tool_permission_contracts.rs b/src/crates/contracts/product-domains/tests/tool_permission_contracts.rs index 1d00bdc4c1..b3e93a16ad 100644 --- a/src/crates/contracts/product-domains/tests/tool_permission_contracts.rs +++ b/src/crates/contracts/product-domains/tests/tool_permission_contracts.rs @@ -7,6 +7,9 @@ use bitfun_product_domains::tool_permissions::{ PermissionRequestSourceKind, PermissionResourceCaseSensitivity, PermissionRule, PermissionRuntimeCeiling, ResolvedPermissionPolicy, ToolPermissionConfig, }; +use bitfun_product_domains::tool_permissions::{ + resolve_permission_mode, PermissionMode, PermissionModeLayers, PermissionModeSource, +}; use serde_json::json; use serde_json::Map; @@ -80,6 +83,7 @@ fn policy_presets_expand_into_ordinary_baseline_rules() { let ask_rules = resolve_permission_policy(PermissionPolicyLayers { product_defaults: &[], global: &ask, + mode: None, project: &[], agent: &[], enforced: &[], @@ -87,6 +91,7 @@ fn policy_presets_expand_into_ordinary_baseline_rules() { let full_access_rules = resolve_permission_policy(PermissionPolicyLayers { product_defaults: &[], global: &full_access, + mode: None, project: &[], agent: &[], enforced: &[], @@ -134,6 +139,7 @@ fn ask_preset_allows_low_risk_actions_and_keeps_mutations_guarded() { let rules = resolve_permission_policy(PermissionPolicyLayers { product_defaults: &[], global: &policy(PermissionPolicyPreset::Ask, Vec::new()), + mode: None, project: &[], agent: &[], enforced: &[], @@ -200,6 +206,7 @@ fn resolved_policy_preserves_layer_order_and_enforced_limits() { let resolved = resolve_permission_policy(PermissionPolicyLayers { product_defaults: &product_defaults, global: &global, + mode: None, project: &project, agent: &agent, enforced: &enforced, @@ -278,6 +285,7 @@ fn child_policy_preserves_exact_layer_order_and_security_precedence() { let resolved = resolve_child_permission_policy(ChildPermissionPolicyLayers { product_defaults: &product_defaults, global: &global, + mode: None, project: &project, child_agent: &child_agent, parent_runtime_ceiling: &ceiling, @@ -316,6 +324,7 @@ fn parent_ceiling_overrides_child_agent_allow() { let resolved = resolve_child_permission_policy(ChildPermissionPolicyLayers { product_defaults: &[], global: &global, + mode: None, project: &[], child_agent: &child_agent, parent_runtime_ceiling: &ceiling, @@ -343,6 +352,7 @@ fn parent_ceiling_ask_does_not_loosen_child_agent_deny() { let resolved = resolve_child_permission_policy(ChildPermissionPolicyLayers { product_defaults: &[], global: &global, + mode: None, project: &[], child_agent: &child_agent, parent_runtime_ceiling: &ceiling, @@ -366,6 +376,7 @@ fn task_and_skill_default_allow_do_not_authorize_child_tools() { let resolved = resolve_child_permission_policy(ChildPermissionPolicyLayers { product_defaults: &[], global: &global, + mode: None, project: &[], child_agent: &[], parent_runtime_ceiling: &ceiling, @@ -671,3 +682,246 @@ fn multi_resource_decision_is_atomic_with_deny_then_ask_precedence() { PermissionEffect::Deny ); } + +#[test] +fn permission_mode_projects_onto_preset_and_auto_approval() { + assert_eq!(PermissionMode::Ask.preset(), PermissionPolicyPreset::Ask); + assert!(!PermissionMode::Ask.auto_approve_ask()); + + assert_eq!( + PermissionMode::AutoApprove.preset(), + PermissionPolicyPreset::Ask + ); + assert!(PermissionMode::AutoApprove.auto_approve_ask()); + + assert_eq!( + PermissionMode::FullAccess.preset(), + PermissionPolicyPreset::FullAccess + ); + assert!(!PermissionMode::FullAccess.auto_approve_ask()); +} + +#[test] +fn permission_mode_round_trips_through_stored_configuration() { + let mut config = ToolPermissionConfig::default(); + assert_eq!(PermissionMode::from_config(&config), PermissionMode::Ask); + + config.interaction.auto_approve_ask = true; + assert_eq!( + PermissionMode::from_config(&config), + PermissionMode::AutoApprove + ); + + // Full access already resolves every ask, so it outranks auto approval. + config.policy.preset = PermissionPolicyPreset::FullAccess; + assert_eq!( + PermissionMode::from_config(&config), + PermissionMode::FullAccess + ); + + for mode in [ + PermissionMode::Ask, + PermissionMode::AutoApprove, + PermissionMode::FullAccess, + ] { + assert_eq!(PermissionMode::parse(mode.as_str()), Some(mode)); + } + assert_eq!( + PermissionMode::parse("auto"), + Some(PermissionMode::AutoApprove) + ); + assert_eq!( + PermissionMode::parse(" Full "), + Some(PermissionMode::FullAccess) + ); + assert_eq!(PermissionMode::parse("elevated"), None); +} + +#[test] +fn permission_mode_resolution_prefers_the_narrowest_layer() { + let base = PermissionModeLayers::new(PermissionMode::Ask); + + let global_only = resolve_permission_mode(base); + assert_eq!(global_only.mode, PermissionMode::Ask); + assert_eq!(global_only.source, PermissionModeSource::GlobalDefault); + + let session = resolve_permission_mode(base.with_session(Some(PermissionMode::FullAccess))); + assert_eq!(session.mode, PermissionMode::FullAccess); + assert_eq!(session.source, PermissionModeSource::Session); + + let turn = resolve_permission_mode( + base.with_session(Some(PermissionMode::FullAccess)) + .with_turn(Some(PermissionMode::Ask)), + ); + assert_eq!(turn.mode, PermissionMode::Ask); + assert_eq!(turn.source, PermissionModeSource::Turn); + + let project = resolve_permission_mode(PermissionModeLayers { + project: Some(PermissionMode::AutoApprove), + ..base + }); + assert_eq!(project.mode, PermissionMode::AutoApprove); + assert_eq!(project.source, PermissionModeSource::Project); +} + +#[test] +fn full_access_mode_stays_bounded_by_project_and_enforced_layers() { + let global = policy(PermissionPolicyPreset::Ask, Vec::new()); + let project = vec![rule("edit", "generated/*", PermissionEffect::Deny)]; + let enforced = vec![rule("bash", "rm *", PermissionEffect::Deny)]; + + let resolved = resolve_permission_policy(PermissionPolicyLayers { + product_defaults: &[], + global: &global, + mode: Some(PermissionMode::FullAccess), + project: &project, + agent: &[], + enforced: &enforced, + }); + let evaluator = PermissionEvaluator::case_sensitive(); + + // The mode raises the baseline... + assert_eq!( + evaluator.evaluate_policy_resource("edit", "src/main.rs", &resolved), + PermissionEffect::Allow + ); + // ...but never past a later deny layer. + assert_eq!( + evaluator.evaluate_policy_resource("edit", "generated/api.rs", &resolved), + PermissionEffect::Deny + ); + assert_eq!( + evaluator.evaluate_policy_resource("bash", "rm -rf target", &resolved), + PermissionEffect::Deny + ); +} + +#[test] +fn inherited_full_access_mode_cannot_widen_a_parent_runtime_ceiling() { + let global = policy(PermissionPolicyPreset::Ask, Vec::new()); + let ceiling = PermissionRuntimeCeiling::try_new(vec![ + rule("bash", "rm *", PermissionEffect::Deny), + rule("external_directory", "*", PermissionEffect::Ask), + ]) + .expect("ceiling without allow rules should be valid"); + + let resolved = resolve_child_permission_policy(ChildPermissionPolicyLayers { + product_defaults: &[], + global: &global, + mode: Some(PermissionMode::FullAccess), + project: &[], + child_agent: &[], + parent_runtime_ceiling: &ceiling, + enforced: &[], + }); + let evaluator = PermissionEvaluator::case_sensitive(); + + assert_eq!( + evaluator.evaluate_policy_resource("edit", "src/main.rs", &resolved), + PermissionEffect::Allow + ); + assert_eq!( + evaluator.evaluate_policy_resource("bash", "rm -rf target", &resolved), + PermissionEffect::Deny + ); + assert_eq!( + evaluator.evaluate_policy_resource("external_directory", "C:/outside", &resolved), + PermissionEffect::Ask + ); +} + +#[test] +fn omitted_mode_keeps_the_stored_global_preset() { + let global = policy(PermissionPolicyPreset::FullAccess, Vec::new()); + + let resolved = resolve_permission_policy(PermissionPolicyLayers { + product_defaults: &[], + global: &global, + mode: None, + project: &[], + agent: &[], + enforced: &[], + }); + + assert_eq!( + PermissionEvaluator::case_sensitive().evaluate_policy_resource( + "edit", + "src/main.rs", + &resolved + ), + PermissionEffect::Allow + ); +} + +#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)] +struct PersistedModeCarrier { + #[serde( + default, + deserialize_with = "bitfun_product_domains::tool_permissions::deserialize_optional_permission_mode", + skip_serializing_if = "Option::is_none" + )] + permission_mode: Option, + keep: String, +} + +#[test] +fn persisted_permission_mode_reads_every_known_value() { + for (stored, expected) in [ + ("ask", PermissionMode::Ask), + ("auto_approve", PermissionMode::AutoApprove), + ("full_access", PermissionMode::FullAccess), + ] { + let carrier: PersistedModeCarrier = serde_json::from_value(json!({ + "permission_mode": stored, + "keep": "value", + })) + .expect("known mode should deserialize"); + assert_eq!(carrier.permission_mode, Some(expected)); + } +} + +#[test] +fn persisted_permission_mode_degrades_instead_of_failing_the_record() { + // A value written by a newer build, a null, and a wrong type must all leave + // the surrounding record readable. Failing here would take the whole + // persisted session state down with one unknown field. + for stored in [ + json!("read_only"), + json!(null), + json!(7), + json!({"mode": "ask"}), + ] { + let carrier: PersistedModeCarrier = serde_json::from_value(json!({ + "permission_mode": stored, + "keep": "value", + })) + .expect("an unreadable mode must not fail the record"); + assert_eq!(carrier.permission_mode, None); + assert_eq!(carrier.keep, "value"); + } + + // An absent field is the ordinary "follows the user-level default" case. + let carrier: PersistedModeCarrier = + serde_json::from_value(json!({ "keep": "value" })).expect("absent mode is valid"); + assert_eq!(carrier.permission_mode, None); +} + +#[test] +fn unset_permission_mode_is_omitted_so_old_builds_see_unchanged_files() { + let omitted = serde_json::to_value(PersistedModeCarrier { + permission_mode: None, + keep: "value".to_string(), + }) + .expect("serialize"); + assert_eq!(omitted, json!({ "keep": "value" })); + + let written = serde_json::to_value(PersistedModeCarrier { + permission_mode: Some(PermissionMode::FullAccess), + keep: "value".to_string(), + }) + .expect("serialize"); + assert_eq!( + written, + json!({ "permission_mode": "full_access", "keep": "value" }) + ); +} diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index a3e065ae28..28c0c5b003 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -24,15 +24,16 @@ mod plugin; mod script_tool; #[cfg(feature = "permission")] pub use bitfun_product_domains::tool_permissions::{ - resolve_child_permission_policy, resolve_permission_policy, wildcard_matches, - ChildPermissionPolicyLayers, PermissionAuditEvent, PermissionAuditRecord, - PermissionConstraintLayer, PermissionDelegationContext, PermissionEffect, PermissionEvaluator, - PermissionGrant, PermissionGrantKey, PermissionInteractionConfig, PermissionPolicyConfig, - PermissionPolicyLayers, PermissionPolicyPreset, PermissionReply, PermissionReplySource, - PermissionRequest, PermissionRequestEvent, PermissionRequestSource, + deserialize_optional_permission_mode, resolve_child_permission_policy, resolve_permission_mode, + resolve_permission_policy, wildcard_matches, ChildPermissionPolicyLayers, PermissionAuditEvent, + PermissionAuditRecord, PermissionConstraintLayer, PermissionDelegationContext, + PermissionEffect, PermissionEvaluator, PermissionGrant, PermissionGrantKey, + PermissionInteractionConfig, PermissionMode, PermissionModeLayers, PermissionModeSource, + PermissionPolicyConfig, PermissionPolicyLayers, PermissionPolicyPreset, PermissionReply, + PermissionReplySource, PermissionRequest, PermissionRequestEvent, PermissionRequestSource, PermissionRequestSourceKind, PermissionResourceCaseSensitivity, PermissionRule, PermissionRuleset, PermissionRuntimeCeiling, PermissionRuntimeCeilingValidationError, - ResolvedPermissionPolicy, ToolPermissionConfig, + ResolvedPermissionMode, ResolvedPermissionPolicy, ToolPermissionConfig, }; pub use local_workspace_snapshot::{ LocalWorkspaceSnapshotPort, LocalWorkspaceSnapshotSessionRequest, LocalWorkspaceSnapshotStats, diff --git a/src/crates/execution/agent-runtime/src/permission.rs b/src/crates/execution/agent-runtime/src/permission.rs index b05171fa6e..e301f77aed 100644 --- a/src/crates/execution/agent-runtime/src/permission.rs +++ b/src/crates/execution/agent-runtime/src/permission.rs @@ -20,8 +20,20 @@ const PERMISSION_EVENT_CAPACITY: usize = 128; /// /// Product surfaces may set this in dialog metadata to keep invocation-scoped /// policies (such as CLI `--auto`) separate from persisted user preferences. +/// +/// Superseded by [`PERMISSION_MODE_CONTEXT_KEY`], which carries the whole mode +/// instead of the auto-approval half. Still read so existing surfaces and +/// persisted submissions keep working. pub const AUTO_APPROVE_ASK_CONTEXT_KEY: &str = "auto_approve_ask"; +/// Effective permission mode for one dialog turn. +/// +/// The owning surface resolves `turn -> session -> global default` once at +/// submission time and writes the result here. Everything downstream, including +/// delegated subagents, reads this single value instead of re-resolving the +/// layers with partial context. +pub const PERMISSION_MODE_CONTEXT_KEY: &str = "permission_mode"; + /// Provider-neutral result of applying resolved policy and remembered grants. /// /// Product orchestration remains responsible for scope derivation, native hooks, diff --git a/src/crates/execution/agent-runtime/src/session.rs b/src/crates/execution/agent-runtime/src/session.rs index c380648bcb..c8357eca73 100644 --- a/src/crates/execution/agent-runtime/src/session.rs +++ b/src/crates/execution/agent-runtime/src/session.rs @@ -4,6 +4,7 @@ pub use bitfun_core_types::{ SessionAgentRouteOwner, SessionContinuationPolicy, SessionExecutionTarget, SessionModelBindingPolicy, }; +pub use bitfun_runtime_ports::PermissionMode; use serde::{Deserialize, Serialize}; use std::time::SystemTime; use uuid::Uuid; @@ -189,6 +190,18 @@ pub struct SessionConfig { /// model's default preset (Auto). #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning_preset: Option, + /// Explicit tool permission mode selected for this session. `None` follows + /// the user-level default, so an unset session keeps tracking global + /// configuration changes instead of freezing the value it was created with. + /// + /// Read leniently: a mode written by a newer build must not fail the whole + /// persisted session state. See `deserialize_optional_permission_mode`. + #[serde( + default, + deserialize_with = "bitfun_runtime_ports::deserialize_optional_permission_mode", + skip_serializing_if = "Option::is_none" + )] + pub permission_mode: Option, /// Whether this child session accepts another delegated turn. #[serde(default, skip_serializing_if = "is_reusable_continuation_policy")] pub continuation_policy: SessionContinuationPolicy, @@ -234,6 +247,7 @@ impl Default for SessionConfig { remote_ssh_host: None, model_id: None, reasoning_preset: None, + permission_mode: None, continuation_policy: SessionContinuationPolicy::default(), model_binding_policy: SessionModelBindingPolicy::default(), model_binding_fingerprint: None, @@ -309,11 +323,55 @@ pub fn sanitize_persisted_session_state(state: &SessionState) -> SessionState { #[cfg(test)] mod tests { use super::{ - sanitize_persisted_session_state, CompressionState, PersistedSessionStateFile, Session, - SessionAgentRouteOwner, SessionConfig, SessionContinuationPolicy, - SessionModelBindingPolicy, + sanitize_persisted_session_state, CompressionState, PermissionMode, + PersistedSessionStateFile, Session, SessionAgentRouteOwner, SessionConfig, + SessionContinuationPolicy, SessionModelBindingPolicy, }; use crate::session_state::{ProcessingPhase, SessionState}; + + fn persisted_state_json(permission_mode: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "schema_version": 1, + "config": { + "max_context_tokens": 128128, + "auto_compact": true, + "enable_tools": true, + "safe_mode": true, + "max_turns": 200, + "enable_context_compression": true, + "permission_mode": permission_mode, + }, + "snapshot_session_id": null, + "compression_state": { "last_compression_at": null, "compression_count": 0 }, + "runtime_state": "Idle", + }) + } + + #[test] + fn persisted_session_state_survives_a_permission_mode_from_a_newer_build() { + let known: PersistedSessionStateFile = + serde_json::from_value(persisted_state_json(serde_json::json!("full_access"))) + .expect("known mode should load"); + assert_eq!( + known.config.permission_mode, + Some(PermissionMode::FullAccess) + ); + + // The whole state file must still load; only the unreadable selection is + // dropped, leaving the session on the user-level default. + let unknown: PersistedSessionStateFile = + serde_json::from_value(persisted_state_json(serde_json::json!("read_only"))) + .expect("an unknown mode must not fail the state file"); + assert_eq!(unknown.config.permission_mode, None); + assert_eq!(unknown.config.max_context_tokens, 128128); + assert!(unknown.config.enable_tools); + } + + #[test] + fn unset_permission_mode_keeps_persisted_config_bytes_unchanged() { + let serialized = serde_json::to_value(SessionConfig::default()).expect("serialize"); + assert!(serialized.get("permission_mode").is_none()); + } use bitfun_core_types::{ SessionExecutionTarget, SessionExecutionTargetKind, WorktreeLifecycle, }; diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index cc2ce0daf9..8823d0316a 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -124,6 +124,7 @@ import { } from '../utils/chatInputMode'; import { collectModifiedFilePathsFromTurns } from '../utils/modifiedFilePaths'; import { useSceneStore } from '@/app/stores/sceneStore'; +import { useSettingsStore } from '@/app/scenes/settings/settingsStore'; import type { SceneTabId } from '@/app/components/SceneBar/types'; import { useAgentsStore } from '@/app/scenes/agents/agentsStore'; import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; @@ -174,6 +175,12 @@ import { type ContextUsageDisplay, } from '../utils/tokenUsageDisplay'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; +import type { SessionPermissionMode } from '@/infrastructure/api/service-api/AgentAPI'; +import { + chatInputPermissionMode, + permissionModeFromConfig, + sessionPermissionMode as toBackendPermissionMode, +} from '../utils/permissionMode'; import { ExternalSourceApiError, externalSourcesAPI, @@ -453,6 +460,15 @@ export const ChatInput: React.FC = ({ ); const [permissionModeSaving, setPermissionModeSaving] = useState(false); const [showPermissionModeControl, setShowPermissionModeControl] = useState(true); + // The session's own selection. `null` means it follows the global default, + // which is what keeps switching modes in one conversation from moving every + // other open session. + const [sessionPermissionMode, setSessionPermissionMode] = + useState(null); + // Armed for the next submission only, never persisted. Cleared once a + // submission has carried it, or when the target session changes. + const [turnPermissionMode, setTurnPermissionMode] = + useState(null); const { addMessage: addToHistory, getSessionHistory } = useInputHistoryStore(); const contexts = useContextStore(state => state.contexts); @@ -976,13 +992,16 @@ export const ChatInput: React.FC = ({ [effectiveTargetSession] ); const isAcpTargetSession = Boolean(acpTargetAgentType); + const globalPermissionMode = permissionModeFromConfig(toolPermissionConfig); + // Session selection wins over the user-level default, matching how the + // backend resolves the mode for each submission. + // The session-scoped mode, which is what the menu checkmark marks. An armed + // one-off is reported separately so the two states stay distinguishable. const permissionMode: ChatInputPermissionMode = isAcpTargetSession ? 'acp' - : toolPermissionConfig.policy.preset === 'full_access' - ? 'full_access' - : toolPermissionConfig.interaction.auto_approve_ask - ? 'auto' - : 'ask'; + : chatInputPermissionMode(sessionPermissionMode ?? globalPermissionMode); + const permissionModeOverridden = + !isAcpTargetSession && (turnPermissionMode !== null || sessionPermissionMode !== null); const activeSessionMode = effectiveTargetSessionId ? acpTargetAgentType || flowChatState.sessions.get(effectiveTargetSessionId)?.mode : undefined; @@ -1678,6 +1697,8 @@ export const ChatInput: React.FC = ({ contexts, onClearContexts: clearContexts, onSuccess: onSendMessage, + turnPermissionMode, + onTurnPermissionModeConsumed: () => setTurnPermissionMode(null), onSessionConflictRetryStart: ({ sessionId }) => { sessionConflictRetryBaselinesRef.current.set( sessionId, @@ -1984,46 +2005,153 @@ export const ChatInput: React.FC = ({ }; }, []); - const handlePermissionModeChange = useCallback(async ( - nextMode: Exclude, - ) => { - if (permissionModeSaving || isAcpTargetSession) return; - if (nextMode === 'full_access') { - const confirmed = await confirmDanger( - t('chatInput.permissionMode.fullAccessWarningTitle'), - t('chatInput.permissionMode.fullAccessWarningMessage'), - { - confirmText: t('chatInput.permissionMode.fullAccessConfirm'), - cancelText: t('chatInput.permissionMode.cancel'), - }, - ); - if (!confirmed) return; + // Reads the session's own selection whenever the target session changes, so + // switching conversations shows that conversation's mode rather than the last + // one the user touched. + React.useEffect(() => { + let cancelled = false; + if (!effectiveTargetSessionId || isAcpTargetSession) { + setSessionPermissionMode(null); + setTurnPermissionMode(null); + return undefined; } - - const previousConfig = toolPermissionConfig; - const nextConfig: ToolPermissionConfig = { - policy: { - ...previousConfig.policy, - preset: nextMode === 'full_access' ? 'full_access' : 'ask', - }, - interaction: { - ...previousConfig.interaction, - auto_approve_ask: nextMode === 'auto', - }, + setTurnPermissionMode(null); + void (async () => { + try { + const response = await agentAPI.getSessionPermissionMode({ + sessionId: effectiveTargetSessionId, + workspacePath: effectiveTargetSession?.workspacePath, + remoteConnectionId: effectiveTargetSession?.remoteConnectionId, + remoteSshHost: effectiveTargetSession?.remoteSshHost, + }); + if (!cancelled) setSessionPermissionMode(response.mode ?? null); + } catch (error) { + log.warn('Failed to read session permission mode', error); + // Falling back to the global default is the safe read: it never shows a + // wider mode than the session actually runs with. + if (!cancelled) setSessionPermissionMode(null); + } + })(); + return () => { + cancelled = true; }; - setToolPermissionConfig(nextConfig); + }, [ + effectiveTargetSessionId, + effectiveTargetSession?.workspacePath, + effectiveTargetSession?.remoteConnectionId, + effectiveTargetSession?.remoteSshHost, + isAcpTargetSession, + ]); + + const applySessionPermissionMode = useCallback(async ( + nextMode: SessionPermissionMode | null, + ) => { + if (!effectiveTargetSessionId) { + notificationService.error(t('chatInput.permissionMode.noSession')); + return; + } + const previousMode = sessionPermissionMode; + setSessionPermissionMode(nextMode); setPermissionModeSaving(true); try { - const saved = await permissionConfigService.saveConfig(nextConfig); - setToolPermissionConfig(saved); + const response = await agentAPI.updateSessionPermissionMode({ + sessionId: effectiveTargetSessionId, + mode: nextMode, + workspacePath: effectiveTargetSession?.workspacePath, + remoteConnectionId: effectiveTargetSession?.remoteConnectionId, + remoteSshHost: effectiveTargetSession?.remoteSshHost, + }); + setSessionPermissionMode(response.mode ?? null); } catch (error) { - log.error('Failed to change permission mode', error); - setToolPermissionConfig(previousConfig); + log.error('Failed to change session permission mode', error); + setSessionPermissionMode(previousMode); notificationService.error(t('chatInput.permissionMode.changeFailed')); } finally { setPermissionModeSaving(false); } - }, [isAcpTargetSession, permissionModeSaving, t, toolPermissionConfig]); + }, [ + effectiveTargetSessionId, + effectiveTargetSession?.workspacePath, + effectiveTargetSession?.remoteConnectionId, + effectiveTargetSession?.remoteSshHost, + sessionPermissionMode, + t, + ]); + + // Full access is the one mode worth a confirmation in either scope: a + // one-off turn still runs every tool without asking. + const confirmFullAccessIfNeeded = useCallback(async ( + nextMode: Exclude, + scope: 'session' | 'turn', + ) => { + if (nextMode !== 'full_access') return true; + return confirmDanger( + t('chatInput.permissionMode.fullAccessWarningTitle'), + t(scope === 'turn' + ? 'chatInput.permissionMode.fullAccessWarningMessageNextTurn' + : 'chatInput.permissionMode.fullAccessWarningMessage'), + { + confirmText: t('chatInput.permissionMode.fullAccessConfirm'), + cancelText: t('chatInput.permissionMode.cancel'), + }, + ); + }, [t]); + + /** Writes the session's own mode. */ + const handlePermissionModeChange = useCallback(async ( + nextMode: Exclude, + ) => { + if (permissionModeSaving || isAcpTargetSession) return; + if (!(await confirmFullAccessIfNeeded(nextMode, 'session'))) return; + const backendMode = toBackendPermissionMode( + nextMode as Exclude, + ); + // An armed one-off would otherwise keep masking the session mode the user + // just chose, making the write look like it did nothing. + setTurnPermissionMode(null); + await applySessionPermissionMode(backendMode); + }, [ + applySessionPermissionMode, + confirmFullAccessIfNeeded, + isAcpTargetSession, + permissionModeSaving, + ]); + + /** + * Arms a mode for the next submission without touching the session. Picking + * the already-armed mode disarms it, so an accidental arm is undoable + * without disturbing the session's own selection. + */ + const handlePermissionModeForNextTurn = useCallback(async ( + nextMode: Exclude, + ) => { + if (permissionModeSaving || isAcpTargetSession) return; + const backendMode = toBackendPermissionMode( + nextMode as Exclude, + ); + if (turnPermissionMode === backendMode) { + setTurnPermissionMode(null); + return; + } + if (!(await confirmFullAccessIfNeeded(nextMode, 'turn'))) return; + setTurnPermissionMode(backendMode); + }, [confirmFullAccessIfNeeded, isAcpTargetSession, permissionModeSaving, turnPermissionMode]); + + // The reset row follows the user-level default, so give it a way to reach the + // page that owns that default instead of making the user hunt for it. + const handleOpenPermissionDefaultSettings = useCallback(() => { + useSettingsStore.getState().setActiveTab('session-permissions'); + openScene('settings'); + }, [openScene]); + + const handleResetPermissionModeToDefault = useCallback(async () => { + if (permissionModeSaving || isAcpTargetSession) return; + // Drop the armed one-off first; otherwise it would keep masking the + // session mode the user just asked to restore. + setTurnPermissionMode(null); + if (sessionPermissionMode === null) return; + await applySessionPermissionMode(null); + }, [applySessionPermissionMode, isAcpTargetSession, permissionModeSaving, sessionPermissionMode]); const dispatchPermissionMode: ChatInputPermissionMode = effectiveTargetSession?.config.dispatchApprovalPolicy === 'auto' @@ -5990,7 +6118,23 @@ export const ChatInput: React.FC = ({ : { mode: permissionMode, saving: permissionModeSaving, + scopeLabel: turnPermissionMode + ? t('chatInput.permissionMode.turnScope') + : t('chatInput.permissionMode.sessionScope'), + overridden: permissionModeOverridden, + nextTurnMode: turnPermissionMode + ? chatInputPermissionMode(turnPermissionMode) + : null, + onChangeForNextTurn: isAcpTargetSession + ? undefined + : handlePermissionModeForNextTurn, onChange: isAcpTargetSession ? undefined : handlePermissionModeChange, + onResetToDefault: isAcpTargetSession + ? undefined + : handleResetPermissionModeToDefault, + onOpenDefaultSettings: isAcpTargetSession + ? undefined + : handleOpenPermissionDefaultSettings, onHide: isAcpTargetSession ? undefined : handleHidePermissionModeControl, } : undefined} diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.appearance.ts b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.appearance.ts index 29e537134e..90c672862f 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.appearance.ts +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.appearance.ts @@ -4,10 +4,13 @@ export const chatInputWorkspaceStripAppearanceDescriptor: AppearanceSurfaceDescr parts: [ { id: 'root' }, { id: 'main' }, { id: 'workspace' }, { id: 'branch' }, { id: 'actions' }, { id: 'permission' }, { id: 'permissionMenu' }, - { id: 'permissionOptions' }, { id: 'permissionOption' }, { id: 'usageAction' }, + { id: 'permissionOptions' }, { id: 'permissionOptionRow' }, + { id: 'permissionOption' }, { id: 'permissionOptionTrailing' }, + { id: 'permissionOptionNextTurn' }, { id: 'usageAction' }, ], states: [ { id: 'open', selector: { kind: 'self', suffix: '[data-bf-state~="open"]' } }, { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + { id: 'armed', selector: { kind: 'self', suffix: '[data-bf-state~="armed"]' } }, ], }; diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss index 759f425def..197ff30678 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss @@ -209,11 +209,179 @@ white-space: nowrap; } + // Scope is chosen per click: the row body writes the session, the trailing + // button arms the same mode for one submission only. + // The row owns the background so hover and selection cover the trailing + // controls as well; the inner button stays transparent. + &__permission-option-row { + display: flex; + align-items: center; + gap: 4px; + padding-right: 8px; + border-radius: $size-radius-sm; + + &:hover, + &:focus-within { + background: var(--bf-appearance-token-element-bg-medium); + } + + &--selected { + background: color-mix(in srgb, var(--bf-appearance-token-color-accent-500) 9%, transparent); + + &:hover, + &:focus-within { + background: color-mix(in srgb, var(--bf-appearance-token-color-accent-500) 14%, transparent); + } + } + } + + &__permission-option-trailing { + display: flex; + flex: none; + align-items: center; + min-width: 16px; + justify-content: flex-end; + } + + &__permission-option-check { + flex: none; + color: var(--bf-appearance-token-color-accent-500); + } + + // Session state and a temporary override share one slot, so the checkmark + // steps aside exactly when the one-off control claims it. + &__permission-option-row:hover &__permission-option-check, + &__permission-option-row:focus-within &__permission-option-check, + &__permission-option-row--armed &__permission-option-check { + display: none; + } + + &__permission-option-icon { + flex: none; + + &--ask { + color: var(--bf-appearance-token-color-success); + } + + &--auto { + color: var(--bf-appearance-token-color-warning); + } + + &--full_access { + color: var(--bf-appearance-token-color-error); + } + } + + // Hidden until the row is hovered or focused so the menu stays compact; an + // armed one stays visible because a pending override must not be invisible. + &__permission-option-next-turn { + flex: none; + display: none; + padding: 1px 5px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--bf-appearance-token-color-text-muted); + font: inherit; + font-size: var(--bf-appearance-token-flowchat-font-size-xxs); + line-height: 1.4; + white-space: nowrap; + cursor: pointer; + + &:hover:not(:disabled), + &:focus-visible { + color: var(--bf-appearance-token-color-text-primary); + background: var(--bf-appearance-token-element-bg-medium); + outline: none; + } + + &:disabled { + cursor: default; + opacity: 0.5; + } + + &--armed { + display: inline-flex; + color: var(--bf-appearance-token-color-accent-500); + font-weight: 600; + } + } + + &__permission-option-row:hover &__permission-option-next-turn, + &__permission-option-row:focus-within &__permission-option-next-turn { + display: inline-flex; + } + + // Marks a pending one-off override. A session-level choice is not dotted: + // the trigger label already names it, and it is not waiting to expire. + &__permission-next-turn-dot { + flex: none; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--bf-appearance-token-color-accent-500); + } + + // The reset row keeps a trailing shortcut into the settings page that owns + // the default it follows, revealed on hover so the menu stays quiet. + &__permission-action-row { + display: flex; + align-items: center; + gap: 2px; + padding-right: 6px; + border-radius: $size-radius-sm; + + &:hover, + &:focus-within { + background: var(--bf-appearance-token-element-bg-medium); + } + + .bitfun-chat-input-workspace-strip__permission-visibility-action { + flex: 1 1 auto; + padding-right: 0; + + &:hover, + &:focus-visible { + background: transparent; + } + } + } + + &__permission-action-settings { + flex: none; + display: none; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--bf-appearance-token-color-text-muted); + cursor: pointer; + + &:hover, + &:focus-visible { + color: var(--bf-appearance-token-color-text-primary); + background: var(--bf-appearance-token-element-bg-strong); + outline: none; + } + } + + &__permission-action-row:hover &__permission-action-settings, + &__permission-action-row:focus-within &__permission-action-settings { + display: inline-flex; + } + &__permission-menu { position: fixed; z-index: $z-popover; box-sizing: border-box; - width: min(286px, calc(100vw - 16px)); + // Sized to the widest row now that mode descriptions are tooltips: the + // header's scope label (longest for dispatched sessions) rather than the + // option rows, which are single-line. + width: min(240px, calc(100vw - 16px)); max-height: calc(100vh - 16px); overflow-y: auto; padding: 6px; @@ -239,7 +407,20 @@ font-weight: 600; letter-spacing: 0; + // A longer localized scope label truncates instead of wrapping the header + // onto a second line and reopening the width the tooltips just reclaimed. + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + span:first-child { + flex: none; + } + span:last-child { + min-width: 0; color: var(--bf-appearance-token-color-text-muted); font-weight: 400; } @@ -258,13 +439,13 @@ } &__permission-option { - display: grid; - grid-template-columns: minmax(0, 1fr) 16px; + display: flex; + flex: 1 1 auto; align-items: center; - gap: 10px; - width: 100%; - min-height: 48px; - padding: 7px 8px; + gap: 7px; + min-width: 0; + min-height: 30px; + padding: 5px 0 5px 8px; border: 0; border-radius: $size-radius-sm; background: transparent; @@ -274,46 +455,25 @@ text-align: left; cursor: pointer; - &:hover:not(:disabled), &:focus-visible { - background: var(--bf-appearance-token-element-bg-medium); outline: none; } - &--selected { - background: color-mix(in srgb, var(--bf-appearance-token-color-accent-500) 9%, transparent); - - > svg { - color: var(--bf-appearance-token-color-accent-500); - } - } - &:disabled { cursor: wait; opacity: 0.6; } } - &__permission-option-copy { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; - } - &__permission-option-label { + min-width: 0; + overflow: hidden; color: var(--bf-appearance-token-color-text-primary); font-size: var(--bf-appearance-token-flowchat-font-size-xs); font-weight: 500; line-height: 1.25; - } - - &__permission-option-description { - color: var(--bf-appearance-token-color-text-muted); - font-size: var(--bf-appearance-token-flowchat-font-size-xxs); - font-weight: 400; - line-height: 1.35; - white-space: normal; + text-overflow: ellipsis; + white-space: nowrap; } &__permission-visibility-action { diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx index c055cc6137..9a0920f504 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx @@ -155,6 +155,293 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { expect(document.querySelector('[data-testid="chat-input-permission-menu"]')).toBeNull(); }); + it('chooses the scope per click instead of through a separate toggle', async () => { + const onChange = vi.fn(); + const onChangeForNextTurn = vi.fn(); + await act(async () => { + root.render( + + ); + }); + + const trigger = container.querySelector( + '[data-testid="chat-input-permission-trigger"]', + ); + await act(async () => { + trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // The row body is the session scope. + await act(async () => { + document + .querySelector('[data-testid="chat-input-permission-option-auto"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(onChange).toHaveBeenCalledWith('auto'); + expect(onChangeForNextTurn).not.toHaveBeenCalled(); + + // The trailing button is the one-off scope, and never writes the session. + await act(async () => { + trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await act(async () => { + document + .querySelector( + '[data-testid="chat-input-permission-next-turn-full_access"]', + ) + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(onChangeForNextTurn).toHaveBeenCalledWith('full_access'); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('keeps the mode descriptions out of the row and in the accessible name', async () => { + await act(async () => { + root.render( + + ); + }); + await act(async () => { + container + .querySelector('[data-testid="chat-input-permission-trigger"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + const option = document.querySelector( + '[data-testid="chat-input-permission-option-ask"]', + ); + // The row itself stays single-line; the description lives in the tooltip. + expect(option?.textContent).toBe('chatInput.permissionMode.ask.label'); + expect(option?.getAttribute('aria-label')).toContain( + 'chatInput.permissionMode.ask.description', + ); + }); + + it('marks the armed one-off mode and omits the affordance without a handler', async () => { + await act(async () => { + root.render( + + ); + }); + + const trigger = container.querySelector( + '[data-testid="chat-input-permission-trigger"]', + ); + expect(trigger?.dataset.permissionOverridden).toBe('true'); + await act(async () => { + trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect( + document + .querySelector('[data-testid="chat-input-permission-next-turn-full_access"]') + ?.getAttribute('aria-checked'), + ).toBe('true'); + expect( + document + .querySelector('[data-testid="chat-input-permission-next-turn-ask"]') + ?.getAttribute('aria-checked'), + ).toBe('false'); + + // Surfaces without a one-off handler (dispatch, ACP) keep the plain rows. + await act(async () => { + root.render( + + ); + }); + await act(async () => { + container + .querySelector('[data-testid="chat-input-permission-trigger"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect( + document.querySelector('[data-testid="chat-input-permission-next-turn-ask"]'), + ).toBeNull(); + }); + + it('separates the session checkmark from an armed one-off override', async () => { + await act(async () => { + root.render( + + ); + }); + + // The trigger reports what the next submission will run with. + const trigger = container.querySelector( + '[data-testid="chat-input-permission-trigger"]', + ); + expect(trigger?.dataset.permissionMode).toBe('full_access'); + + await act(async () => { + trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // The checkmark stays on the session's own mode, not the armed one. + expect( + document.querySelector('[data-testid="chat-input-permission-selected-auto"]'), + ).not.toBeNull(); + expect( + document.querySelector('[data-testid="chat-input-permission-selected-full_access"]'), + ).toBeNull(); + expect( + document + .querySelector('[data-testid="chat-input-permission-next-turn-full_access"]') + ?.getAttribute('aria-checked'), + ).toBe('true'); + }); + + it('marks a session-scoped override and offers a reset to the default', async () => { + const onResetToDefault = vi.fn(); + await act(async () => { + root.render( + + ); + }); + + const trigger = container.querySelector( + '[data-testid="chat-input-permission-trigger"]', + ); + expect(trigger?.dataset.permissionOverridden).toBe('true'); + // A session-level choice is not pending, so it gets no dot; the trigger + // label already names the mode it runs with. + expect( + container.querySelector('[data-testid="chat-input-permission-next-turn-dot"]'), + ).toBeNull(); + + await act(async () => { + trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(document.body.textContent).toContain('This session'); + + await act(async () => { + document + .querySelector('[data-testid="chat-input-permission-reset-default"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(onResetToDefault).toHaveBeenCalledOnce(); + }); + + it('dots the trigger only while a one-off override is pending', async () => { + const onOpenDefaultSettings = vi.fn(); + await act(async () => { + root.render( + + ); + }); + + const trigger = container.querySelector( + '[data-testid="chat-input-permission-trigger"]', + ); + expect(trigger?.dataset.permissionNextTurn).toBe('true'); + expect( + container.querySelector('[data-testid="chat-input-permission-next-turn-dot"]'), + ).not.toBeNull(); + + // The reset row reaches the settings page that owns the default. + await act(async () => { + trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await act(async () => { + document + .querySelector( + '[data-testid="chat-input-permission-open-default-settings"]', + ) + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(onOpenDefaultSettings).toHaveBeenCalledOnce(); + }); + + it('hides the override affordances when the session follows the default', async () => { + await act(async () => { + root.render( + + ); + }); + + const trigger = container.querySelector( + '[data-testid="chat-input-permission-trigger"]', + ); + expect(trigger?.dataset.permissionOverridden).toBeUndefined(); + expect( + container.querySelector('[data-testid="chat-input-permission-next-turn-dot"]'), + ).toBeNull(); + + await act(async () => { + trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect( + document.querySelector('[data-testid="chat-input-permission-reset-default"]'), + ).toBeNull(); + }); + it('shows ACP ownership without exposing native permission choices', async () => { await act(async () => { root.render( diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx index ee2cf7a3db..d0ab7f52e7 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx @@ -11,6 +11,7 @@ import { EyeOff, GitBranch, RefreshCw, + Settings, Shield, ShieldAlert, ShieldCheck, @@ -46,14 +47,40 @@ export interface ChatInputWorkspaceStripProps { goal: ThreadGoalSnapshot | null; onOpen: () => void; }; - /** Global native-tool permission mode exposed as a compact strip control. */ + /** Native-tool permission mode for this session, exposed as a compact strip control. */ permissionControl?: { + /** + * The session-scoped mode. This is what the checkmark marks, so it stays + * separate from `nextTurnMode`: one is session state, the other a temporary + * override, and conflating them would make the menu lie about which is + * which once a one-off is armed. + */ mode: ChatInputPermissionMode; saving?: boolean; disabled?: boolean; options?: Array>; scopeLabel?: string; + /** + * The session chose its own mode instead of following the default. Shown so + * two sessions sitting on different modes is legible rather than confusing. + */ + overridden?: boolean; + /** Clears the session's own selection and follows the default again. */ + onResetToDefault?: () => void | Promise; + /** Opens the settings page that owns the default this row follows. */ + onOpenDefaultSettings?: () => void; + /** + * Mode armed for the next submission only, or `null` when none is. + * Scope is chosen per click rather than by a separate toggle, so a mode + * can never be written to the session by one click and then be "corrected" + * to one-off by a later one. + */ + nextTurnMode?: ChatInputPermissionMode | null; onChange?: (mode: Exclude) => void | Promise; + /** Arms the mode for the next submission; re-picking the armed one disarms it. */ + onChangeForNextTurn?: ( + mode: Exclude, + ) => void | Promise; onHide?: () => void | Promise; }; /** Keep the strip on cached Git state while historical content is still restoring. */ @@ -96,6 +123,18 @@ const NATIVE_PERMISSION_MODES: Array = { + ask: Shield, + auto: ShieldCheck, + full_access: ShieldAlert, + reject: Shield, + acp: Shield, +}; + export const ChatInputWorkspaceStrip: React.FC = ({ repositoryPath, workspaceLabel, @@ -259,15 +298,23 @@ export const ChatInputWorkspaceStrip: React.FC = ( permissionControl?.disabled || permissionControl?.saving || permissionMode === 'acp'; - const permissionModeLabel = permissionCopy[permissionMode].label; + const permissionOverridden = !!permissionControl?.overridden && permissionMode !== 'acp'; + const permissionNextTurnMode = permissionMode === 'acp' + ? null + : permissionControl?.nextTurnMode ?? null; + const permissionNextTurnArmed = permissionNextTurnMode !== null; + // The trigger reports what the next submission will actually run with, so an + // armed one-off outranks the session mode there. + const permissionDisplayMode = permissionNextTurnMode ?? permissionMode; + const permissionModeLabel = permissionCopy[permissionDisplayMode].label; const permissionTooltip = permissionMode === 'acp' ? t('chatInput.permissionMode.acp.tooltip') - : t('chatInput.permissionMode.current', { mode: permissionModeLabel }); - const PermissionIcon = permissionMode === 'auto' - ? ShieldCheck - : permissionMode === 'full_access' - ? ShieldAlert - : Shield; + : permissionNextTurnArmed + ? t('chatInput.permissionMode.currentTurnOverride', { mode: permissionModeLabel }) + : permissionOverridden + ? t('chatInput.permissionMode.currentSessionOverride', { mode: permissionModeLabel }) + : t('chatInput.permissionMode.current', { mode: permissionModeLabel }); + const PermissionIcon = PERMISSION_MODE_ICONS[permissionDisplayMode]; const showPermissionLabel = permissionMode !== 'acp'; const handleWorktreeToggle = () => { @@ -412,7 +459,7 @@ export const ChatInputWorkspaceStrip: React.FC = ( type="button" className={[ 'bitfun-chat-input-workspace-strip__permission-trigger', - `bitfun-chat-input-workspace-strip__permission-trigger--${permissionMode}`, + `bitfun-chat-input-workspace-strip__permission-trigger--${permissionDisplayMode}`, permissionMenuOpen && 'bitfun-chat-input-workspace-strip__permission-trigger--open', ] .filter(Boolean) @@ -422,7 +469,9 @@ export const ChatInputWorkspaceStrip: React.FC = ( aria-expanded={permissionDisabled ? undefined : permissionMenuOpen} disabled={permissionDisabled} data-testid="chat-input-permission-trigger" - data-permission-mode={permissionMode} + data-permission-mode={permissionDisplayMode} + data-permission-overridden={permissionOverridden ? 'true' : undefined} + data-permission-next-turn={permissionNextTurnArmed ? 'true' : undefined} onClick={event => { event.stopPropagation(); if (!permissionDisabled) { @@ -436,6 +485,16 @@ export const ChatInputWorkspaceStrip: React.FC = ( {permissionModeLabel} ) : null} + {/* Only a one-off override gets a dot: a session-level choice + is already legible from the label the trigger shows, and + marking both made every customized session look pending. */} + {permissionNextTurnArmed ? ( + + ) : null} @@ -466,42 +525,153 @@ export const ChatInputWorkspaceStrip: React.FC = ( {permissionModes.map(mode => { const selected = permissionMode === mode; const copy = permissionCopy[mode]; + const armed = permissionNextTurnMode === mode; + const OptionIcon = PERMISSION_MODE_ICONS[mode]; return ( - + + {/* One trailing slot: the checkmark reports session + state, the one-off chip reports a temporary + override, and they never claim it together. */} + + {selected ? ( + + ) : null} + {permissionControl.onChangeForNextTurn ? ( + + + + ) : null} + + + ); + })} + + {permissionOverridden && permissionControl.onResetToDefault ? ( + <> +
+
+ - ); - })} -
+ {permissionControl.onOpenDefaultSettings ? ( + + + + ) : null} +
+ + ) : null} {permissionControl.onHide ? ( <>
diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts index 3654f48f07..a4d4065229 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts @@ -39,7 +39,7 @@ describe('ChatInputWorkspaceStrip layout styles', () => { expect(stylesheet).toContain('&--ask {'); expect(stylesheet).toContain('border-color: var(--bf-appearance-token-color-success-border);'); expect(stylesheet).toContain('background: var(--bf-appearance-token-color-success-bg);'); - expect(stylesheet).toContain('width: min(286px, calc(100vw - 16px));'); + expect(stylesheet).toContain('width: min(240px, calc(100vw - 16px));'); expect(stylesheet).toContain('@media (max-width: 560px)'); expect(stylesheet).toContain('&__permission-label'); expect(stylesheet).toContain('display: none;'); diff --git a/src/web-ui/src/flow_chat/hooks/useMessageSender.ts b/src/web-ui/src/flow_chat/hooks/useMessageSender.ts index 793a924099..54be293991 100644 --- a/src/web-ui/src/flow_chat/hooks/useMessageSender.ts +++ b/src/web-ui/src/flow_chat/hooks/useMessageSender.ts @@ -24,7 +24,10 @@ import { composerPresentationSessionReferences, type ComposerPresentation, } from '../utils/composerPresentation'; -import type { AgentDialogTurnExecution } from '@/infrastructure/api/service-api/AgentAPI'; +import type { + AgentDialogTurnExecution, + SessionPermissionMode, +} from '@/infrastructure/api/service-api/AgentAPI'; const log = createLogger('FlowChat'); @@ -53,6 +56,14 @@ interface UseMessageSenderProps { message: string; contextIds: string[]; }) => void; + /** + * One-off permission mode armed for the next submission only. It outranks the + * session's own mode for that turn and is never persisted, so the session + * returns to its own selection afterwards. + */ + turnPermissionMode?: SessionPermissionMode | null; + /** Disarms the one-off mode once a submission has carried it. */ + onTurnPermissionModeConsumed?: () => void; } interface UseMessageSenderReturn { @@ -79,6 +90,8 @@ export function useMessageSender(props: UseMessageSenderProps): UseMessageSender currentAgentType, onSessionConflictRetryStart, onSessionConflictRetrySuccess, + turnPermissionMode, + onTurnPermissionModeConsumed, } = props; const sendMessage = useCallback(async ( @@ -152,12 +165,15 @@ export function useMessageSender(props: UseMessageSenderProps): UseMessageSender remoteSshHost: context.remoteSshHost, })); const userMessageMetadata = - options?.composerPresentation || sessionReferences.length > 0 + options?.composerPresentation || sessionReferences.length > 0 || turnPermissionMode ? { ...(options?.composerPresentation ? { composerPresentation: options.composerPresentation } : {}), ...(sessionReferences.length > 0 ? { sessionReferences } : {}), + // Read by the coordinator as the turn layer of + // `turn -> session -> global default`. + ...(turnPermissionMode ? { permission_mode: turnPermissionMode } : {}), } : undefined; let imagePayload: Awaited>; @@ -223,6 +239,12 @@ export function useMessageSender(props: UseMessageSenderProps): UseMessageSender onClearContexts(); + // The one-off mode belongs to the submission that just left, not to the + // next one the user types. + if (turnPermissionMode) { + onTurnPermissionModeConsumed?.(); + } + onExitTemplateMode?.(); onSuccess?.(trimmedMessage); @@ -250,6 +272,8 @@ export function useMessageSender(props: UseMessageSenderProps): UseMessageSender currentAgentType, onSessionConflictRetryStart, onSessionConflictRetrySuccess, + turnPermissionMode, + onTurnPermissionModeConsumed, ]); return { diff --git a/src/web-ui/src/flow_chat/utils/permissionMode.test.ts b/src/web-ui/src/flow_chat/utils/permissionMode.test.ts new file mode 100644 index 0000000000..087263f110 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/permissionMode.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { + chatInputPermissionMode, + permissionModeFromConfig, + permissionModeToConfig, + sessionPermissionMode, +} from './permissionMode'; +import type { ToolPermissionConfig } from '@/infrastructure/config/types'; + +function config( + preset: ToolPermissionConfig['policy']['preset'], + autoApproveAsk: boolean, +): ToolPermissionConfig { + return { + policy: { preset, rules: [] }, + interaction: { auto_approve_ask: autoApproveAsk }, + }; +} + +describe('permissionMode', () => { + it('maps between the control naming and the backend naming', () => { + expect(chatInputPermissionMode('ask')).toBe('ask'); + expect(chatInputPermissionMode('auto_approve')).toBe('auto'); + expect(chatInputPermissionMode('full_access')).toBe('full_access'); + + expect(sessionPermissionMode('ask')).toBe('ask'); + expect(sessionPermissionMode('auto')).toBe('auto_approve'); + expect(sessionPermissionMode('full_access')).toBe('full_access'); + }); + + it('round trips every mode through the control naming', () => { + for (const mode of ['ask', 'auto_approve', 'full_access'] as const) { + expect(sessionPermissionMode(chatInputPermissionMode(mode))).toBe(mode); + } + }); + + it('derives the mode a stored configuration represents', () => { + expect(permissionModeFromConfig(config('ask', false))).toBe('ask'); + expect(permissionModeFromConfig(config('ask', true))).toBe('auto_approve'); + // Full access already resolves every ask, so it outranks auto approval. + expect(permissionModeFromConfig(config('full_access', true))).toBe('full_access'); + }); + + it('projects a mode back onto both configuration knobs', () => { + const base = config('ask', true); + + expect(permissionModeToConfig(base, 'ask')).toMatchObject({ + policy: { preset: 'ask' }, + interaction: { auto_approve_ask: false }, + }); + expect(permissionModeToConfig(base, 'auto_approve')).toMatchObject({ + policy: { preset: 'ask' }, + interaction: { auto_approve_ask: true }, + }); + expect(permissionModeToConfig(base, 'full_access')).toMatchObject({ + policy: { preset: 'full_access' }, + interaction: { auto_approve_ask: false }, + }); + }); + + it('round trips a configuration through the mode it represents', () => { + for (const stored of [config('ask', false), config('ask', true), config('full_access', false)]) { + const mode = permissionModeFromConfig(stored); + expect(permissionModeFromConfig(permissionModeToConfig(stored, mode))).toBe(mode); + } + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/permissionMode.ts b/src/web-ui/src/flow_chat/utils/permissionMode.ts new file mode 100644 index 0000000000..50f2fac143 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/permissionMode.ts @@ -0,0 +1,50 @@ +import type { ToolPermissionConfig } from '@/infrastructure/config/types'; +import type { SessionPermissionMode } from '@/infrastructure/api/service-api/AgentAPI'; +import type { ChatInputPermissionMode } from '../components/ChatInputWorkspaceStrip'; + +/** + * The chat input control and the backend name the same three modes slightly + * differently: the control has carried `auto` since before the backend had a + * single mode value, and the backend spells it `auto_approve`. These helpers + * keep that one difference in one place instead of at every call site. + */ + +type NativePermissionMode = Exclude; + +/** Backend mode -> chat input control mode. */ +export function chatInputPermissionMode(mode: SessionPermissionMode): NativePermissionMode { + return mode === 'auto_approve' ? 'auto' : mode; +} + +/** Chat input control mode -> backend mode. */ +export function sessionPermissionMode(mode: NativePermissionMode): SessionPermissionMode { + return mode === 'auto' ? 'auto_approve' : mode; +} + +/** + * Derives the mode a stored configuration represents. + * + * Mirrors `PermissionMode::from_config`: full access already resolves every + * ask, so it outranks the auto-approve preference. + */ +export function permissionModeFromConfig(config: ToolPermissionConfig): SessionPermissionMode { + if (config.policy.preset === 'full_access') return 'full_access'; + return config.interaction.auto_approve_ask ? 'auto_approve' : 'ask'; +} + +/** Projects a mode back onto the two stored configuration knobs. */ +export function permissionModeToConfig( + config: ToolPermissionConfig, + mode: SessionPermissionMode, +): ToolPermissionConfig { + return { + policy: { + ...config.policy, + preset: mode === 'full_access' ? 'full_access' : 'ask', + }, + interaction: { + ...config.interaction, + auto_approve_ask: mode === 'auto_approve', + }, + }; +} diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index ce019e6662..5a8d5d4a83 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -322,6 +322,23 @@ export interface UpdateSessionModelRequest { includeInternal?: boolean; } +/** `ask` | `auto_approve` | `full_access`; `null` clears the session override. */ +export type SessionPermissionMode = 'ask' | 'auto_approve' | 'full_access'; + +export interface SessionPermissionModeRequest { + sessionId: string; + /** Omit or pass null to clear the override and follow the global default. */ + mode?: SessionPermissionMode | null; + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; + includeInternal?: boolean; +} + +export interface SessionPermissionModeResponse { + mode: SessionPermissionMode | null; +} + export interface UpdateSessionModeRequest { sessionId: string; modeId: string; @@ -972,6 +989,38 @@ export class AgentAPI { } } + /** + * Sets the tool permission mode for one session. Other open sessions keep + * their own selection; passing no mode returns this session to the + * user-level default. + */ + async updateSessionPermissionMode( + request: SessionPermissionModeRequest, + ): Promise { + try { + return await api.invoke( + 'update_session_permission_mode', + { request }, + ); + } catch (error) { + throw createTauriCommandError('update_session_permission_mode', error, request); + } + } + + /** Reads a session's own permission mode; `null` means it follows the default. */ + async getSessionPermissionMode( + request: SessionPermissionModeRequest, + ): Promise { + try { + return await api.invoke( + 'get_session_permission_mode', + { request }, + ); + } catch (error) { + throw createTauriCommandError('get_session_permission_mode', error, request); + } + } + async updateSessionMode(request: UpdateSessionModeRequest): Promise { try { await api.invoke('update_session_mode', { request }); diff --git a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx index 48220465d2..3fecf2894f 100644 --- a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx @@ -1118,11 +1118,11 @@ const SessionSettingsPanels: React.FC = ({ variant } >
diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 97fc9ed608..66607ba893 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -709,9 +709,19 @@ "tooltip": "Permissions for this session are controlled by the ACP client." }, "fullAccessWarningTitle": "Enable full access?", - "fullAccessWarningMessage": "Full access allows tools by default without asking each time.", + "fullAccessWarningMessage": "Full access allows tools by default without asking each time. This applies to the current session only; project and enforced rules still apply.", "fullAccessConfirm": "Enable full access", - "cancel": "Cancel" + "cancel": "Cancel", + "sessionScope": "This session", + "currentSessionOverride": "Permissions: {{mode}} (this session only)", + "resetToDefault": "Follow the default mode", + "noSession": "Open or start a session before changing its permission mode.", + "turnScope": "Next message only", + "currentTurnOverride": "Permissions: {{mode}} (next message only)", + "nextTurnOnly": "Use {{mode}} for the next message only", + "nextTurnOnlyShort": "Once", + "fullAccessWarningMessageNextTurn": "Full access allows tools by default without asking each time. This applies to the next message only; project and enforced rules still apply.", + "openDefaultSettings": "Open permission settings" }, "addModeTooltip": "Add Plan or Debug", "boostSectionAgent": "Agent", diff --git a/src/web-ui/src/locales/en-US/settings/session-config.json b/src/web-ui/src/locales/en-US/settings/session-config.json index 2583345f87..782d69696a 100644 --- a/src/web-ui/src/locales/en-US/settings/session-config.json +++ b/src/web-ui/src/locales/en-US/settings/session-config.json @@ -47,8 +47,8 @@ }, "permissionPolicy": { "sectionTitle": "Tool permissions", - "sectionDescription": "Choose the default tool access policy and how permission prompts are handled.", - "mode": "Permission mode", + "sectionDescription": "Choose the default tool access policy for sessions and how permission prompts are handled.", + "mode": "Default permission mode", "ask": "Ask for confirmation", "askDescription": "External access, file changes, and command execution require confirmation.", "fullAccess": "Full access", @@ -60,7 +60,7 @@ "autoApprove": "Auto approve", "autoApproveDescription": "Automatically approve requests that require confirmation.", "showInChatInput": "Show permission mode selector", - "showInChatInputDescription": "Show the selector below the chat input. Hiding it does not change the current permission mode.", + "showInChatInputDescription": "Show the selector below the chat input, where the mode applies to the current session only. Hiding it does not change any session's permission mode.", "globalRules": "Global rules", "globalRulesDescription": "Define user-level rules that apply after the selected mode and before project and Agent rules.", "manageGlobalRules": "Manage rules", @@ -78,7 +78,8 @@ "removeGlobalRule": "Remove rule", "discardGlobalRules": "Discard changes", "saveGlobalRules": "Save rules", - "globalRulesEffects": { "allow": "Allow", "ask": "Ask", "deny": "Deny" } + "globalRulesEffects": { "allow": "Allow", "ask": "Ask", "deny": "Deny" }, + "modeDescription": "Applies to sessions that have not chosen their own mode. Each session can override it from the chat input." }, "projectPermissions": { "description": "Always allow decisions are saved as remembered grants for the current project; static project rules apply before a tool runs.", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 9d1f686bfb..8784928c36 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -709,9 +709,19 @@ "tooltip": "此会话的权限由 ACP 客户端控制。" }, "fullAccessWarningTitle": "启用完全访问?", - "fullAccessWarningMessage": "完全访问会默认允许工具执行,不再逐次确认。", + "fullAccessWarningMessage": "完全访问会默认放行工具调用,不再逐次确认。该设置仅作用于当前会话,项目规则与强制规则仍然生效。", "fullAccessConfirm": "启用完全访问", - "cancel": "取消" + "cancel": "取消", + "sessionScope": "当前会话", + "currentSessionOverride": "权限:{{mode}}(仅当前会话)", + "resetToDefault": "跟随默认模式", + "noSession": "请先打开或新建会话,再修改其权限模式。", + "turnScope": "仅下一条消息", + "currentTurnOverride": "权限:{{mode}}(仅下一条消息)", + "nextTurnOnly": "仅下一条消息使用「{{mode}}」", + "nextTurnOnlyShort": "仅一次", + "fullAccessWarningMessageNextTurn": "完全访问会默认放行工具调用,不再逐次确认。该设置仅作用于下一条消息,项目规则与强制规则仍然生效。", + "openDefaultSettings": "打开权限设置" }, "addModeTooltip": "附加 Plan 或 Debug", "boostSectionAgent": "智能体", diff --git a/src/web-ui/src/locales/zh-CN/settings/session-config.json b/src/web-ui/src/locales/zh-CN/settings/session-config.json index b44001982e..e9a867620d 100644 --- a/src/web-ui/src/locales/zh-CN/settings/session-config.json +++ b/src/web-ui/src/locales/zh-CN/settings/session-config.json @@ -47,8 +47,8 @@ }, "permissionPolicy": { "sectionTitle": "工具权限", - "sectionDescription": "设置默认工具访问策略和权限询问的处理方式。", - "mode": "权限模式", + "sectionDescription": "选择会话的默认工具访问策略,以及权限请求的处理方式。", + "mode": "默认权限模式", "ask": "需要确认", "askDescription": "外部访问,修改文件和执行命令需要确认。", "fullAccess": "完全访问", @@ -60,7 +60,7 @@ "autoApprove": "自动批准", "autoApproveDescription": "自动批准需要确认的请求。", "showInChatInput": "显示权限模式选择器", - "showInChatInputDescription": "在聊天输入框下方显示权限模式选择器。隐藏它不会更改当前权限模式。", + "showInChatInputDescription": "在输入框下方显示选择器,在那里切换的模式仅作用于当前会话。隐藏它不会改变任何会话的权限模式。", "globalRules": "全局规则", "globalRulesDescription": "定义用户级规则;它们在所选模式之后、项目和 Agent 规则之前应用。", "manageGlobalRules": "管理规则", @@ -78,7 +78,8 @@ "removeGlobalRule": "删除规则", "discardGlobalRules": "放弃更改", "saveGlobalRules": "保存规则", - "globalRulesEffects": { "allow": "允许", "ask": "询问", "deny": "拒绝" } + "globalRulesEffects": { "allow": "允许", "ask": "询问", "deny": "拒绝" }, + "modeDescription": "作用于尚未单独设置的会话。每个会话都可以在输入框中覆盖该默认值。" }, "projectPermissions": { "description": "“始终允许”会保存为当前项目的记忆授权;静态项目规则会在工具执行前直接应用。", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 76a016de51..767bde69b2 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -709,9 +709,19 @@ "tooltip": "此工作階段的權限由 ACP 用戶端控制。" }, "fullAccessWarningTitle": "啟用完全存取?", - "fullAccessWarningMessage": "完全存取會預設允許工具執行,不再逐次確認。", + "fullAccessWarningMessage": "完全存取會預設放行工具呼叫,不再逐次確認。此設定僅作用於目前工作階段,專案規則與強制規則仍然生效。", "fullAccessConfirm": "啟用完全存取", - "cancel": "取消" + "cancel": "取消", + "sessionScope": "目前工作階段", + "currentSessionOverride": "權限:{{mode}}(僅目前工作階段)", + "resetToDefault": "跟隨預設模式", + "noSession": "請先開啟或新建工作階段,再變更其權限模式。", + "turnScope": "僅下一則訊息", + "currentTurnOverride": "權限:{{mode}}(僅下一則訊息)", + "nextTurnOnly": "僅下一則訊息使用「{{mode}}」", + "nextTurnOnlyShort": "僅一次", + "fullAccessWarningMessageNextTurn": "完全存取會預設放行工具呼叫,不再逐次確認。此設定僅作用於下一則訊息,專案規則與強制規則仍然生效。", + "openDefaultSettings": "開啟權限設定" }, "addModeTooltip": "附加 Plan 或 Debug", "boostSectionAgent": "智能體", diff --git a/src/web-ui/src/locales/zh-TW/settings/session-config.json b/src/web-ui/src/locales/zh-TW/settings/session-config.json index 9a95a0ca5a..2363f928ac 100644 --- a/src/web-ui/src/locales/zh-TW/settings/session-config.json +++ b/src/web-ui/src/locales/zh-TW/settings/session-config.json @@ -47,8 +47,8 @@ }, "permissionPolicy": { "sectionTitle": "工具權限", - "sectionDescription": "設定預設工具存取策略和權限詢問的處理方式。", - "mode": "權限模式", + "sectionDescription": "選擇工作階段的預設工具存取原則,以及權限請求的處理方式。", + "mode": "預設權限模式", "ask": "需要確認", "askDescription": "外部存取、修改檔案和執行命令需要確認。", "fullAccess": "完全存取", @@ -60,7 +60,7 @@ "autoApprove": "自動批准", "autoApproveDescription": "自動批准需要確認的請求。", "showInChatInput": "顯示權限模式選擇器", - "showInChatInputDescription": "在聊天輸入框下方顯示權限模式選擇器。隱藏它不會變更目前權限模式。", + "showInChatInputDescription": "在輸入框下方顯示選擇器,在該處切換的模式僅作用於目前工作階段。隱藏它不會變更任何工作階段的權限模式。", "globalRules": "全域規則", "globalRulesDescription": "定義使用者級規則;它們在所選模式之後、專案和 Agent 規則之前套用。", "manageGlobalRules": "管理規則", @@ -78,7 +78,8 @@ "removeGlobalRule": "刪除規則", "discardGlobalRules": "放棄變更", "saveGlobalRules": "儲存規則", - "globalRulesEffects": { "allow": "允許", "ask": "詢問", "deny": "拒絕" } + "globalRulesEffects": { "allow": "允許", "ask": "詢問", "deny": "拒絕" }, + "modeDescription": "作用於尚未單獨設定的工作階段。每個工作階段都可以在輸入框中覆寫該預設值。" }, "projectPermissions": { "description": "「始終允許」會儲存為目前專案的記憶授權;靜態專案規則會在工具執行前直接套用。",