diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index 119aa73e1c..8f930db647 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -81,7 +81,6 @@ export const lightweightBoundaryRules = [ 'bitfun-services-integrations', 'bitfun-agent-tools', 'bitfun-tool-packs', - 'bitfun-product-domains', 'bitfun-transport', 'terminal-core', 'tool-runtime', diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index ce898b85bd..3d222bb740 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -1,6 +1,14 @@ // Boundary rules for feature assembly and optional dependency ownership. export const optionalDependencyFeatureOwnerRules = [ + { + crateName: 'runtime-ports', + reason: + 'runtime-ports may expose product-domain permission ports only through the explicit permission contract slice', + dependencies: [ + { depName: 'bitfun-product-domains', ownerFeatures: ['permission'] }, + ], + }, { crateName: 'core', reason: diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 8dc741c792..767114adb8 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -1770,12 +1770,12 @@ export const forbiddenContentRules = [ { regex: /\bpub enum ConfirmationResponse\b/, message: - 'core tool pipeline must not own confirmation channel responses; use bitfun-agent-runtime tool_confirmation', + 'core tool pipeline must not reintroduce legacy confirmation channel responses; use permission requests', }, { regex: /\boneshot::Sender<\s*ConfirmationResponse\s*>/, message: - 'core tool pipeline must not own confirmation wait-channel storage; use bitfun-agent-runtime tool_confirmation', + 'core tool pipeline must not reintroduce legacy confirmation wait-channel storage; use permission requests', }, { regex: /\bArc>\b/, @@ -2508,7 +2508,7 @@ export const forbiddenContentRules = [ { regex: /ToolConfirmationOutcome::(?:Rejected|ChannelClosed|Timeout)/, message: - 'core tool pipeline must not own confirmation wait-result mapping; use bitfun-agent-runtime', + 'core tool pipeline must not reintroduce legacy confirmation wait-result mapping; use permission requests', }, ], }, diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 8c12ca13d3..f3e6270223 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -1822,69 +1822,6 @@ export const requiredContentRules = [ }, ], }, - { - path: 'src/crates/execution/agent-runtime/src/tool_confirmation.rs', - reason: - 'agent-runtime must own portable tool confirmation planning, failure mapping, and wait-channel lifecycle state', - patterns: [ - { - regex: /\bpub struct ToolConfirmationRequestFacts\b/, - message: 'missing tool confirmation request facts', - }, - { - regex: /\bpub struct ToolConfirmationGateFacts\b/, - message: 'missing tool confirmation gate facts', - }, - { - regex: /\bpub enum ToolConfirmationGatePlan\b/, - message: 'missing tool confirmation gate plan', - }, - { - regex: /\bpub enum ToolConfirmationPlan\b/, - message: 'missing tool confirmation plan contract', - }, - { - regex: /\bpub enum ToolConfirmationOutcome\b/, - message: 'missing tool confirmation outcome contract', - }, - { - regex: /\bpub enum ToolConfirmationWaitResult\b/, - message: 'missing tool confirmation wait-result contract', - }, - { - regex: /\bpub enum ToolConfirmationResponse\b/, - message: 'missing tool confirmation channel response', - }, - { - regex: /\bpub enum ConfirmationFailureKind\b/, - message: 'missing tool confirmation failure kind', - }, - { - regex: /\bpub struct ToolConfirmationChannelStore\b/, - message: 'missing tool confirmation channel store', - }, - { - regex: /\bpub fn resolve_tool_confirmation_plan\b/, - message: 'missing tool confirmation plan resolver', - }, - { - regex: /\bpub fn resolve_tool_confirmation_gate\b/, - message: 'missing tool confirmation gate resolver', - }, - { - regex: /\bpub fn resolve_confirmation_failure\b/, - message: 'missing tool confirmation failure resolver', - }, - { - regex: /\bpub fn resolve_confirmation_wait_result\b/, - message: 'missing tool confirmation wait-result resolver', - }, - { - regex: /\bconfirmation_channel_store_delivers_confirmation_once\b/, - message: 'missing confirmation channel delivery regression', - }, - ], - }, { path: 'src/crates/execution/agent-runtime/src/checkpoint.rs', reason: @@ -1908,37 +1845,6 @@ export const requiredContentRules = [ }, ], }, - { - path: 'src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs', - reason: - 'agent-runtime tool confirmation owner must keep behavior-equivalence contracts for legacy permission planning and failures', - patterns: [ - { - regex: /\bconfirmation_plan_requires_permission_only_when_both_flags_are_true\b/, - message: 'missing tool confirmation gate regression', - }, - { - regex: /\bconfirmation_gate_preserves_skip_policy_precedence\b/, - message: 'missing tool confirmation skip-policy regression', - }, - { - regex: /\bconfirmation_gate_requires_confirmation_only_for_permissioned_tools\b/, - message: 'missing tool confirmation permissioned-tool regression', - }, - { - regex: /\bconfirmation_plan_preserves_legacy_no_timeout_one_year_deadline\b/, - message: 'missing tool confirmation no-timeout regression', - }, - { - regex: /\bconfirmation_failure_mapping_preserves_legacy_reasons_and_errors\b/, - message: 'missing tool confirmation failure mapping regression', - }, - { - regex: /\bconfirmation_wait_result_mapping_preserves_legacy_timeout_and_rejection\b/, - message: 'missing tool confirmation wait-result mapping regression', - }, - ], - }, { path: 'src/crates/execution/agent-runtime/src/scheduler.rs', reason: @@ -2791,27 +2697,15 @@ export const requiredContentRules = [ { path: 'src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs', reason: - 'core tool pipeline must delegate portable confirmation planning, failure mapping, and channel ownership to agent-runtime while retaining state/event/tool execution wiring', + 'core tool pipeline must delegate portable cancellation and retry policy while retaining state/event/tool execution wiring', patterns: [ { - regex: /\bresolve_tool_confirmation_plan\b/, - message: 'missing tool confirmation plan delegation', - }, - { - regex: /\bresolve_confirmation_failure\b/, - message: 'missing tool confirmation failure mapping delegation', - }, - { - regex: /\bresolve_confirmation_wait_result\b/, - message: 'missing tool confirmation wait-result mapping delegation', + regex: /\bremote_workspace_route_root_isolated_from_same_local_path\b/, + message: 'missing remote workspace permission identity isolation regression', }, { - regex: /\bToolConfirmationPlan::Await\b/, - message: 'missing tool confirmation await-plan handling', - }, - { - regex: /\bToolConfirmationChannelStore\b/, - message: 'missing tool confirmation channel owner delegation', + regex: /\bonce_and_always_replies_control_execution_and_remembered_grants\b/, + message: 'missing permission project and remote grant isolation regression', }, { regex: /\bToolCancellationTokenStore\b/, @@ -5354,10 +5248,6 @@ export const requiredContentRules = [ regex: /\bpub fn get_tool_spec_is_concurrency_safe\b/, message: 'missing pure GetToolSpec concurrency metadata contract', }, - { - regex: /\bpub fn get_tool_spec_needs_permissions\b/, - message: 'missing pure GetToolSpec permission metadata contract', - }, { regex: /\bpub fn validate_get_tool_spec_input\b/, message: 'missing pure GetToolSpec input validation contract', @@ -6627,10 +6517,6 @@ export const requiredContentRules = [ regex: /\bremote_poll_handler_preserves_missing_workspace_error\b/, message: 'missing remote poll missing-workspace regression', }, - { - regex: /\bremote_interaction_handler_preserves_default_reject_reason\b/, - message: 'missing remote interaction default reject regression', - }, ], }, { @@ -6950,12 +6836,8 @@ export const requiredContentRules = [ { path: 'src/crates/assembly/core/src/agentic/coordination/scheduler.rs', reason: - 'core scheduler keeps remote queue policy semantics until agent-runtime migration is reviewed', + 'core scheduler must keep dialog lifecycle and requester-aware cancellation adapters', patterns: [ - { - regex: /\bremote_queue_policy_preserves_confirmation_boundary\b/, - message: 'missing remote queue policy regression', - }, { regex: /\bimpl AgentDialogTurnPort for DialogScheduler\b/, message: 'missing dialog lifecycle port implementation', diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index c37e1340e3..1c98f7310c 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -1999,25 +1999,6 @@ export function runManifestParserSelfTest({ path: 'src/crates/execution/agent-runtime/tests/post_call_hook_execution_contracts.rs', contracts: ['successful_tool_post_call_executor_runs_deep_review_measurement_route'], }, - { - path: 'src/crates/execution/agent-runtime/src/tool_confirmation.rs', - contracts: [ - 'ToolConfirmationRequestFacts', - 'ToolConfirmationGateFacts', - 'ToolConfirmationGatePlan', - 'ToolConfirmationPlan', - 'ToolConfirmationOutcome', - 'ToolConfirmationWaitResult', - 'ToolConfirmationResponse', - 'ToolConfirmationChannelStore', - 'ConfirmationFailureKind', - 'resolve_tool_confirmation_gate', - 'resolve_tool_confirmation_plan', - 'resolve_confirmation_failure', - 'resolve_confirmation_wait_result', - 'confirmation_channel_store_delivers_confirmation_once', - ], - }, { path: 'src/crates/execution/agent-runtime/src/user_questions.rs', contracts: [ @@ -2154,17 +2135,6 @@ export function runManifestParserSelfTest({ 'ShellType::Custom(name)', ], }, - { - path: 'src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs', - contracts: [ - 'confirmation_gate_preserves_skip_policy_precedence', - 'confirmation_gate_requires_confirmation_only_for_permissioned_tools', - 'confirmation_plan_requires_permission_only_when_both_flags_are_true', - 'confirmation_plan_preserves_legacy_no_timeout_one_year_deadline', - 'confirmation_failure_mapping_preserves_legacy_reasons_and_errors', - 'confirmation_wait_result_mapping_preserves_legacy_timeout_and_rejection', - ], - }, { path: 'src/crates/execution/agent-runtime/src/checkpoint.rs', contracts: [ @@ -2630,10 +2600,8 @@ export function runManifestParserSelfTest({ { path: 'src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs', contracts: [ - 'resolve_tool_confirmation_plan', - 'resolve_confirmation_failure', - 'resolve_confirmation_wait_result', - 'ToolConfirmationPlan::Await', + 'remote_workspace_route_root_isolated_from_same_local_path', + 'once_and_always_replies_control_execution_and_remembered_grants', 'should_retry_tool_attempt', 'retry_delay_ms', 'build_tool_call_truncation_recovery_notice', @@ -2734,7 +2702,6 @@ export function runManifestParserSelfTest({ 'render_get_tool_spec_tool_use_message', 'get_tool_spec_is_readonly', 'get_tool_spec_is_concurrency_safe', - 'get_tool_spec_needs_permissions', 'validate_get_tool_spec_input', 'build_get_tool_spec_assistant_detail', 'build_get_tool_spec_duplicate_load_result', @@ -3032,7 +2999,6 @@ export function runManifestParserSelfTest({ 'remote_poll_handler_preserves_missing_workspace_error', 'RemoteInteractionRuntimeHost', 'handle_remote_interaction_command', - 'remote_interaction_handler_preserves_default_reject_reason', 'RemoteDefaultModelsConfig', 'RemoteModelConfig', 'RemoteModelCatalog', @@ -3104,7 +3070,6 @@ export function runManifestParserSelfTest({ { path: 'src/crates/assembly/core/src/agentic/coordination/scheduler.rs', contracts: [ - 'remote_queue_policy_preserves_confirmation_boundary', 'AgentDialogTurnPort', 'AgentLifecycleDeliveryPort', 'AgentTurnCancellationPort', diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index 1941de3429..0f02e87d0d 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -71,6 +71,7 @@ pub(crate) enum ActionHandler { Init, History, Usage, + ToggleAutoApprove, Exit, Login, Logout, @@ -444,6 +445,21 @@ static ACTION_SPECS: &[ActionSpec] = &[ shortcut_label: None, slash_on_startup: true, }, + ActionSpec { + id: "toggle_auto_approve", + name: "Auto mode", + aliases: &["/auto"], + description: "Toggle Auto mode for the current session", + contexts: CHAT, + availability: ActionAvailability::Idle, + handler: ActionHandler::ToggleAutoApprove, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Session", false), + shortcut_label: None, + slash_on_startup: false, + }, ActionSpec { id: "exit", name: "Exit the app", @@ -1694,6 +1710,16 @@ mod tests { ); } + #[test] + fn auto_mode_is_chat_only_and_idle_only() { + assert!(action_for_alias("/auto", ActionContext::Startup).is_none()); + + let action = action_for_alias("/auto", ActionContext::Chat).unwrap(); + assert_eq!(action.handler, ActionHandler::ToggleAutoApprove); + assert!(action.available(ActionState::chat(false, false))); + assert!(!action.available(ActionState::chat(true, false))); + } + #[test] fn extension_management_uses_capability_entries_instead_of_external_commands() { let tools = action_for_alias("/tools", ActionContext::Chat).unwrap(); diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index e711501cb0..ab8d4aa91c 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -13,9 +13,9 @@ use bitfun_agent_runtime::sdk::{ AgentDialogTurnRequest, AgentRuntime, AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionForkRequest, AgentSessionForkResult, AgentSessionListRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionRestoreRequest, - AgentSessionUsageRequest, AgentToolConfirmationRequest, AgentToolRejectionRequest, - AgentTurnCancellationRequest, AgentTurnSettlementRequest, AgentUserAnswersRequest, - PortErrorKind, RuntimeError, SessionTranscript, SessionTranscriptRequest, SessionUsageReport, + AgentSessionUsageRequest, AgentTurnCancellationRequest, AgentTurnSettlementRequest, + AgentUserAnswersRequest, PortErrorKind, RuntimeError, SessionTranscript, + SessionTranscriptRequest, SessionUsageReport, AUTO_APPROVE_ASK_CONTEXT_KEY, }; use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; use bitfun_runtime_ports::{AgentSessionSummary, AgentSubmissionSource, DialogSubmissionPolicy}; @@ -41,6 +41,33 @@ fn validated_session_summary( }) } +fn cli_approval_metadata( + approval_policy: CliApprovalPolicy, +) -> serde_json::Map { + let mut metadata = serde_json::Map::new(); + if matches!( + approval_policy, + CliApprovalPolicy::Reject | CliApprovalPolicy::Auto + ) { + metadata.insert( + USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), + serde_json::Value::Bool(false), + ); + } + let auto_approve_ask = match approval_policy { + CliApprovalPolicy::Ask => None, + CliApprovalPolicy::DisableAuto | CliApprovalPolicy::Reject => Some(false), + CliApprovalPolicy::Auto => Some(true), + }; + if let Some(auto_approve_ask) = auto_approve_ask { + metadata.insert( + AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), + serde_json::Value::Bool(auto_approve_ask), + ); + } + metadata +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct SessionModeMigrationNotice { pub(crate) previous_mode_id: String, @@ -71,7 +98,7 @@ fn session_mode_migration_notice( pub(crate) struct CliAgentRuntimeClient { runtime: AgentRuntime, event_source: CliAgentEventSource, - approval_policy: CliApprovalPolicy, + approval_policy: Arc>, workspace_path: Arc>>, /// Session ID — uses Mutex for interior mutability session_id: Arc>>, @@ -84,7 +111,7 @@ impl CliAgentRuntimeClient { Self { runtime: runtime.agent_runtime().clone(), event_source: runtime.agent_events().clone(), - approval_policy: runtime.approval_policy(), + approval_policy: Arc::new(RwLock::new(runtime.approval_policy())), workspace_path: Arc::new(RwLock::new(workspace_path)), session_id: Arc::new(Mutex::new(None)), current_turn_id: Arc::new(Mutex::new(None)), @@ -95,6 +122,20 @@ impl CliAgentRuntimeClient { &self.event_source } + pub(crate) fn set_approval_policy(&self, policy: CliApprovalPolicy) { + *self + .approval_policy + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = policy; + } + + pub(crate) fn approval_policy(&self) -> CliApprovalPolicy { + *self + .approval_policy + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + pub(crate) fn workspace_path_buf(&self) -> PathBuf { self.workspace_path .read() @@ -426,13 +467,7 @@ impl CliAgentRuntimeClient { } // Start the dialog turn; events arrive through the shared broadcast source. - let mut metadata = serde_json::Map::new(); - if self.approval_policy != CliApprovalPolicy::Ask { - metadata.insert( - USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), - serde_json::Value::Bool(false), - ); - } + let metadata = cli_approval_metadata(self.approval_policy()); let request = AgentDialogTurnRequest { session_id: session_id.clone(), message: message.clone(), @@ -442,8 +477,7 @@ impl CliAgentRuntimeClient { workspace_path: Some(self.workspace_path_string()), remote_connection_id: None, remote_ssh_host: None, - policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli) - .with_skip_tool_confirmation(self.approval_policy == CliApprovalPolicy::Auto), + policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), reply_route: None, prepended_reminders: Vec::new(), attachments: Vec::new(), @@ -533,32 +567,6 @@ impl CliAgentRuntimeClient { Ok(()) } - pub(crate) async fn confirm_tool( - &self, - tool_id: &str, - updated_input: Option, - ) -> Result<()> { - tracing::info!("Confirming tool execution: {}", tool_id); - self.runtime - .confirm_tool(AgentToolConfirmationRequest { - tool_id: tool_id.to_string(), - updated_input, - }) - .await - .map_err(|e| anyhow::anyhow!("Confirm tool failed: {}", e.into_message())) - } - - pub(crate) async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<()> { - tracing::info!("Rejecting tool execution: {}, reason: {}", tool_id, reason); - self.runtime - .reject_tool(AgentToolRejectionRequest { - tool_id: tool_id.to_string(), - reason, - }) - .await - .map_err(|e| anyhow::anyhow!("Reject tool failed: {}", e.into_message())) - } - pub(crate) async fn submit_user_answers( &self, tool_id: &str, @@ -603,7 +611,30 @@ mod tests { use bitfun_runtime_ports::AgentSessionSummary; - use super::{session_mode_migration_notice, validated_session_summary}; + use bitfun_agent_runtime::sdk::AUTO_APPROVE_ASK_CONTEXT_KEY; + use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; + + use crate::runtime::approval::CliApprovalPolicy; + + use super::{cli_approval_metadata, session_mode_migration_notice, validated_session_summary}; + + #[test] + fn cli_approval_metadata_keeps_auto_invocation_scoped() { + let auto = cli_approval_metadata(CliApprovalPolicy::Auto); + assert_eq!(auto[AUTO_APPROVE_ASK_CONTEXT_KEY], true); + assert_eq!(auto[USER_INPUT_AVAILABLE_CONTEXT_KEY], false); + + let reject = cli_approval_metadata(CliApprovalPolicy::Reject); + assert_eq!(reject[AUTO_APPROVE_ASK_CONTEXT_KEY], false); + + let ask = cli_approval_metadata(CliApprovalPolicy::Ask); + assert!(!ask.contains_key(AUTO_APPROVE_ASK_CONTEXT_KEY)); + assert!(!ask.contains_key(USER_INPUT_AVAILABLE_CONTEXT_KEY)); + + let disabled = cli_approval_metadata(CliApprovalPolicy::DisableAuto); + assert_eq!(disabled[AUTO_APPROVE_ASK_CONTEXT_KEY], false); + assert!(!disabled.contains_key(USER_INPUT_AVAILABLE_CONTEXT_KEY)); + } #[test] fn model_updates_use_the_runtime_sdk_without_the_core_compatibility_facade() { diff --git a/src/apps/cli/src/chat_state.rs b/src/apps/cli/src/chat_state.rs index a14b5cd0b6..86b2f81329 100644 --- a/src/apps/cli/src/chat_state.rs +++ b/src/apps/cli/src/chat_state.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; /// Chat state module /// /// Pure UI rendering state for the chat interface. @@ -7,7 +7,9 @@ use std::collections::HashMap; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bitfun_agent_runtime::prompt_markup::strip_prompt_markup; -use bitfun_agent_runtime::sdk::{SessionTranscript, TranscriptContent, TranscriptMessage}; +use bitfun_agent_runtime::sdk::{ + PermissionRequest, SessionTranscript, TranscriptContent, TranscriptMessage, +}; use bitfun_agent_tools::effective_tool_invocation; use bitfun_events::ToolEventData; @@ -307,6 +309,8 @@ pub(crate) struct ChatState { pub workspace: Option, /// Current model display name (shown in shortcuts bar) pub current_model_name: String, + /// Effective Auto mode for permission results that evaluate to Ask. + pub auto_approve_ask: bool, /// Messages for UI rendering pub messages: Vec, /// Session statistics @@ -327,6 +331,8 @@ pub(crate) struct ChatState { // -- Permission state -- /// Current pending permission prompt (if a tool needs user confirmation) pub permission_prompt: Option, + /// Additional permission requests waiting behind the visible prompt. + permission_queue: VecDeque, // -- Question state -- /// Current pending question prompt (if AskUserQuestion tool is waiting for answers) @@ -347,6 +353,7 @@ impl ChatState { agent_type, workspace, current_model_name: String::new(), + auto_approve_ask: false, messages: Vec::new(), metadata: ChatMetadata::default(), current_turn_id: None, @@ -354,10 +361,92 @@ impl ChatState { tool_index: HashMap::new(), is_processing: false, permission_prompt: None, + permission_queue: VecDeque::new(), question_prompt: None, } } + pub(crate) fn enqueue_permission_request(&mut self, request: PermissionRequest) -> bool { + let request_id = request.request_id.as_str(); + if self + .permission_prompt + .as_ref() + .is_some_and(|prompt| prompt.request.request_id == request_id) + || self + .permission_queue + .iter() + .any(|queued| queued.request_id == request_id) + { + return false; + } + + if self.permission_prompt.is_none() { + self.permission_prompt = Some(PermissionPrompt::new(request)); + } else if self.permission_prompt.as_ref().is_some_and(|prompt| { + prompt.request.round_id == request.round_id && request.order < prompt.request.order + }) { + let current = self + .permission_prompt + .take() + .expect("permission prompt should exist when reordering"); + self.permission_prompt = Some(PermissionPrompt::new(request)); + self.insert_permission_request_sorted(current.request); + } else { + self.insert_permission_request_sorted(request); + } + true + } + + fn insert_permission_request_sorted(&mut self, request: PermissionRequest) { + let same_round_positions = self + .permission_queue + .iter() + .enumerate() + .filter_map(|(index, queued)| (queued.round_id == request.round_id).then_some(index)) + .collect::>(); + + let insert_position = if let Some(position) = same_round_positions + .iter() + .copied() + .find(|&index| request.order < self.permission_queue[index].order) + { + position + } else if let Some(position) = same_round_positions.last().copied() { + position + 1 + } else if self + .permission_prompt + .as_ref() + .is_some_and(|prompt| prompt.request.round_id == request.round_id) + { + 0 + } else { + self.permission_queue.len() + }; + + self.permission_queue.insert(insert_position, request); + } + + pub(crate) fn resolve_permission_request(&mut self, request_id: &str) -> bool { + if self + .permission_prompt + .as_ref() + .is_some_and(|prompt| prompt.request.request_id == request_id) + { + self.permission_prompt = self.permission_queue.pop_front().map(PermissionPrompt::new); + return true; + } + + let Some(position) = self + .permission_queue + .iter() + .position(|request| request.request_id == request_id) + else { + return false; + }; + self.permission_queue.remove(position); + true + } + /// Load historical messages from the portable runtime transcript. /// /// Tool results (ToolResult messages) are merged back into the corresponding @@ -652,12 +741,6 @@ impl ChatState { tool.parameters = effective_params.clone(); tool.progress_message = Some("Waiting for user confirmation".to_string()); }); - // Auto-create permission prompt for user interaction - self.permission_prompt = Some(PermissionPrompt::new( - identity.tool_id.clone(), - tool_name.to_string(), - effective_params.clone(), - )); self.rebuild_streaming_message(); } @@ -665,10 +748,6 @@ impl ChatState { self.update_tool(&identity.tool_id, |tool| { tool.status = ToolDisplayStatus::Confirmed; }); - // Clear permission prompt if it matches this tool - if self.permission_prompt.as_ref().map(|p| &p.tool_id) == Some(&identity.tool_id) { - self.permission_prompt = None; - } self.rebuild_streaming_message(); } @@ -677,10 +756,6 @@ impl ChatState { tool.status = ToolDisplayStatus::Rejected; tool.result = Some("User rejected execution".to_string()); }); - // Clear permission prompt if it matches this tool - if self.permission_prompt.as_ref().map(|p| &p.tool_id) == Some(&identity.tool_id) { - self.permission_prompt = None; - } self.rebuild_streaming_message(); } @@ -854,7 +929,6 @@ impl ChatState { self.current_flow_items.clear(); self.tool_index.clear(); self.is_processing = false; - self.permission_prompt = None; self.question_prompt = None; } @@ -876,7 +950,6 @@ impl ChatState { self.current_flow_items.clear(); self.tool_index.clear(); self.is_processing = false; - self.permission_prompt = None; self.question_prompt = None; } @@ -897,7 +970,6 @@ impl ChatState { self.current_flow_items.clear(); self.tool_index.clear(); self.is_processing = false; - self.permission_prompt = None; self.question_prompt = None; } @@ -1132,11 +1204,40 @@ fn truncate_string(s: &str, max_len: usize) -> String { mod tests { use super::{ChatState, FlowItem, ToolDisplayStatus}; use bitfun_agent_runtime::sdk::{ - SessionTranscript, TranscriptContent, TranscriptMessage, TranscriptToolCall, + PermissionDelegationContext, PermissionRequest, PermissionRequestSource, + PermissionRequestSourceKind, SessionTranscript, TranscriptContent, TranscriptMessage, + TranscriptToolCall, }; use bitfun_events::{ToolEventData, ToolEventIdentity}; use serde_json::json; + fn permission_request(request_id: &str, child_session_id: &str) -> PermissionRequest { + PermissionRequest { + request_id: request_id.to_string(), + round_id: format!("synthetic:{request_id}"), + order: 0, + tool_call_id: Some(format!("{request_id}-tool")), + project_path: None, + project_id: "project-1".to_string(), + session_id: child_session_id.to_string(), + agent_id: "Explore".to_string(), + action: "edit".to_string(), + resources: vec!["src/main.rs".to_string()], + save_resources: Vec::new(), + source: PermissionRequestSource { + kind: PermissionRequestSourceKind::ToolCall, + identity: "Write".to_string(), + }, + delegation: Some(PermissionDelegationContext { + parent_session_id: "parent-session".to_string(), + parent_dialog_turn_id: Some("parent-turn".to_string()), + parent_tool_call_id: format!("{request_id}-parent-task"), + subagent_type: "Explore".to_string(), + }), + display_metadata: serde_json::Map::new(), + } + } + fn deferred_input() -> serde_json::Value { json!({ "tool_name": "CreatePlan", @@ -1161,6 +1262,97 @@ mod tests { ); } + #[test] + fn permission_queue_deduplicates_and_advances_in_fifo_order() { + let mut state = ChatState::new( + "parent-session".to_string(), + "Session".to_string(), + "agentic".to_string(), + None, + ); + let first = permission_request("request-z", "child-a"); + let second = permission_request("request-a", "child-b"); + let third = permission_request("request-m", "child-c"); + + assert!(state.enqueue_permission_request(first.clone())); + assert!(state.enqueue_permission_request(second.clone())); + assert!(state.enqueue_permission_request(third.clone())); + assert!(!state.enqueue_permission_request(second)); + assert_eq!( + state + .permission_prompt + .as_ref() + .map(|prompt| prompt.request.request_id.as_str()), + Some("request-z") + ); + + assert!(state.resolve_permission_request("request-a")); + assert!(state.resolve_permission_request("request-z")); + assert_eq!( + state + .permission_prompt + .as_ref() + .map(|prompt| prompt.request.request_id.as_str()), + Some("request-m") + ); + assert!(!state.resolve_permission_request("unrelated")); + assert!(state.resolve_permission_request("request-m")); + assert!(state.permission_prompt.is_none()); + } + + #[test] + fn permission_queue_orders_requests_within_their_round() { + let mut state = ChatState::new( + "session-1".to_string(), + "Session".to_string(), + "agentic".to_string(), + None, + ); + let first = PermissionRequest { + round_id: "round-1".to_string(), + order: 2, + ..permission_request("request-2", "session-1") + }; + let second = PermissionRequest { + round_id: "round-1".to_string(), + order: 0, + ..permission_request("request-0", "session-1") + }; + let third = PermissionRequest { + round_id: "round-1".to_string(), + order: 1, + ..permission_request("request-1", "session-1") + }; + + assert!(state.enqueue_permission_request(first)); + assert!(state.enqueue_permission_request(second)); + assert!(state.enqueue_permission_request(third)); + assert_eq!( + state + .permission_prompt + .as_ref() + .map(|prompt| prompt.request.request_id.as_str()), + Some("request-0") + ); + + assert!(state.resolve_permission_request("request-0")); + assert_eq!( + state + .permission_prompt + .as_ref() + .map(|prompt| prompt.request.request_id.as_str()), + Some("request-1") + ); + assert!(state.resolve_permission_request("request-1")); + assert_eq!( + state + .permission_prompt + .as_ref() + .map(|prompt| prompt.request.request_id.as_str()), + Some("request-2") + ); + } + #[test] fn deferred_started_event_replaces_early_wire_display_with_effective_view() { let mut state = ChatState::new( diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 51712d1e45..2e8044d25d 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -941,13 +941,11 @@ async fn run_cli() -> Result<()> { Some(Commands::Doctor) => { use std::sync::Arc; - use runtime::approval::{CliApprovalPolicy, CliPermissionService}; use runtime::services::{CliClock, CliRuntimeEventSink, CliRuntimeServicesProvider}; let workspace = std::env::current_dir()?; let services = CliRuntimeServicesProvider::new( &workspace, - Arc::new(CliPermissionService::new(CliApprovalPolicy::Reject)), Arc::new(CliRuntimeEventSink::new(16)), Arc::new(CliClock), )? diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 90bb5a5f34..723150d454 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -21,7 +21,7 @@ use tokio::sync::broadcast::error::TryRecvError; use bitfun_agent_runtime::sdk::{ AgentLocalCommandTurnRecordRequest, AgentSessionUsageRequest, SessionUsageReport, }; -use bitfun_events::{AgenticEvent, ToolEventData}; +use bitfun_events::AgenticEvent; use resize::ResizeRedrawState; use crate::actions::{ @@ -42,7 +42,7 @@ use crate::ui::mcp_add_dialog::McpAddAction; use crate::ui::mcp_selector::{McpItem, McpItemAction}; use crate::ui::model_config_form::{ModelFormAction, ModelFormResult}; use crate::ui::model_selector::ModelItem; -use crate::ui::permission::{PermissionAction, ALLOW_ALWAYS_RUNTIME_SCOPE}; +use crate::ui::permission::PermissionAction; use crate::ui::provider_selector::ProviderSelection; use crate::ui::question::QuestionAction; use crate::ui::session_selector::{SessionAction, SessionItem}; @@ -186,6 +186,10 @@ pub(crate) struct ChatMode { workspace: Option, agent: Arc, runtime: Arc, + /// User-level default resolved from shared config for this TUI run. + auto_approve_ask_default: bool, + /// Temporary override for the current session only. + auto_approve_ask_override: Option, /// If set, restore this existing session instead of creating a new one restore_session_id: Option, /// If set, send this prompt automatically when the session starts @@ -238,6 +242,8 @@ impl ChatMode { workspace, agent, runtime, + auto_approve_ask_default: false, + auto_approve_ask_override: None, restore_session_id: None, initial_prompt: None, pending_mcp_op: None, diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index a20c729db5..8f08111606 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -27,6 +27,47 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) -> Result> { + if action_id == "toggle_auto_approve" || action_id.starts_with("toggle_auto_approve:") { + let action = action_by_id("toggle_auto_approve", ActionContext::Chat) + .expect("Auto mode action must remain registered"); + let state = ActionState::chat(chat_state.is_processing, false); + if !action.available(state) { + chat_view.set_status(Some(action.unavailable_message(state))); + return Ok(None); + } + let argument = action_id.strip_prefix("toggle_auto_approve:"); + let next = match argument { + Some("on") => Some(true), + Some("off") => Some(false), + Some("default") => None, + _ => Some(!chat_state.auto_approve_ask), + }; + self.auto_approve_ask_override = next; + chat_state.auto_approve_ask = next.unwrap_or(self.auto_approve_ask_default); + self.agent + .set_approval_policy(if chat_state.auto_approve_ask { + crate::runtime::approval::CliApprovalPolicy::Auto + } else if next.is_some() { + crate::runtime::approval::CliApprovalPolicy::DisableAuto + } else { + crate::runtime::approval::CliApprovalPolicy::Ask + }); + chat_view.set_status(Some(if next.is_none() { + format!( + "Auto mode reset to user default ({}) for this session", + if self.auto_approve_ask_default { + "on" + } else { + "off" + } + ) + } else if chat_state.auto_approve_ask { + "Auto mode enabled for this session".to_string() + } else { + "Auto mode disabled for this session".to_string() + })); + return Ok(None); + } if let Some(external) = self.external_command_projection_for_action(action_id) { return self.select_and_handle_external_command( &external, "", chat_view, chat_state, rt_handle, @@ -88,6 +129,29 @@ impl ChatMode { .get(token.len()..) .map(str::trim_start) .unwrap_or(""); + if command_name == "auto" { + let action_id = match arguments.trim() { + "on" | "enable" => "toggle_auto_approve:on", + "off" | "disable" => "toggle_auto_approve:off", + "default" | "reset" => "toggle_auto_approve:default", + "" | "toggle" => "toggle_auto_approve", + other => { + chat_view.set_status(Some(format!( + "Usage: /auto [on|off|default|toggle] (current: {})", + if chat_state.auto_approve_ask { + "on" + } else { + "off" + } + ))); + chat_state.add_system_message(format!( + "Unknown Auto mode value '{other}'. Use on, off, default, or toggle." + )); + return Ok(None); + } + }; + return self.handle_action_id(action_id, chat_view, chat_state, rt_handle); + } if let Some(candidate) = self.external_conflict_projection_for_alias(token) { return self.select_and_handle_external_command( &candidate, arguments, chat_view, chat_state, rt_handle, @@ -670,6 +734,7 @@ impl ChatMode { )); } ActionHandler::Usage => self.show_usage_report(chat_view, chat_state, rt_handle), + ActionHandler::ToggleAutoApprove => {} ActionHandler::Exit => { if chat_state.is_processing { self.cancel_active_turn(chat_view, rt_handle); diff --git a/src/apps/cli/src/modes/chat/input.rs b/src/apps/cli/src/modes/chat/input.rs index 16be06ce2d..42408b6860 100644 --- a/src/apps/cli/src/modes/chat/input.rs +++ b/src/apps/cli/src/modes/chat/input.rs @@ -16,74 +16,26 @@ impl ChatMode { return self.dispatch_action(action, modal_state, chat_view, chat_state, rt_handle); } - // ── Permission prompt intercepts all keys when active ── if let Some(ref mut prompt) = chat_state.permission_prompt { - let action = prompt.handle_key_event(key); - match action { - PermissionAction::AllowOnce => { - let tool_id = prompt.tool_id.clone(); - let agent = self.agent.clone(); - tracing::info!("User allowed tool once: {}", tool_id); - match tokio::task::block_in_place(|| { - rt_handle.block_on(agent.confirm_tool(&tool_id, None)) - }) { - Ok(()) => { - chat_state.permission_prompt = None; - chat_view.set_status(Some("Tool confirmed".to_string())); - } - Err(error) => { - tracing::error!("Failed to confirm tool: {}", error); - chat_view.set_status(Some(format!("Error: {error}"))); - } - } - } - PermissionAction::AllowAlways => { - let tool_id = prompt.tool_id.clone(); - let tool_name = prompt.tool_name().to_string(); - let agent = self.agent.clone(); - tracing::info!( - "User allowed tool {}: tool_id={}, tool_name={}", - ALLOW_ALWAYS_RUNTIME_SCOPE, - tool_id, - tool_name - ); - match tokio::task::block_in_place(|| { - rt_handle.block_on(agent.confirm_tool(&tool_id, None)) - }) { - Ok(()) => { - self.runtime.approval_controller().allow_always(&tool_name); - chat_state.permission_prompt = None; - chat_view.set_status(Some(format!( - "Tool approved {ALLOW_ALWAYS_RUNTIME_SCOPE}" - ))); - } - Err(error) => { - tracing::error!("Failed to confirm tool: {}", error); - chat_view.set_status(Some(format!("Error: {error}"))); - } - } - } - PermissionAction::Reject(reason) => { - let tool_id = prompt.tool_id.clone(); - let agent = self.agent.clone(); - tracing::info!("User rejected tool: {}, reason: {}", tool_id, reason); - let reason_clone = reason.clone(); - match tokio::task::block_in_place(|| { - rt_handle.block_on(agent.reject_tool(&tool_id, reason_clone)) - }) { + match prompt.handle_key_event(key) { + PermissionAction::Reply(reply) => { + let request_id = prompt.request.request_id.clone(); + let runtime = self.runtime.agent_runtime().clone(); + let result = tokio::task::block_in_place(|| { + rt_handle.block_on(runtime.respond_permission(&request_id, reply)) + }); + match result { Ok(()) => { - chat_state.permission_prompt = None; - chat_view.set_status(Some(format!("Tool rejected: {}", reason))); + chat_state.resolve_permission_request(&request_id); + chat_view.set_status(Some("Permission response sent".to_string())); } Err(error) => { - tracing::error!("Failed to reject tool: {}", error); + tracing::error!("Failed to respond to permission request: {error}"); chat_view.set_status(Some(format!("Error: {error}"))); } } } - PermissionAction::None => { - // Permission prompt consumed the key, no further action - } + PermissionAction::None => {} } return Ok(None); } diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index 9729b62ff1..68a013d6a7 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -29,6 +29,19 @@ impl ChatMode { // Create or restore core session let rt_handle = tokio::runtime::Handle::current(); + self.auto_approve_ask_default = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let Ok(service) = bitfun_core::service::config::get_global_config_service().await + else { + return false; + }; + service + .get_config::(None) + .await + .map(|config| config.tool_permissions.interaction.auto_approve_ask) + .unwrap_or(false) + }) + }); let (mut session_id, mut chat_state, mode_migration_notice) = if let Some(ref restore_id) = self.restore_session_id { @@ -85,6 +98,10 @@ impl ChatMode { ); (session_id, state, None) }; + self.auto_approve_ask_override = None; + self.agent + .set_approval_policy(crate::runtime::approval::CliApprovalPolicy::Ask); + chat_state.auto_approve_ask = self.auto_approve_ask_default; // Keep ChatMode workspace in sync with the session's effective workspace self.agent_type = chat_state.agent_type.clone(); @@ -158,6 +175,18 @@ impl ChatMode { } let mut event_rx = self.agent.event_source().subscribe(); + let mut permission_rx = self + .runtime + .agent_runtime() + .subscribe_permission_requests() + .ok(); + if let Ok(pending) = self.runtime.agent_runtime().pending_permission_requests() { + for request in pending.into_iter().filter(|request| { + crate::runtime::approval::permission_request_targets_session(request, &session_id) + }) { + chat_state.enqueue_permission_request(request); + } + } if let Some(notice) = &mode_migration_notice { chat_state.add_system_message(notice.user_message()); @@ -248,6 +277,43 @@ impl ChatMode { needs_redraw = true; } + if let Some(receiver) = permission_rx.as_mut() { + for _ in 0..4 { + match receiver.try_recv() { + Ok(bitfun_agent_runtime::sdk::PermissionRequestEvent::Asked { + request, + }) if crate::runtime::approval::permission_request_targets_session( + &request, + &session_id, + ) => + { + if chat_state.enqueue_permission_request(request) { + needs_redraw = true; + } + } + Ok(bitfun_agent_runtime::sdk::PermissionRequestEvent::Replied { + request_id, + .. + }) + | Ok(bitfun_agent_runtime::sdk::PermissionRequestEvent::Cancelled { + request_id, + .. + }) => { + if chat_state.resolve_permission_request(&request_id) { + needs_redraw = true; + } + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Lagged(_)) => continue, + Err(TryRecvError::Closed) => { + permission_rx = None; + break; + } + Ok(_) => {} + } + } + } + let mut external_source_closed = false; if let Some(receiver) = external_source_rx.as_mut() { let mut latest = None; @@ -497,25 +563,6 @@ impl ChatMode { ); continue; } - if let ToolEventData::ConfirmationNeeded { identity, .. } = tool_event { - if self - .runtime - .approval_controller() - .is_allowed(identity.effective_name()) - { - let agent = self.agent.clone(); - let tool_id = identity.tool_id.clone(); - match tokio::task::block_in_place(|| { - rt_handle.block_on(agent.confirm_tool(&tool_id, None)) - }) { - Ok(()) => continue, - Err(error) => tracing::error!( - "Failed to confirm runtime-approved tool; showing the permission prompt again: {}", - error - ), - } - } - } chat_state.handle_tool_event(tool_event); chat_view.invalidate_lines_cache(); needs_redraw = true; diff --git a/src/apps/cli/src/modes/chat/sessions.rs b/src/apps/cli/src/modes/chat/sessions.rs index 5603614dc2..d394cc171c 100644 --- a/src/apps/cli/src/modes/chat/sessions.rs +++ b/src/apps/cli/src/modes/chat/sessions.rs @@ -11,39 +11,44 @@ impl ChatMode { let agent = self.agent.clone(); let sid = new_session_id.to_string(); - let (new_state, restored_agent_type, migration_notice) = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let (session_summary, effective_workspace_path, migration_notice) = - agent.restore_session_in_current_workspace(&sid).await?; - let restored_agent_type = session_summary.agent_type.clone(); - let effective_workspace = - Some(effective_workspace_path.to_string_lossy().to_string()); - - // Load historical messages through the runtime transcript contract. - let transcript = agent.get_transcript(&sid).await.unwrap_or_else(|_| { - bitfun_agent_runtime::sdk::SessionTranscript { - session_id: sid.clone(), - messages: Vec::new(), - } - }); - - let state = ChatState::from_session_transcript( - sid.clone(), - session_summary.session_name, - restored_agent_type.clone(), - effective_workspace, - &transcript, - ); - - Ok::<_, anyhow::Error>((state, restored_agent_type, migration_notice)) - }) - })?; + let (new_state, restored_agent_type, migration_notice) = + tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let (session_summary, effective_workspace_path, migration_notice) = + agent.restore_session_in_current_workspace(&sid).await?; + let restored_agent_type = session_summary.agent_type.clone(); + let effective_workspace = + Some(effective_workspace_path.to_string_lossy().to_string()); + + // Load historical messages through the runtime transcript contract. + let transcript = agent.get_transcript(&sid).await.unwrap_or_else(|_| { + bitfun_agent_runtime::sdk::SessionTranscript { + session_id: sid.clone(), + messages: Vec::new(), + } + }); + + let state = ChatState::from_session_transcript( + sid.clone(), + session_summary.session_name, + restored_agent_type.clone(), + effective_workspace, + &transcript, + ); + + Ok::<_, anyhow::Error>((state, restored_agent_type, migration_notice)) + }) + })?; // Update session state *session_id = new_session_id.to_string(); *chat_state = new_state; self.agent_type = restored_agent_type; self.workspace = chat_state.workspace.clone(); + self.auto_approve_ask_override = None; + chat_state.auto_approve_ask = self.auto_approve_ask_default; + self.agent + .set_approval_policy(crate::runtime::approval::CliApprovalPolicy::Ask); // Reload model name self.load_current_model_name(chat_state, rt_handle); @@ -85,6 +90,10 @@ impl ChatMode { *session_id = new_session_id; *chat_state = new_state; self.workspace = chat_state.workspace.clone(); + self.auto_approve_ask_override = None; + chat_state.auto_approve_ask = self.auto_approve_ask_default; + self.agent + .set_approval_policy(crate::runtime::approval::CliApprovalPolicy::Ask); // Reload model name self.load_current_model_name(chat_state, rt_handle); @@ -220,5 +229,4 @@ impl ChatMode { } } } - } diff --git a/src/apps/cli/src/modes/exec/lifecycle.rs b/src/apps/cli/src/modes/exec/lifecycle.rs index d076265e29..3f3237120e 100644 --- a/src/apps/cli/src/modes/exec/lifecycle.rs +++ b/src/apps/cli/src/modes/exec/lifecycle.rs @@ -5,13 +5,16 @@ use anyhow::Result; use clap::ValueEnum; use serde::Serialize; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet, VecDeque}; use std::io::Write; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use bitfun_agent_runtime::sdk::{PortErrorKind, RuntimeError}; +use bitfun_agent_runtime::sdk::{ + PermissionReply, PermissionReplySource, PermissionRequest, PermissionRequestEvent, + PortErrorKind, RuntimeError, +}; use bitfun_agent_tools::effective_tool_invocation; use bitfun_events::{AgenticEvent, ToolEventIdentity}; use tokio::time::Instant; @@ -47,12 +50,6 @@ pub(crate) enum ExecApprovalMode { Auto, } -impl ExecApprovalMode { - pub(super) const fn rejects_confirmation(self) -> bool { - matches!(self, Self::Reject) - } -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub(super) struct ExecTokenUsage { pub(super) input_tokens: usize, @@ -206,6 +203,33 @@ pub(super) fn completed_turn_failure( }) } +pub(super) fn permission_action_required_message(request: &PermissionRequest) -> String { + match request.delegation.as_ref() { + Some(delegation) => format!( + "action-required: permission needed for {} by {} subagent (child session {}, parent session {}, parent task {}, request {})", + request.action, + delegation.subagent_type, + request.session_id, + delegation.parent_session_id, + delegation.parent_tool_call_id, + request.request_id + ), + None => format!( + "action-required: permission needed for {} ({})", + request.action, request.request_id + ), + } +} + +pub(super) fn should_reject_permission_request( + request: &PermissionRequest, + session_id: &str, + approval_mode: ExecApprovalMode, +) -> bool { + approval_mode == ExecApprovalMode::Reject + && crate::runtime::approval::permission_request_targets_session(request, session_id) +} + fn exec_terminal_decision(event: &AgenticEvent, turn_id: &str) -> Option { match event { AgenticEvent::DialogTurnCompleted { @@ -416,7 +440,7 @@ pub(crate) struct ExecMode { message: String, agent_type: String, agent: Arc, - _runtime: Arc, + runtime: Arc, pub(super) workspace_path: Option, /// None: no patch output, Some("-"): output to stdout, Some(path): save to file pub(super) output_patch: Option, @@ -439,6 +463,7 @@ impl ExecMode { let approval_mode = match runtime.approval_policy() { crate::runtime::approval::CliApprovalPolicy::Auto => ExecApprovalMode::Auto, crate::runtime::approval::CliApprovalPolicy::Ask + | crate::runtime::approval::CliApprovalPolicy::DisableAuto | crate::runtime::approval::CliApprovalPolicy::Reject => ExecApprovalMode::Reject, }; let agent = Arc::new(CliAgentRuntimeClient::new( @@ -451,7 +476,7 @@ impl ExecMode { message, agent_type, agent, - _runtime: runtime, + runtime, workspace_path, output_patch, output_format, @@ -584,6 +609,70 @@ impl ExecMode { }; tracing::info!(session_id = %session_id, turn_id = %turn_id, "Message sent"); + let mut permission_rx = self + .runtime + .agent_runtime() + .subscribe_permission_requests() + .map_err(|error| anyhow::anyhow!(error.into_message()))?; + let permission_runtime = self.runtime.agent_runtime().clone(); + let permission_session_id = session_id.clone(); + let permission_mode = self.approval_mode; + let (permission_action_tx, mut permission_action_rx) = tokio::sync::mpsc::channel(1); + let permission_task = tokio::spawn(async move { + let mut initial_requests = permission_runtime + .pending_permission_requests() + .unwrap_or_default() + .into_iter() + .collect::>(); + let mut handled_request_ids = HashSet::new(); + loop { + let request = if let Some(request) = initial_requests.pop_front() { + request + } else { + let Ok(event) = permission_rx.recv().await else { + break; + }; + let PermissionRequestEvent::Asked { request } = event else { + continue; + }; + request + }; + if !should_reject_permission_request( + &request, + &permission_session_id, + permission_mode, + ) { + continue; + } + if !handled_request_ids.insert(request.request_id.clone()) { + continue; + } + let reply = PermissionReply::Reject { + feedback: Some( + "Non-interactive execution requires an explicit permission policy" + .to_string(), + ), + }; + if let Err(error) = permission_runtime + .respond_permission_with_source( + &request.request_id, + reply, + PermissionReplySource::System, + ) + .await + { + let _ = permission_action_tx + .send(format!("Failed to respond to permission request: {error}")) + .await; + break; + } + let _ = permission_action_tx + .send(permission_action_required_message(&request)) + .await; + break; + } + }); + // Observe the shared Agentic event stream without consuming other clients' events. let mut total_tool_calls = 0usize; let mut subagent_parent_turns: HashMap = HashMap::new(); @@ -636,6 +725,23 @@ impl ExecMode { break 'event_loop; } }, + permission = permission_action_rx.recv() => { + let message = permission.unwrap_or_else(|| { + "Permission response channel closed before execution settled".to_string() + }); + if let Err(error) = self.agent.cancel_current_turn().await { + tracing::error!("Failed to cancel after permission action: {error}"); + } + emit_exit_diagnostic( + ExitKind::PermissionRejected, + &message, + &self.exit_context(Some(&session_id), Some(&turn_id)), + ); + terminal_status = Some(ExecTerminalStatus::Error); + terminal_message = Some(message.clone()); + terminal_outcome = Some(Err(anyhow::anyhow!(message))); + break 'event_loop; + } signal = tokio::signal::ctrl_c() => { let interrupted = signal.is_ok(); let mut message = match signal { @@ -788,71 +894,15 @@ impl ExecMode { break; } - let confirmation = self - .project_exec_nonterminal_event( - &envelope, - &session_id, - &turn_id, - &mut assistant_text, - &mut usage, - &mut total_tool_calls, - ) - .await?; - if let Some((tool_id, tool_name)) = confirmation { - if self.approval_mode.rejects_confirmation() { - let mut message = format!( - "Permission rejected for {tool_name}; rerun with --auto to approve tool requests" - ); - if let Err(error) = self.agent.reject_tool(&tool_id, message.clone()).await - { - message - .push_str(&format!("; failed to deliver tool rejection: {error}")); - } - if let Err(error) = self.agent.cancel_current_turn().await { - message.push_str(&format!("; failed to cancel active turn: {error}")); - } - let (drain_result, settlement_result) = self - .observe_cancelled_turn_settlement(&mut event_rx, &session_id, &turn_id) - .await; - cancellation_observation = Some(drain_result); - cancellation_settlement = Some(settlement_result); - cancelled_terminal_override = Some(( - ExecTerminalStatus::Error, - ExitKind::PermissionRejected, - message.clone(), - )); - self.print_text(|| eprintln!("{message}")); - terminal_exit_kind = Some(ExitKind::PermissionRejected); - terminal_status = Some(ExecTerminalStatus::Error); - terminal_message = Some(message.clone()); - terminal_outcome = Some(Err(anyhow::anyhow!(message))); - break; - } - if let Err(error) = self.agent.confirm_tool(&tool_id, None).await { - let mut message = - format!("Failed to approve tool request for {tool_name}: {error}"); - if let Err(cancel_error) = self.agent.cancel_current_turn().await { - message.push_str(&format!( - "; failed to cancel active turn: {cancel_error}" - )); - } - let (drain_result, settlement_result) = self - .observe_cancelled_turn_settlement(&mut event_rx, &session_id, &turn_id) - .await; - cancellation_observation = Some(drain_result); - cancellation_settlement = Some(settlement_result); - cancelled_terminal_override = Some(( - ExecTerminalStatus::Error, - ExitKind::PermissionRejected, - message.clone(), - )); - terminal_exit_kind = Some(ExitKind::PermissionRejected); - terminal_status = Some(ExecTerminalStatus::Error); - terminal_message = Some(message.clone()); - terminal_outcome = Some(Err(anyhow::anyhow!(message))); - break; - } - } + self.project_exec_nonterminal_event( + &envelope, + &session_id, + &turn_id, + &mut assistant_text, + &mut usage, + &mut total_tool_calls, + ) + .await?; } if terminal_outcome.is_some() { @@ -860,6 +910,8 @@ impl ExecMode { } } + permission_task.abort(); + let turn_settled = if let Some(settlement_result) = cancellation_settlement.take() { let settled = settlement_result.is_ok(); let observation = cancellation_observation.take().unwrap_or_else(|| { @@ -1051,7 +1103,7 @@ impl ExecMode { assistant_text: &mut String, usage: &mut Option, total_tool_calls: &mut usize, - ) -> Result> { + ) -> Result<()> { self.emit_stream_envelope(envelope)?; let event = &envelope.event; if let Some(model_config_id) = ExecTokenUsage::accumulate_event(usage, event, turn_id) { @@ -1101,12 +1153,7 @@ impl ExecMode { } if event_turn_id == turn_id => { use bitfun_events::ToolEventData; match tool_event { - ToolEventData::ConfirmationNeeded { identity, .. } => { - return Ok(Some(( - identity.tool_id.clone(), - identity.effective_name().to_string(), - ))); - } + ToolEventData::ConfirmationNeeded { .. } => {} ToolEventData::Started { identity, params, .. } => { @@ -1143,7 +1190,7 @@ impl ExecMode { } _ => {} } - Ok(None) + Ok(()) } async fn record_resolved_model_config_id(&self, session_id: &str, model_config_id: &str) { diff --git a/src/apps/cli/src/modes/exec/tests.rs b/src/apps/cli/src/modes/exec/tests.rs index b66dfc2140..03ec496348 100644 --- a/src/apps/cli/src/modes/exec/tests.rs +++ b/src/apps/cli/src/modes/exec/tests.rs @@ -3,15 +3,75 @@ use std::process::Command; use super::lifecycle::{ completed_turn_failure, drain_interrupted_turn_events, effective_event_invocation, event_belongs_to_exec_turn, event_turn_id, is_exec_terminal, - resolve_cancelled_turn_observation, serialize_stream_envelope, settlement_failure, + permission_action_required_message, resolve_cancelled_turn_observation, + serialize_stream_envelope, settlement_failure, should_reject_permission_request, ExecApprovalMode, ExecJsonResult, ExecMode, ExecTokenUsage, TOOL_START_INPUT_PREVIEW_CHARS, }; use super::patch::write_patch_to_path; use crate::diagnostics::ExitKind; -use bitfun_agent_runtime::sdk::{PortError, PortErrorKind, RuntimeError}; +use bitfun_agent_runtime::sdk::{ + PermissionDelegationContext, PermissionRequest, PermissionRequestSource, + PermissionRequestSourceKind, PortError, PortErrorKind, RuntimeError, +}; use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority, ToolEventIdentity}; use serde_json::json; +fn delegated_permission_request() -> PermissionRequest { + PermissionRequest { + request_id: "request-1".to_string(), + round_id: "synthetic:request-1".to_string(), + order: 0, + tool_call_id: Some("child-tool".to_string()), + project_path: None, + project_id: "project-1".to_string(), + session_id: "child-session".to_string(), + agent_id: "Explore".to_string(), + action: "edit".to_string(), + resources: vec!["src/main.rs".to_string()], + save_resources: Vec::new(), + source: PermissionRequestSource { + kind: PermissionRequestSourceKind::ToolCall, + identity: "Write".to_string(), + }, + delegation: Some(PermissionDelegationContext { + parent_session_id: "parent-session".to_string(), + parent_dialog_turn_id: Some("parent-turn".to_string()), + parent_tool_call_id: "parent-task".to_string(), + subagent_type: "Explore".to_string(), + }), + display_metadata: serde_json::Map::new(), + } +} + +#[test] +fn permission_action_required_message_includes_subagent_lineage() { + assert_eq!( + permission_action_required_message(&delegated_permission_request()), + "action-required: permission needed for edit by Explore subagent (child session child-session, parent session parent-session, parent task parent-task, request request-1)" + ); +} + +#[test] +fn exec_rejects_owned_child_requests_without_event_driven_auto_approval() { + let request = delegated_permission_request(); + + assert!(should_reject_permission_request( + &request, + "parent-session", + ExecApprovalMode::Reject + )); + assert!(!should_reject_permission_request( + &request, + "parent-session", + ExecApprovalMode::Auto + )); + assert!(!should_reject_permission_request( + &request, + "unrelated-session", + ExecApprovalMode::Reject + )); +} + #[test] fn write_patch_to_path_creates_nested_parent_directories() { let temp = tempfile::tempdir().expect("tempdir"); @@ -343,12 +403,6 @@ fn stream_json_reuses_the_existing_agentic_envelope() { assert!(value.get("sequence").is_none()); } -#[test] -fn default_exec_policy_rejects_confirmation_events() { - assert!(ExecApprovalMode::Reject.rejects_confirmation()); - assert!(!ExecApprovalMode::Auto.rejects_confirmation()); -} - #[test] fn unsuccessful_completed_turn_is_an_error_outcome() { assert_eq!( diff --git a/src/apps/cli/src/peer_host/commands/dialog.rs b/src/apps/cli/src/peer_host/commands/dialog.rs index b847e8f4d9..9ec6374997 100644 --- a/src/apps/cli/src/peer_host/commands/dialog.rs +++ b/src/apps/cli/src/peer_host/commands/dialog.rs @@ -1,10 +1,7 @@ -//! Dialog / tool confirmation HostInvoke handlers. +//! Dialog HostInvoke handlers. use serde_json::{json, Value}; -use bitfun_agent_runtime::sdk::{ - AgentToolConfirmationRequest, AgentToolRejectionRequest, RuntimeError, -}; use bitfun_runtime_ports::{ AgentDialogTurnRequest, AgentSubmissionSource, AgentTurnCancellationRequest, DialogSubmissionPolicy, DialogTriggerSource, @@ -14,17 +11,6 @@ use crate::peer_host::args::{get_string, optional_string, request_value}; use crate::peer_host::control::{attached_controller_lease, is_controller_lease_current}; use crate::peer_host::state::{PeerHostState, PeerTurnKey}; -fn restore_confirmation_after_runtime_error( - turns: &crate::peer_host::state::PeerTurnTracker, - tool_id: String, - ownership: PeerTurnKey, - action: &str, - error: RuntimeError, -) -> String { - turns.restore_confirmation(tool_id, ownership); - format!("{action} tool failed: {}", error.into_message()) -} - fn peer_dialog_metadata(request: &Value) -> Result, String> { let mut metadata = match request.get("userMessageMetadata") { Some(Value::Object(metadata)) => metadata.clone(), @@ -38,10 +24,10 @@ fn peer_dialog_metadata(request: &Value) -> Result Result { - let request = request_value(args); - let tool_id = get_string(request, "toolId")?; - let ownership = state - .turns - .claim_confirmation(&tool_id) - .ok_or_else(|| "Tool confirmation is not owned by an active Peer turn".to_string())?; - if optional_string(request, "sessionId") - .is_some_and(|value| value.as_str() != ownership.session_id.as_str()) - || optional_string(request, "dialogTurnId") - .is_some_and(|value| value.as_str() != ownership.turn_id.as_str()) - { - state.turns.restore_confirmation(tool_id, ownership); - return Err("Tool confirmation session or turn does not match its Peer owner".to_string()); - } - let updated_input = request.get("updatedInput").cloned(); - if let Err(error) = state - .agent_runtime - .confirm_tool(AgentToolConfirmationRequest { - tool_id: tool_id.clone(), - updated_input, - }) - .await - { - return Err(restore_confirmation_after_runtime_error( - &state.turns, - tool_id, - ownership, - "Confirm", - error, - )); - } - Ok(Value::Null) -} - -pub(crate) async fn reject_tool_execution( - state: &PeerHostState, - args: &Value, -) -> Result { - let request = request_value(args); - let tool_id = get_string(request, "toolId")?; - let ownership = state - .turns - .claim_confirmation(&tool_id) - .ok_or_else(|| "Tool confirmation is not owned by an active Peer turn".to_string())?; - if optional_string(request, "sessionId") - .is_some_and(|value| value.as_str() != ownership.session_id.as_str()) - || optional_string(request, "dialogTurnId") - .is_some_and(|value| value.as_str() != ownership.turn_id.as_str()) - { - state.turns.restore_confirmation(tool_id, ownership); - return Err("Tool confirmation session or turn does not match its Peer owner".to_string()); - } - let reason = optional_string(request, "reason").unwrap_or_else(|| "User rejected".to_string()); - if let Err(error) = state - .agent_runtime - .reject_tool(AgentToolRejectionRequest { - tool_id: tool_id.clone(), - reason, - }) - .await - { - return Err(restore_confirmation_after_runtime_error( - &state.turns, - tool_id, - ownership, - "Reject", - error, - )); - } - Ok(Value::Null) -} - #[cfg(test)] mod tests { use serde_json::json; - use super::{peer_dialog_metadata, restore_confirmation_after_runtime_error}; - use crate::peer_host::state::{PeerTurnKey, PeerTurnTracker}; + use super::peer_dialog_metadata; #[test] - fn peer_metadata_forces_confirmation_and_cannot_claim_acp_transport() { + fn peer_metadata_removes_reserved_runtime_fields() { let metadata = peer_dialog_metadata(&json!({ "userMessageMetadata": { "acp_transport": true, @@ -258,10 +167,7 @@ mod tests { })) .expect("metadata"); - assert_eq!( - metadata.get("require_tool_confirmation"), - Some(&json!(true)) - ); + assert!(!metadata.contains_key("require_tool_confirmation")); assert!(!metadata.contains_key("acp_transport")); for reserved_key in [ "backgroundTaskId", @@ -277,34 +183,6 @@ mod tests { assert_eq!(metadata.get("caller"), Some(&json!("desktop"))); } - #[test] - fn runtime_failure_restores_the_exact_peer_confirmation_claim() { - let turns = PeerTurnTracker::new(); - turns.mark_event_stream_ready(); - let owner = PeerTurnKey::new("session-1", "turn-1"); - turns.register_root(owner.clone()).expect("register turn"); - turns - .record_confirmation(&owner, "tool-1".to_string()) - .expect("record confirmation"); - let claimed = turns - .claim_confirmation("tool-1") - .expect("claim confirmation"); - - let message = restore_confirmation_after_runtime_error( - &turns, - "tool-1".to_string(), - claimed, - "Confirm", - bitfun_agent_runtime::sdk::RuntimeError::MissingInteractionResponsePort, - ); - - assert_eq!( - message, - "Confirm tool failed: agent interaction response port is not registered" - ); - assert_eq!(turns.claim_confirmation("tool-1"), Some(owner)); - } - #[test] fn peer_metadata_preserves_non_lineage_classification() { let metadata = peer_dialog_metadata(&json!({ diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index 981c3be896..cbdd7953c4 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -5,6 +5,7 @@ mod dialog; mod external_sources; mod filesystem; mod git; +mod permission; mod session; mod snapshot; mod soft; @@ -93,8 +94,22 @@ pub(crate) async fn dispatch( // Dialog / tools "start_dialog_turn" => dialog::start_dialog_turn(state, args).await, "cancel_dialog_turn" => dialog::cancel_dialog_turn(state, args).await, - "confirm_tool_execution" => dialog::confirm_tool_execution(state, args).await, - "reject_tool_execution" => dialog::reject_tool_execution(state, args).await, + "list_pending_permission_requests" => permission::list_pending_permission_requests(state), + "subscribe_permission_requests" => permission::subscribe_permission_requests(), + "respond_permission" => permission::respond_permission(state, args).await, + "respond_permission_batch" => permission::respond_permission_batch(state, args).await, + "list_project_permission_grants" => { + permission::list_project_permission_grants(state, args).await + } + "remove_project_permission_grant" => { + permission::remove_project_permission_grant(state, args).await + } + "clear_project_permission_grants" => { + permission::clear_project_permission_grants(state, args).await + } + "list_project_permission_audit" => { + permission::list_project_permission_audit(state, args).await + } // Git (local workspace only) "git_is_repository" => git::git_is_repository(args).await, diff --git a/src/apps/cli/src/peer_host/commands/permission.rs b/src/apps/cli/src/peer_host/commands/permission.rs new file mode 100644 index 0000000000..ccc78adacc --- /dev/null +++ b/src/apps/cli/src/peer_host/commands/permission.rs @@ -0,0 +1,261 @@ +//! Permission HostInvoke handlers for CLI Peer Host. + +use serde_json::{json, Value}; + +use bitfun_agent_runtime::sdk::{PermissionGrantKey, PermissionReply}; +use bitfun_core::service::remote_ssh::workspace_state::resolve_workspace_session_identity; +use bitfun_core::service::workspace::WorkspaceKind; + +use crate::peer_host::args::{get_string, request_value}; +use crate::peer_host::control::attached_controller_lease; +use crate::peer_host::state::PeerHostState; + +fn permission_reply(request: &Value) -> Result { + match get_string(request, "reply")?.as_str() { + "once" => Ok(PermissionReply::Once), + "always" => Ok(PermissionReply::Always), + "reject" => Ok(PermissionReply::Reject { + feedback: request + .get("feedback") + .and_then(Value::as_str) + .map(str::to_string), + }), + value => Err(format!("Unsupported permission reply: {value}")), + } +} + +fn pagination_value(request: &Value, key: &str, default: usize) -> usize { + request + .get(key) + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(default) +} + +async fn permission_project_id_for_workspace( + state: &PeerHostState, + workspace_id: &str, +) -> Result { + let workspace = state + .workspace_service + .get_workspace(workspace_id) + .await + .ok_or_else(|| format!("Workspace not found: {workspace_id}"))?; + let is_remote = workspace.workspace_kind == WorkspaceKind::Remote; + let connection_id = workspace + .metadata + .get("connectionId") + .and_then(Value::as_str); + let ssh_host = workspace.metadata.get("sshHost").and_then(Value::as_str); + let identity = resolve_workspace_session_identity( + &workspace.root_path.to_string_lossy(), + connection_id, + ssh_host, + ) + .await + .ok_or_else(|| format!("Workspace identity is unavailable: {workspace_id}"))?; + bitfun_core::agentic::tools::pipeline::permission_project_id_for_workspace_identity( + &identity, is_remote, + ) + .map_err(|error| error.to_string()) +} + +pub(crate) fn list_pending_permission_requests(state: &PeerHostState) -> Result { + let requests = state + .agent_runtime + .pending_permission_requests() + .map_err(|error| error.into_message())? + .into_iter() + .filter(|request| state.turns.owns_permission_request(request)) + .collect::>(); + serde_json::to_value(requests) + .map_err(|error| format!("Failed to serialize permission requests: {error}")) +} + +pub(crate) fn subscribe_permission_requests() -> Result { + attached_controller_lease()?; + Ok(Value::Null) +} + +pub(crate) async fn respond_permission( + state: &PeerHostState, + args: &Value, +) -> Result { + let lease = attached_controller_lease()?; + let request = request_value(args); + let request_id = get_string(request, "requestId")?; + let pending = state + .agent_runtime + .pending_permission_requests() + .map_err(|error| error.into_message())? + .into_iter() + .find(|pending| pending.request_id == request_id) + .ok_or_else(|| format!("Permission request not found: {request_id}"))?; + if !state.turns.owns_permission_request(&pending) { + return Err("The permission request is not owned by the Peer controller".to_string()); + } + if !crate::peer_host::control::is_controller_lease_current(lease) { + return Err("Peer controller continuity was lost before permission response".to_string()); + } + let reply = permission_reply(request)?; + state + .agent_runtime + .respond_permission(&request_id, reply) + .await + .map_err(|error| error.into_message())?; + Ok(Value::Null) +} + +pub(crate) async fn respond_permission_batch( + state: &PeerHostState, + args: &Value, +) -> Result { + let lease = attached_controller_lease()?; + let request = request_value(args); + let request_id = get_string(request, "requestId")?; + let pending = state + .agent_runtime + .pending_permission_requests() + .map_err(|error| error.into_message())? + .into_iter() + .find(|pending| pending.request_id == request_id) + .ok_or_else(|| format!("Permission request not found: {request_id}"))?; + if !state.turns.owns_permission_request(&pending) { + return Err("The permission request is not owned by the Peer controller".to_string()); + } + if !crate::peer_host::control::is_controller_lease_current(lease) { + return Err("Peer controller continuity was lost before permission response".to_string()); + } + let reply = permission_reply(request)?; + let resolved_request_ids = state + .agent_runtime + .respond_permission_batch(&request_id, reply) + .await + .map_err(|error| error.into_message())?; + serde_json::to_value(resolved_request_ids).map_err(|error| error.to_string()) +} + +pub(crate) async fn list_project_permission_grants( + state: &PeerHostState, + args: &Value, +) -> Result { + let request = request_value(args); + let workspace_id = get_string(request, "workspaceId")?; + let project_id = permission_project_id_for_workspace(state, &workspace_id).await?; + let grants = state + .agent_runtime + .list_project_permission_grants(&project_id) + .await + .map_err(|error| error.into_message())?; + serde_json::to_value(grants) + .map_err(|error| format!("Failed to serialize permission grants: {error}")) +} + +pub(crate) async fn remove_project_permission_grant( + state: &PeerHostState, + args: &Value, +) -> Result { + attached_controller_lease()?; + let request = request_value(args); + let workspace_id = get_string(request, "workspaceId")?; + let project_id = permission_project_id_for_workspace(state, &workspace_id).await?; + let removed = state + .agent_runtime + .remove_project_permission_grant(PermissionGrantKey { + project_id, + action: get_string(request, "action")?, + resource: get_string(request, "resource")?, + }) + .await + .map_err(|error| error.into_message())?; + Ok(json!(removed)) +} + +pub(crate) async fn clear_project_permission_grants( + state: &PeerHostState, + args: &Value, +) -> Result { + attached_controller_lease()?; + let request = request_value(args); + let workspace_id = get_string(request, "workspaceId")?; + let project_id = permission_project_id_for_workspace(state, &workspace_id).await?; + let removed = state + .agent_runtime + .clear_project_permission_grants(&project_id) + .await + .map_err(|error| error.into_message())?; + Ok(json!(removed)) +} + +pub(crate) async fn list_project_permission_audit( + state: &PeerHostState, + args: &Value, +) -> Result { + let request = request_value(args); + let workspace_id = get_string(request, "workspaceId")?; + let project_id = permission_project_id_for_workspace(state, &workspace_id).await?; + let mut records = state + .agent_runtime + .list_project_permission_audit(&project_id) + .await + .map_err(|error| error.into_message())?; + records.sort_by(|left, right| { + right + .timestamp_ms + .cmp(&left.timestamp_ms) + .then_with(|| right.audit_id.cmp(&left.audit_id)) + }); + let page = pagination_value(request, "page", 0); + let page_size = pagination_value(request, "pageSize", 50).clamp(1, 100); + let total = records.len(); + let offset = page.saturating_mul(page_size).min(total); + let records = records + .into_iter() + .skip(offset) + .take(page_size) + .collect::>(); + Ok(json!({ + "projectId": project_id, + "records": records, + "page": page, + "pageSize": page_size, + "total": total, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn permission_reply_rejects_unknown_values() { + let error = permission_reply(&json!({ "reply": "later" })).unwrap_err(); + assert_eq!(error, "Unsupported permission reply: later"); + } + + #[test] + fn permission_reply_preserves_rejection_feedback() { + assert_eq!( + permission_reply(&json!({ "reply": "reject", "feedback": "not now" })).unwrap(), + PermissionReply::Reject { + feedback: Some("not now".to_string()), + } + ); + } + + #[test] + fn permission_audit_page_size_is_bounded() { + assert_eq!( + pagination_value(&json!({}), "pageSize", 50).clamp(1, 100), + 50 + ); + assert_eq!( + pagination_value(&json!({ "pageSize": 0 }), "pageSize", 50).clamp(1, 100), + 1 + ); + assert_eq!( + pagination_value(&json!({ "pageSize": 500 }), "pageSize", 50).clamp(1, 100), + 100 + ); + } +} diff --git a/src/apps/cli/src/peer_host/fanout.rs b/src/apps/cli/src/peer_host/fanout.rs index 4df08c6c7c..eec31f4f8b 100644 --- a/src/apps/cli/src/peer_host/fanout.rs +++ b/src/apps/cli/src/peer_host/fanout.rs @@ -3,6 +3,7 @@ use std::collections::HashSet; use std::sync::OnceLock; +use bitfun_agent_runtime::sdk::PermissionRequestEvent; use bitfun_agent_tools::effective_tool_invocation; use bitfun_core::service::remote_connect::encryption::encrypt_to_base64; use bitfun_core::service::remote_connect::remote_server::RemoteCommand; @@ -74,6 +75,7 @@ fn peer_event_sender() -> &'static mpsc::Sender { /// Subscribe to the invocation-scoped event source and forward only Peer-owned turns. pub(crate) fn start_peer_event_fanout(state: PeerHostState) { + start_peer_permission_event_fanout(state.clone()); let mut rx = state.agent_events.subscribe(); state.turns.mark_event_stream_ready(); tokio::spawn(async move { @@ -113,6 +115,82 @@ pub(crate) fn start_peer_event_fanout(state: PeerHostState) { }); } +fn start_peer_permission_event_fanout(state: PeerHostState) { + let Ok(mut receiver) = state.agent_runtime.subscribe_permission_requests() else { + tracing::warn!("CLI Peer permission event fanout is unavailable"); + return; + }; + tokio::spawn(async move { + let mut owned_request_ids = HashSet::new(); + loop { + match receiver.recv().await { + Ok(event) => match &event { + PermissionRequestEvent::Asked { request } => { + if !state.turns.owns_permission_request(request) { + continue; + } + owned_request_ids.insert(request.request_id.clone()); + fanout_permission_event(event).await; + } + PermissionRequestEvent::Replied { request_id, .. } + | PermissionRequestEvent::Cancelled { request_id, .. } => { + if owned_request_ids.remove(request_id) { + fanout_permission_event(event).await; + } + } + }, + Err(broadcast::error::RecvError::Lagged(skipped)) => { + tracing::warn!("CLI Peer permission event fanout lagged by {skipped} events"); + let pending = state + .agent_runtime + .pending_permission_requests() + .unwrap_or_default() + .into_iter() + .filter(|request| state.turns.owns_permission_request(request)) + .collect::>(); + let pending_ids = pending + .iter() + .map(|request| request.request_id.clone()) + .collect::>(); + let stale_request_ids = owned_request_ids + .difference(&pending_ids) + .cloned() + .collect::>(); + for request_id in stale_request_ids { + fanout_permission_event(PermissionRequestEvent::Cancelled { + request_id, + reason: "Permission event stream resynchronized".to_string(), + }) + .await; + } + owned_request_ids = pending_ids; + for request in pending { + fanout_permission_event(PermissionRequestEvent::Asked { request }).await; + } + } + Err(broadcast::error::RecvError::Closed) => { + if let Err(error) = state + .cancel_and_drain_peer_turns("Peer permission event stream closed") + .await + { + tracing::warn!( + "Peer work was not fully cancelled after permission event closure: {error}" + ); + } + break; + } + } + } + }); +} + +async fn fanout_permission_event(event: PermissionRequestEvent) { + match serde_json::to_value(event) { + Ok(payload) => fanout_peer_device_event("permission://event".to_string(), payload).await, + Err(error) => tracing::warn!("CLI Peer permission event serialization failed: {error}"), + } +} + async fn interrupt_and_fail_peer_turns(state: &PeerHostState, closed: bool, reason: &'static str) { let drain = state.turns.interrupt_event_stream(closed); let interrupted_turns = drain.turns.clone(); @@ -309,19 +387,6 @@ async fn handle_agentic_event(state: &PeerHostState, event: AgenticEvent) -> Res } } - if let AgenticEvent::ToolEvent { - session_id, - turn_id, - tool_event: ToolEventData::ConfirmationNeeded { identity, .. }, - .. - } = &event - { - state.turns.record_confirmation( - &PeerTurnKey::new(session_id, turn_id), - identity.tool_id.clone(), - )?; - } - let Some(projected) = project_agentic_frontend_event(event) else { if let Some(turn) = terminal_turn { state.turns.finish_turn(&turn); diff --git a/src/apps/cli/src/peer_host/state.rs b/src/apps/cli/src/peer_host/state.rs index 6c0852a0de..e2a269101d 100644 --- a/src/apps/cli/src/peer_host/state.rs +++ b/src/apps/cli/src/peer_host/state.rs @@ -4,6 +4,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex, OnceLock}; use bitfun_agent_runtime::sdk::AgentRuntime; +use bitfun_agent_runtime::sdk::PermissionRequest; use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; use bitfun_core::service::filesystem::FileSystemService; use bitfun_core::service::workspace::WorkspaceService; @@ -14,7 +15,6 @@ use crate::runtime::events::CliAgentEventSource; const MAX_TRACKED_PEER_TURNS: usize = 256; const MAX_BACKGROUND_PEER_AUTHORIZATIONS: usize = 256; const MAX_PENDING_PEER_TASK_CANCELLATIONS: usize = 256; -const MAX_PENDING_PEER_CONFIRMATIONS: usize = 512; #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub(crate) struct PeerTurnKey { @@ -65,7 +65,6 @@ struct PeerTurnTrackerInner { background_follow_ups: HashSet, early_background_follow_ups: HashSet, completed_background_sources: HashMap, - confirmations: HashMap, } #[derive(Default)] @@ -97,7 +96,6 @@ impl PeerTurnTracker { background_follow_ups: HashSet::new(), early_background_follow_ups: HashSet::new(), completed_background_sources: HashMap::new(), - confirmations: HashMap::new(), })), } } @@ -408,6 +406,14 @@ impl PeerTurnTracker { .unwrap_or(false) } + pub(crate) fn owns_permission_request(&self, request: &PermissionRequest) -> bool { + self.owns(&request.session_id, None) + || request + .delegation + .as_ref() + .is_some_and(|delegation| self.owns(&delegation.parent_session_id, None)) + } + pub(crate) fn mark_started(&self, key: &PeerTurnKey) -> bool { self.inner .lock() @@ -421,48 +427,6 @@ impl PeerTurnTracker { .unwrap_or(false) } - pub(crate) fn record_confirmation( - &self, - key: &PeerTurnKey, - tool_id: String, - ) -> Result<(), String> { - let mut inner = self - .inner - .lock() - .map_err(|_| "Peer turn tracker is unavailable".to_string())?; - if !inner.active.contains(key) { - return Err("Tool confirmation does not belong to a Peer-owned turn".to_string()); - } - if let Some(existing_key) = inner.confirmations.get(&tool_id) { - if existing_key == key { - return Ok(()); - } - return Err("Tool confirmation is already owned by another Peer turn".to_string()); - } - if inner.confirmations.len() >= MAX_PENDING_PEER_CONFIRMATIONS { - return Err("Peer tool confirmation capacity is exhausted".to_string()); - } - inner.confirmations.insert(tool_id, key.clone()); - Ok(()) - } - - pub(crate) fn claim_confirmation(&self, tool_id: &str) -> Option { - self.inner - .lock() - .ok() - .and_then(|mut inner| inner.confirmations.remove(tool_id)) - } - - pub(crate) fn restore_confirmation(&self, tool_id: String, key: PeerTurnKey) { - if let Ok(mut inner) = self.inner.lock() { - if inner.active.contains(&key) - && inner.confirmations.len() < MAX_PENDING_PEER_CONFIRMATIONS - { - inner.confirmations.insert(tool_id, key); - } - } - } - pub(crate) fn finish_turn(&self, key: &PeerTurnKey) { if let Ok(mut inner) = self.inner.lock() { finish_turn_locked(&mut inner, key); @@ -602,7 +566,6 @@ fn drain_peer_turns(inner: &mut PeerTurnTrackerInner) -> PeerTurnDrain { inner.background_follow_ups.clear(); inner.early_background_follow_ups.clear(); inner.completed_background_sources.clear(); - inner.confirmations.clear(); drain } @@ -649,10 +612,6 @@ fn finish_turn_locked(inner: &mut PeerTurnTrackerInner, key: &PeerTurnKey) { if let Some(root) = root_for(inner, key).or_else(|| parent.clone()) { prune_idle_tree(inner, &root); } - let owned = inner.active.clone(); - inner - .confirmations - .retain(|_, confirmation_key| owned.contains(confirmation_key)); } fn bind_background_source_task(inner: &mut PeerTurnTrackerInner, call_key: &(PeerTurnKey, String)) { @@ -891,7 +850,6 @@ fn remove_tracked_turns(inner: &mut PeerTurnTrackerInner, removed: &HashSet) -> PermissionRequest { + PermissionRequest { + request_id: format!("request-{session_id}"), + round_id: format!("synthetic:request-{session_id}"), + order: 0, + tool_call_id: Some("tool-call".to_string()), + project_path: None, + project_id: "project".to_string(), + session_id: session_id.to_string(), + agent_id: "Explore".to_string(), + action: "read".to_string(), + resources: vec!["README.md".to_string()], + save_resources: Vec::new(), + source: PermissionRequestSource { + kind: PermissionRequestSourceKind::ToolCall, + identity: "Read".to_string(), + }, + delegation: parent_session_id.map(|parent_session_id| PermissionDelegationContext { + parent_session_id: parent_session_id.to_string(), + parent_dialog_turn_id: Some("parent-turn".to_string()), + parent_tool_call_id: "parent-task".to_string(), + subagent_type: "Explore".to_string(), + }), + display_metadata: serde_json::Map::new(), + } + } + #[test] fn detach_reports_any_unconfirmed_cancellation_round() { assert!(aggregate_cancellation_results(Ok(()), Ok(())).is_ok()); @@ -1165,7 +1155,24 @@ mod tests { } #[test] - fn finishing_a_root_preserves_an_active_child_and_its_confirmation() { + fn permission_ownership_includes_delegated_child_requests_without_leaking_unrelated_sessions() { + let tracker = PeerTurnTracker::new(); + tracker.mark_event_stream_ready(); + let root = PeerTurnKey::new("parent-session", "parent-turn"); + tracker.register_root(root.clone()).expect("register root"); + assert!(tracker.mark_started(&root)); + + assert!(tracker.owns_permission_request(&permission_request("parent-session", None))); + assert!(tracker + .owns_permission_request( + &permission_request("child-session", Some("parent-session"),) + )); + assert!(!tracker + .owns_permission_request(&permission_request("other-child", Some("other-parent"),))); + } + + #[test] + fn finishing_a_root_preserves_an_active_child() { let tracker = PeerTurnTracker::new(); tracker.mark_event_stream_ready(); let root = PeerTurnKey::new("session-1", "turn-1"); @@ -1174,35 +1181,10 @@ mod tests { assert!(tracker .register_child(&root, child.clone()) .expect("register child")); - tracker - .record_confirmation(&child, "tool-1".to_string()) - .expect("record confirmation"); - tracker.finish_turn(&root); assert!(!tracker.owns("session-1", Some("turn-1"))); assert!(tracker.owns("session-2", Some("turn-2"))); - assert_eq!(tracker.claim_confirmation("tool-1"), Some(child)); - } - - #[test] - fn confirmation_claim_is_bound_to_the_exact_turn_and_can_be_restored() { - let tracker = PeerTurnTracker::new(); - tracker.mark_event_stream_ready(); - let turn = PeerTurnKey::new("session-1", "turn-1"); - tracker.register_root(turn.clone()).expect("register turn"); - tracker - .record_confirmation(&turn, "tool-1".to_string()) - .expect("record confirmation"); - - let claimed = tracker - .claim_confirmation("tool-1") - .expect("claim confirmation"); - assert_eq!(claimed, turn); - assert!(tracker.claim_confirmation("tool-1").is_none()); - - tracker.restore_confirmation("tool-1".to_string(), claimed); - assert_eq!(tracker.claim_confirmation("tool-1"), Some(turn)); } #[test] diff --git a/src/apps/cli/src/product_assembly.rs b/src/apps/cli/src/product_assembly.rs index eaedd61151..bd4255fdb1 100644 --- a/src/apps/cli/src/product_assembly.rs +++ b/src/apps/cli/src/product_assembly.rs @@ -28,10 +28,7 @@ mod tests { use std::sync::Arc; use super::{assemble_acp_runtime_parts, assemble_cli_runtime_parts}; - use crate::runtime::{ - approval::{CliApprovalPolicy, CliPermissionService}, - services::{CliClock, CliRuntimeEventSink, CliRuntimeServicesProvider}, - }; + use crate::runtime::services::{CliClock, CliRuntimeEventSink, CliRuntimeServicesProvider}; use bitfun_core::product_assembly::ProductServiceCapabilityStatus; use bitfun_core::product_assembly::{product_assembly_plan_for_profile, DeliveryProfile}; use bitfun_runtime_ports::{ @@ -48,7 +45,6 @@ mod tests { RuntimeServiceCapability::FileSystem, RuntimeServiceCapability::Workspace, RuntimeServiceCapability::SessionStore, - RuntimeServiceCapability::Permission, RuntimeServiceCapability::Events, RuntimeServiceCapability::Clock, RuntimeServiceCapability::Terminal, @@ -76,7 +72,6 @@ mod tests { let workspace = tempfile::tempdir().expect("workspace"); let services = CliRuntimeServicesProvider::new( workspace.path(), - Arc::new(CliPermissionService::new(CliApprovalPolicy::Reject)), Arc::new(CliRuntimeEventSink::new(8)), Arc::new(CliClock), ) @@ -106,7 +101,6 @@ mod tests { let workspace = tempfile::tempdir().expect("workspace"); let services = CliRuntimeServicesProvider::new( workspace.path(), - Arc::new(CliPermissionService::new(CliApprovalPolicy::Ask)), Arc::new(CliRuntimeEventSink::new(8)), Arc::new(CliClock), ) diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 723830242d..307e57ffdf 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -776,13 +776,11 @@ pub(crate) fn handle_health_command() -> Result<()> { use bitfun_core::runtime_ports::PluginRuntimeAvailability; - use crate::runtime::approval::{CliApprovalPolicy, CliPermissionService}; use crate::runtime::services::{CliClock, CliRuntimeEventSink, CliRuntimeServicesProvider}; let workspace = std::env::current_dir().context("Failed to resolve current directory")?; let services = CliRuntimeServicesProvider::new( &workspace, - Arc::new(CliPermissionService::new(CliApprovalPolicy::Reject)), Arc::new(CliRuntimeEventSink::new(16)), Arc::new(CliClock), )? diff --git a/src/apps/cli/src/runtime/approval.rs b/src/apps/cli/src/runtime/approval.rs index f5fe2d6230..c0901036da 100644 --- a/src/apps/cli/src/runtime/approval.rs +++ b/src/apps/cli/src/runtime/approval.rs @@ -1,154 +1,77 @@ -use std::collections::HashSet; -use std::sync::RwLock; - -use bitfun_runtime_ports::{ - PermissionDecision, PermissionPort, PermissionRequest, PortResult, RuntimeServiceCapability, - RuntimeServicePort, -}; +use bitfun_agent_runtime::sdk::PermissionRequest; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum CliApprovalPolicy { + /// Inherit the persisted user interaction preference. Ask, + /// Explicitly disable Auto mode for this invocation/session. + DisableAuto, Reject, Auto, } -#[derive(Debug, Default)] -pub(crate) struct CliApprovalController { - allowed_tools: RwLock>, -} - -impl CliApprovalController { - pub(crate) fn new() -> Self { - Self::default() - } - - pub(crate) fn allow_always(&self, tool_name: &str) { - let tool_name = normalize_tool_name(tool_name); - if tool_name.is_empty() { - return; - } - - self.allowed_tools - .write() - .expect("CLI approval controller lock poisoned") - .insert(tool_name); - } - - pub(crate) fn is_allowed(&self, tool_name: &str) -> bool { - let tool_name = normalize_tool_name(tool_name); - !tool_name.is_empty() - && self - .allowed_tools - .read() - .expect("CLI approval controller lock poisoned") - .contains(&tool_name) - } -} - -fn normalize_tool_name(tool_name: &str) -> String { - tool_name.trim().to_ascii_lowercase() -} - -#[derive(Debug)] -pub(crate) struct CliPermissionService { - policy: CliApprovalPolicy, -} - -impl CliPermissionService { - pub(crate) const fn new(policy: CliApprovalPolicy) -> Self { - Self { policy } - } -} - -impl RuntimeServicePort for CliPermissionService { - fn capability(&self) -> RuntimeServiceCapability { - RuntimeServiceCapability::Permission - } -} - -#[async_trait::async_trait] -impl PermissionPort for CliPermissionService { - async fn request_permission( - &self, - request: PermissionRequest, - ) -> PortResult { - Ok(match self.policy { - CliApprovalPolicy::Auto => PermissionDecision::Allow, - CliApprovalPolicy::Reject => PermissionDecision::Deny { - reason: format!( - "non-interactive permission rejected: {}:{}", - request.scope, request.action - ), - }, - CliApprovalPolicy::Ask => PermissionDecision::Deny { - reason: format!( - "interactive approval required: {}:{}", - request.scope, request.action - ), - }, - }) - } +pub(crate) fn permission_request_targets_session( + request: &PermissionRequest, + session_id: &str, +) -> bool { + request.session_id == session_id + || request + .delegation + .as_ref() + .is_some_and(|delegation| delegation.parent_session_id == session_id) } #[cfg(test)] mod tests { - use super::{CliApprovalController, CliApprovalPolicy, CliPermissionService}; - use bitfun_runtime_ports::{PermissionDecision, PermissionPort, PermissionRequest}; + use super::permission_request_targets_session; + use bitfun_agent_runtime::sdk::{ + PermissionDelegationContext, PermissionRequest, PermissionRequestSource, + PermissionRequestSourceKind, + }; + use serde_json::Map; fn request() -> PermissionRequest { PermissionRequest { - scope: "tool".to_string(), - action: "run_terminal_cmd".to_string(), - metadata: serde_json::Map::new(), + request_id: "request-1".to_string(), + round_id: "synthetic:request-1".to_string(), + order: 0, + tool_call_id: Some("child-tool".to_string()), + project_path: None, + project_id: "project-1".to_string(), + session_id: "child-session".to_string(), + agent_id: "Explore".to_string(), + action: "edit".to_string(), + resources: vec!["src/main.rs".to_string()], + save_resources: Vec::new(), + source: PermissionRequestSource { + kind: PermissionRequestSourceKind::ToolCall, + identity: "Write".to_string(), + }, + delegation: Some(PermissionDelegationContext { + parent_session_id: "parent-session".to_string(), + parent_dialog_turn_id: Some("parent-turn".to_string()), + parent_tool_call_id: "parent-task".to_string(), + subagent_type: "Explore".to_string(), + }), + display_metadata: Map::new(), } } - #[tokio::test] - async fn non_interactive_permission_policy_is_invocation_scoped() { - let reject = CliPermissionService::new(CliApprovalPolicy::Reject); - assert!(matches!( - reject - .request_permission(request()) - .await - .expect("decision"), - PermissionDecision::Deny { .. } - )); - - let auto = CliPermissionService::new(CliApprovalPolicy::Auto); - assert_eq!( - auto.request_permission(request()).await.expect("decision"), - PermissionDecision::Allow - ); - } - - #[tokio::test] - async fn interactive_policy_never_silently_approves() { - let service = CliPermissionService::new(CliApprovalPolicy::Ask); - - let decision = service - .request_permission(request()) - .await - .expect("decision"); - - assert!( - matches!(decision, PermissionDecision::Deny { reason } if reason.contains("interactive")) - ); - } - #[test] - fn allow_always_is_scoped_to_one_controller_and_tool_pattern() { - let controller = CliApprovalController::new(); - assert!(!controller.is_allowed("run_terminal_cmd")); + fn permission_requests_target_their_execution_and_parent_interaction_sessions() { + let request = request(); - controller.allow_always("run_terminal_cmd"); - - assert!(controller.is_allowed("run_terminal_cmd")); - assert!(controller.is_allowed("RUN_TERMINAL_CMD")); - assert!(!controller.is_allowed("write_file")); - assert!( - !CliApprovalController::new().is_allowed("run_terminal_cmd"), - "approval must not survive a runtime context" - ); + assert!(permission_request_targets_session( + &request, + "child-session" + )); + assert!(permission_request_targets_session( + &request, + "parent-session" + )); + assert!(!permission_request_targets_session( + &request, + "unrelated-session" + )); } } diff --git a/src/apps/cli/src/runtime/mod.rs b/src/apps/cli/src/runtime/mod.rs index 0fd85d50cc..c4b7e1dac9 100644 --- a/src/apps/cli/src/runtime/mod.rs +++ b/src/apps/cli/src/runtime/mod.rs @@ -19,7 +19,7 @@ pub(crate) mod approval; pub(crate) mod events; pub(crate) mod services; -use approval::{CliApprovalController, CliApprovalPolicy, CliPermissionService}; +use approval::CliApprovalPolicy; use events::CliAgentEventSource; use services::{CliClock, CliRuntimeEventSink, CliRuntimeServicesProvider}; @@ -61,7 +61,6 @@ pub(crate) struct CliRuntimeContext { services: RuntimeServices, product: CliProductRuntimeState, approval_policy: CliApprovalPolicy, - approval_controller: Arc, } impl CliRuntimeContext { @@ -74,7 +73,6 @@ impl CliRuntimeContext { let runtime_events = Arc::new(CliRuntimeEventSink::new(RUNTIME_EVENT_BUFFER)); let provider = CliRuntimeServicesProvider::new( workspace_root, - Arc::new(CliPermissionService::new(approval_policy)), runtime_events.clone(), Arc::new(CliClock), )?; @@ -126,7 +124,6 @@ impl CliRuntimeContext { services, product, approval_policy, - approval_controller: Arc::new(CliApprovalController::new()), }) } @@ -161,10 +158,6 @@ impl CliRuntimeContext { pub(crate) const fn approval_policy(&self) -> CliApprovalPolicy { self.approval_policy } - - pub(crate) fn approval_controller(&self) -> &Arc { - &self.approval_controller - } } #[derive(Clone)] @@ -181,12 +174,8 @@ impl AcpRuntimeContext { ) -> Result { let scheduler = ensure_dialog_scheduler(&agentic_system); let runtime_events = Arc::new(CliRuntimeEventSink::new(RUNTIME_EVENT_BUFFER)); - let provider = CliRuntimeServicesProvider::new( - workspace_root, - Arc::new(CliPermissionService::new(CliApprovalPolicy::Ask)), - runtime_events, - Arc::new(CliClock), - )?; + let provider = + CliRuntimeServicesProvider::new(workspace_root, runtime_events, Arc::new(CliClock))?; let parts = assemble_acp_runtime_parts(provider.build()?) .context("Failed to assemble ACP product runtime")?; let (services, harness_registry, _disabled_plugin_runtime) = parts.into_runtime_parts(); diff --git a/src/apps/cli/src/runtime/services.rs b/src/apps/cli/src/runtime/services.rs index 123be451ab..df8aa5db5a 100644 --- a/src/apps/cli/src/runtime/services.rs +++ b/src/apps/cli/src/runtime/services.rs @@ -5,7 +5,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use bitfun_core::product_runtime::CoreRuntimeServicesProvider; use bitfun_runtime_ports::{ - ClockPort, FileSystemPort, PermissionPort, PortResult, RuntimeEventEnvelope, RuntimeEventSink, + ClockPort, FileSystemPort, PortResult, RuntimeEventEnvelope, RuntimeEventSink, RuntimeServiceCapability, RuntimeServicePort, WorkspacePort, }; use bitfun_runtime_services::{ @@ -100,7 +100,6 @@ pub(crate) struct CliRuntimeServicesProvider { workspace_root: PathBuf, filesystem: Arc, workspace: Arc, - permission: Arc, events: Arc, clock: Arc, } @@ -117,7 +116,6 @@ impl fmt::Debug for CliRuntimeServicesProvider { impl CliRuntimeServicesProvider { pub(crate) fn new( workspace_root: impl AsRef, - permission: Arc, events: Arc, clock: Arc, ) -> anyhow::Result { @@ -143,7 +141,6 @@ impl CliRuntimeServicesProvider { workspace: Arc::new(CliWorkspaceService { workspace_root: canonical_root, }), - permission, events, clock, }) @@ -170,7 +167,6 @@ impl RuntimeServicesProvider for CliRuntimeServicesProvider { builder .with_filesystem(filesystem) .with_workspace(workspace) - .with_permission(self.permission.clone()) .with_events(self.events.clone()) .with_clock(self.clock.clone()) } @@ -185,19 +181,14 @@ mod tests { }; use super::{CliClock, CliRuntimeEventSink, CliRuntimeServicesProvider}; - use crate::runtime::approval::{CliApprovalPolicy, CliPermissionService}; #[tokio::test] async fn provider_registers_required_capability_contracts() { let workspace = tempfile::tempdir().expect("workspace"); let events = Arc::new(CliRuntimeEventSink::new(8)); - let provider = CliRuntimeServicesProvider::new( - workspace.path(), - Arc::new(CliPermissionService::new(CliApprovalPolicy::Reject)), - events.clone(), - Arc::new(CliClock), - ) - .expect("provider"); + let provider = + CliRuntimeServicesProvider::new(workspace.path(), events.clone(), Arc::new(CliClock)) + .expect("provider"); let services = provider.build().expect("runtime services"); @@ -209,7 +200,6 @@ mod tests { RuntimeServiceCapability::FileSystem, RuntimeServiceCapability::Workspace, RuntimeServiceCapability::SessionStore, - RuntimeServiceCapability::Permission, RuntimeServiceCapability::Events, RuntimeServiceCapability::Clock, RuntimeServiceCapability::Terminal, @@ -246,7 +236,6 @@ mod tests { let error = CliRuntimeServicesProvider::new( &missing, - Arc::new(CliPermissionService::new(CliApprovalPolicy::Reject)), Arc::new(CliRuntimeEventSink::new(8)), Arc::new(CliClock), ) diff --git a/src/apps/cli/src/ui/chat/render.rs b/src/apps/cli/src/ui/chat/render.rs index f8ef577d3c..0807c4eddd 100644 --- a/src/apps/cli/src/ui/chat/render.rs +++ b/src/apps/cli/src/ui/chat/render.rs @@ -109,9 +109,7 @@ impl ChatView { // Render permission overlay on top of messages area if active (highest priority) if let Some(ref prompt) = chat_state.permission_prompt { render_permission_overlay(frame, prompt, &self.theme, chunks[1]); - } - // Render question overlay (second priority, only if no permission prompt) - else if let Some(ref prompt) = chat_state.question_prompt { + } else if let Some(ref prompt) = chat_state.question_prompt { render_question_overlay(frame, prompt, &self.theme, chunks[1]); } @@ -142,7 +140,12 @@ impl ChatView { /// Render header fn render_header(&self, frame: &mut Frame, area: Rect, chat_state: &ChatState) { let title = format!(" BitFun CLI v{} ", env!("CARGO_PKG_VERSION")); - let agent_info = format!(" Agent: {} ", chat_state.agent_type); + let auto_mode = if chat_state.auto_approve_ask { + "Auto: on" + } else { + "Auto: off" + }; + let agent_info = format!(" Agent: {} | {} ", chat_state.agent_type, auto_mode); let workspace = chat_state .workspace diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index 8e57bcbb37..f34f93e5db 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -43,6 +43,7 @@ const DEFAULT_ITEM_ORDER: &[&str] = &[ "new_session", "sessions", "usage", + "toggle_auto_approve", "skills", "select_model", "add_model", @@ -763,6 +764,7 @@ mod tests { assert!(ids.iter().any(|id| id == "switch_agent")); assert!(!ids.iter().any(|id| id == "new_session")); + assert!(!ids.iter().any(|id| id == "toggle_auto_approve")); assert!(ids.iter().any(|id| id == "help")); } diff --git a/src/apps/cli/src/ui/permission.rs b/src/apps/cli/src/ui/permission.rs index ac33f42e46..26b49278af 100644 --- a/src/apps/cli/src/ui/permission.rs +++ b/src/apps/cli/src/ui/permission.rs @@ -1,10 +1,5 @@ -/// Permission confirmation modal panel +/// Permission request modal panel. /// -/// Inspired by opencode TUI's PermissionPrompt component. -/// Three-level permission system: -/// - Allow once: execute this tool call only -/// - Allow always: auto-approve this tool type until the CLI runtime exits -/// - Reject: deny execution (optionally with a reason) use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, @@ -15,175 +10,80 @@ use ratatui::{ }; use super::string_utils::truncate_str; -use super::theme::{tool_icon, StyleKind, Theme}; +use super::theme::Theme; +use bitfun_agent_runtime::sdk::{PermissionReply, PermissionRequest}; -pub(crate) const ALLOW_ALWAYS_RUNTIME_SCOPE: &str = "until this CLI runtime exits"; - -fn allow_always_confirmation_text(tool_name: &str) -> String { - format!("This will auto-approve '{tool_name}' tool calls {ALLOW_ALWAYS_RUNTIME_SCOPE}.") -} - -// ============ Data Types ============ - -/// Permission prompt stage -#[derive(Debug, Clone, PartialEq)] -enum PermissionStage { - /// Main permission screen: Allow once / Allow always / Reject - Permission, - /// Confirm "Allow always" action - ConfirmAlways, - /// Reject with reason input - RejectWithReason, -} - -/// Permission prompt state #[derive(Debug, Clone)] pub(crate) struct PermissionPrompt { - pub(crate) tool_id: String, - tool_name: String, - params: serde_json::Value, - stage: PermissionStage, - /// Selected option index: 0=Allow once, 1=Allow always, 2=Reject - selected_option: usize, - /// Reject reason input buffer - reject_reason: String, + pub(crate) request: PermissionRequest, + pub(crate) selected_option: usize, + reject_feedback: String, + editing_reject_feedback: bool, } -/// Result of handling a key event in the permission prompt -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub(crate) enum PermissionAction { - /// No action, continue showing the prompt None, - /// User confirmed: allow once (with optional updated input) - AllowOnce, - /// User confirmed: allow always - AllowAlways, - /// User rejected with a reason - Reject(String), + Reply(PermissionReply), } impl PermissionPrompt { - /// Create a new permission prompt from a ConfirmationNeeded event - pub(crate) fn new(tool_id: String, tool_name: String, params: serde_json::Value) -> Self { + pub(crate) fn new(request: PermissionRequest) -> Self { Self { - tool_id, - tool_name, - params, - stage: PermissionStage::Permission, + request, selected_option: 0, - reject_reason: String::new(), + reject_feedback: String::new(), + editing_reject_feedback: false, } } - pub(crate) fn tool_name(&self) -> &str { - &self.tool_name - } - - /// Handle a key event. Returns a PermissionAction if the user made a decision. pub(crate) fn handle_key_event(&mut self, key: KeyEvent) -> PermissionAction { if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat { return PermissionAction::None; } - - match &self.stage { - PermissionStage::Permission => self.handle_permission_key(key), - PermissionStage::ConfirmAlways => self.handle_confirm_always_key(key), - PermissionStage::RejectWithReason => self.handle_reject_reason_key(key), - } - } - - fn handle_permission_key(&mut self, key: KeyEvent) -> PermissionAction { - match (key.code, key.modifiers) { - // Navigate options - (KeyCode::Left, _) | (KeyCode::Char('h'), KeyModifiers::NONE) => { - if self.selected_option > 0 { - self.selected_option -= 1; - } - PermissionAction::None - } - (KeyCode::Right, _) | (KeyCode::Char('l'), KeyModifiers::NONE) => { - if self.selected_option < 2 { - self.selected_option += 1; - } - PermissionAction::None - } - - // Confirm selection - (KeyCode::Enter, _) => match self.selected_option { - 0 => PermissionAction::AllowOnce, - 1 => { - self.stage = PermissionStage::ConfirmAlways; - self.selected_option = 0; // Reset to "Confirm" + if self.editing_reject_feedback { + return match (key.code, key.modifiers) { + (KeyCode::Enter, _) => PermissionAction::Reply(PermissionReply::Reject { + feedback: match self.reject_feedback.trim() { + "" => None, + feedback => Some(feedback.to_string()), + }, + }), + (KeyCode::Esc, _) => { + self.editing_reject_feedback = false; PermissionAction::None } - 2 => { - self.stage = PermissionStage::RejectWithReason; + (KeyCode::Backspace, _) => { + self.reject_feedback.pop(); PermissionAction::None } - _ => PermissionAction::None, - }, - - // Escape = reject - (KeyCode::Esc, _) => PermissionAction::Reject("User dismissed".to_string()), - - _ => PermissionAction::None, - } - } - - fn handle_confirm_always_key(&mut self, key: KeyEvent) -> PermissionAction { - match (key.code, key.modifiers) { - (KeyCode::Left, _) - | (KeyCode::Right, _) - | (KeyCode::Char('h'), KeyModifiers::NONE) - | (KeyCode::Char('l'), KeyModifiers::NONE) => { - self.selected_option = if self.selected_option == 0 { 1 } else { 0 }; - PermissionAction::None - } - (KeyCode::Enter, _) => { - if self.selected_option == 0 { - PermissionAction::AllowAlways - } else { - // Cancel — go back to main - self.stage = PermissionStage::Permission; - self.selected_option = 1; + (KeyCode::Char(character), KeyModifiers::NONE | KeyModifiers::SHIFT) + if !character.is_control() => + { + self.reject_feedback.push(character); PermissionAction::None } - } - (KeyCode::Esc, _) => { - self.stage = PermissionStage::Permission; - self.selected_option = 1; - PermissionAction::None - } - _ => PermissionAction::None, + _ => PermissionAction::None, + }; } - } - - fn handle_reject_reason_key(&mut self, key: KeyEvent) -> PermissionAction { - match (key.code, key.modifiers) { - (KeyCode::Enter, _) => { - let reason = if self.reject_reason.trim().is_empty() { - "User rejected".to_string() - } else { - self.reject_reason.clone() - }; - PermissionAction::Reject(reason) - } - (KeyCode::Esc, _) => { - self.stage = PermissionStage::Permission; - self.selected_option = 2; - self.reject_reason.clear(); + match key.code { + KeyCode::Left | KeyCode::Char('h') => { + self.selected_option = self.selected_option.saturating_sub(1); PermissionAction::None } - (KeyCode::Backspace, _) => { - self.reject_reason.pop(); + KeyCode::Right | KeyCode::Char('l') => { + self.selected_option = (self.selected_option + 1).min(2); PermissionAction::None } - (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => { - if !c.is_control() { - self.reject_reason.push(c); + KeyCode::Esc => PermissionAction::Reply(PermissionReply::Reject { feedback: None }), + KeyCode::Enter => match self.selected_option { + 0 => PermissionAction::Reply(PermissionReply::Once), + 1 => PermissionAction::Reply(PermissionReply::Always), + _ => { + self.editing_reject_feedback = true; + PermissionAction::None } - PermissionAction::None - } + }, _ => PermissionAction::None, } } @@ -191,219 +91,141 @@ impl PermissionPrompt { // ============ Rendering ============ -/// Render the permission overlay on top of the message area. -/// -/// This renders at the bottom of the given area, taking up a fixed height. +fn permission_delegation_lines(request: &PermissionRequest) -> Option<[String; 2]> { + let delegation = request.delegation.as_ref()?; + Some([ + format!( + "Subagent: {} Child session: {}", + delegation.subagent_type, request.session_id + ), + format!( + "Parent session: {} Task: {}", + delegation.parent_session_id, delegation.parent_tool_call_id + ), + ]) +} + +fn permission_project_display_label(request: &PermissionRequest) -> &str { + request + .project_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + .unwrap_or(&request.project_id) +} + +fn permission_footer_secondary_style(theme: &Theme) -> Style { + Style::default() + .fg(theme.primary) + .bg(theme.background_element) +} + pub(super) fn render_permission_overlay( frame: &mut Frame, prompt: &PermissionPrompt, theme: &Theme, area: Rect, ) { - match &prompt.stage { - PermissionStage::Permission => render_permission_main(frame, prompt, theme, area), - PermissionStage::ConfirmAlways => render_confirm_always(frame, prompt, theme, area), - PermissionStage::RejectWithReason => render_reject_reason(frame, prompt, theme, area), + let overlay_height = 11u16.min(area.height.saturating_sub(2)); + let overlay_height = if prompt.request.delegation.is_some() { + overlay_height.saturating_add(2) + } else { + overlay_height } -} - -/// Render the main permission prompt (Allow once / Allow always / Reject) -fn render_permission_main(frame: &mut Frame, prompt: &PermissionPrompt, theme: &Theme, area: Rect) { - // Calculate overlay height based on content - let overlay_height = 8u16.min(area.height.saturating_sub(2)); + .min(area.height.saturating_sub(2)); let overlay_area = Rect { x: area.x, y: area.y + area.height.saturating_sub(overlay_height), width: area.width, height: overlay_height, }; - - // Clear the area frame.render_widget(Clear, overlay_area); - - // Split into content + button bar let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Min(3), // content - Constraint::Length(2), // button bar - ]) + .constraints([Constraint::Min(4), Constraint::Length(2)]) .split(overlay_area); - - // Content block with warning left border let content_block = Block::default() .borders(Borders::LEFT | Borders::TOP | Borders::RIGHT) .border_style(Style::default().fg(theme.warning)) .style(Style::default().bg(theme.background_panel)); - let inner = content_block.inner(chunks[0]); frame.render_widget(content_block, chunks[0]); - // Build content lines - let mut lines = vec![ - Line::from(vec![ - Span::styled("\u{25b3} ", theme.style(StyleKind::Warning)), // △ - Span::styled( - "Permission required", - Style::default() - .fg(theme.warning) - .add_modifier(Modifier::BOLD), - ), - ]), - Line::from(""), - ]; - - // Tool details - let icon = tool_icon(&prompt.tool_name); - let detail = build_tool_detail(prompt); - lines.push(Line::from(vec![ - Span::styled(format!("{} ", icon), theme.style(StyleKind::Muted)), - Span::styled(detail, Style::default()), - ])); - - let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }); - frame.render_widget(paragraph, inner); - - // Button bar - render_button_bar( - frame, - chunks[1], - theme, - &["Allow once", "Allow always", "Reject"], - prompt.selected_option, - "\u{21c6} select Enter confirm Esc reject", - ); -} - -/// Render the "Confirm Always" stage -fn render_confirm_always(frame: &mut Frame, prompt: &PermissionPrompt, theme: &Theme, area: Rect) { - let overlay_height = 6u16.min(area.height.saturating_sub(2)); - let overlay_area = Rect { - x: area.x, - y: area.y + area.height.saturating_sub(overlay_height), - width: area.width, - height: overlay_height, + let request = &prompt.request; + let resources = request + .resources + .iter() + .map(|resource| truncate_str(resource, 80)) + .collect::>() + .join(", "); + let save_scope = if request.save_resources.is_empty() { + "No remembered scope".to_string() + } else { + format!( + "Always saves {} project resource(s)", + request.save_resources.len() + ) }; - - frame.render_widget(Clear, overlay_area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(2), Constraint::Length(2)]) - .split(overlay_area); - - let content_block = Block::default() - .borders(Borders::LEFT | Borders::TOP | Borders::RIGHT) - .border_style(Style::default().fg(theme.warning)) - .style(Style::default().bg(theme.background_panel)); - - let inner = content_block.inner(chunks[0]); - frame.render_widget(content_block, chunks[0]); - - let lines = vec![ - Line::from(vec![ - Span::styled("\u{25b3} ", theme.style(StyleKind::Warning)), - Span::styled( - "Always allow", - Style::default() - .fg(theme.warning) - .add_modifier(Modifier::BOLD), - ), - ]), - Line::from(""), + let risk = request + .display_metadata + .get("riskDescription") + .or_else(|| request.display_metadata.get("risk")) + .and_then(serde_json::Value::as_str) + .unwrap_or("No additional risk information"); + let mut lines = vec![ Line::from(Span::styled( - allow_always_confirmation_text(&prompt.tool_name), - theme.style(StyleKind::Muted), + "Permission required", + Style::default() + .fg(theme.warning) + .add_modifier(Modifier::BOLD), + )), + Line::from(format!( + "Action: {} Source: {:?}:{}", + request.action, request.source.kind, request.source.identity )), ]; - - let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }); - frame.render_widget(paragraph, inner); - + if let Some(delegation_lines) = permission_delegation_lines(request) { + lines.extend(delegation_lines.map(Line::from)); + } + lines.extend([ + Line::from(format!("Resources: {resources}")), + Line::from(format!( + "Project: {} {save_scope}", + permission_project_display_label(request) + )), + Line::from(format!("Risk: {}", truncate_str(risk, 100))), + if prompt.editing_reject_feedback { + Line::from(format!( + "Rejection feedback (optional): {}_", + prompt.reject_feedback + )) + } else { + Line::from("") + }, + ]); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), inner); render_button_bar( frame, chunks[1], theme, - &["Confirm", "Cancel"], - prompt.selected_option, - "Enter confirm Esc cancel", + if prompt.editing_reject_feedback { + &["Submit reject"] + } else { + &["Allow once", "Always allow", "Reject"] + }, + if prompt.editing_reject_feedback { + 0 + } else { + prompt.selected_option + }, + if prompt.editing_reject_feedback { + "Enter submit Esc back" + } else { + "\u{21c6} select Enter confirm Esc reject" + }, ); } -/// Render the "Reject with reason" stage -fn render_reject_reason(frame: &mut Frame, prompt: &PermissionPrompt, theme: &Theme, area: Rect) { - let overlay_height = 7u16.min(area.height.saturating_sub(2)); - let overlay_area = Rect { - x: area.x, - y: area.y + area.height.saturating_sub(overlay_height), - width: area.width, - height: overlay_height, - }; - - frame.render_widget(Clear, overlay_area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(3), Constraint::Length(2)]) - .split(overlay_area); - - let content_block = Block::default() - .borders(Borders::LEFT | Borders::TOP | Borders::RIGHT) - .border_style(Style::default().fg(theme.error)) - .style(Style::default().bg(theme.background_panel)); - - let inner = content_block.inner(chunks[0]); - frame.render_widget(content_block, chunks[0]); - - let reason_display = if prompt.reject_reason.is_empty() { - "(optional reason)".to_string() - } else { - format!("{}\u{2588}", prompt.reject_reason) // cursor block - }; - - let lines = vec![ - Line::from(vec![ - Span::styled("\u{25b3} ", theme.style(StyleKind::Error)), - Span::styled( - "Reject permission", - Style::default() - .fg(theme.error) - .add_modifier(Modifier::BOLD), - ), - ]), - Line::from(""), - Line::from(Span::styled( - "Tell the AI what to do differently:", - theme.style(StyleKind::Muted), - )), - Line::from(Span::styled( - reason_display, - if prompt.reject_reason.is_empty() { - theme.style(StyleKind::Muted) - } else { - Style::default() - }, - )), - ]; - - let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }); - frame.render_widget(paragraph, inner); - - // Bottom hint bar - let hint_block = Block::default().style(Style::default().bg(theme.background_element)); - frame.render_widget(hint_block, chunks[1]); - - let hint = Paragraph::new(Line::from(vec![ - Span::raw(" "), - Span::styled("Enter", Style::default()), - Span::styled(" confirm ", theme.style(StyleKind::Muted)), - Span::styled("Esc", Style::default()), - Span::styled(" cancel", theme.style(StyleKind::Muted)), - ])) - .style(Style::default().bg(theme.background_element)); - frame.render_widget(hint, chunks[1]); -} - /// Render a horizontal button bar with selectable options fn render_button_bar( frame: &mut Frame, @@ -433,9 +255,7 @@ fn render_button_bar( } else { spans.push(Span::styled( format!(" {} ", option), - Style::default() - .fg(theme.muted) - .bg(theme.background_element), + permission_footer_secondary_style(theme), )); } } @@ -446,7 +266,10 @@ fn render_button_bar( if buttons_width + hint_width < area.width as usize { let padding = area.width as usize - buttons_width - hint_width; spans.push(Span::raw(" ".repeat(padding))); - spans.push(Span::styled(hint_text, theme.style(StyleKind::Muted))); + spans.push(Span::styled( + hint_text, + permission_footer_secondary_style(theme), + )); } let line = Line::from(spans); @@ -454,124 +277,124 @@ fn render_button_bar( frame.render_widget(paragraph, area); } -/// Build a tool detail string for the permission prompt body -fn build_tool_detail(prompt: &PermissionPrompt) -> String { - match prompt.tool_name.as_str() { - "Bash" | "bash_tool" | "run_terminal_cmd" => { - let cmd = prompt - .params - .get("command") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let desc = prompt.params.get("description").and_then(|v| v.as_str()); - match desc { - Some(d) => format!("{}\n$ {}", d, cmd), - None => format!("$ {}", cmd), - } - } - "Edit" | "search_replace" => { - let path = prompt - .params - .get("file_path") - .or_else(|| prompt.params.get("target_file")) - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - format!("Edit {}", path) - } - "Write" | "write_file" | "write_file_tool" => { - let path = prompt - .params - .get("payload") - .and_then(|value| value.as_str()) - .and_then(|value| { - let first_line = value.split_once('\n').map_or(value, |(path, _)| path); - first_line - .strip_suffix('\r') - .unwrap_or(first_line) - .strip_prefix("+++ ") - }) - .filter(|path| !path.trim().is_empty()) - .or_else(|| { - prompt - .params - .get("file_path") - .or_else(|| prompt.params.get("target_file")) - .and_then(|value| value.as_str()) - }) - .unwrap_or("workspace temporary file"); - format!("Write {}", path) - } - "Delete" => { - let path = prompt - .params - .get("file_path") - .or_else(|| prompt.params.get("path")) - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - format!("Delete {}", path) - } - "Task" => { - let desc = prompt - .params - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or("Task"); - let subagent = prompt - .params - .get("subagent_type") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - format!("{} Task: {}", subagent, desc) - } - _ => { - // Generic: show tool name + key param - let key_param = extract_first_param(&prompt.params); - if key_param.is_empty() { - format!("Call tool {}", prompt.tool_name) - } else { - format!("{} {}", prompt.tool_name, truncate_str(&key_param, 60)) - } +#[cfg(test)] +mod tests { + use super::{ + permission_delegation_lines, permission_footer_secondary_style, + permission_project_display_label, PermissionAction, PermissionPrompt, + }; + use crate::ui::theme::{builtin_theme_json, Appearance, EffectiveColorScheme, Theme}; + use bitfun_agent_runtime::sdk::{ + PermissionDelegationContext, PermissionReply, PermissionRequest, PermissionRequestSource, + PermissionRequestSourceKind, + }; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use serde_json::Map; + + fn request() -> PermissionRequest { + PermissionRequest { + request_id: "request-1".to_string(), + round_id: "synthetic:request-1".to_string(), + order: 0, + tool_call_id: None, + project_path: None, + project_id: "project-1".to_string(), + session_id: "session-1".to_string(), + agent_id: "agentic".to_string(), + action: "edit".to_string(), + resources: vec!["src/main.rs".to_string()], + save_resources: vec!["src/main.rs".to_string()], + source: PermissionRequestSource { + kind: PermissionRequestSourceKind::ToolCall, + identity: "write_file".to_string(), + }, + delegation: None, + display_metadata: Map::new(), } } -} -/// Extract the first meaningful string parameter from JSON -fn extract_first_param(params: &serde_json::Value) -> String { - if let Some(obj) = params.as_object() { - let priority = [ - "command", - "path", - "file_path", - "query", - "pattern", - "url", - "description", - ]; - for key in &priority { - if let Some(v) = obj.get(*key).and_then(|v| v.as_str()) { - return v.to_string(); - } - } - for (_, value) in obj.iter() { - if let Some(s) = value.as_str() { - if s.len() < 100 { - return s.to_string(); - } - } + #[test] + fn v2_prompt_returns_project_always_reply_without_using_legacy_runtime_scope() { + let mut prompt = PermissionPrompt::new(request()); + prompt.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + + assert_eq!( + prompt.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + PermissionAction::Reply(PermissionReply::Always) + ); + } + + #[test] + fn v2_prompt_collects_optional_rejection_feedback() { + let mut prompt = PermissionPrompt::new(request()); + prompt.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + prompt.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!( + prompt.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + PermissionAction::None + ); + for character in "read only".chars() { + prompt.handle_key_event(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE)); } + + assert_eq!( + prompt.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + PermissionAction::Reply(PermissionReply::Reject { + feedback: Some("read only".to_string()), + }) + ); } - String::new() -} -#[cfg(test)] -mod tests { - use super::allow_always_confirmation_text; + #[test] + fn delegated_prompt_names_the_child_and_parent_task_context() { + let mut request = request(); + request.session_id = "child-session".to_string(); + request.agent_id = "Explore".to_string(); + request.delegation = Some(PermissionDelegationContext { + parent_session_id: "parent-session".to_string(), + parent_dialog_turn_id: Some("parent-turn".to_string()), + parent_tool_call_id: "parent-task".to_string(), + subagent_type: "Explore".to_string(), + }); + + assert_eq!( + permission_delegation_lines(&request), + Some([ + "Subagent: Explore Child session: child-session".to_string(), + "Parent session: parent-session Task: parent-task".to_string(), + ]) + ); + } #[test] - fn allow_always_copy_describes_cli_runtime_lifetime() { - let text = allow_always_confirmation_text("run_terminal_cmd"); + fn permission_prompt_prefers_a_nonempty_project_path_for_display() { + let mut with_path = request(); + with_path.project_path = Some(" E:/Projects/BitFun ".to_string()); + assert_eq!( + permission_project_display_label(&with_path), + "E:/Projects/BitFun" + ); + + let mut empty_path = request(); + empty_path.project_path = Some(" ".to_string()); + assert_eq!(permission_project_display_label(&empty_path), "project-1"); + } - assert!(text.contains("until this CLI runtime exits")); - assert!(!text.contains("session")); + #[test] + fn permission_footer_secondary_content_remains_visible_in_ansi16_themes() { + for theme_id in ["bitfun-dark", "bitfun-midnight", "bitfun-tokyo-night"] { + let theme = Theme::dark() + .apply_opencode_theme_json( + builtin_theme_json(theme_id).expect("built-in theme must exist"), + Appearance::Dark, + ) + .expect("built-in theme must resolve") + .with_effective_scheme(EffectiveColorScheme::Ansi16); + let style = permission_footer_secondary_style(&theme); + + assert_eq!(style.fg, Some(theme.primary), "{theme_id}"); + assert_eq!(style.bg, Some(theme.background_element), "{theme_id}"); + assert_ne!(style.fg, style.bg, "{theme_id}"); + } } } diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 2268b9cb77..6f7bed43a1 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1044,6 +1044,7 @@ impl StartupPage { | ActionHandler::Tools | ActionHandler::Extensions | ActionHandler::History + | ActionHandler::ToggleAutoApprove | ActionHandler::Interrupt | ActionHandler::ToggleFocusedTool | ActionHandler::PreviousTool diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index aa8447ea92..db95701ac7 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -2,7 +2,8 @@ use log::{debug, warn}; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use sha1::{Digest, Sha1}; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Instant; use tauri::{AppHandle, State}; @@ -15,8 +16,8 @@ use crate::runtime::{ use crate::startup_trace::DesktopStartupTrace; use bitfun_agent_runtime::sdk::{ AgentDialogTurnRequest, AgentInputAttachment, AgentSessionModelUpdateRequest, - AgentSubmissionSource, AgentToolConfirmationRequest, AgentToolRejectionRequest, - AgentTurnCancellationRequest, + AgentSubmissionSource, AgentTurnCancellationRequest, PermissionAuditRecord, PermissionGrant, + PermissionGrantKey, PermissionReply, PermissionRequest, }; use bitfun_core::agentic::agents::AgentSource; use bitfun_core::agentic::coordination::{ @@ -42,10 +43,17 @@ use bitfun_core::agentic::tools::implementations::exec_command::{ ReadBackgroundCommandOutputRequest as CoreReadBackgroundCommandOutputRequest, ReadBackgroundCommandOutputResponse, }; +use bitfun_core::service::config::project_permission_store::{ + deserialize_project_permission_config, project_permission_file_path, + project_permission_file_path_for_remote, ProjectPermissionConfig, +}; +use bitfun_core::service::remote_ssh::workspace_state::resolve_workspace_session_identity; use bitfun_core::service::session::{ DialogTurnData, SessionMemoryMode, SessionMetadata, SessionRelationship, SessionRelationshipKind, }; +use bitfun_core::service::workspace::WorkspaceKind; +use bitfun_product_domains::tool_permissions::PermissionRule; const SESSION_VIEW_TOOL_RESULT_TOTAL_CHAR_BUDGET: usize = 512 * 1024; const SESSION_VIEW_TOOL_RESULT_STRING_CHAR_LIMIT: usize = 16 * 1024; @@ -722,20 +730,446 @@ pub struct ListSessionsRequest { pub remote_ssh_host: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct ConfirmToolRequest { - pub session_id: String, - pub tool_id: String, - pub updated_input: Option, +pub struct PermissionResponseRequest { + pub request_id: String, + pub reply: PermissionReplyKind, + pub feedback: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct RejectToolRequest { - pub session_id: String, - pub tool_id: String, - pub reason: Option, +pub struct PermissionProjectRequest { + pub workspace_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemovePermissionGrantRequest { + pub workspace_id: String, + pub action: String, + pub resource: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionAuditRequest { + pub workspace_id: String, + #[serde(default)] + pub page: usize, + #[serde(default = "default_permission_audit_page_size")] + pub page_size: usize, +} + +const fn default_permission_audit_page_size() -> usize { + 50 +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionAuditPage { + pub project_id: String, + pub records: Vec, + pub page: usize, + pub page_size: usize, + pub total: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPermissionRulesResponse { + pub rules: Vec, + pub revision: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveProjectPermissionRulesRequest { + pub workspace_id: String, + pub rules: Vec, + pub revision: String, +} + +#[derive(Debug, Clone)] +struct ProjectPermissionConfigTarget { + path: String, + remote_connection_id: Option, +} + +async fn permission_project_id_for_workspace( + state: &AppState, + workspace_id: &str, +) -> Result { + let workspace = state + .workspace_service + .get_workspace(workspace_id) + .await + .ok_or_else(|| format!("Workspace not found: {workspace_id}"))?; + let remote = workspace.workspace_kind == WorkspaceKind::Remote; + let connection_id = workspace + .metadata + .get("connectionId") + .and_then(|value| value.as_str()); + let ssh_host = workspace + .metadata + .get("sshHost") + .and_then(|value| value.as_str()); + let identity = resolve_workspace_session_identity( + &workspace.root_path.to_string_lossy(), + connection_id, + ssh_host, + ) + .await + .ok_or_else(|| format!("Workspace identity is unavailable: {workspace_id}"))?; + bitfun_core::agentic::tools::pipeline::permission_project_id_for_workspace_identity( + &identity, remote, + ) + .map_err(|error| error.to_string()) +} + +async fn project_permission_config_target_for_workspace( + state: &AppState, + workspace_id: &str, +) -> Result { + let workspace = state + .workspace_service + .get_workspace(workspace_id) + .await + .ok_or_else(|| format!("Workspace not found: {workspace_id}"))?; + + if workspace.workspace_kind == WorkspaceKind::Remote { + let remote_connection_id = workspace + .metadata + .get("connectionId") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + format!( + "Remote workspace is missing a connection ID: {}", + workspace.id + ) + })? + .to_string(); + return Ok(ProjectPermissionConfigTarget { + path: project_permission_file_path_for_remote(&workspace.root_path.to_string_lossy()), + remote_connection_id: Some(remote_connection_id), + }); + } + + Ok(ProjectPermissionConfigTarget { + path: project_permission_file_path(&workspace.root_path) + .to_string_lossy() + .to_string(), + remote_connection_id: None, + }) +} + +async fn read_project_permission_config_content( + state: &AppState, + target: &ProjectPermissionConfigTarget, +) -> Result, String> { + let Some(connection_id) = target.remote_connection_id.as_deref() else { + return match tokio::fs::read_to_string(&target.path).await { + Ok(content) => Ok(Some(content)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!( + "Failed to read project permission rules '{}': {error}", + target.path + )), + }; + }; + + let remote_fs = state + .get_remote_file_service_async() + .await + .map_err(|error| format!("Remote file service is not available: {error}"))?; + let exists = remote_fs + .exists(connection_id, &target.path) + .await + .map_err(|error| format!("Failed to check remote project permission rules: {error}"))?; + if !exists { + return Ok(None); + } + let bytes = remote_fs + .read_file(connection_id, &target.path) + .await + .map_err(|error| format!("Failed to read remote project permission rules: {error}"))?; + String::from_utf8(bytes) + .map(Some) + .map_err(|error| format!("Project permission rules are not valid UTF-8: {error}")) +} + +async fn write_project_permission_config_content( + state: &AppState, + target: &ProjectPermissionConfigTarget, + content: &str, +) -> Result<(), String> { + let Some(connection_id) = target.remote_connection_id.as_deref() else { + let parent = Path::new(&target.path).parent().ok_or_else(|| { + format!( + "Project permission rules path has no parent directory: {}", + target.path + ) + })?; + tokio::fs::create_dir_all(parent).await.map_err(|error| { + format!( + "Failed to create project permission rules directory '{}': {error}", + parent.display() + ) + })?; + return tokio::fs::write(&target.path, content) + .await + .map_err(|error| { + format!( + "Failed to write project permission rules '{}': {error}", + target.path + ) + }); + }; + + let remote_fs = state + .get_remote_file_service_async() + .await + .map_err(|error| format!("Remote file service is not available: {error}"))?; + let parent = target + .path + .rsplit_once('/') + .map(|(parent, _)| parent) + .ok_or_else(|| { + format!( + "Remote project permission rules path has no parent directory: {}", + target.path + ) + })?; + remote_fs + .create_dir_all(connection_id, parent) + .await + .map_err(|error| { + format!("Failed to create remote project permission rules directory: {error}") + })?; + remote_fs + .write_file(connection_id, &target.path, content.as_bytes()) + .await + .map_err(|error| format!("Failed to write remote project permission rules: {error}")) +} + +fn project_permission_rules_revision(content: Option<&str>) -> String { + let mut hasher = Sha1::new(); + match content { + Some(content) => { + hasher.update(b"present\0"); + hasher.update(content.as_bytes()); + } + None => hasher.update(b"missing\0"), + } + format!("{:x}", hasher.finalize()) +} + +fn validate_project_permission_rules(rules: &[PermissionRule]) -> Result<(), String> { + if rules + .iter() + .any(|rule| rule.action.trim().is_empty() || rule.resource.trim().is_empty()) + { + return Err("Project permission rule action and resource must be non-empty".to_string()); + } + Ok(()) +} + +#[tauri::command] +pub async fn get_project_permission_rules( + state: State<'_, AppState>, + request: PermissionProjectRequest, +) -> Result { + let target = + project_permission_config_target_for_workspace(&state, &request.workspace_id).await?; + let content = read_project_permission_config_content(&state, &target).await?; + let rules = content + .as_deref() + .map(deserialize_project_permission_config) + .transpose() + .map_err(|error| error.to_string())? + .unwrap_or_default() + .rules; + Ok(ProjectPermissionRulesResponse { + rules, + revision: project_permission_rules_revision(content.as_deref()), + }) +} + +#[tauri::command] +pub async fn save_project_permission_rules( + state: State<'_, AppState>, + request: SaveProjectPermissionRulesRequest, +) -> Result { + validate_project_permission_rules(&request.rules)?; + + let target = + project_permission_config_target_for_workspace(&state, &request.workspace_id).await?; + let current_content = read_project_permission_config_content(&state, &target).await?; + let current_revision = project_permission_rules_revision(current_content.as_deref()); + if request.revision != current_revision { + return Err( + "Project permission rules changed outside BitFun. Reload before saving.".to_string(), + ); + } + + let content = format!( + "{}\n", + serde_json::to_string_pretty(&ProjectPermissionConfig { + rules: request.rules.clone(), + }) + .map_err(|error| format!("Failed to serialize project permission rules: {error}"))? + ); + write_project_permission_config_content(&state, &target, &content).await?; + Ok(ProjectPermissionRulesResponse { + rules: request.rules, + revision: project_permission_rules_revision(Some(&content)), + }) +} + +#[tauri::command] +pub async fn list_project_permission_grants( + state: State<'_, AppState>, + runtime: State<'_, DesktopRuntimeContext>, + request: PermissionProjectRequest, +) -> Result, String> { + let project_id = permission_project_id_for_workspace(&state, &request.workspace_id).await?; + runtime + .agent_runtime() + .list_project_permission_grants(&project_id) + .await + .map_err(|error| error.into_message()) +} + +#[tauri::command] +pub async fn remove_project_permission_grant( + state: State<'_, AppState>, + runtime: State<'_, DesktopRuntimeContext>, + request: RemovePermissionGrantRequest, +) -> Result { + let project_id = permission_project_id_for_workspace(&state, &request.workspace_id).await?; + runtime + .agent_runtime() + .remove_project_permission_grant(PermissionGrantKey { + project_id, + action: request.action, + resource: request.resource, + }) + .await + .map_err(|error| error.into_message()) +} + +#[tauri::command] +pub async fn clear_project_permission_grants( + state: State<'_, AppState>, + runtime: State<'_, DesktopRuntimeContext>, + request: PermissionProjectRequest, +) -> Result { + let project_id = permission_project_id_for_workspace(&state, &request.workspace_id).await?; + runtime + .agent_runtime() + .clear_project_permission_grants(&project_id) + .await + .map_err(|error| error.into_message()) +} + +#[tauri::command] +pub async fn list_project_permission_audit( + state: State<'_, AppState>, + runtime: State<'_, DesktopRuntimeContext>, + request: PermissionAuditRequest, +) -> Result { + let project_id = permission_project_id_for_workspace(&state, &request.workspace_id).await?; + let mut records = runtime + .agent_runtime() + .list_project_permission_audit(&project_id) + .await + .map_err(|error| error.into_message())?; + records.sort_by(|left, right| { + right + .timestamp_ms + .cmp(&left.timestamp_ms) + .then_with(|| right.audit_id.cmp(&left.audit_id)) + }); + let total = records.len(); + let page_size = request.page_size.clamp(1, 100); + let offset = request.page.saturating_mul(page_size).min(total); + let records = records.into_iter().skip(offset).take(page_size).collect(); + Ok(PermissionAuditPage { + project_id, + records, + page: request.page, + page_size, + total, + }) +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PermissionReplyKind { + Once, + Always, + Reject, +} + +fn permission_reply(request: PermissionResponseRequest) -> PermissionReply { + match request.reply { + PermissionReplyKind::Once => PermissionReply::Once, + PermissionReplyKind::Always => PermissionReply::Always, + PermissionReplyKind::Reject => PermissionReply::Reject { + feedback: request.feedback, + }, + } +} + +#[tauri::command] +pub fn list_pending_permission_requests( + runtime: State<'_, DesktopRuntimeContext>, +) -> Result, String> { + runtime + .agent_runtime() + .pending_permission_requests() + .map_err(|error| error.into_message()) +} + +#[tauri::command] +pub fn subscribe_permission_requests( + app: AppHandle, + runtime: State<'_, DesktopRuntimeContext>, +) -> Result<(), String> { + runtime + .start_permission_event_forwarding(app) + .map_err(|error| error.into_message()) +} + +#[tauri::command] +pub async fn respond_permission( + runtime: State<'_, DesktopRuntimeContext>, + request: PermissionResponseRequest, +) -> Result<(), String> { + let request_id = request.request_id.clone(); + let reply = permission_reply(request); + runtime + .agent_runtime() + .respond_permission(&request_id, reply) + .await + .map_err(|error| error.into_message()) +} + +#[tauri::command] +pub async fn respond_permission_batch( + runtime: State<'_, DesktopRuntimeContext>, + request: PermissionResponseRequest, +) -> Result, String> { + let request_id = request.request_id.clone(); + let reply = permission_reply(request); + runtime + .agent_runtime() + .respond_permission_batch(&request_id, reply) + .await + .map_err(|error| error.into_message()) } #[derive(Debug, Deserialize)] @@ -2239,40 +2673,6 @@ pub async fn list_sessions( Ok(responses) } -#[tauri::command] -pub async fn confirm_tool_execution( - runtime: State<'_, DesktopRuntimeContext>, - request: ConfirmToolRequest, -) -> Result<(), String> { - runtime - .agent_runtime() - .confirm_tool(AgentToolConfirmationRequest { - tool_id: request.tool_id, - updated_input: request.updated_input, - }) - .await - .map_err(|error| format!("Confirm tool failed: {}", error.into_message())) -} - -#[tauri::command] -pub async fn reject_tool_execution( - runtime: State<'_, DesktopRuntimeContext>, - request: RejectToolRequest, -) -> Result<(), String> { - let reason = request - .reason - .unwrap_or_else(|| "User rejected".to_string()); - - runtime - .agent_runtime() - .reject_tool(AgentToolRejectionRequest { - tool_id: request.tool_id, - reason, - }) - .await - .map_err(|error| format!("Reject tool failed: {}", error.into_message())) -} - #[tauri::command] pub async fn generate_session_title( coordinator: State<'_, Arc>, @@ -2438,8 +2838,37 @@ mod tests { use bitfun_core::service::session::{ ModelRoundData, ToolCallData, ToolItemData, ToolResultData, TurnStatus, UserMessageData, }; + use bitfun_product_domains::tool_permissions::{PermissionEffect, PermissionRule}; use serde_json::json; + #[test] + fn project_permission_rule_revisions_distinguish_missing_and_present_files() { + assert_eq!( + project_permission_rules_revision(Some("{\"rules\":[]}")), + project_permission_rules_revision(Some("{\"rules\":[]}")) + ); + assert_ne!( + project_permission_rules_revision(None), + project_permission_rules_revision(Some("")) + ); + } + + #[test] + fn project_permission_rule_validation_requires_action_and_resource() { + assert!(validate_project_permission_rules(&[PermissionRule::new( + "edit", + "src/*", + PermissionEffect::Ask, + )]) + .is_ok()); + assert!(validate_project_permission_rules(&[PermissionRule::new( + " ", + "src/*", + PermissionEffect::Ask, + )]) + .is_err()); + } + #[test] fn desktop_dialog_turn_request_preserves_runtime_contract() { let request: StartDialogTurnRequest = serde_json::from_value(json!({ @@ -2531,6 +2960,26 @@ mod tests { ); } + #[test] + fn permission_response_dto_uses_stable_camel_case_wire_shape() { + let request: PermissionResponseRequest = serde_json::from_value(json!({ + "requestId": "permission-1", + "reply": "reject", + "feedback": "Use a read-only path" + })) + .expect("permission response request"); + + assert_eq!(request.request_id, "permission-1"); + assert!(matches!(request.reply, PermissionReplyKind::Reject)); + assert_eq!(request.feedback.as_deref(), Some("Use a read-only path")); + assert_eq!( + permission_reply(request), + PermissionReply::Reject { + feedback: Some("Use a read-only path".to_string()), + } + ); + } + #[test] fn desktop_interaction_dtos_keep_existing_camel_case_shape() { let cancel: CancelDialogTurnRequest = serde_json::from_value(json!({ @@ -2538,27 +2987,8 @@ mod tests { "dialogTurnId": "turn-1" })) .expect("cancel request"); - let confirm: ConfirmToolRequest = serde_json::from_value(json!({ - "sessionId": "session-1", - "toolId": "tool-1", - "updatedInput": { "path": "updated.txt" } - })) - .expect("confirm request"); - let reject: RejectToolRequest = serde_json::from_value(json!({ - "sessionId": "session-1", - "toolId": "tool-1", - "reason": "Use a read-only path" - })) - .expect("reject request"); - assert_eq!(cancel.session_id, "session-1"); assert_eq!(cancel.dialog_turn_id, "turn-1"); - assert_eq!(confirm.tool_id, "tool-1"); - assert_eq!( - confirm.updated_input, - Some(json!({ "path": "updated.txt" })) - ); - assert_eq!(reject.reason.as_deref(), Some("Use a read-only path")); assert_eq!( serde_json::to_value(StartDialogTurnResponse { success: true, diff --git a/src/apps/desktop/src/api/miniapp_agent_api.rs b/src/apps/desktop/src/api/miniapp_agent_api.rs index bddb0578e1..0ac9527108 100644 --- a/src/apps/desktop/src/api/miniapp_agent_api.rs +++ b/src/apps/desktop/src/api/miniapp_agent_api.rs @@ -266,8 +266,7 @@ pub async fn miniapp_agent_run( session.session_id }; - let policy = DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopApi) - .with_skip_tool_confirmation(true); + let policy = DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopApi); let outcome = scheduler .submit( diff --git a/src/apps/desktop/src/api/miniapp_api.rs b/src/apps/desktop/src/api/miniapp_api.rs index 776fa1d29e..f2375be93a 100644 --- a/src/apps/desktop/src/api/miniapp_api.rs +++ b/src/apps/desktop/src/api/miniapp_api.rs @@ -1522,15 +1522,12 @@ pub async fn miniapp_ai_list_models( model_name: model.model_name.clone(), provider: model.provider.clone(), enabled: model.enabled, - supports_text_chat: model - .capabilities - .iter() - .any(|capability| { - matches!( - capability, - bitfun_core::service::config::types::ModelCapability::TextChat - ) - }), + supports_text_chat: model.capabilities.iter().any(|capability| { + matches!( + capability, + bitfun_core::service::config::types::ModelCapability::TextChat + ) + }), }), ai_perms.allowed_models.as_deref().unwrap_or(&[]), &primary_id, diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index 91e453f1fa..a9cbfb40ff 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -103,8 +103,15 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ static PENDING: OnceLock>>> = OnceLock::new(); -/// Controllers currently attached for DeviceEvent fan-out (device ids). -static CONTROL_SUBSCRIBERS: OnceLock>> = OnceLock::new(); +#[derive(Default)] +struct PeerControlState { + controllers: HashSet, + permission_request_ids: HashSet, +} + +/// Controllers currently attached for DeviceEvent fan-out and the pending +/// permission requests projected to them. +static PEER_CONTROL_STATE: OnceLock> = OnceLock::new(); /// True while this process is acting as a Peer Mode controller (Remote: B). /// Used to pause cloud settings pull that would rewrite local disk mid-remote. @@ -114,8 +121,8 @@ fn pending_map() -> &'static Mutex &'static Mutex> { - CONTROL_SUBSCRIBERS.get_or_init(|| Mutex::new(HashSet::new())) +fn peer_control_state() -> &'static Mutex { + PEER_CONTROL_STATE.get_or_init(|| Mutex::new(PeerControlState::default())) } pub fn set_peer_controller_active(active: bool) { @@ -150,21 +157,121 @@ pub fn is_local_only_command(command: &str) -> bool { /// Register a controller device id to receive peer UI events. pub fn attach_controller(device_id: String) { - if let Ok(mut set) = control_subscribers().lock() { - set.insert(device_id); + if let Ok(mut state) = peer_control_state().lock() { + state.controllers.insert(device_id); + } +} + +fn detach_from_state(state: &mut PeerControlState, device_id: &str) -> Vec { + let removed = state.controllers.remove(device_id); + if removed && state.controllers.is_empty() { + return state.permission_request_ids.drain().collect(); } + Vec::new() +} + +/// Detach one controller and return requests that must fail closed when it was +/// the final controller. +pub fn detach_controller(device_id: &str) -> Vec { + let Ok(mut state) = peer_control_state().lock() else { + return Vec::new(); + }; + detach_from_state(&mut state, device_id) } -pub fn detach_controller(device_id: &str) { - if let Ok(mut set) = control_subscribers().lock() { - set.remove(device_id); +fn retain_online_in_state( + state: &mut PeerControlState, + online_device_ids: &HashSet, +) -> Vec { + let had_controllers = !state.controllers.is_empty(); + state + .controllers + .retain(|device_id| online_device_ids.contains(device_id)); + if had_controllers && state.controllers.is_empty() { + return state.permission_request_ids.drain().collect(); + } + Vec::new() +} + +/// Remove controllers missing from account presence and return requests that +/// lost their final control surface. +pub fn retain_online_controllers(online_device_ids: &HashSet) -> Vec { + let Ok(mut state) = peer_control_state().lock() else { + return Vec::new(); + }; + retain_online_in_state(&mut state, online_device_ids) +} + +/// Clear all attached controllers after the device-routing stream closes. +pub fn disconnect_controllers() -> Vec { + let Ok(mut state) = peer_control_state().lock() else { + return Vec::new(); + }; + state.controllers.clear(); + state.permission_request_ids.drain().collect() +} + +pub fn track_permission_event(event: &bitfun_agent_runtime::sdk::PermissionRequestEvent) -> bool { + let Ok(mut state) = peer_control_state().lock() else { + return false; + }; + match event { + bitfun_agent_runtime::sdk::PermissionRequestEvent::Asked { request } => { + if !state.controllers.is_empty() { + state + .permission_request_ids + .insert(request.request_id.clone()); + true + } else { + false + } + } + bitfun_agent_runtime::sdk::PermissionRequestEvent::Replied { request_id, .. } + | bitfun_agent_runtime::sdk::PermissionRequestEvent::Cancelled { request_id, .. } => { + let was_tracked = state.permission_request_ids.remove(request_id); + was_tracked && !state.controllers.is_empty() + } + } +} + +pub fn take_tracked_permission_requests() -> Vec { + peer_control_state() + .lock() + .map(|mut state| state.permission_request_ids.drain().collect()) + .unwrap_or_default() +} + +pub async fn fail_closed_permission_requests( + request_ids: Vec, + reason: &str, +) -> Result<(), String> { + if request_ids.is_empty() { + return Ok(()); + } + let manager = bitfun_core::product_runtime::core_permission_request_manager()?; + let mut failures = Vec::new(); + for request_id in request_ids { + if let Err(error) = manager + .cancel_request(&request_id, reason.to_string()) + .await + { + failures.push(format!("{request_id}: {error}")); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(format!( + "Failed to cancel Peer permission requests: {}", + failures.join("; ") + )) } } pub fn attached_controllers() -> Vec { - control_subscribers() + peer_control_state() .lock() - .map(|set| set.iter().cloned().collect()) + .map(|state| state.controllers.iter().cloned().collect()) .unwrap_or_default() } @@ -199,8 +306,8 @@ pub async fn peer_control_attach(controller_device_id: String) -> Result<(), Str #[tauri::command] pub async fn peer_control_detach(controller_device_id: String) -> Result<(), String> { - detach_controller(&controller_device_id); - Ok(()) + let request_ids = detach_controller(&controller_device_id); + fail_closed_permission_requests(request_ids, "Last Peer controller detached").await } #[tauri::command] @@ -295,3 +402,38 @@ async fn bridge_via_webview( } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn control_state(controllers: &[&str], requests: &[&str]) -> PeerControlState { + PeerControlState { + controllers: controllers.iter().map(|value| value.to_string()).collect(), + permission_request_ids: requests.iter().map(|value| value.to_string()).collect(), + } + } + + #[test] + fn only_the_final_detach_drains_peer_permission_requests() { + let mut state = control_state(&["controller-a", "controller-b"], &["request-1"]); + + assert!(detach_from_state(&mut state, "controller-a").is_empty()); + assert_eq!( + detach_from_state(&mut state, "controller-b"), + vec!["request-1".to_string()] + ); + } + + #[test] + fn presence_loss_drains_requests_only_when_every_controller_is_offline() { + let mut state = control_state(&["controller-a", "controller-b"], &["request-1"]); + let online = HashSet::from(["controller-b".to_string()]); + assert!(retain_online_in_state(&mut state, &online).is_empty()); + + assert_eq!( + retain_online_in_state(&mut state, &HashSet::new()), + vec!["request-1".to_string()] + ); + } +} diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index 6a20258baa..c3c8404cc4 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -206,6 +206,7 @@ fn should_fanout_peer_ui_event(event: &str) -> bool { | "backend-event-toolexecutionerror" | "backend-event-toolawaitinguserinput" | "backend-event-toolcallconfirmation" + | "permission://event" ) } @@ -1606,6 +1607,21 @@ pub async fn account_connect_devices() -> Result, String> } RelayEvent::DevicePresence { devices } => { log::info!("Device presence updated: {} online", devices.len()); + let online_device_ids = devices + .iter() + .map(|device| device.device_id.clone()) + .collect::>(); + let request_ids = + crate::api::peer_host_invoke::retain_online_controllers(&online_device_ids); + if let Err(error) = + crate::api::peer_host_invoke::fail_closed_permission_requests( + request_ids, + "Last Peer controller went offline", + ) + .await + { + log::warn!("Peer permission requests were not fully cancelled: {error}"); + } let pairs: Vec<(String, String)> = devices .iter() .map(|d| (d.device_id.clone(), d.device_name.clone())) @@ -1763,6 +1779,17 @@ pub async fn account_connect_devices() -> Result, String> } RelayEvent::Disconnected => { log::info!("Device routing disconnected"); + let request_ids = + crate::api::peer_host_invoke::take_tracked_permission_requests(); + if let Err(error) = + crate::api::peer_host_invoke::fail_closed_permission_requests( + request_ids, + "Peer device-routing connection lost", + ) + .await + { + log::warn!("Peer permission requests were not fully cancelled: {error}"); + } } RelayEvent::Reconnected => { log::info!("Device routing reconnected — AuthConnect re-sent by transport"); @@ -1770,6 +1797,15 @@ pub async fn account_connect_devices() -> Result, String> _ => {} } } + let request_ids = crate::api::peer_host_invoke::disconnect_controllers(); + if let Err(error) = crate::api::peer_host_invoke::fail_closed_permission_requests( + request_ids, + "Peer device-routing stream closed", + ) + .await + { + log::warn!("Peer permission requests were not fully cancelled: {error}"); + } }); let devices = service.online_devices().await; @@ -2956,7 +2992,13 @@ async fn execute_local_remote_command( .or_else(|| args.get("controller_device_id")) .and_then(|v| v.as_str()) .unwrap_or(""); - crate::api::peer_host_invoke::detach_controller(controller_id); + let request_ids = crate::api::peer_host_invoke::detach_controller(controller_id); + crate::api::peer_host_invoke::fail_closed_permission_requests( + request_ids, + "Last Peer controller detached", + ) + .await + .map_err(anyhow::Error::msg)?; return serde_json::to_value(RemoteResponse::HostInvokeResult { ok: true, value: Some(serde_json::json!({ "detached": true })), @@ -3119,3 +3161,14 @@ mod sync_state_tests { assert!(relay_turns_import_is_complete(&metadata, 2)); } } + +#[cfg(test)] +mod peer_event_tests { + use super::should_fanout_peer_ui_event; + + #[test] + fn permission_events_are_fanned_out_to_peer_controllers() { + assert!(should_fanout_peer_ui_event("permission://event")); + assert!(!should_fanout_peer_ui_event("permission://internal")); + } +} diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index eef7b596b2..77aeca4918 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -267,10 +267,6 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "computer_use_request_permissions", RemoteWorkspacePolicy::LocalOnly, ), - ( - "confirm_tool_execution", - RemoteWorkspacePolicy::LegacyUnaudited, - ), ( "control_background_command", RemoteWorkspacePolicy::LegacyUnaudited, @@ -743,6 +739,26 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "list_background_command_activities", RemoteWorkspacePolicy::LegacyUnaudited, ), + ( + "list_pending_permission_requests", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "list_project_permission_grants", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "list_project_permission_audit", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "get_project_permission_rules", + RemoteWorkspacePolicy::RemoteRouted, + ), + ( + "save_project_permission_rules", + RemoteWorkspacePolicy::RemoteRouted, + ), ("list_cron_jobs", RemoteWorkspacePolicy::LegacyUnaudited), ( "list_directory_files", @@ -1127,8 +1143,20 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ("reject_file", RemoteWorkspacePolicy::LegacyUnaudited), ("reject_operation", RemoteWorkspacePolicy::LegacyUnaudited), ( - "reject_tool_execution", - RemoteWorkspacePolicy::LegacyUnaudited, + "respond_permission", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "respond_permission_batch", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "remove_project_permission_grant", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "clear_project_permission_grants", + RemoteWorkspacePolicy::WorkspaceAgnostic, ), ("reload_config", RemoteWorkspacePolicy::LegacyUnaudited), ( @@ -1520,6 +1548,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::LegacyUnaudited, ), ("start_dialog_turn", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "subscribe_permission_requests", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ("start_file_watch", RemoteWorkspacePolicy::LegacyUnaudited), ( "start_mcp_remote_oauth", @@ -1805,7 +1837,6 @@ mod tests { "compact_session", "compress_path", "compute_diff", - "confirm_tool_execution", "control_background_command", "control_deep_review_queue", "create_acp_flow_session", @@ -2041,7 +2072,6 @@ mod tests { "refresh_model_client", "reject_file", "reject_operation", - "reject_tool_execution", "reload_config", "reload_custom_agents", "reload_global_config", diff --git a/src/apps/desktop/src/api/tool_api.rs b/src/apps/desktop/src/api/tool_api.rs index 5ab103c701..06dbc09ad0 100644 --- a/src/apps/desktop/src/api/tool_api.rs +++ b/src/apps/desktop/src/api/tool_api.rs @@ -63,7 +63,6 @@ pub struct ToolInfo { pub input_schema: serde_json::Value, pub is_readonly: bool, pub is_concurrency_safe: bool, - pub needs_permissions: bool, #[serde(skip_serializing_if = "Option::is_none")] pub dynamic_info: Option, } @@ -95,29 +94,6 @@ pub struct ToolValidationResponse { pub meta: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ToolConfirmationRequest { - #[serde(alias = "tool_use_id")] - pub tool_use_id: String, - #[serde(alias = "tool_name")] - pub tool_name: String, - pub action: String, - #[serde(alias = "task_id")] - pub task_id: Option, - #[serde(alias = "updated_input")] - pub updated_input: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ToolConfirmationResponse { - #[serde(alias = "tool_use_id")] - pub tool_use_id: String, - pub success: bool, - pub message: String, -} - async fn build_tool_context(workspace_path: Option<&str>) -> ToolUseContext { let normalized_workspace_path = workspace_path .map(str::trim) @@ -221,7 +197,6 @@ async fn build_tool_info(tool: &Arc anyhow::Result<( let tool_registry = tools::registry::get_global_tool_registry(); let tool_state_manager = Arc::new(tools::pipeline::ToolStateManager::new(event_queue.clone())); + let permission_request_manager = + bitfun_core::product_runtime::core_permission_request_manager() + .map_err(anyhow::Error::msg)?; let computer_use_host: ComputerUseHostRef = Arc::new(computer_use::DesktopComputerUseHost::new()); set_computer_use_desktop_available(true); - let tool_pipeline = Arc::new(tools::pipeline::ToolPipeline::new( - tool_registry, - tool_state_manager, - Some(computer_use_host), - )); + let tool_pipeline = Arc::new( + tools::pipeline::ToolPipeline::new( + tool_registry, + tool_state_manager, + Some(computer_use_host), + ) + .with_permission_request_manager(permission_request_manager), + ); let stream_processor = Arc::new(execution::StreamProcessor::new(event_queue.clone())); let round_executor = Arc::new(execution::RoundExecutor::new( diff --git a/src/apps/desktop/src/runtime/mod.rs b/src/apps/desktop/src/runtime/mod.rs index 64fd1524df..0f7f3e0c37 100644 --- a/src/apps/desktop/src/runtime/mod.rs +++ b/src/apps/desktop/src/runtime/mod.rs @@ -1,6 +1,7 @@ +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use bitfun_agent_runtime::sdk::AgentRuntime; +use bitfun_agent_runtime::sdk::{AgentRuntime, PermissionRequestEvent}; use bitfun_core::agentic::coordination::{ConversationCoordinator, DialogScheduler}; use bitfun_core::product_runtime::CoreLocalWorkspaceSnapshot; use bitfun_core::service::remote_ssh::SSHConnectionManager; @@ -28,6 +29,7 @@ pub(crate) use session_application::{ pub struct DesktopRuntimeContext { session_application: DesktopSessionApplication, local_workspace_snapshot: Arc, + permission_events_started: AtomicBool, } impl DesktopRuntimeContext { @@ -53,6 +55,7 @@ impl DesktopRuntimeContext { Ok(Self { session_application, local_workspace_snapshot, + permission_events_started: AtomicBool::new(false), }) } @@ -67,6 +70,80 @@ impl DesktopRuntimeContext { pub(crate) fn local_workspace_snapshot(&self) -> &dyn LocalWorkspaceSnapshotPort { self.local_workspace_snapshot.as_ref() } + + pub(crate) fn start_permission_event_forwarding( + &self, + app: tauri::AppHandle, + ) -> Result<(), bitfun_agent_runtime::sdk::RuntimeError> { + if self.permission_events_started.swap(true, Ordering::AcqRel) { + return Ok(()); + } + + let mut receiver = match self.agent_runtime().subscribe_permission_requests() { + Ok(receiver) => receiver, + Err(error) => { + self.permission_events_started + .store(false, Ordering::Release); + return Err(error); + } + }; + let runtime = self.agent_runtime().clone(); + tauri::async_runtime::spawn(async move { + use tauri::Emitter; + + loop { + match receiver.recv().await { + Ok(event) => { + let fanout = crate::api::peer_host_invoke::track_permission_event(&event); + if fanout { + if let Ok(payload) = serde_json::to_value(&event) { + crate::api::remote_connect_api::maybe_fanout_peer_ui_event( + "permission://event", + payload, + ); + } + } + let _ = app.emit("permission://event", event); + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + if let Ok(requests) = runtime.pending_permission_requests() { + for request in requests { + let event = PermissionRequestEvent::Asked { request }; + let fanout = + crate::api::peer_host_invoke::track_permission_event(&event); + if fanout { + if let Ok(payload) = serde_json::to_value(&event) { + crate::api::remote_connect_api::maybe_fanout_peer_ui_event( + "permission://event", + payload, + ); + } + } + let _ = app.emit("permission://event", event); + } + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + let request_ids = + crate::api::peer_host_invoke::take_tracked_permission_requests(); + if let Err(error) = + crate::api::peer_host_invoke::fail_closed_permission_requests( + request_ids, + "Peer permission event stream closed", + ) + .await + { + log::warn!( + "Peer permission requests were not fully cancelled: {error}" + ); + } + break; + } + } + } + }); + Ok(()) + } } #[cfg(test)] diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index 690417f62d..b80b32d0b4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -117,7 +117,6 @@ export interface RemoteCommand { model_id?: string; reason?: string; answers?: Object; - updated_input?: Object; image_contexts?: RemoteImageContext[]; images?: ImageAttachment[]; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/Index.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/Index.ets index 2e08152a59..a5b5927668 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/Index.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/Index.ets @@ -319,8 +319,8 @@ struct Index { onLoadOlder: () => { this.loadOlderMessages(); }, - onApproveTool: (toolId: string, updatedInput?: Object) => { - this.approveTool(toolId, updatedInput); + onApproveTool: (toolId: string) => { + this.approveTool(toolId); }, onRejectTool: (toolId: string) => { this.rejectTool(toolId); @@ -1577,9 +1577,9 @@ struct Index { this.sendChatMessage(); } - private async approveTool(toolId: string, updatedInput?: Object): Promise { + private async approveTool(toolId: string): Promise { await this.runToolAction(toolId, RemoteI18n.t('status.approvingTool'), RemoteI18n.t('status.toolApproved'), () => { - return this.sessionManager.confirmTool(toolId, updatedInput); + return this.sessionManager.confirmTool(toolId); }); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets index bebe727b26..603aebbbd7 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets @@ -47,7 +47,7 @@ export struct ChatMessageBubble { @Prop downloadingFilePath: string = ''; @Prop downloadedFilePath: string = ''; @Prop fileDownloadStatus: string = ''; - onApproveTool: (toolId: string, updatedInput?: Object) => void = (_toolId: string, _updatedInput?: Object) => {}; + onApproveTool: (toolId: string) => void = (_toolId: string) => {}; onRejectTool: (toolId: string) => void = (_toolId: string) => {}; onCancelTool: (toolId: string) => void = (_toolId: string) => {}; onAnswerQuestion: (toolId: string, answers: RemoteQuestionAnswerPayload) => void = @@ -348,8 +348,8 @@ export struct ChatMessageBubble { ToolStatusList({ tools, isBusy: this.isBusy, - onApproveTool: (toolId: string, updatedInput?: Object) => { - this.onApproveTool(toolId, updatedInput); + onApproveTool: (toolId: string) => { + this.onApproveTool(toolId); }, onRejectTool: (toolId: string) => { this.onRejectTool(toolId); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets index 8e402b7f31..90d307b1fe 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets @@ -15,7 +15,7 @@ export struct ChatTimeline { @Prop downloadedFilePath: string = ''; @Prop fileDownloadStatus: string = ''; onLoadOlder: () => void = () => {}; - onApproveTool: (toolId: string, updatedInput?: Object) => void = (_toolId: string, _updatedInput?: Object) => {}; + onApproveTool: (toolId: string) => void = (_toolId: string) => {}; onRejectTool: (toolId: string) => void = (_toolId: string) => {}; onCancelTool: (toolId: string) => void = (_toolId: string) => {}; onAnswerQuestion: (toolId: string, answers: RemoteQuestionAnswerPayload) => void = @@ -99,8 +99,8 @@ export struct ChatTimeline { downloadingFilePath: this.downloadingFilePath, downloadedFilePath: this.downloadedFilePath, fileDownloadStatus: this.fileDownloadStatus, - onApproveTool: (toolId: string, updatedInput?: Object) => { - this.onApproveTool(toolId, updatedInput); + onApproveTool: (toolId: string) => { + this.onApproveTool(toolId); }, onRejectTool: (toolId: string) => { this.onRejectTool(toolId); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatView.ets index ea0374a39b..ade23b2e06 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatView.ets @@ -39,7 +39,7 @@ export struct ChatView { onBack: () => void = () => {}; onStop: () => void = () => {}; onLoadOlder: () => void = () => {}; - onApproveTool: (toolId: string, updatedInput?: Object) => void = (_toolId: string, _updatedInput?: Object) => {}; + onApproveTool: (toolId: string) => void = (_toolId: string) => {}; onRejectTool: (toolId: string) => void = (_toolId: string) => {}; onCancelTool: (toolId: string) => void = (_toolId: string) => {}; onAnswerQuestion: (toolId: string, answers: RemoteQuestionAnswerPayload) => void = @@ -123,8 +123,8 @@ export struct ChatView { onLoadOlder: () => { this.onLoadOlder(); }, - onApproveTool: (toolId: string, updatedInput?: Object) => { - this.onApproveTool(toolId, updatedInput); + onApproveTool: (toolId: string) => { + this.onApproveTool(toolId); }, onRejectTool: (toolId: string) => { this.onRejectTool(toolId); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets index f911ddad4f..8151330292 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets @@ -53,7 +53,7 @@ interface ToolRenderEntry { export struct ToolStatusList { @Prop tools: RemoteToolStatusResponse[] = []; @Prop isBusy: boolean = false; - onApproveTool: (toolId: string, updatedInput?: Object) => void = (_toolId: string, _updatedInput?: Object) => {}; + onApproveTool: (toolId: string) => void = (_toolId: string) => {}; onRejectTool: (toolId: string) => void = (_toolId: string) => {}; onCancelTool: (toolId: string) => void = (_toolId: string) => {}; onAnswerQuestion: (toolId: string, answers: RemoteQuestionAnswerPayload) => void = diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets index 450f5ea133..457ef0bf83 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets @@ -129,15 +129,11 @@ export class RemoteCommandFactory { }; } - static confirmTool(toolId: string, updatedInput?: Object): RemoteCommand { - const command: RemoteCommand = { + static confirmTool(toolId: string): RemoteCommand { + return { cmd: 'confirm_tool', tool_id: toolId }; - if (updatedInput) { - command.updated_input = updatedInput; - } - return command; } static rejectTool(toolId: string, reason: string): RemoteCommand { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets index ae0d2ae2a1..a5411ee345 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets @@ -207,8 +207,8 @@ export class RemoteSessionManager { await this.send(RemoteCommandFactory.deleteSession(sessionId)); } - async confirmTool(toolId: string, updatedInput?: Object): Promise { - await this.send(RemoteCommandFactory.confirmTool(toolId, updatedInput)); + async confirmTool(toolId: string): Promise { + await this.send(RemoteCommandFactory.confirmTool(toolId)); } async rejectTool(toolId: string, reason: string): Promise { diff --git a/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets index ee34ca66be..0674edf3c3 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets @@ -14,10 +14,6 @@ import { TimeFormat } from '../main/ets/services/TimeFormat'; import { X25519 } from '../main/ets/services/X25519'; import { ChatMessage, ImageAttachment, PollSessionResult, RemoteQuestionAnswerPayload } from '../main/ets/model/RemoteModels'; -interface ToolInputFixture { - safe: boolean; -} - interface CryptoImageFixture { id: string; data_url: string; @@ -338,10 +334,9 @@ export default function localUnitTest() { }); it('builds tool and file commands', 0, () => { - const toolInput: ToolInputFixture = { safe: true }; const answers: RemoteQuestionAnswerPayload = { answer: 'yes', '0': 'yes' }; - expectCommandJson(RemoteCommandFactory.confirmTool('tool-1', toolInput), '{"cmd":"confirm_tool","tool_id":"tool-1","updated_input":{"safe":true}}'); + expectCommandJson(RemoteCommandFactory.confirmTool('tool-1'), '{"cmd":"confirm_tool","tool_id":"tool-1"}'); expectCommandJson(RemoteCommandFactory.rejectTool('tool-1', 'no'), '{"cmd":"reject_tool","tool_id":"tool-1","reason":"no"}'); expectCommandJson(RemoteCommandFactory.cancelTool('tool-1', 'stop'), '{"cmd":"cancel_tool","tool_id":"tool-1","reason":"stop"}'); expectCommandJson(RemoteCommandFactory.answerQuestion('tool-2', answers), '{"cmd":"answer_question","tool_id":"tool-2","answers":{"0":"yes","answer":"yes"}}'); diff --git a/src/apps/server/src/rpc_dispatcher.rs b/src/apps/server/src/rpc_dispatcher.rs index bafe7a5aee..08875a1bf8 100644 --- a/src/apps/server/src/rpc_dispatcher.rs +++ b/src/apps/server/src/rpc_dispatcher.rs @@ -509,33 +509,6 @@ pub async fn dispatch( "has_more": has_more, })) } - "confirm_tool_execution" => { - let request = extract_request(¶ms)?; - let tool_id = get_string(&request, "toolId")?; - let updated_input = request.get("updatedInput").cloned(); - state - .coordinator - .confirm_tool(&tool_id, updated_input) - .await - .map_err(|e| anyhow!("{}", e))?; - Ok(serde_json::json!({ "success": true })) - } - "reject_tool_execution" => { - let request = extract_request(¶ms)?; - let tool_id = get_string(&request, "toolId")?; - let reason = request - .get("reason") - .and_then(|v| v.as_str()) - .unwrap_or("User rejected") - .to_string(); - state - .coordinator - .reject_tool(&tool_id, reason) - .await - .map_err(|e| anyhow!("{}", e))?; - Ok(serde_json::json!({ "success": true })) - } - // ── I18n ───────────────────────────────────────────── "i18n_get_current_language" => { let lang: String = state diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index ed5d960c3d..52397a7b51 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -99,6 +99,7 @@ bitfun-services-core = { path = "../../services/services-core", default-features "lsp", "markdown", "workspace-runtime", + "permission", ] } # Integration service owner crate @@ -135,7 +136,7 @@ russh = { workspace = true, optional = true } # Event layer dependency (lowest layer) bitfun-core-types = { path = "../../contracts/core-types" } bitfun-events = { path = "../../contracts/events" } -bitfun-runtime-ports = { path = "../../contracts/runtime-ports" } +bitfun-runtime-ports = { path = "../../contracts/runtime-ports", features = ["permission"] } bitfun-runtime-services = { path = "../../execution/runtime-services" } # Reviewed product-full plugin composition root. diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 0a302e5513..36ec81b234 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -68,6 +68,7 @@ use crate::util::errors::{BitFunError, BitFunResult}; 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::remote_file_delivery::{ needs_computer_links_for_source, remote_file_delivery_reminder, TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, @@ -75,8 +76,9 @@ use bitfun_agent_runtime::remote_file_delivery::{ use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; use bitfun_runtime_ports::{ AgentSessionWorkspaceBinding, AgentThreadGoalDeliveryKind, AgentThreadGoalDeliveryRequest, - DelegationPolicy, RemoteExecPort, SessionStoragePathRequest, SessionStorePort, - SubagentContextMode, TerminalPort, ThreadGoal, ThreadGoalContinuationPlan, ThreadGoalStatus, + DelegationPolicy, PermissionDelegationContext, PermissionRuntimeCeiling, RemoteExecPort, + SessionStoragePathRequest, SessionStorePort, SubagentContextMode, TerminalPort, ThreadGoal, + ThreadGoalContinuationPlan, ThreadGoalStatus, }; use dashmap::DashMap; use log::{debug, error, info, warn}; @@ -194,15 +196,6 @@ fn metadata_bool(metadata: Option<&serde_json::Value>, key: &str) -> Option, -) -> bool { - (policy.requires_tool_confirmation() - || metadata_bool(user_message_metadata, "require_tool_confirmation") == Some(true)) - && metadata_bool(user_message_metadata, "acp_transport") != Some(true) -} - /// Subagent execution result /// /// Contains the text response after subagent execution @@ -241,6 +234,7 @@ pub(crate) struct SubagentExecutionRequest { pub(crate) inherit_parent_model: bool, pub(crate) subagent_parent_info: SubagentParentInfo, pub(crate) context: HashMap, + pub(crate) permission_runtime_ceiling: PermissionRuntimeCeiling, /// Execution policy for the child subagent session being launched. pub(crate) delegation_policy: DelegationPolicy, /// Pins an immutable external generation from Task validation until the @@ -371,6 +365,74 @@ fn session_lineage_matches_parent( }) } +fn subagent_parent_info_from_relationship( + relationship: Option<&SessionRelationship>, +) -> Option { + let relationship = relationship?; + if relationship.kind != Some(SessionRelationshipKind::Subagent) { + return None; + } + + let parent_session_id = relationship.parent_session_id.as_deref()?.trim(); + let parent_dialog_turn_id = relationship.parent_dialog_turn_id.as_deref()?.trim(); + let parent_tool_call_id = relationship.parent_tool_call_id.as_deref()?.trim(); + if parent_session_id.is_empty() + || parent_dialog_turn_id.is_empty() + || parent_tool_call_id.is_empty() + { + return None; + } + + Some(SubagentParentInfo { + session_id: parent_session_id.to_string(), + dialog_turn_id: parent_dialog_turn_id.to_string(), + tool_call_id: parent_tool_call_id.to_string(), + }) +} + +fn permission_delegation_from_relationship( + relationship: Option<&SessionRelationship>, + fallback_subagent_type: &str, +) -> Option { + let relationship = relationship?; + if relationship.kind != Some(SessionRelationshipKind::Subagent) { + return None; + } + + let parent_session_id = relationship.parent_session_id.as_deref()?.trim(); + let parent_tool_call_id = relationship.parent_tool_call_id.as_deref()?.trim(); + if parent_session_id.is_empty() || parent_tool_call_id.is_empty() { + return None; + } + + let parent_dialog_turn_id = relationship + .parent_dialog_turn_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let subagent_type = relationship + .subagent_type + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(fallback_subagent_type) + .to_string(); + + Some(PermissionDelegationContext { + parent_session_id: parent_session_id.to_string(), + parent_dialog_turn_id, + parent_tool_call_id: parent_tool_call_id.to_string(), + subagent_type, + }) +} + +#[derive(Default)] +struct PersistedSubagentContinuationContext { + subagent_parent_info: Option, + permission_delegation: Option, +} + #[derive(Debug, Clone)] pub(crate) struct HiddenSubagentExecutionRequest { target_session_id: Option, @@ -384,6 +446,7 @@ pub(crate) struct HiddenSubagentExecutionRequest { created_by: Option, subagent_parent_info: Option, context: HashMap, + permission_runtime_ceiling: Option, delegation_policy: DelegationPolicy, runtime_tool_restrictions: ToolRuntimeRestrictions, prompt_cache_source_session_id: Option, @@ -2233,8 +2296,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Some(workspace_root.to_string_lossy().to_string()), None, None, - DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopApi) - .with_skip_tool_confirmation(true), + DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopApi), Some(metadata), vec![Message::internal_reminder( InternalReminderKind::Generic, @@ -3033,8 +3095,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet workspace: manual_workspace, context: HashMap::new(), subagent_parent_info: None, + permission_delegation: None, + permission_runtime_ceiling: None, delegation_policy: DelegationPolicy::top_level(), - skip_tool_confirmation: true, runtime_tool_restrictions: ToolRuntimeRestrictions::default(), workspace_services: manual_workspace_services, terminal_port: self.terminal_port(), @@ -3211,7 +3274,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Self::track_session_workspace_activity_best_effort(&session.config, "dialog_started").await; debug!( - "Resolved dialog turn agent type: session_id={}, turn_id={}, requested_agent_type={}, session_agent_type={}, effective_agent_type={}, trigger_source={:?}, queue_priority={:?}, skip_tool_confirmation={}", + "Resolved dialog turn agent type: session_id={}, turn_id={}, requested_agent_type={}, session_agent_type={}, effective_agent_type={}, trigger_source={:?}, queue_priority={:?}", session_id, turn_id.as_deref().unwrap_or(""), if requested_agent_type.is_empty() { @@ -3226,8 +3289,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }, effective_agent_type, submission_policy.trigger_source, - submission_policy.queue_priority, - submission_policy.skip_tool_confirmation + submission_policy.queue_priority ); if session.agent_type != effective_agent_type { @@ -3680,6 +3742,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet user_input_available.to_string(), ); } + if let Some(auto_approve_ask) = + metadata_bool(user_message_metadata.as_ref(), AUTO_APPROVE_ASK_CONTEXT_KEY) + { + context_vars.insert( + AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), + auto_approve_ask.to_string(), + ); + } if needs_computer_links_for_source(submission_policy.trigger_source) { context_vars.insert( TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY.to_string(), @@ -3692,9 +3762,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet "true".to_string(), ); } - if should_require_tool_confirmation(submission_policy, user_message_metadata.as_ref()) { - context_vars.insert("require_tool_confirmation".to_string(), "true".to_string()); - } let session_workspace_path = session_workspace .as_ref() .map(|workspace| workspace.root_path_string()); @@ -3713,6 +3780,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } else { ToolRuntimeRestrictions::default() }; + let persisted_subagent_context = self + .load_persisted_subagent_continuation_context(&session) + .await; let execution_context = ExecutionContext { session_id: session_id.clone(), @@ -3721,9 +3791,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet agent_type: effective_agent_type.clone(), workspace: session_workspace, context: context_vars, - subagent_parent_info: None, + subagent_parent_info: persisted_subagent_context.subagent_parent_info, + permission_delegation: persisted_subagent_context.permission_delegation, + permission_runtime_ceiling: None, delegation_policy: DelegationPolicy::top_level(), - skip_tool_confirmation: submission_policy.skip_tool_confirmation, runtime_tool_restrictions, workspace_services, terminal_port: self.terminal_port(), @@ -4725,22 +4796,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.event_router.unsubscribe_internal(subscriber_id); } - /// Confirm tool execution - pub async fn confirm_tool( - &self, - tool_id: &str, - updated_input: Option, - ) -> BitFunResult<()> { - self.tool_pipeline - .confirm_tool(tool_id, updated_input) - .await - } - - /// Reject tool execution - pub async fn reject_tool(&self, tool_id: &str, reason: String) -> BitFunResult<()> { - self.tool_pipeline.reject_tool(tool_id, reason).await - } - /// Cancel tool execution pub async fn cancel_tool(&self, tool_id: &str, reason: String) -> BitFunResult<()> { self.tool_pipeline.cancel_tool(tool_id, reason).await @@ -5002,6 +5057,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet created_by, subagent_parent_info, context, + permission_runtime_ceiling, delegation_policy, runtime_tool_restrictions, prompt_cache_source_session_id, @@ -5416,11 +5472,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet workspace: subagent_workspace, context, subagent_parent_info: subagent_parent_info.clone(), + permission_delegation: subagent_parent_info + .as_ref() + .map(|parent| parent.permission_delegation_context(&agent_type)), + permission_runtime_ceiling, delegation_policy, - // Subagents run autonomously without user interaction; always skip - // tool confirmation to prevent them from blocking indefinitely on a - // confirmation channel that nobody will ever respond to. - skip_tool_confirmation: true, runtime_tool_restrictions, workspace_services: subagent_services, terminal_port: self.terminal_port(), @@ -6154,8 +6210,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet child_session.config.workspace_path.clone(), child_session.config.remote_connection_id.clone(), child_session.config.remote_ssh_host.clone(), - DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopApi) - .with_skip_tool_confirmation(true), + DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopApi), Some(user_message_metadata), prepended_messages, true, @@ -6285,6 +6340,52 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_created_by_parent(session, parent_session_id) } + async fn load_persisted_subagent_continuation_context( + &self, + session: &Session, + ) -> PersistedSubagentContinuationContext { + if session.kind != SessionKind::Subagent { + return PersistedSubagentContinuationContext::default(); + } + + let storage_path = match self + .restore_path_for_existing_session(&session.session_id) + .await + { + Ok(storage_path) => storage_path, + Err(error) => { + debug!( + "Failed to resolve subagent session storage for permission delegation: session_id={}, error={}", + session.session_id, error + ); + return PersistedSubagentContinuationContext::default(); + } + }; + match self + .session_manager + .load_session_metadata(&storage_path, &session.session_id) + .await + { + Ok(Some(metadata)) => PersistedSubagentContinuationContext { + subagent_parent_info: subagent_parent_info_from_relationship( + metadata.relationship.as_ref(), + ), + permission_delegation: permission_delegation_from_relationship( + metadata.relationship.as_ref(), + &session.agent_type, + ), + }, + Ok(None) => PersistedSubagentContinuationContext::default(), + Err(error) => { + debug!( + "Failed to load subagent session lineage for permission delegation: session_id={}, error={}", + session.session_id, error + ); + PersistedSubagentContinuationContext::default() + } + } + } + async fn load_reusable_subagent_context_messages( &self, session: &Session, @@ -6488,6 +6589,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet created_by: session.created_by.clone(), subagent_parent_info: Some(request.subagent_parent_info), context: request.context, + permission_runtime_ceiling: Some(request.permission_runtime_ceiling), delegation_policy: request.delegation_policy, runtime_tool_restrictions: runtime_tool_restrictions_for_delegation_policy( request.delegation_policy, @@ -6566,6 +6668,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet created_by, subagent_parent_info: Some(request.subagent_parent_info), context: request.context, + permission_runtime_ceiling: Some(request.permission_runtime_ceiling), delegation_policy: request.delegation_policy, runtime_tool_restrictions: runtime_tool_restrictions_for_delegation_policy( request.delegation_policy, @@ -6649,6 +6752,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet created_by, subagent_parent_info: Some(request.subagent_parent_info), context: request.context, + permission_runtime_ceiling: Some(request.permission_runtime_ceiling), delegation_policy: request.delegation_policy, runtime_tool_restrictions: runtime_tool_restrictions_for_delegation_policy( request.delegation_policy, @@ -7048,6 +7152,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet created_by: request.created_by, subagent_parent_info: None, context: request.context, + permission_runtime_ceiling: None, delegation_policy: request.delegation_policy, runtime_tool_restrictions: request.runtime_tool_restrictions, prompt_cache_source_session_id: None, @@ -8064,24 +8169,6 @@ impl bitfun_runtime_ports::AgentLocalCommandTurnPort for ConversationCoordinator #[async_trait::async_trait] impl bitfun_agent_runtime::sdk::AgentInteractionResponsePort for ConversationCoordinator { - async fn confirm_tool( - &self, - request: bitfun_agent_runtime::sdk::AgentToolConfirmationRequest, - ) -> bitfun_runtime_ports::PortResult<()> { - self.confirm_tool(&request.tool_id, request.updated_input) - .await - .map_err(runtime_port_error_preserving_message) - } - - async fn reject_tool( - &self, - request: bitfun_agent_runtime::sdk::AgentToolRejectionRequest, - ) -> bitfun_runtime_ports::PortResult<()> { - self.reject_tool(&request.tool_id, request.reason) - .await - .map_err(runtime_port_error_preserving_message) - } - async fn submit_user_answers( &self, request: bitfun_agent_runtime::sdk::AgentUserAnswersRequest, @@ -8424,9 +8511,8 @@ mod tests { 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, - should_require_tool_confirmation, turn_review_manifest_for_agent, - BackgroundSubagentWaitMode, ConversationCoordinator, SubagentExecutionRequest, - TEST_AGENT_MODEL_DEFAULTS, + turn_review_manifest_for_agent, BackgroundSubagentWaitMode, ConversationCoordinator, + SubagentExecutionRequest, TEST_AGENT_MODEL_DEFAULTS, }; use crate::agentic::coordination::coordination_store::{ BackgroundTaskRegistration, RegisteredBackgroundTask, @@ -8455,12 +8541,13 @@ mod tests { use crate::service::config::{AgentModelDefaultsConfig, SubagentModelSelection}; use crate::service::remote_ssh::workspace_state::init_remote_workspace_manager; use crate::service::session::{SessionMetadata, SessionStatus}; + use bitfun_agent_runtime::permission::AUTO_APPROVE_ASK_CONTEXT_KEY; use bitfun_runtime_ports::{ AgentSessionArchiveRequest, AgentSessionCreateRequest, AgentSessionManagementPort, AgentSessionRenameRequest, AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionSource, AgentThreadGoalGetRequest, AgentThreadGoalManagementPort, - DelegationPolicy, DialogQueuePriority, DialogSubmissionPolicy, SubagentContextMode, - ThreadGoal, ThreadGoalStatus, + DelegationPolicy, PermissionEffect, PermissionRule, PermissionRuntimeCeiling, + SubagentContextMode, ThreadGoal, ThreadGoalStatus, }; use std::collections::HashMap; use std::sync::Arc; @@ -8480,11 +8567,8 @@ mod tests { } #[tokio::test] - async fn interaction_response_port_uses_core_owners_and_typed_stale_errors() { - use bitfun_agent_runtime::sdk::{ - AgentInteractionResponsePort, AgentToolConfirmationRequest, AgentToolRejectionRequest, - AgentUserAnswersRequest, - }; + async fn interaction_response_port_uses_user_question_owner_and_typed_stale_errors() { + use bitfun_agent_runtime::sdk::{AgentInteractionResponsePort, AgentUserAnswersRequest}; let (coordinator, _) = test_coordinator(); let answer_tool_id = format!("answer-{}", uuid::Uuid::new_v4()); @@ -8525,43 +8609,6 @@ mod tests { stale_answer.message, format!("Tool error: Waiting channel not found: {answer_tool_id}") ); - - let missing_tool_id = format!("tool-{}", uuid::Uuid::new_v4()); - let confirmation = AgentInteractionResponsePort::confirm_tool( - &coordinator, - AgentToolConfirmationRequest { - tool_id: missing_tool_id.clone(), - updated_input: Some(serde_json::json!({ "path": "updated.txt" })), - }, - ) - .await - .expect_err("missing confirmation task"); - assert_eq!( - confirmation.kind, - bitfun_runtime_ports::PortErrorKind::NotFound - ); - assert_eq!( - confirmation.message, - format!("Not found: Tool task not found: {missing_tool_id}") - ); - - let rejection = AgentInteractionResponsePort::reject_tool( - &coordinator, - AgentToolRejectionRequest { - tool_id: missing_tool_id.clone(), - reason: "Use a read-only path".to_string(), - }, - ) - .await - .expect_err("missing rejection task"); - assert_eq!( - rejection.kind, - bitfun_runtime_ports::PortErrorKind::NotFound - ); - assert_eq!( - rejection.message, - format!("Not found: Tool task not found: {missing_tool_id}") - ); } #[tokio::test] @@ -8886,43 +8933,6 @@ mod tests { assert_state_port::(); } - #[test] - fn local_cli_confirmation_override_excludes_acp_transport() { - let policy = DialogSubmissionPolicy::new( - AgentSubmissionSource::Cli, - DialogQueuePriority::Normal, - false, - ); - - assert!(should_require_tool_confirmation(policy, None)); - assert!(!should_require_tool_confirmation( - policy, - Some(&serde_json::json!({ "acp_transport": true })), - )); - } - - #[test] - fn peer_metadata_can_only_strengthen_tool_confirmation() { - let desktop_policy = DialogSubmissionPolicy::new( - AgentSubmissionSource::DesktopUi, - DialogQueuePriority::Normal, - false, - ); - let peer_metadata = serde_json::json!({ "require_tool_confirmation": true }); - - assert!(should_require_tool_confirmation( - desktop_policy, - Some(&peer_metadata) - )); - assert!(!should_require_tool_confirmation( - desktop_policy, - Some(&serde_json::json!({ - "require_tool_confirmation": true, - "acp_transport": true, - })) - )); - } - #[test] fn hidden_subagent_dialog_turn_id_reuses_existing_or_generates_raw_uuid() { let mut missing = None; @@ -9363,6 +9373,68 @@ mod tests { )); } + #[test] + fn persisted_subagent_lineage_restores_permission_delegation_context() { + use crate::service::session::{SessionRelationship, SessionRelationshipKind}; + + let relationship = SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some("parent-session".to_string()), + parent_request_id: None, + parent_dialog_turn_id: Some("parent-turn".to_string()), + parent_turn_index: None, + parent_tool_call_id: Some("task-tool-call".to_string()), + subagent_type: Some("Explore".to_string()), + continuation_policy: None, + }; + + assert_eq!( + super::subagent_parent_info_from_relationship(Some(&relationship)).map(|info| ( + info.session_id, + info.dialog_turn_id, + info.tool_call_id + )), + Some(( + "parent-session".to_string(), + "parent-turn".to_string(), + "task-tool-call".to_string(), + )) + ); + } + + #[test] + fn persisted_subagent_lineage_without_parent_turn_preserves_permission_routing() { + use crate::service::session::{SessionRelationship, SessionRelationshipKind}; + + let relationship = SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some("parent-session".to_string()), + parent_request_id: None, + parent_dialog_turn_id: None, + parent_turn_index: None, + parent_tool_call_id: Some("task-tool-call".to_string()), + subagent_type: Some("Explore".to_string()), + continuation_policy: None, + }; + + assert!(super::subagent_parent_info_from_relationship(Some(&relationship)).is_none()); + assert_eq!( + super::permission_delegation_from_relationship(Some(&relationship), "GeneralPurpose") + .map(|delegation| ( + delegation.parent_session_id, + delegation.parent_dialog_turn_id, + delegation.parent_tool_call_id, + delegation.subagent_type, + )), + Some(( + "parent-session".to_string(), + None, + "task-tool-call".to_string(), + "Explore".to_string(), + )) + ); + } + #[test] fn agent_submission_turn_id_prefers_explicit_field_over_metadata() { let mut metadata = serde_json::Map::new(); @@ -10136,7 +10208,15 @@ mod tests { dialog_turn_id: "parent-turn".to_string(), tool_call_id: "task-tool".to_string(), }, - context: HashMap::new(), + context: HashMap::from([( + AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), + "false".to_string(), + )]), + permission_runtime_ceiling: PermissionRuntimeCeiling::try_new(vec![ + PermissionRule::new("bash", "rm *", PermissionEffect::Deny), + PermissionRule::new("external_directory", "*", PermissionEffect::Ask), + ]) + .expect("test ceiling should be valid"), delegation_policy: DelegationPolicy::top_level().spawn_child(), external_generation_lease: None, }; @@ -10147,6 +10227,25 @@ mod tests { .expect("send_input request should prepare with a requested model"); assert_eq!(prepared.session_config.model_id.as_deref(), Some("fast")); + assert_eq!( + prepared + .context + .get(AUTO_APPROVE_ASK_CONTEXT_KEY) + .map(String::as_str), + Some("false"), + "reused subagent runs must use the current invocation override" + ); + assert_eq!( + prepared + .permission_runtime_ceiling + .as_ref() + .expect("child request should retain the parent ceiling") + .rules(), + [ + PermissionRule::new("bash", "rm *", PermissionEffect::Deny), + PermissionRule::new("external_directory", "*", PermissionEffect::Ask,), + ] + ); assert_eq!( session_manager .get_session(&subagent_session.session_id) @@ -10174,6 +10273,7 @@ mod tests { tool_call_id: "task-tool".to_string(), }, context: HashMap::new(), + permission_runtime_ceiling: PermissionRuntimeCeiling::default(), delegation_policy: DelegationPolicy::top_level().spawn_child(), external_generation_lease: None, }; @@ -10245,6 +10345,7 @@ mod tests { tool_call_id: "task-tool".to_string(), }, context: HashMap::new(), + permission_runtime_ceiling: PermissionRuntimeCeiling::default(), delegation_policy: DelegationPolicy::top_level().spawn_child(), external_generation_lease: None, }; @@ -10255,6 +10356,7 @@ mod tests { .expect("fork request should prepare with a requested model"); assert_eq!(prepared.session_config.model_id.as_deref(), Some("fast")); + assert!(!prepared.context.contains_key(AUTO_APPROVE_ASK_CONTEXT_KEY)); assert_eq!( prepared.prompt_cache_source_session_id.as_deref(), Some(parent_session.session_id.as_str()) @@ -10286,6 +10388,7 @@ mod tests { tool_call_id: "task-tool".to_string(), }, context: HashMap::new(), + permission_runtime_ceiling: PermissionRuntimeCeiling::default(), delegation_policy: DelegationPolicy::top_level().spawn_child(), external_generation_lease: None, }; diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs index fbca5c672f..7935b63e06 100644 --- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs @@ -512,10 +512,7 @@ impl DialogScheduler { ); Ok(()) } - BackgroundDeliveryAction::SubmitAgentSessionFollowUp { - queue_priority, - skip_tool_confirmation, - } => { + BackgroundDeliveryAction::SubmitAgentSessionFollowUp { queue_priority } => { let prepended = thread_goal_delivery_messages(plan.prepended_reminders); self.submit_with_prepended_messages( session_id, @@ -526,11 +523,7 @@ impl DialogScheduler { workspace_path, remote_connection_id, remote_ssh_host, - DialogSubmissionPolicy::new( - DialogTriggerSource::AgentSession, - queue_priority, - skip_tool_confirmation, - ), + DialogSubmissionPolicy::new(DialogTriggerSource::AgentSession, queue_priority), None, Some(plan.user_message_metadata), prepended, @@ -574,10 +567,7 @@ impl DialogScheduler { ); Ok(()) } - BackgroundDeliveryAction::SubmitAgentSessionFollowUp { - queue_priority, - skip_tool_confirmation, - } => { + BackgroundDeliveryAction::SubmitAgentSessionFollowUp { queue_priority } => { let prepended = thread_goal_delivery_messages(plan.prepended_reminders); self.submit_with_prepended_messages( session_id, @@ -588,11 +578,7 @@ impl DialogScheduler { workspace_path, remote_connection_id, remote_ssh_host, - DialogSubmissionPolicy::new( - DialogTriggerSource::AgentSession, - queue_priority, - skip_tool_confirmation, - ), + DialogSubmissionPolicy::new(DialogTriggerSource::AgentSession, queue_priority), None, Some(plan.user_message_metadata), prepended, @@ -667,16 +653,9 @@ impl DialogScheduler { self.round_injection_buffer.push(&session_id, injection); Ok(()) } - BackgroundDeliveryAction::SubmitAgentSessionFollowUp { - queue_priority, - skip_tool_confirmation, - } => { - self.submit_background_result_follow_up_locked( - delivery, - queue_priority, - skip_tool_confirmation, - ) - .await + BackgroundDeliveryAction::SubmitAgentSessionFollowUp { queue_priority } => { + self.submit_background_result_follow_up_locked(delivery, queue_priority) + .await } } } @@ -685,7 +664,6 @@ impl DialogScheduler { &self, delivery: BackgroundResultDelivery, queue_priority: DialogQueuePriority, - skip_tool_confirmation: bool, ) -> Result<(), String> { let resolved_turn_id = Uuid::new_v4().to_string(); let queued_turn = QueuedTurn { @@ -697,11 +675,7 @@ impl DialogScheduler { workspace_path: delivery.workspace_path, remote_connection_id: delivery.remote_connection_id, remote_ssh_host: delivery.remote_ssh_host, - policy: DialogSubmissionPolicy::new( - DialogTriggerSource::AgentSession, - queue_priority, - skip_tool_confirmation, - ), + policy: DialogSubmissionPolicy::new(DialogTriggerSource::AgentSession, queue_priority), reply_route: None, user_message_metadata: delivery.user_message_metadata, image_contexts: None, @@ -891,8 +865,7 @@ impl DialogScheduler { workspace_path: session.config.workspace_path.clone(), remote_connection_id: session.config.remote_connection_id.clone(), remote_ssh_host: session.config.remote_ssh_host.clone(), - policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession) - .with_skip_tool_confirmation(true), + policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), reply_route: None, user_message_metadata: None, image_contexts: None, @@ -3211,18 +3184,15 @@ mod tests { } #[test] - fn remote_queue_policy_preserves_confirmation_boundary() { + fn remote_queue_policy_preserves_priority_boundary() { let remote = DialogSubmissionPolicy::for_source(DialogTriggerSource::RemoteRelay); assert_eq!(remote.queue_priority, DialogQueuePriority::Normal); - assert!(remote.skip_tool_confirmation); let bot = DialogSubmissionPolicy::for_source(DialogTriggerSource::Bot); assert_eq!(bot.queue_priority, DialogQueuePriority::Normal); - assert!(bot.skip_tool_confirmation); let agent_session = DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession); assert_eq!(agent_session.queue_priority, DialogQueuePriority::Low); - assert!(agent_session.skip_tool_confirmation); } #[test] diff --git a/src/crates/assembly/core/src/agentic/core/state.rs b/src/crates/assembly/core/src/agentic/core/state.rs index 5b846a03ac..eecdca15f4 100644 --- a/src/crates/assembly/core/src/agentic/core/state.rs +++ b/src/crates/assembly/core/src/agentic/core/state.rs @@ -30,12 +30,6 @@ pub enum ToolExecutionState { chunks_received: usize, }, - /// Waiting for user confirmation - AwaitingConfirmation { - params: serde_json::Value, - timeout_at: SystemTime, - }, - /// Execution completed Completed { result: ToolResult, @@ -105,7 +99,6 @@ pub struct ToolStats { pub waiting: usize, pub running: usize, pub streaming: usize, - pub awaiting_confirmation: usize, pub completed: usize, pub failed: usize, pub rejected: usize, diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 7c4930aa35..01b863448a 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -1412,6 +1412,7 @@ impl ExecutionEngine { let round_context = RoundContext { session_id: input.context.session_id.clone(), subagent_parent_info: input.context.subagent_parent_info.clone(), + permission_delegation: input.context.permission_delegation.clone(), dialog_turn_id: input.context.dialog_turn_id.clone(), turn_index: input.context.turn_index, round_number: input.round_number, @@ -1426,6 +1427,7 @@ impl ExecutionEngine { primary_model_facts: input.primary_model_facts.clone(), agent_type: input.agent_type, context_vars: input.execution_context_vars.clone(), + permission_runtime_ceiling: input.context.permission_runtime_ceiling.clone(), delegation_policy: input.context.delegation_policy, runtime_tool_restrictions: finalize_runtime_tool_restrictions, steering_interrupt: None, @@ -3173,10 +3175,7 @@ impl ExecutionEngine { ); // Create round context - let mut round_context_vars = execution_context_vars.clone(); - if context.skip_tool_confirmation { - round_context_vars.insert("skip_tool_confirmation".to_string(), "true".to_string()); - } + let round_context_vars = execution_context_vars.clone(); let loaded_deferred_tool_specs = collect_product_loaded_deferred_tool_specs(&messages, &deferred_tools); @@ -3187,6 +3186,7 @@ impl ExecutionEngine { let round_context = RoundContext { session_id: context.session_id.clone(), subagent_parent_info: context.subagent_parent_info.clone(), + permission_delegation: context.permission_delegation.clone(), dialog_turn_id: context.dialog_turn_id.clone(), turn_index: context.turn_index, round_number: round_index, @@ -3201,6 +3201,7 @@ impl ExecutionEngine { primary_model_facts: primary_model_facts.clone(), agent_type: agent_type.clone(), context_vars: round_context_vars, + permission_runtime_ceiling: context.permission_runtime_ceiling.clone(), delegation_policy: context.delegation_policy, runtime_tool_restrictions: context.runtime_tool_restrictions.clone(), steering_interrupt: context.round_injection.as_ref().map(|source| { @@ -4336,8 +4337,9 @@ mod tests { workspace: None, context: HashMap::new(), subagent_parent_info: None, + permission_delegation: None, + permission_runtime_ceiling: None, delegation_policy: bitfun_runtime_ports::DelegationPolicy::top_level(), - skip_tool_confirmation: false, runtime_tool_restrictions: ToolRuntimeRestrictions::default(), workspace_services: None, terminal_port: None, 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 9e6d7c1382..a20b23bc4a 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -11,62 +11,37 @@ 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_rules; use crate::agentic::tools::computer_use_host::ComputerUseHostRef; use crate::agentic::tools::pipeline::{ SubagentBatchExecutionPolicy as PipelineSubagentBatchExecutionPolicy, ToolExecutionContext, ToolExecutionOptions, ToolPipeline, }; -use crate::agentic::tools::registry::{get_global_tool_registry, ToolRegistry}; use crate::agentic::tools::tool_context_runtime; use crate::agentic::tools::tool_result_storage; use crate::agentic::MessageContent; use crate::infrastructure::ai::AIClient; +use crate::service::config::project_permission_store::{ + load_project_permission_config_local, load_project_permission_config_remote, +}; +use crate::service::config::types::AgentProfileConfig; use crate::service::config::types::SubagentBatchExecutionPolicy as ConfigSubagentBatchExecutionPolicy; use crate::service::config::GlobalConfigManager; 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::tool_confirmation::{ - resolve_tool_confirmation_policy_gate, ToolConfirmationContextPolicy, - ToolConfirmationPolicyGateFacts, -}; +use bitfun_agent_runtime::permission::AUTO_APPROVE_ASK_CONTEXT_KEY; use bitfun_agent_runtime::turn_cancellation::DialogTurnCancellationTokenStore; -use bitfun_agent_tools::ResolvedToolInvocation; use bitfun_ai_adapters::{ ModelExchangeRequestTraceHandle, ModelExchangeResponseTrace, ModelExchangeTraceConfig, }; +use bitfun_runtime_ports::PermissionRule; use log::{debug, error, warn}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio_util::sync::CancellationToken; -fn tool_call_needs_permission( - registry: &ToolRegistry, - tool_call: &ToolCall, - workspace_root: Option<&std::path::Path>, - is_remote: bool, -) -> bool { - let invocation = ResolvedToolInvocation::from_wire_call( - tool_call.tool_name.clone(), - tool_call.arguments.clone(), - ) - .unwrap_or_else(|_| { - ResolvedToolInvocation::direct(tool_call.tool_name.clone(), tool_call.arguments.clone()) - }); - - registry - .get_tool(&invocation.effective_tool_name) - .and_then(|tool| { - crate::external_tools::resolve_external_tool_for_workspace( - tool, - crate::external_tools::external_tool_route_root(workspace_root, is_remote), - ) - }) - .map(|tool| tool.needs_permissions(Some(&invocation.effective_arguments))) - .unwrap_or(false) -} - /// Round executor pub struct RoundExecutor { stream_processor: Arc, @@ -118,6 +93,31 @@ impl RoundExecutor { } } + fn resolve_permission_rules( + global: &crate::service::config::types::GlobalConfig, + project_rules: &[PermissionRule], + agent_profile: Option<&AgentProfileConfig>, + parent_runtime_ceiling: Option<&bitfun_runtime_ports::PermissionRuntimeCeiling>, + ) -> Vec { + resolve_effective_permission_rules( + global, + project_rules, + agent_profile, + parent_runtime_ceiling, + &[], + ) + } + + fn resolve_auto_approve_ask( + 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) + } + async fn sleep_with_cancellation( delay_ms: u64, cancel_token: &CancellationToken, @@ -747,6 +747,11 @@ impl RoundExecutor { let tool_results = if let Some(tool_pipeline) = &self.tool_pipeline { // Create tool execution context let allowed_tools = context.available_tools.clone(); + let permission_delegation = context.permission_delegation.clone().or_else(|| { + subagent_parent_info + .as_ref() + .map(|parent| parent.permission_delegation_context(&context.agent_type)) + }); let tool_context = ToolExecutionContext { session_id: context.session_id.clone(), dialog_turn_id: context.dialog_turn_id.clone(), @@ -758,6 +763,7 @@ impl RoundExecutor { primary_model_facts: context.primary_model_facts.clone(), context_vars: context.context_vars.clone(), subagent_parent_info, + permission_delegation, delegation_policy: context.delegation_policy, deferred_tools: context.deferred_tools.clone(), loaded_deferred_tool_specs: context.loaded_deferred_tool_specs.clone(), @@ -769,103 +775,60 @@ impl RoundExecutor { remote_exec_port: context.remote_exec_port.clone(), }; - // Read tool execution related configuration from global config - let ( - needs_confirmation, - tool_execution_timeout, - tool_confirmation_timeout, - subagent_batch_execution_policy, - ) = { - let config_service = GlobalConfigManager::get_service().await.ok(); - - // Timeout and skip confirmation settings - let (exec_timeout, confirm_timeout, skip_confirmation, task_policy) = - if let Some(ref service) = config_service { - let ai_config: crate::service::config::types::AIConfig = - service.get_config(Some("ai")).await.unwrap_or_default(); - - if ai_config.skip_tool_confirmation { - debug!("Global config skips tool confirmation"); - } - - ( - ai_config.tool_execution_timeout_secs, - ai_config.tool_confirmation_timeout_secs, - ai_config.skip_tool_confirmation, - Self::map_subagent_batch_execution_policy( - ai_config.subagent_batch_execution_policy, - ), - ) - } else { - ( - None, - None, - false, - PipelineSubagentBatchExecutionPolicy::default(), - ) // Default: no timeout, requires confirmation - }; - - let skip_from_context = context - .context_vars - .get("skip_tool_confirmation") - .map(|v| v == "true") - .unwrap_or(false); - let require_from_context = context - .context_vars - .get("require_tool_confirmation") - .map(|v| v == "true") - .unwrap_or(false); - let context_policy = if require_from_context { - ToolConfirmationContextPolicy::Require - } else if skip_from_context { - ToolConfirmationContextPolicy::Skip - } else { - ToolConfirmationContextPolicy::Inherit - }; - - let skips_confirmation = match context_policy { - ToolConfirmationContextPolicy::Require => false, - ToolConfirmationContextPolicy::Skip => true, - ToolConfirmationContextPolicy::Inherit => skip_confirmation, - }; - let any_tool_needs_permission = if skips_confirmation { - false - } else { - let registry = get_global_tool_registry(); - let tool_registry = registry.read().await; - - stream_result.tool_calls.iter().any(|tool_call| { - tool_call_needs_permission( - &tool_registry, - tool_call, - context - .workspace - .as_ref() - .map(|workspace| workspace.root_path()), - context - .workspace - .as_ref() - .is_some_and(|workspace| workspace.is_remote()), - ) - }) + // Read tool execution related configuration from global config. + let global_config: crate::service::config::types::GlobalConfig = + match GlobalConfigManager::get_service().await { + Ok(service) => service.get_config(None).await.unwrap_or_default(), + Err(_) => Default::default(), }; - let needs_confirm = - resolve_tool_confirmation_policy_gate(ToolConfirmationPolicyGateFacts { - global_skip_tool_confirmation: skip_confirmation, - context_policy, - any_tool_needs_permission, - }) - .confirm_before_run(); - - (needs_confirm, exec_timeout, confirm_timeout, task_policy) + let tool_execution_timeout = global_config.ai.tool_execution_timeout_secs; + 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 project_rules = match context.workspace.as_ref() { + Some(workspace) if workspace.is_remote() => { + match context.workspace_services.as_ref() { + Some(services) => { + load_project_permission_config_remote( + services.fs.as_ref(), + &workspace.root_path_string(), + ) + .await? + .rules + } + None => Vec::new(), + } + } + Some(workspace) => { + load_project_permission_config_local(workspace.root_path()) + .await? + .rules + } + None => Vec::new(), }; + let agent_profile_id = + crate::agentic::agents::resolve_mode_config_profile_id(&context.agent_type); + let agent_profile = global_config + .ai + .agent_profiles + .get(agent_profile_id.as_ref()); + let permission_rules = Self::resolve_permission_rules( + &global_config, + &project_rules, + agent_profile, + context.permission_runtime_ceiling.as_ref(), + ); + // Create tool execution options (use configured timeout values) let tool_options = ToolExecutionOptions { - confirm_before_run: needs_confirmation, timeout_secs: tool_execution_timeout, - confirmation_timeout_secs: tool_confirmation_timeout, subagent_batch_execution_policy, + permission_rules, + auto_approve_ask, ..ToolExecutionOptions::default() }; @@ -1401,17 +1364,21 @@ fn token_details_from_usage( #[cfg(test)] mod tests { - use super::{tool_call_needs_permission, RoundExecutor, StreamProcessor}; + use super::{RoundExecutor, StreamProcessor}; use crate::agentic::core::ToolCall; use crate::agentic::events::{EventQueue, EventQueueConfig}; use crate::agentic::execution::stream_processor::StreamResult; use crate::agentic::execution::types::RoundContext; - use crate::agentic::tools::registry::create_tool_registry; use crate::agentic::tools::ToolRuntimeRestrictions; + 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::turn_cancellation::DialogTurnCancellationTokenStore; - use bitfun_runtime_ports::DelegationPolicy; + use bitfun_runtime_ports::{ + DelegationPolicy, PermissionEffect, PermissionEvaluator, PermissionPolicyPreset, + PermissionRule, + }; use serde_json::json; use std::collections::HashMap; use std::sync::Arc; @@ -1428,28 +1395,11 @@ mod tests { } } - #[test] - fn deferred_permission_gate_uses_effective_target() { - let registry = create_tool_registry(); - let call = ToolCall { - tool_id: "tool-1".to_string(), - tool_name: bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME.to_string(), - arguments: json!({ - "tool_name": "Write", - "args": { "payload": "+++ file.txt\ncontent" } - }), - raw_arguments: None, - is_error: false, - recovered_from_truncation: false, - }; - - assert!(tool_call_needs_permission(®istry, &call, None, false)); - } - fn test_round_context() -> RoundContext { RoundContext { session_id: "session-1".to_string(), subagent_parent_info: None, + permission_delegation: None, dialog_turn_id: "turn-1".to_string(), turn_index: 0, round_number: 0, @@ -1466,6 +1416,7 @@ mod tests { ), agent_type: "agentic".to_string(), context_vars: HashMap::new(), + permission_runtime_ceiling: None, delegation_policy: DelegationPolicy::top_level(), runtime_tool_restrictions: ToolRuntimeRestrictions::default(), steering_interrupt: None, @@ -1477,6 +1428,83 @@ mod tests { } } + #[test] + fn resolves_global_project_and_agent_permission_rules_before_execution() { + let mut global = GlobalConfig::default(); + global.tool_permissions.policy.preset = PermissionPolicyPreset::FullAccess; + global.tool_permissions.policy.rules = + vec![PermissionRule::new("bash", "rm *", PermissionEffect::Ask)]; + let project_rules = vec![PermissionRule::new( + "edit", + "generated/*", + PermissionEffect::Deny, + )]; + let agent = AgentProfileConfig { + tool_permission_rules: vec![PermissionRule::new( + "edit", + "generated/review.md", + PermissionEffect::Allow, + )], + ..AgentProfileConfig::default() + }; + + let resolved = + RoundExecutor::resolve_permission_rules(&global, &project_rules, Some(&agent), None); + let evaluator = PermissionEvaluator::case_sensitive(); + + assert_eq!( + evaluator.evaluate_resource("bash", "rm -rf target", &resolved), + PermissionEffect::Ask + ); + assert_eq!( + evaluator.evaluate_resource("edit", "generated/review.md", &resolved), + PermissionEffect::Allow + ); + assert_eq!( + evaluator.evaluate_resource("edit", "generated/api.rs", &resolved), + PermissionEffect::Deny + ); + assert_eq!( + evaluator.evaluate_resource("read", "src/main.rs", &resolved), + PermissionEffect::Allow + ); + } + + #[test] + fn auto_approve_context_overrides_persisted_interaction_preference() { + 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 + )); + context_vars.insert( + AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), + "false".to_string(), + ); + + assert!(!RoundExecutor::resolve_auto_approve_ask( + &global, + &context_vars + )); + context_vars.insert(AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), "true".to_string()); + assert!(RoundExecutor::resolve_auto_approve_ask( + &global, + &context_vars + )); + + context_vars.insert( + AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), + "invalid".to_string(), + ); + assert!(RoundExecutor::resolve_auto_approve_ask( + &global, + &context_vars + )); + } + #[tokio::test] async fn cancel_token_for_dialog_turn_returns_registered_token() { let executor = test_round_executor(); diff --git a/src/crates/assembly/core/src/agentic/execution/types.rs b/src/crates/assembly/core/src/agentic/execution/types.rs index e5897a23ac..24a41fb6cc 100644 --- a/src/crates/assembly/core/src/agentic/execution/types.rs +++ b/src/crates/assembly/core/src/agentic/execution/types.rs @@ -8,7 +8,10 @@ use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; pub use bitfun_agent_runtime::events::FinishReason; use bitfun_agent_tools::LoadedDeferredToolSpec; -use bitfun_runtime_ports::{DelegationPolicy, RemoteExecPort, TerminalPort}; +use bitfun_runtime_ports::{ + DelegationPolicy, PermissionDelegationContext, PermissionRuntimeCeiling, RemoteExecPort, + TerminalPort, +}; use serde_json::Value; use std::collections::HashMap; use std::path::PathBuf; @@ -26,8 +29,12 @@ pub struct ExecutionContext { pub workspace: Option, pub context: HashMap, pub subagent_parent_info: Option, + /// Permission routing context. This can survive a partially persisted + /// subagent lineage even when the historical parent turn is unavailable. + pub permission_delegation: Option, + /// Parent runtime restrictions inherited only by delegated child agents. + pub permission_runtime_ceiling: Option, pub(crate) delegation_policy: DelegationPolicy, - pub skip_tool_confirmation: bool, pub runtime_tool_restrictions: ToolRuntimeRestrictions, /// Workspace I/O services (filesystem + shell) injected into tools pub workspace_services: Option, @@ -50,6 +57,7 @@ pub struct ExecutionContext { pub struct RoundContext { pub session_id: String, pub subagent_parent_info: Option, + pub permission_delegation: Option, pub dialog_turn_id: String, pub turn_index: usize, pub round_number: usize, @@ -66,6 +74,7 @@ pub struct RoundContext { pub primary_model_facts: PrimaryModelFacts, pub agent_type: String, pub context_vars: HashMap, + pub permission_runtime_ceiling: Option, pub(crate) delegation_policy: DelegationPolicy, pub runtime_tool_restrictions: ToolRuntimeRestrictions, /// Cooperative interrupt checked by tool execution so round injections can be diff --git a/src/crates/assembly/core/src/agentic/mod.rs b/src/crates/assembly/core/src/agentic/mod.rs index ac8230b483..d6d91f5f12 100644 --- a/src/crates/assembly/core/src/agentic/mod.rs +++ b/src/crates/assembly/core/src/agentic/mod.rs @@ -36,6 +36,7 @@ pub mod round_preempt; pub mod image_analysis; pub(crate) mod keyed_lock; pub mod memories; +pub(crate) mod permission_policy; // Ephemeral side-question module (used by desktop /btw overlay) pub mod side_question; diff --git a/src/crates/assembly/core/src/agentic/permission_policy.rs b/src/crates/assembly/core/src/agentic/permission_policy.rs new file mode 100644 index 0000000000..b442e1993e --- /dev/null +++ b/src/crates/assembly/core/src/agentic/permission_policy.rs @@ -0,0 +1,166 @@ +use crate::service::config::types::{AgentProfileConfig, GlobalConfig}; +use bitfun_runtime_ports::{ + resolve_child_permission_policy, resolve_permission_policy, ChildPermissionPolicyLayers, + PermissionEffect, PermissionPolicyLayers, PermissionRule, PermissionRuntimeCeiling, +}; + +pub(crate) fn derive_parent_permission_runtime_ceiling( + agent_profile: Option<&AgentProfileConfig>, +) -> PermissionRuntimeCeiling { + let rules = agent_profile + .into_iter() + .flat_map(|profile| profile.tool_permission_rules.iter()) + .filter(|rule| { + rule.effect == PermissionEffect::Deny + || (rule.action == "external_directory" && rule.effect == PermissionEffect::Ask) + }) + .cloned() + .collect(); + + PermissionRuntimeCeiling::try_new(rules) + .expect("parent permission ceiling extraction must exclude allow rules") +} + +pub(crate) fn resolve_effective_permission_rules( + global: &GlobalConfig, + project_rules: &[PermissionRule], + agent_profile: Option<&AgentProfileConfig>, + parent_runtime_ceiling: Option<&PermissionRuntimeCeiling>, + enforced: &[PermissionRule], +) -> Vec { + let agent_rules = agent_profile + .map(|profile| profile.tool_permission_rules.as_slice()) + .unwrap_or(&[]); + + match parent_runtime_ceiling { + Some(parent_runtime_ceiling) => { + resolve_child_permission_policy(ChildPermissionPolicyLayers { + product_defaults: &[], + global: &global.tool_permissions.policy, + project: project_rules, + child_agent: agent_rules, + parent_runtime_ceiling, + enforced, + }) + } + None => resolve_permission_policy(PermissionPolicyLayers { + product_defaults: &[], + global: &global.tool_permissions.policy, + project: project_rules, + agent: agent_rules, + enforced, + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_runtime_ports::{PermissionEvaluator, PermissionPolicyPreset}; + + fn rule(action: &str, resource: &str, effect: PermissionEffect) -> PermissionRule { + PermissionRule::new(action, resource, effect) + } + + #[test] + fn parent_ceiling_keeps_only_profile_denies_and_external_directory_asks() { + let profile = AgentProfileConfig { + tool_permission_rules: vec![ + rule("read", "*", PermissionEffect::Allow), + rule("bash", "rm *", PermissionEffect::Deny), + rule("edit", "src/*", PermissionEffect::Ask), + rule("external_directory", "*", PermissionEffect::Ask), + rule("external_directory", "C:/blocked", PermissionEffect::Deny), + rule("external_directory", "C:/trusted", PermissionEffect::Allow), + ], + ..AgentProfileConfig::default() + }; + + let ceiling = derive_parent_permission_runtime_ceiling(Some(&profile)); + + assert_eq!( + ceiling.rules(), + [ + rule("bash", "rm *", PermissionEffect::Deny), + rule("external_directory", "*", PermissionEffect::Ask), + rule("external_directory", "C:/blocked", PermissionEffect::Deny), + ] + ); + assert!(ceiling + .rules() + .iter() + .all(|rule| rule.effect != PermissionEffect::Allow)); + } + + #[test] + fn top_level_resolution_keeps_existing_global_project_and_agent_order() { + let mut global = GlobalConfig::default(); + global.tool_permissions.policy.preset = PermissionPolicyPreset::FullAccess; + global.tool_permissions.policy.rules = vec![rule("bash", "rm *", PermissionEffect::Ask)]; + let project = vec![rule("edit", "generated/*", PermissionEffect::Deny)]; + let profile = AgentProfileConfig { + tool_permission_rules: vec![rule( + "edit", + "generated/review.md", + PermissionEffect::Allow, + )], + ..AgentProfileConfig::default() + }; + + let resolved = + resolve_effective_permission_rules(&global, &project, Some(&profile), None, &[]); + + assert_eq!( + PermissionEvaluator::case_sensitive().evaluate_resource( + "edit", + "generated/review.md", + &resolved, + ), + PermissionEffect::Allow + ); + assert_eq!( + PermissionEvaluator::case_sensitive().evaluate_resource( + "edit", + "generated/api.rs", + &resolved, + ), + PermissionEffect::Deny + ); + } + + #[test] + fn child_profile_allow_cannot_loosen_parent_deny_or_external_directory_ask() { + let mut global = GlobalConfig::default(); + global.tool_permissions.policy.preset = PermissionPolicyPreset::FullAccess; + let child_profile = AgentProfileConfig { + tool_permission_rules: vec![ + rule("bash", "rm *", PermissionEffect::Allow), + rule("external_directory", "*", PermissionEffect::Allow), + ], + ..AgentProfileConfig::default() + }; + let ceiling = PermissionRuntimeCeiling::try_new(vec![ + rule("bash", "rm *", PermissionEffect::Deny), + rule("external_directory", "*", PermissionEffect::Ask), + ]) + .expect("test ceiling should be valid"); + + let resolved = resolve_effective_permission_rules( + &global, + &[], + Some(&child_profile), + Some(&ceiling), + &[], + ); + let evaluator = PermissionEvaluator::case_sensitive(); + + assert_eq!( + evaluator.evaluate_resource("bash", "rm -rf target", &resolved), + PermissionEffect::Deny + ); + assert_eq!( + evaluator.evaluate_resource("external_directory", "C:/outside", &resolved), + PermissionEffect::Ask + ); + } +} diff --git a/src/crates/assembly/core/src/agentic/system.rs b/src/crates/assembly/core/src/agentic/system.rs index b9a264ed9a..eecfd0923e 100644 --- a/src/crates/assembly/core/src/agentic/system.rs +++ b/src/crates/assembly/core/src/agentic/system.rs @@ -54,11 +54,12 @@ pub async fn init_agentic_system() -> Result { let tool_registry = tools::registry::get_global_tool_registry(); let tool_state_manager = Arc::new(tools::pipeline::ToolStateManager::new(event_queue.clone())); - let tool_pipeline = Arc::new(tools::pipeline::ToolPipeline::new( - tool_registry, - tool_state_manager, - None, - )); + let permission_request_manager = + crate::product_runtime::core_permission_request_manager().map_err(anyhow::Error::msg)?; + let tool_pipeline = Arc::new( + tools::pipeline::ToolPipeline::new(tool_registry, tool_state_manager, None) + .with_permission_request_manager(permission_request_manager), + ); let stream_processor = Arc::new(execution::StreamProcessor::new(event_queue.clone())); let round_executor = Arc::new(execution::RoundExecutor::new( diff --git a/src/crates/assembly/core/src/agentic/tools/file_permissions.rs b/src/crates/assembly/core/src/agentic/tools/file_permissions.rs new file mode 100644 index 0000000000..cfea4f6cc5 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/file_permissions.rs @@ -0,0 +1,232 @@ +use crate::agentic::tools::framework::{PermissionIntent, ToolPathResolution, ToolUseContext}; +use crate::agentic::tools::restrictions::{ + canonicalize_local_path_best_effort, is_local_path_within_root, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use std::collections::HashSet; +use std::path::Path; + +pub(crate) fn file_permission_intents<'a>( + action: &str, + paths: impl IntoIterator, + context: &ToolUseContext, +) -> BitFunResult> { + let mut resources = Vec::new(); + let mut external_directories = Vec::new(); + let mut seen_resources = HashSet::new(); + let mut seen_external_directories = HashSet::new(); + + for path in paths { + let resolved = context.resolve_tool_path(path)?; + let resource = normalized_permission_resource(&resolved)?; + if seen_resources.insert(resource.clone()) { + resources.push(resource); + } + + if let Some(directory) = external_directory_resource(context, &resolved)? { + if seen_external_directories.insert(directory.clone()) { + external_directories.push(directory); + } + } + } + + if resources.is_empty() { + return Err(BitFunError::validation( + "File permission intent requires at least one resource".to_string(), + )); + } + + let mut intents = vec![PermissionIntent::new(action, resources)]; + if !external_directories.is_empty() { + intents.push(PermissionIntent::new( + "external_directory", + external_directories, + )); + } + Ok(intents) +} + +fn normalized_permission_resource(resolved: &ToolPathResolution) -> BitFunResult { + if resolved.uses_remote_workspace_backend() || resolved.runtime_scope.is_some() { + return Ok(resolved.resolved_path.replace('\\', "/")); + } + + Ok( + canonicalize_local_path_best_effort(Path::new(&resolved.resolved_path))? + .to_string_lossy() + .replace('\\', "/"), + ) +} + +fn external_directory_resource( + context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> BitFunResult> { + if resolved.uses_remote_workspace_backend() || resolved.runtime_scope.is_some() { + return Ok(None); + } + + let workspace_root = context.workspace_root().ok_or_else(|| { + BitFunError::validation("A workspace is required for file permissions".to_string()) + })?; + let path = Path::new(&resolved.resolved_path); + if is_local_path_within_root(path, workspace_root)? { + return Ok(None); + } + + let directory = if path.is_dir() { + path + } else { + path.parent().ok_or_else(|| { + BitFunError::validation(format!( + "External path '{}' has no parent directory", + path.display() + )) + })? + }; + Ok(Some( + canonicalize_local_path_best_effort(directory)? + .to_string_lossy() + .replace('\\', "/"), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::tools::framework::Tool; + use crate::agentic::tools::implementations::{ + DeleteFileTool, FileEditTool, FileReadTool, FileWriteTool, + }; + use crate::agentic::WorkspaceBinding; + use serde_json::{json, Value}; + use std::fs; + + #[test] + fn local_external_file_adds_external_directory_intent() { + let temp = tempfile::tempdir().expect("temp dir"); + let workspace = temp.path().join("workspace"); + let external = temp.path().join("external"); + fs::create_dir_all(&workspace).expect("workspace dir"); + fs::create_dir_all(&external).expect("external dir"); + let external_file = external.join("outside.txt"); + fs::write(&external_file, "outside").expect("external file"); + let context = + ToolUseContext::for_tool_listing(Some(WorkspaceBinding::new(None, workspace)), None); + + let intents = + file_permission_intents("read", [external_file.to_string_lossy().as_ref()], &context) + .expect("permission intents"); + + assert_eq!(intents.len(), 2); + assert_eq!(intents[0].action, "read"); + assert_eq!(intents[1].action, "external_directory"); + assert_eq!(intents[1].resources.len(), 1); + assert_eq!( + intents[1].resources[0], + canonicalize_local_path_best_effort(&external) + .expect("canonical external dir") + .to_string_lossy() + .replace('\\', "/") + ); + } + + #[test] + fn workspace_file_does_not_add_external_directory_intent() { + let temp = tempfile::tempdir().expect("temp dir"); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + let workspace_file = workspace.join("inside.txt"); + fs::write(&workspace_file, "inside").expect("workspace file"); + let context = + ToolUseContext::for_tool_listing(Some(WorkspaceBinding::new(None, workspace)), None); + + let intents = file_permission_intents( + "read", + [workspace_file.to_string_lossy().as_ref()], + &context, + ) + .expect("permission intents"); + + assert_eq!(intents.len(), 1); + assert_eq!(intents[0].action, "read"); + } + + #[test] + fn multi_file_edit_keeps_patch_targets_in_one_atomic_intent() { + let temp = tempfile::tempdir().expect("temp dir"); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + let first = workspace.join("first.txt"); + let second = workspace.join("second.txt"); + fs::write(&first, "first").expect("first file"); + fs::write(&second, "second").expect("second file"); + let context = + ToolUseContext::for_tool_listing(Some(WorkspaceBinding::new(None, workspace)), None); + + let intents = file_permission_intents( + "edit", + [ + first.to_string_lossy().as_ref(), + second.to_string_lossy().as_ref(), + ], + &context, + ) + .expect("multi-file edit intent"); + + assert_eq!(intents.len(), 1); + assert_eq!(intents[0].action, "edit"); + assert_eq!(intents[0].resources.len(), 2); + assert_eq!(intents[0].save_resources, intents[0].resources); + } + + #[test] + fn migrated_file_tools_emit_read_and_edit_intents() { + let temp = tempfile::tempdir().expect("temp dir"); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + let file = workspace.join("file.txt"); + fs::write(&file, "old").expect("workspace file"); + let mut context = + ToolUseContext::for_tool_listing(Some(WorkspaceBinding::new(None, workspace)), None); + context.tool_call_id = Some("tool-call-123".to_string()); + let file_path = file.to_string_lossy(); + + let read = FileReadTool::new(); + let write = FileWriteTool::new(); + let edit = FileEditTool::new(); + let delete = DeleteFileTool::new(); + let cases: Vec<(&dyn Tool, Value, &str)> = vec![ + (&read, json!({ "file_path": file_path.as_ref() }), "read"), + ( + &write, + json!({ "payload": format!("+++ {}\nnew", file_path) }), + "edit", + ), + ( + &edit, + json!({ + "file_path": file_path.as_ref(), + "old_string": "old", + "new_string": "new" + }), + "edit", + ), + (&delete, json!({ "path": file_path.as_ref() }), "edit"), + ]; + + for (tool, input, expected_action) in cases { + let intents = tool + .permission_intents(&input, &context) + .expect("file tool permission intent"); + assert_eq!(intents.len(), 1, "{}", tool.name()); + assert_eq!(intents[0].action, expected_action, "{}", tool.name()); + assert_eq!(intents[0].resources.len(), 1, "{}", tool.name()); + } + + let fallback = FileWriteTool::new() + .permission_intents(&json!({ "payload": "new file" }), &context) + .expect("fallback write intent"); + assert!(fallback[0].resources[0].ends_with("write_toolcall123.tmp")); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/framework.rs b/src/crates/assembly/core/src/agentic/tools/framework.rs index 81ca8de38b..7c7280a79f 100644 --- a/src/crates/assembly/core/src/agentic/tools/framework.rs +++ b/src/crates/assembly/core/src/agentic/tools/framework.rs @@ -6,7 +6,7 @@ pub use bitfun_agent_tools::{ build_tool_path_policy_denial_message, build_tool_runtime_artifact_reference, build_tool_session_runtime_artifact_reference, is_tool_path_allowed_by_resolved_roots, resolve_tool_path_with_context, resolve_tool_path_with_context_roots, - tool_path_is_effectively_absolute, DynamicMcpToolInfo, DynamicToolInfo, + tool_path_is_effectively_absolute, DynamicMcpToolInfo, DynamicToolInfo, PermissionIntent, PortableToolContextProvider, ToolContextFacts, ToolExposure, ToolPathBackend, ToolPathResolution, ToolRenderOptions, ToolResult, ToolWorkspaceKind, ValidationResult, }; @@ -111,9 +111,20 @@ pub trait Tool: Send + Sync { self.is_readonly() } - /// Whether to need permissions - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - !self.is_readonly() + /// Describe permission actions and resources without performing side effects. + fn permission_intents( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + if self.is_readonly() { + Ok(Vec::new()) + } else { + Ok(vec![PermissionIntent::new( + "custom_tool", + vec![self.name().to_string()], + )]) + } } /// Whether this tool manages its own execution timeout (for example via the diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/agent_wait_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/agent_wait_tool.rs index 797e868d85..102c3d6a13 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/agent_wait_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/agent_wait_tool.rs @@ -3,7 +3,7 @@ use crate::agentic::coordination::{ BackgroundSubagentWaitResult, }; use crate::agentic::tools::framework::{ - Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + PermissionIntent, Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; @@ -201,8 +201,12 @@ The selected task set is fixed when the call starts. wait_mode defaults to `all` false } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false + fn permission_intents( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Ok(Vec::new()) } fn render_tool_use_message(&self, _input: &Value, options: &ToolRenderOptions) -> String { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/analyze_image_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/analyze_image_tool.rs index 690113d39c..dec2b96e91 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/analyze_image_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/analyze_image_tool.rs @@ -202,10 +202,6 @@ impl Tool for AnalyzeImageTool { true } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - async fn validate_input( &self, input: &Value, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs index eb5f1333e1..472db3e199 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs @@ -1,5 +1,5 @@ use crate::agentic::tools::framework::{ - Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + PermissionIntent, Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::agentic::workspace::WorkspaceCommandOptions; use crate::infrastructure::events::event_system::get_global_event_system; @@ -368,8 +368,21 @@ Usage notes: false } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - true + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let command = input + .get("command") + .and_then(Value::as_str) + .map(str::trim) + .filter(|command| !command.is_empty()) + .ok_or_else(|| BitFunError::validation("command is required".to_string()))?; + Ok(vec![PermissionIntent::new( + "bash", + vec![command.to_string()], + )]) } async fn validate_input( diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/canvas_tools.rs b/src/crates/assembly/core/src/agentic/tools/implementations/canvas_tools.rs index 4095540a15..86a796ea55 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/canvas_tools.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/canvas_tools.rs @@ -121,10 +121,6 @@ Returns a stable `bitfun-canvas://...` artifact reference. Use ReadCanvas to ins }) } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - async fn call_impl( &self, input: &Value, @@ -318,10 +314,6 @@ Provide either `artifact_reference` or `canvas_id`, plus one complete replacemen }) } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - async fn call_impl( &self, input: &Value, @@ -416,10 +408,6 @@ Use this for small, targeted edits such as changing a label, number, style prop, }) } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - async fn call_impl( &self, input: &Value, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs index 6f1adcc265..e8eb1c2ac7 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs @@ -8,7 +8,9 @@ use crate::agentic::tools::computer_use_host::{ ScreenshotCropCenter, UiElementLocateQuery, }; use crate::agentic::tools::computer_use_optimizer::hash_screenshot_bytes; -use crate::agentic::tools::framework::{Tool, ToolExposure, ToolResult, ToolUseContext}; +use crate::agentic::tools::framework::{ + PermissionIntent, Tool, ToolExposure, ToolResult, ToolUseContext, +}; use crate::service::config::global::GlobalConfigManager; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::ToolImageAttachment; @@ -22,6 +24,33 @@ use bitfun_agent_tools::computer_use::{ use log::{debug, warn}; use serde_json::{json, Value}; +fn computer_use_permission_resource(input: &Value) -> String { + let action = input + .get("action") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("unknown"); + let target = [ + "app_name", + "url", + "path", + "title_contains", + "identifier_contains", + ] + .into_iter() + .find_map(|field| { + input + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!("{field}={value}")) + }); + + target.map_or_else(|| action.to_string(), |target| format!("{action}:{target}")) +} + /// Merges [`ComputerUseHost::computer_use_session_snapshot`] + optional `input_coordinates` into tool JSON. /// Also records the action for loop detection and adds loop warnings if detected. pub(crate) async fn computer_use_augment_result_json( @@ -1055,8 +1084,15 @@ impl Tool for ComputerUseTool { false } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - true + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Ok(vec![PermissionIntent::new( + "computer_use", + vec![computer_use_permission_resource(input)], + )]) } async fn is_enabled(&self) -> bool { @@ -1982,8 +2018,33 @@ fn req_i32(input: &Value, key: &str) -> BitFunResult { #[cfg(test)] mod tests { use super::ComputerUseTool; - use crate::agentic::tools::framework::Tool; - use serde_json::Value; + use crate::agentic::tools::framework::{Tool, ToolUseContext}; + use serde_json::{json, Value}; + + #[test] + fn computer_use_permission_resource_identifies_action_and_safe_target() { + let tool = ComputerUseTool::new(); + let context = ToolUseContext::for_tool_listing(None, None); + let intents = tool + .permission_intents( + &json!({ + "action": "open_app", + "app_name": "Visual Studio Code", + "text": "secret text must not be projected", + "script": "secret script must not be projected" + }), + &context, + ) + .expect("permission intent"); + + assert_eq!(intents.len(), 1); + assert_eq!(intents[0].action, "computer_use"); + assert_eq!( + intents[0].resources, + ["open_app:app_name=Visual Studio Code".to_string()] + ); + assert!(!intents[0].resources[0].contains("secret")); + } fn action_enum(schema: &Value) -> Vec { schema diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs index c47c7af36d..ac8caf0998 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs @@ -1915,10 +1915,6 @@ impl Tool for ControlHubTool { }) } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - true - } - fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs index 664bb16758..0bd0abf83c 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs @@ -748,10 +748,6 @@ Patch schema for "update": matches!(action, "get_time" | "list") } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - async fn validate_input( &self, input: &Value, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/delete_file_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/delete_file_tool.rs index 4c3c1d5de3..7942795343 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/delete_file_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/delete_file_tool.rs @@ -1,5 +1,6 @@ +use crate::agentic::tools::file_permissions::file_permission_intents; use crate::agentic::tools::framework::{ - Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + PermissionIntent, Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::agentic::tools::workspace_paths::is_bitfun_tool_uri; use crate::agentic::tools::ToolPathOperation; @@ -115,8 +116,16 @@ Important notes: false } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - true + fn permission_intents( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let path = input + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| BitFunError::validation("path parameter is required".to_string()))?; + file_permission_intents("edit", [path], context) } async fn validate_input( diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs index 01128144de..71456c321a 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs @@ -7,7 +7,9 @@ use super::env_snapshot::{remote_env_snapshot_for, RemoteEnvSnapshot}; use super::local_shell::{resolve_local_exec_shell, ResolvedLocalExecShell}; use super::progress::ExecOutputProgressBridge; use super::shell_kind::{exec_command_shell_kind, terminal_shell_type}; -use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; +use crate::agentic::tools::framework::{ + PermissionIntent, Tool, ToolResult, ToolUseContext, ValidationResult, +}; use crate::infrastructure::events::event_system::{ get_global_event_system, BackendEvent::BackgroundCommandLifecycle, }; @@ -596,8 +598,16 @@ Output: true } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - true + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let command = exec_command_run_input_from_input(input) + .map(|parsed| parsed.cmd.trim().to_string()) + .filter(|command| !command.is_empty()) + .ok_or_else(|| BitFunError::validation("cmd is required".to_string()))?; + Ok(vec![PermissionIntent::new("bash", vec![command])]) } fn manages_own_execution_timeout(&self) -> bool { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/file_edit_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/file_edit_tool.rs index b22c29bbf2..65e8aeaa01 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/file_edit_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/file_edit_tool.rs @@ -1,3 +1,4 @@ +use crate::agentic::tools::file_permissions::file_permission_intents; use crate::agentic::tools::file_read_state_runtime::{ assert_file_not_unexpectedly_modified, file_mutation_timestamp_ms, get_stored_file_read_state, local_file_modification_time_ms, read_current_file_content, read_state_tracking_enabled, @@ -6,7 +7,7 @@ use crate::agentic::tools::file_read_state_runtime::{ }; use crate::agentic::tools::file_tool_guidance::file_tool_guidance_message; use crate::agentic::tools::framework::{ - Tool, ToolPathResolution, ToolResult, ToolUseContext, ValidationResult, + PermissionIntent, Tool, ToolPathResolution, ToolResult, ToolUseContext, ValidationResult, }; use crate::agentic::tools::ToolPathOperation; use crate::util::errors::{BitFunError, BitFunResult}; @@ -157,8 +158,16 @@ impl Tool for FileEditTool { false } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - true + fn permission_intents( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let file_path = input + .get("file_path") + .and_then(Value::as_str) + .ok_or_else(|| BitFunError::validation("file_path is required".to_string()))?; + file_permission_intents("edit", [file_path], context) } async fn validate_input( diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs index 75f90b691a..d4b13b531c 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs @@ -1,8 +1,9 @@ +use crate::agentic::tools::file_permissions::file_permission_intents; use crate::agentic::tools::file_read_state_runtime::{ local_file_modification_time_ms, record_file_read_state, }; use crate::agentic::tools::framework::{ - Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + PermissionIntent, Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::agentic::tools::workspace_paths::is_bitfun_tool_uri; use crate::util::errors::{BitFunError, BitFunResult}; @@ -307,8 +308,16 @@ Usage: true } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false + fn permission_intents( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let file_path = input + .get("file_path") + .and_then(Value::as_str) + .ok_or_else(|| BitFunError::validation("file_path is required".to_string()))?; + file_permission_intents("read", [file_path], context) } async fn validate_input( diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs index 908a0576c8..68b42c6362 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs @@ -1,3 +1,4 @@ +use crate::agentic::tools::file_permissions::file_permission_intents; use crate::agentic::tools::file_read_state_runtime::{ assert_file_not_unexpectedly_modified, file_mutation_timestamp_ms, get_stored_file_read_state, local_file_modification_time_ms, read_current_file_content, read_state_tracking_enabled, @@ -8,7 +9,8 @@ use crate::agentic::tools::file_tool_guidance::{ file_tool_guidance_message, is_file_tool_guidance_message, }; use crate::agentic::tools::framework::{ - Tool, ToolPathResolution, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + PermissionIntent, Tool, ToolPathResolution, ToolRenderOptions, ToolResult, ToolUseContext, + ValidationResult, }; use crate::agentic::tools::ToolPathOperation; use crate::util::errors::{BitFunError, BitFunResult}; @@ -43,7 +45,6 @@ impl<'a> ParsedWritePayload<'a> { } const WRITE_PAYLOAD_PATH_PREFIX: &str = "+++ "; -const WRITE_FALLBACK_FILE_ATTEMPTS: usize = 16; const LARGE_WRITE_SOFT_LINE_LIMIT: usize = 200; const LARGE_WRITE_SOFT_BYTE_LIMIT: usize = 20 * 1024; @@ -198,20 +199,21 @@ impl FileWriteTool { Ok(ParsedWritePayload::Target { file_path, content }) } - async fn unused_fallback_file_path(context: &ToolUseContext) -> BitFunResult { - for _ in 0..WRITE_FALLBACK_FILE_ATTEMPTS { - let random_id = uuid::Uuid::new_v4().simple().to_string(); - let file_path = format!("write_{}.tmp", &random_id[..6]); - let resolved = context.resolve_tool_path(&file_path)?; - context.enforce_path_operation(ToolPathOperation::Write, &resolved)?; - if !Self::file_exists(context, &resolved).await { - return Ok(file_path); - } - } - - Err(BitFunError::tool( - "Failed to allocate a unique fallback file for Write".to_string(), - )) + fn fallback_file_path(context: &ToolUseContext) -> String { + let stable_id = context + .tool_call_id + .as_deref() + .unwrap_or("unknown") + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .take(12) + .collect::(); + let stable_id = if stable_id.is_empty() { + "unknown" + } else { + stable_id.as_str() + }; + format!("write_{stable_id}.tmp") } fn ignored_top_level_parameter_names(input: &Value) -> Vec { @@ -371,8 +373,17 @@ impl Tool for FileWriteTool { false } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - true + fn permission_intents( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let parsed = Self::parse_payload(input).map_err(BitFunError::validation)?; + let file_path = match parsed { + ParsedWritePayload::Target { file_path, .. } => file_path.to_string(), + ParsedWritePayload::MissingPath { .. } => Self::fallback_file_path(context), + }; + file_permission_intents("edit", [file_path.as_str()], context) } async fn validate_input( @@ -409,8 +420,8 @@ impl Tool for FileWriteTool { Self::preflight_write_error(ctx, file_path).await } ParsedWritePayload::MissingPath { .. } => { - let fallback_path = "write_000000.tmp"; - match ctx.resolve_tool_path(fallback_path) { + let fallback_path = Self::fallback_file_path(ctx); + match ctx.resolve_tool_path(&fallback_path) { Ok(resolved) => ctx .enforce_path_operation(ToolPathOperation::Write, &resolved) .err() @@ -485,11 +496,9 @@ impl Tool for FileWriteTool { ParsedWritePayload::Target { file_path, content } => { (file_path.to_string(), content.to_string(), false) } - ParsedWritePayload::MissingPath { content } => ( - Self::unused_fallback_file_path(context).await?, - content.to_string(), - true, - ), + ParsedWritePayload::MissingPath { content } => { + (Self::fallback_file_path(context), content.to_string(), true) + } }; let resolved = context.resolve_tool_path(&file_path)?; @@ -840,11 +849,10 @@ mod tests { std::fs::create_dir_all(&root).expect("create temp workspace"); let original_payload = "def main():\n print(\"Hello world\")"; + let mut context = local_context(root.clone()); + context.tool_call_id = Some("fallback123".to_string()); let results = FileWriteTool::new() - .call( - &json!({ "payload": original_payload }), - &local_context(root.clone()), - ) + .call(&json!({ "payload": original_payload }), &context) .await .expect("malformed payload should be preserved"); @@ -854,12 +862,7 @@ mod tests { .expect("collect workspace entries"); assert_eq!(entries.len(), 1); let file_name = entries[0].file_name().to_string_lossy().into_owned(); - assert!(file_name.starts_with("write_")); - assert!(file_name.ends_with(".tmp")); - assert_eq!(file_name.len(), "write_000000.tmp".len()); - assert!(file_name[6..12] - .chars() - .all(|character| character.is_ascii_hexdigit())); + assert_eq!(file_name, "write_fallback123.tmp"); assert_eq!( std::fs::read_to_string(entries[0].path()).expect("read fallback file"), original_payload diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs index a3dca29d0e..748d72249c 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs @@ -323,10 +323,6 @@ Input rules: true } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - async fn validate_input( &self, input: &Value, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs index 2f9fe75944..44b053bbb7 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs @@ -1303,10 +1303,6 @@ Usage: true } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - async fn validate_input( &self, input: &Value, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs index c9e838bd44..2ffdc7e448 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs @@ -3,7 +3,8 @@ //! Provides safe and convenient Git command execution functionality, reuses underlying GitService use crate::agentic::tools::framework::{ - Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + PermissionIntent, Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, + ValidationResult, }; use crate::service::git::{ execute_git_command, execute_git_command_raw, GitAddParams, GitCommitParams, GitDiffParams, @@ -1039,42 +1040,26 @@ When creating commits, use this format for the commit message: false } - fn needs_permissions(&self, input: Option<&Value>) -> bool { - // Read-only operations don't need permissions - if let Some(input) = input { - if let Some(operation) = input.get("operation").and_then(|v| v.as_str()) { - let readonly_ops = [ - "status", - "diff", - "log", - "show", - "branch", - "remote", - "tag", - "blame", - "describe", - "shortlog", - "rev-parse", - ]; - // For branch command, if just listing branches (no args or using -l), it's read-only - if operation == "branch" { - if let Some(args) = input.get("args").and_then(|v| v.as_str()) { - // If there are args but not viewing commands, permissions are needed - if !args.is_empty() - && !args.contains("-l") - && !args.contains("--list") - && !args.contains("-a") - && !args.contains("-r") - { - return true; - } - } - return false; - } - return !readonly_ops.contains(&operation); - } - } - true + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let normalized = Self::normalize_git_input(input.clone()); + let operation = normalized + .get("operation") + .and_then(Value::as_str) + .ok_or_else(|| BitFunError::validation("operation is required".to_string()))?; + let args = normalized + .get("args") + .and_then(Value::as_str) + .map(str::trim) + .filter(|args| !args.is_empty()); + let resource = match args { + Some(args) => format!("git {operation} {args}"), + None => format!("git {operation}"), + }; + Ok(vec![PermissionIntent::new("git", vec![resource])]) } async fn validate_input( diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs index 3d8cc0e50b..287c769057 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs @@ -194,10 +194,6 @@ impl Tool for GlobTool { true } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - async fn call_impl( &self, input: &Value, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/grep_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/grep_tool.rs index 600c9f891a..ad93d141d8 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/grep_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/grep_tool.rs @@ -534,10 +534,6 @@ Usage: true } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - fn render_tool_use_message( &self, input: &Value, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/list_models_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/list_models_tool.rs index 8a48af80c9..2abe8220f3 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/list_models_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/list_models_tool.rs @@ -205,10 +205,6 @@ impl Tool for ListModelsTool { true } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - async fn validate_input( &self, input: &Value, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/ls_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/ls_tool.rs index a0028481c7..e376743399 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/ls_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/ls_tool.rs @@ -86,10 +86,6 @@ Usage: true } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - async fn validate_input( &self, input: &Value, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs index 5778bfe1c6..eb67dadf11 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs @@ -1,7 +1,8 @@ //! Built-in MCP resource/prompt tools. use crate::agentic::tools::framework::{ - Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + PermissionIntent, Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, + ValidationResult, }; use crate::service::mcp::adapter::PromptAdapter; use crate::service::mcp::get_global_mcp_service; @@ -101,6 +102,19 @@ fn validate_required_string(input: &Value, field_name: &str) -> ValidationResult } } +fn mcp_input_string<'a>(input: &'a Value, field_name: &str) -> &'a str { + input + .get(field_name) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("unknown") +} + +fn mcp_permission_intent(resource: String) -> Vec { + vec![PermissionIntent::new("mcp", vec![resource])] +} + fn render_resource_catalog(resources: &[MCPResource]) -> String { if resources.is_empty() { return "No MCP resources available.".to_string(); @@ -279,8 +293,15 @@ impl Tool for ListMCPResourcesTool { true } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Ok(mcp_permission_intent(format!( + "{}:list_resources", + mcp_input_string(input, "server_id") + ))) } async fn validate_input( @@ -396,8 +417,16 @@ impl Tool for ReadMCPResourceTool { true } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Ok(mcp_permission_intent(format!( + "{}:{}", + mcp_input_string(input, "server_id"), + mcp_input_string(input, "uri") + ))) } async fn validate_input( @@ -519,8 +548,15 @@ impl Tool for ListMCPPromptsTool { true } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Ok(mcp_permission_intent(format!( + "{}:list_prompts", + mcp_input_string(input, "server_id") + ))) } async fn validate_input( @@ -643,8 +679,16 @@ impl Tool for GetMCPPromptTool { true } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Ok(mcp_permission_intent(format!( + "{}:prompt:{}", + mcp_input_string(input, "server_id"), + mcp_input_string(input, "name") + ))) } async fn validate_input( @@ -763,3 +807,55 @@ impl Tool for GetMCPPromptTool { )]) } } + +#[cfg(test)] +mod tests { + use super::{GetMCPPromptTool, ListMCPPromptsTool, ListMCPResourcesTool, ReadMCPResourceTool}; + use crate::agentic::tools::framework::{Tool, ToolUseContext}; + use serde_json::json; + + #[test] + fn builtin_mcp_tools_emit_server_scoped_v2_resources() { + let context = ToolUseContext::for_tool_listing(None, None); + let list_resources = ListMCPResourcesTool::new(); + let read_resource = ReadMCPResourceTool::new(); + let list_prompts = ListMCPPromptsTool::new(); + let get_prompt = GetMCPPromptTool::new(); + let cases: Vec<(&dyn Tool, serde_json::Value, &str)> = vec![ + ( + &list_resources, + json!({ "server_id": "docs" }), + "docs:list_resources", + ), + ( + &read_resource, + json!({ "server_id": "docs", "uri": "docs://permission/v2" }), + "docs:docs://permission/v2", + ), + ( + &list_prompts, + json!({ "server_id": "docs" }), + "docs:list_prompts", + ), + ( + &get_prompt, + json!({ "server_id": "docs", "name": "review" }), + "docs:prompt:review", + ), + ]; + + for (tool, input, expected_resource) in cases { + let intents = tool + .permission_intents(&input, &context) + .expect("permission intent"); + assert_eq!(intents.len(), 1, "{}", tool.name()); + assert_eq!(intents[0].action, "mcp", "{}", tool.name()); + assert_eq!( + intents[0].resources, + [expected_resource.to_string()], + "{}", + tool.name() + ); + } + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_init_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_init_tool.rs index 5ad051fe44..d7cd7b9163 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_init_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_init_tool.rs @@ -1,6 +1,6 @@ //! InitMiniApp tool — create a new MiniApp skeleton; AI then uses generic file tools to edit. -use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::agentic::tools::framework::{PermissionIntent, Tool, ToolResult, ToolUseContext}; use crate::infrastructure::events::{emit_global_event, BackendEvent}; use crate::miniapp::try_get_global_miniapp_manager; use crate::miniapp::types::{ @@ -107,8 +107,20 @@ Returns app_id and the app root directory. Use the root directory and file names false } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let name = input + .get("name") + .and_then(Value::as_str) + .unwrap_or("unnamed") + .trim(); + Ok(vec![PermissionIntent::new( + "custom_tool", + vec![format!("miniapp:InitMiniApp:{name}")], + )]) } async fn call_impl( @@ -221,11 +233,28 @@ Returns app_id and the app root directory. Use the root directory and file names #[cfg(test)] mod tests { use super::InitMiniAppTool; - use crate::agentic::tools::framework::{Tool, ToolExposure}; + use crate::agentic::tools::framework::{Tool, ToolExposure, ToolUseContext}; + use serde_json::json; #[test] fn init_miniapp_stays_expanded_for_assistant_creation() { let tool = InitMiniAppTool::new(); assert_eq!(tool.default_exposure(), ToolExposure::Direct); } + + #[test] + fn init_miniapp_emits_stable_permission_identity() { + let tool = InitMiniAppTool::new(); + let context = ToolUseContext::for_tool_listing(None, None); + let intents = tool + .permission_intents(&json!({ "name": "Release Notes" }), &context) + .expect("permission intent"); + + assert_eq!(intents.len(), 1); + assert_eq!(intents[0].action, "custom_tool"); + assert_eq!( + intents[0].resources, + ["miniapp:InitMiniApp:Release Notes".to_string()] + ); + } } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/page_deploy_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/page_deploy_tool.rs index aedeba3424..d0312db44d 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/page_deploy_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/page_deploy_tool.rs @@ -1,7 +1,7 @@ //! PageDeploy tool — deploy a saved BitFun Page version to production. use crate::agentic::tools::account_login_capability::account_login_available; -use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::agentic::tools::framework::{PermissionIntent, Tool, ToolResult, ToolUseContext}; use crate::agentic::tools::page_deploy_host::invoke_page_deploy; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; @@ -68,8 +68,12 @@ Preview a version at /p/{username}/{slug}/@v/{version_id}."# false } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false + fn permission_intents( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Ok(Vec::new()) } async fn is_available_in_context(&self, _context: Option<&ToolUseContext>) -> bool { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs index 1f1a9363c3..ea5293ce2e 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs @@ -108,10 +108,6 @@ Use PageDeploy only to switch an already-saved version_id (rollback / promote a false } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - true - } - async fn is_available_in_context(&self, _context: Option<&ToolUseContext>) -> bool { account_login_available() } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs index 73f60f2f2b..612962ba20 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs @@ -289,10 +289,6 @@ Use this tool when you recognize a common task pattern — it saves planning tim }) } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - fn is_readonly(&self) -> bool { true } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs index 5b8520c393..855a82ed0c 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs @@ -394,13 +394,6 @@ When returning pull request results to the user, include the provider web URL so .is_some_and(|action| !WRITE_ACTIONS.contains(&action)) } - fn needs_permissions(&self, input: Option<&Value>) -> bool { - input - .and_then(Self::action) - .map(|action| WRITE_ACTIONS.contains(&action)) - .unwrap_or(true) - } - async fn validate_input( &self, input: &Value, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index 623221efc0..bff2ff3b64 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -315,10 +315,6 @@ Arguments: false } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - async fn validate_input( &self, input: &Value, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs index 67295625b6..9b1e795cb2 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs @@ -146,10 +146,6 @@ Examples: true } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - async fn validate_input( &self, input: &Value, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs index 73faec5094..1ca8bd6092 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs @@ -336,10 +336,6 @@ Allowed agent types when creating a session: false } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - async fn validate_input( &self, input: &Value, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs index 76521463e3..94b729887d 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs @@ -4,7 +4,7 @@ //! Manages skill enabled/disabled status through SkillRegistry use crate::agentic::tools::framework::{ - Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + PermissionIntent, Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; @@ -150,8 +150,21 @@ impl Tool for SkillTool { true } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let skill_name = input + .get("command") + .and_then(Value::as_str) + .map(str::trim) + .filter(|skill_name| !skill_name.is_empty()) + .ok_or_else(|| BitFunError::validation("command is required".to_string()))?; + Ok(vec![PermissionIntent::new( + "skill", + vec![skill_name.to_string()], + )]) } async fn validate_input( 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 43aa624e0b..a17372e6a2 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 @@ -30,21 +30,27 @@ fn build_deep_review_subagent_context( values } -fn forward_user_input_availability( +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::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; - let Some(value) = context.custom_data.get(USER_INPUT_AVAILABLE_CONTEXT_KEY) else { - return; - }; - let value = match value { - Value::Bool(value) => value.to_string(), - Value::String(value) if matches!(value.as_str(), "true" | "false") => value.clone(), - _ => return, - }; - subagent_context.insert(USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), value); + for key in [ + USER_INPUT_AVAILABLE_CONTEXT_KEY, + AUTO_APPROVE_ASK_CONTEXT_KEY, + ] { + let Some(value) = context.custom_data.get(key) else { + continue; + }; + let value = match value { + Value::Bool(value) => value.to_string(), + Value::String(value) if matches!(value.as_str(), "true" | "false") => value.clone(), + _ => continue, + }; + subagent_context.insert(key.to_string(), value); + } } struct BackgroundTaskStartRequest<'a> { @@ -58,6 +64,7 @@ struct BackgroundTaskStartRequest<'a> { model_binding_policy: SessionModelBindingPolicy, effective_workspace_path: Option, model_id: Option, + permission_runtime_ceiling: PermissionRuntimeCeiling, inherit_parent_model: bool, subagent_context: Option>, prepared_prompt: String, @@ -69,6 +76,21 @@ struct BackgroundTaskStartRequest<'a> { } impl TaskTool { + async fn derive_parent_permission_runtime_ceiling( + context: &ToolUseContext, + ) -> PermissionRuntimeCeiling { + let global: GlobalConfig = match GlobalConfigManager::get_service().await { + Ok(service) => service.get_config(None).await.unwrap_or_default(), + Err(_) => GlobalConfig::default(), + }; + let agent_profile = context.agent_type.as_deref().and_then(|agent_type| { + let profile_id = crate::agentic::agents::resolve_mode_config_profile_id(agent_type); + global.ai.agent_profiles.get(profile_id.as_ref()) + }); + + crate::agentic::permission_policy::derive_parent_permission_runtime_ceiling(agent_profile) + } + pub(super) async fn load_configured_tool_execution_timeout() -> Option { let service = GlobalConfigManager::get_service().await.ok()?; let ai_config: AIConfig = service.get_config(Some("ai")).await.ok()?; @@ -577,8 +599,10 @@ impl TaskTool { ) }) .unwrap_or_default(); - forward_user_input_availability(context, &mut subagent_context); + forward_subagent_invocation_context(context, &mut subagent_context); let subagent_context = (!subagent_context.is_empty()).then_some(subagent_context); + let permission_runtime_ceiling = + Self::derive_parent_permission_runtime_ceiling(context).await; let prepared_prompt = prompt; if run_in_background { return Self::start_background_task(BackgroundTaskStartRequest { @@ -592,6 +616,7 @@ impl TaskTool { model_binding_policy, effective_workspace_path, model_id, + permission_runtime_ceiling, inherit_parent_model, subagent_context, prepared_prompt, @@ -615,6 +640,7 @@ impl TaskTool { model_binding_policy, effective_workspace_path, model_id, + permission_runtime_ceiling, inherit_parent_model, subagent_context, prepared_prompt, @@ -652,6 +678,7 @@ impl TaskTool { model_binding_policy, effective_workspace_path, model_id, + permission_runtime_ceiling, inherit_parent_model, subagent_context, prepared_prompt, @@ -681,6 +708,7 @@ impl TaskTool { inherit_parent_model, subagent_parent_info: parent_info, context: subagent_context.unwrap_or_default(), + permission_runtime_ceiling, delegation_policy: context.delegation_policy().spawn_child(), external_generation_lease, }, @@ -716,6 +744,7 @@ impl TaskTool { model_binding_policy: SessionModelBindingPolicy, effective_workspace_path: Option, model_id: Option, + permission_runtime_ceiling: PermissionRuntimeCeiling, inherit_parent_model: bool, subagent_context: Option>, prepared_prompt: String, @@ -774,6 +803,7 @@ impl TaskTool { inherit_parent_model, subagent_parent_info: parent_info, context: subagent_context.clone().unwrap_or_default(), + permission_runtime_ceiling: permission_runtime_ceiling.clone(), delegation_policy: context.delegation_policy().spawn_child(), external_generation_lease: external_generation_lease.clone(), }, @@ -1073,6 +1103,24 @@ impl TaskTool { mod target_context_tests { use super::*; use bitfun_agent_runtime::deep_review::{append_tool_use_context_data, ReviewTargetEvidence}; + use bitfun_agent_runtime::permission::AUTO_APPROVE_ASK_CONTEXT_KEY; + use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; + + fn parent_tool_context() -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + loaded_deferred_tool_specs: Vec::new(), + primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: Default::default(), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), + } + } #[test] fn deep_review_child_context_preserves_target_evidence_for_tools() { @@ -1113,27 +1161,71 @@ mod target_context_tests { #[test] fn child_context_preserves_non_interactive_user_input_boundary() { - let mut parent = ToolUseContext { - tool_call_id: None, - agent_type: None, - session_id: None, - dialog_turn_id: None, - workspace: None, - loaded_deferred_tool_specs: Vec::new(), - primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), - custom_data: HashMap::new(), - computer_use_host: None, - runtime_tool_restrictions: Default::default(), - runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), - }; + let mut parent = parent_tool_context(); parent.custom_data.insert( - bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), + USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), Value::Bool(false), ); let mut child = HashMap::new(); - forward_user_input_availability(&parent, &mut child); + forward_subagent_invocation_context(&parent, &mut child); assert_eq!(child["user_input_available"], "false"); } + + #[test] + fn child_context_preserves_explicit_auto_approve_true_and_false() { + for value in [true, false] { + let mut parent = parent_tool_context(); + parent + .custom_data + .insert(AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), Value::Bool(value)); + let mut child = HashMap::new(); + + forward_subagent_invocation_context(&parent, &mut child); + + assert_eq!( + child.get(AUTO_APPROVE_ASK_CONTEXT_KEY).map(String::as_str), + Some(if value { "true" } else { "false" }) + ); + } + } + + #[test] + fn child_context_leaves_unset_auto_approve_for_global_fallback() { + let parent = parent_tool_context(); + let mut child = HashMap::new(); + + forward_subagent_invocation_context(&parent, &mut child); + + assert!(!child.contains_key(AUTO_APPROVE_ASK_CONTEXT_KEY)); + } + + #[test] + fn child_context_forwards_only_allowlisted_boolean_invocation_facts() { + let mut parent = parent_tool_context(); + parent.custom_data.insert( + AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), + Value::String("true".to_string()), + ); + parent.custom_data.insert( + USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), + Value::String("invalid".to_string()), + ); + parent.custom_data.insert( + "parent_tool_runtime_state".to_string(), + Value::String("must-not-propagate".to_string()), + ); + let mut child = HashMap::from([( + "deep_review_subagent_role".to_string(), + "reviewer".to_string(), + )]); + + forward_subagent_invocation_context(&parent, &mut child); + + assert_eq!(child[AUTO_APPROVE_ASK_CONTEXT_KEY], "true"); + assert!(!child.contains_key(USER_INPUT_AVAILABLE_CONTEXT_KEY)); + assert!(!child.contains_key("parent_tool_runtime_state")); + assert_eq!(child["deep_review_subagent_role"], "reviewer"); + } } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/launch_review_agent.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/launch_review_agent.rs index a273fc5377..045fbb911f 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/launch_review_agent.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/launch_review_agent.rs @@ -339,8 +339,23 @@ impl Tool for LaunchReviewAgentTool { } } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let subagent_type = input + .get("subagent_type") + .and_then(Value::as_str) + .map(str::trim) + .filter(|subagent_type| !subagent_type.is_empty()) + .ok_or_else(|| BitFunError::validation("subagent_type is required".to_string()))?; + Ok(vec![ + crate::agentic::tools::framework::PermissionIntent::new( + "task", + vec![subagent_type.to_string()], + ), + ]) } async fn validate_input( diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs index fb34c052e3..f7840bd86e 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs @@ -18,15 +18,15 @@ use crate::agentic::deep_review_policy::{ }; use crate::agentic::events::DeepReviewQueueStatus; use crate::agentic::tools::framework::{ - Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + PermissionIntent, Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::agentic::tools::pipeline::SubagentParentInfo; use crate::service::config::global::GlobalConfigManager; -use crate::service::config::types::AIConfig; +use crate::service::config::types::{AIConfig, GlobalConfig}; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::timing::elapsed_ms_u64; use async_trait::async_trait; -use bitfun_runtime_ports::SubagentContextMode; +use bitfun_runtime_ports::{PermissionRuntimeCeiling, SubagentContextMode}; use input::{TaskAction, TaskInvocation}; use log::{debug, warn}; use serde_json::{json, Map, Value}; @@ -179,8 +179,36 @@ impl Tool for TaskTool { } } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let action = TaskAction::parse(input)?; + let resource = match action { + TaskAction::Spawn => input + .get("subagent_type") + .and_then(Value::as_str) + .map(str::trim) + .filter(|subagent_type| !subagent_type.is_empty()) + .unwrap_or("fork_context") + .to_string(), + TaskAction::SendInput => input + .get("session_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|session_id| !session_id.is_empty()) + .map(|session_id| format!("send_input:{session_id}")) + .ok_or_else(|| BitFunError::validation("session_id is required".to_string()))?, + TaskAction::Cancel => input + .get("session_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|session_id| !session_id.is_empty()) + .map(|session_id| format!("cancel:{session_id}")) + .ok_or_else(|| BitFunError::validation("session_id is required".to_string()))?, + }; + Ok(vec![PermissionIntent::new("task", vec![resource])]) } async fn validate_input( diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/terminal_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/terminal_control_tool.rs index 89369d2fb0..30c1b78e89 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/terminal_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/terminal_control_tool.rs @@ -76,10 +76,6 @@ The terminal_session_id is returned inside ...) -> bool { - false - } - async fn is_available_in_context(&self, context: Option<&ToolUseContext>) -> bool { !context.map(|ctx| ctx.is_remote()).unwrap_or(false) } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/view_image_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/view_image_tool.rs index 788b6df524..468fba6e31 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/view_image_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/view_image_tool.rs @@ -263,10 +263,6 @@ impl Tool for ViewImageTool { true } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - async fn validate_input( &self, input: &Value, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs b/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs index 7dc2a108e7..b5a6800c0b 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs @@ -3,7 +3,7 @@ use super::readable::{ RequestedFormat, }; use crate::agentic::tools::framework::{ - Tool, ToolExposure, ToolResult, ToolUseContext, ValidationResult, + PermissionIntent, Tool, ToolExposure, ToolResult, ToolUseContext, ValidationResult, }; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; @@ -87,8 +87,21 @@ Example usage: true } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let url = input + .get("url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|url| !url.is_empty()) + .ok_or_else(|| BitFunError::validation("url is required".to_string()))?; + Ok(vec![PermissionIntent::new( + "webfetch", + vec![url.to_string()], + )]) } async fn validate_input( diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/web/search.rs b/src/crates/assembly/core/src/agentic/tools/implementations/web/search.rs index 2adc454521..1fbb8600a6 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/web/search.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/web/search.rs @@ -1,4 +1,6 @@ -use crate::agentic::tools::framework::{Tool, ToolExposure, ToolResult, ToolUseContext}; +use crate::agentic::tools::framework::{ + PermissionIntent, Tool, ToolExposure, ToolResult, ToolUseContext, +}; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use bitfun_services_integrations::web_tools::{ExaSearchRequest, WebToolNetworkProvider}; @@ -176,8 +178,21 @@ Advanced features: true } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let query = input + .get("query") + .and_then(Value::as_str) + .map(str::trim) + .filter(|query| !query.is_empty()) + .ok_or_else(|| BitFunError::validation("query is required".to_string()))?; + Ok(vec![PermissionIntent::new( + "websearch", + vec![query.to_string()], + )]) } async fn call_impl( diff --git a/src/crates/assembly/core/src/agentic/tools/mod.rs b/src/crates/assembly/core/src/agentic/tools/mod.rs index 7cdb44a177..a1fdd41fb0 100644 --- a/src/crates/assembly/core/src/agentic/tools/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/mod.rs @@ -6,6 +6,7 @@ pub mod computer_use_capability; pub mod computer_use_host; pub mod computer_use_optimizer; pub mod computer_use_verification; +pub(crate) mod file_permissions; pub mod file_read_state_runtime; pub mod file_tool_guidance; pub mod framework; diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs index ad1e00e266..e3042468c7 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs @@ -20,7 +20,6 @@ pub(crate) fn tool_task_state_kind(state: &ToolExecutionState) -> ToolTaskStateK ToolExecutionState::Waiting { .. } => ToolTaskStateKind::Waiting, ToolExecutionState::Running { .. } => ToolTaskStateKind::Running, ToolExecutionState::Streaming { .. } => ToolTaskStateKind::Streaming, - ToolExecutionState::AwaitingConfirmation { .. } => ToolTaskStateKind::AwaitingConfirmation, ToolExecutionState::Completed { .. } => ToolTaskStateKind::Completed, ToolExecutionState::Failed { .. } => ToolTaskStateKind::Failed, ToolExecutionState::Rejected { .. } => ToolTaskStateKind::Rejected, @@ -92,19 +91,6 @@ impl ToolStateManager { self.tasks.get(tool_id).map(|t| t.clone()) } - /// Update task arguments - pub fn update_task_arguments(&self, tool_id: &str, new_arguments: serde_json::Value) { - if let Some(mut task) = self.tasks.get_mut(tool_id) { - debug!( - "Updated tool arguments: tool_id={}, old_args={:?}, new_args={:?}", - tool_id, - task.effective_arguments(), - new_arguments - ); - task.update_effective_arguments(new_arguments); - } - } - /// Get all tasks of a session pub fn get_session_tasks(&self, session_id: &str) -> Vec { self.tasks @@ -162,25 +148,6 @@ impl ToolStateManager { } => ToolStateEventKind::Streaming { chunks_received: *chunks_received, }, - ToolExecutionState::AwaitingConfirmation { - params: _, - timeout_at, - } => { - let confirmation_timeout_secs = task - .options - .confirmation_timeout_secs - .filter(|seconds| *seconds > 0); - ToolStateEventKind::AwaitingConfirmation { - params: task.invocation.wire_arguments.clone(), - timeout_at: confirmation_timeout_secs.map(|_| { - timeout_at - .duration_since(std::time::SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .min(u128::from(u64::MAX)) as u64 - }), - } - } ToolExecutionState::Completed { result, duration_ms, @@ -268,7 +235,6 @@ impl ToolStateManager { waiting: counts.waiting, running: counts.running, streaming: counts.streaming, - awaiting_confirmation: counts.awaiting_confirmation, completed: counts.completed, failed: counts.failed, rejected: counts.rejected, @@ -340,6 +306,7 @@ mod tests { primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), context_vars: HashMap::new(), subagent_parent_info: None, + permission_delegation: None, delegation_policy: bitfun_runtime_ports::DelegationPolicy::top_level(), deferred_tools: Vec::new(), loaded_deferred_tool_specs: Vec::new(), @@ -452,7 +419,6 @@ pub struct ToolStats { pub waiting: usize, pub running: usize, pub streaming: usize, - pub awaiting_confirmation: usize, pub completed: usize, pub failed: usize, pub rejected: usize, diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index e3d77a1284..dadee63a78 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -1,7 +1,7 @@ //! Tool pipeline //! //! Manages the complete lifecycle of tools: -//! confirmation, execution, caching, retries, etc. +//! permission authorization, execution, caching, retries, etc. use super::state_manager::{tool_task_state_kind, ToolStateManager}; use super::types::*; @@ -15,27 +15,31 @@ use crate::agentic::tools::tool_context_runtime::ToolUseContext; use crate::agentic::tools::tool_result_storage; use crate::util::elapsed_ms_u64; use crate::util::errors::{BitFunError, BitFunResult}; -use bitfun_agent_runtime::tool_confirmation::{ - resolve_confirmation_failure, resolve_confirmation_wait_result, resolve_tool_confirmation_plan, - ConfirmationFailureKind, ToolConfirmationChannelStore, ToolConfirmationPlan, - ToolConfirmationRequestFacts, ToolConfirmationResponse, ToolConfirmationWaitResult, +use bitfun_agent_runtime::permission::{ + PendingPermissionReceiver, PermissionRequestManager, PermissionWaitOutcome, }; use bitfun_agent_tools::{ - build_invalid_tool_call_error_message, build_tool_call_truncation_recovery_notice, - build_tool_confirmation_timeout_presentation, build_tool_execution_error_presentation, + build_invalid_tool_call_error_message, build_permission_denied_tool_presentation, + build_tool_call_truncation_recovery_notice, build_tool_execution_error_presentation, build_tool_execution_timeout_presentation, build_user_rejected_tool_presentation_with_instruction, build_user_steering_interrupted_presentation, render_tool_result_for_assistant, truncate_raw_tool_arguments_preview, truncate_tool_arguments_preview, - validate_tool_execution_admission, ResolvedToolInvocation, ToolExecutionAdmissionRejection, - ToolExecutionAdmissionRequest, GET_TOOL_SPEC_TOOL_NAME, USER_STEERING_INTERRUPTED_MESSAGE, + validate_tool_execution_admission, PermissionIntent, ResolvedToolInvocation, + ToolExecutionAdmissionRejection, ToolExecutionAdmissionRequest, ToolExecutionErrorPresentation, + GET_TOOL_SPEC_TOOL_NAME, USER_STEERING_INTERRUPTED_MESSAGE, +}; +use bitfun_runtime_ports::{ + wildcard_matches, PermissionEffect, PermissionGrant, PermissionReply, PermissionRequest, + PermissionRequestSource, PermissionRequestSourceKind, PermissionResourceCaseSensitivity, + PermissionRule, RoundInjectionToolPreemption, }; -use bitfun_runtime_ports::RoundInjectionToolPreemption; use futures::future::join_all; use log::{debug, error, info, warn}; +use std::collections::HashMap; use std::sync::Arc; use std::time::{Instant, SystemTime}; -use tokio::sync::RwLock as TokioRwLock; +use tokio::sync::{Mutex as TokioMutex, RwLock as TokioRwLock}; use tokio::time::{timeout, Duration}; use tokio_util::sync::CancellationToken; use tool_runtime::pipeline::{ @@ -302,7 +306,27 @@ fn build_user_steering_interrupted_result( fn build_user_rejected_tool_result( task_id: &str, task: Option, - instruction: Option<&str>, + feedback: Option<&str>, +) -> ToolExecutionResult { + build_permission_rejected_tool_result(task_id, task, |tool_name| { + build_user_rejected_tool_presentation_with_instruction(tool_name, feedback) + }) +} + +fn build_permission_denied_tool_result( + task_id: &str, + task: Option, + reason: &str, +) -> ToolExecutionResult { + build_permission_rejected_tool_result(task_id, task, |tool_name| { + build_permission_denied_tool_presentation(tool_name, reason) + }) +} + +fn build_permission_rejected_tool_result( + task_id: &str, + task: Option, + presentation_for: impl FnOnce(&str) -> ToolExecutionErrorPresentation, ) -> ToolExecutionResult { let (tool_id, wire_tool_name, effective_tool_name, execution_time_ms) = if let Some(task) = task { @@ -325,8 +349,7 @@ fn build_user_rejected_tool_result( ) }; - let presentation = - build_user_rejected_tool_presentation_with_instruction(&effective_tool_name, instruction); + let presentation = presentation_for(&effective_tool_name); let persisted_effective_tool_name = persisted_effective_tool_name(&wire_tool_name, &effective_tool_name); @@ -397,6 +420,157 @@ fn recovered_write_has_potentially_truncated_marked_path( .is_some_and(|value| value.starts_with("+++ ") && !value.contains('\n')) } +enum PermissionAuthorization { + Allowed, + UserRejected { feedback: Option }, + PolicyDenied { reason: String }, +} + +fn user_rejection_audit_reason(tool_name: &str, feedback: Option<&str>) -> String { + match feedback { + Some(feedback) => { + format!("User rejected permission for tool '{tool_name}' with feedback: {feedback}") + } + None => format!("User rejected permission for tool '{tool_name}'"), + } +} + +#[derive(Debug)] +enum PermissionExecutionPlan { + Allowed, + Rejected { reason: String }, + Awaiting(Vec), +} + +#[derive(Debug, Clone)] +enum PermissionPlanDraft { + Allowed, + Rejected { reason: String }, + Requests(Vec), +} + +pub fn permission_project_id_for_workspace_identity( + identity: &crate::service::remote_ssh::workspace_state::WorkspaceSessionIdentity, + is_remote: bool, +) -> BitFunResult { + if !is_remote { + return Ok( + bitfun_services_integrations::remote_ssh::paths::local_workspace_stable_storage_id( + identity.logical_workspace_path(), + ), + ); + } + + if identity.hostname == "_unresolved" { + let connection_id = identity.remote_connection_id.as_deref().ok_or_else(|| { + BitFunError::validation( + "Unresolved remote workspace permission identity has no connection id".to_string(), + ) + })?; + let key = + bitfun_services_integrations::remote_ssh::paths::unresolved_remote_session_storage_key( + connection_id, + identity.logical_workspace_path(), + ); + return Ok(format!("remote_unresolved_{key}")); + } + + Ok( + bitfun_services_integrations::remote_ssh::paths::remote_workspace_stable_id( + &identity.hostname, + identity.logical_workspace_path(), + ), + ) +} + +fn permission_project_id(context: &ToolUseContext) -> BitFunResult { + let workspace = context.workspace.as_ref().ok_or_else(|| { + BitFunError::validation("A workspace is required for file permissions".to_string()) + })?; + permission_project_id_for_workspace_identity(&workspace.session_identity, workspace.is_remote()) +} + +fn permission_project_path(context: &ToolUseContext) -> BitFunResult { + let workspace = context.workspace.as_ref().ok_or_else(|| { + BitFunError::validation("A workspace is required for file permissions".to_string()) + })?; + Ok(workspace + .session_identity + .logical_workspace_path() + .to_string()) +} + +fn permission_resource_case_sensitivity( + context: &ToolUseContext, +) -> PermissionResourceCaseSensitivity { + if context.is_remote() || !cfg!(windows) { + PermissionResourceCaseSensitivity::Sensitive + } else { + PermissionResourceCaseSensitivity::Insensitive + } +} + +fn permission_intent_effect( + intent: &PermissionIntent, + rules: &[PermissionRule], + grants: &[PermissionGrant], + case_sensitivity: PermissionResourceCaseSensitivity, +) -> PermissionEffect { + let evaluator = bitfun_runtime_ports::PermissionEvaluator::new(case_sensitivity); + let mut aggregate = PermissionEffect::Allow; + + for resource in &intent.resources { + let configured_effect = if intent.action == "bash" { + rules + .iter() + .rev() + .find(|rule| { + wildcard_matches( + &intent.action, + &rule.action, + PermissionResourceCaseSensitivity::Sensitive, + ) && match rule.effect { + PermissionEffect::Allow => rule.resource == *resource, + PermissionEffect::Ask | PermissionEffect::Deny => { + wildcard_matches(resource, &rule.resource, case_sensitivity) + } + } + }) + .map(|rule| rule.effect) + .unwrap_or(PermissionEffect::Ask) + } else { + evaluator.evaluate_resource(&intent.action, resource, rules) + }; + + match configured_effect { + PermissionEffect::Deny => return PermissionEffect::Deny, + PermissionEffect::Allow => {} + PermissionEffect::Ask => { + let remembered = grants.iter().any(|grant| { + if intent.action == "bash" { + grant.action == intent.action && grant.resource == *resource + } else { + wildcard_matches( + &intent.action, + &grant.action, + PermissionResourceCaseSensitivity::Sensitive, + ) && wildcard_matches(resource, &grant.resource, case_sensitivity) + } + }); + if !remembered { + aggregate = PermissionEffect::Ask; + } + } + } + } + + if intent.resources.is_empty() { + PermissionEffect::Ask + } else { + aggregate + } +} + const SUBAGENT_LAUNCH_TOOL_NAME: &str = "Task"; /// Tool pipeline @@ -404,9 +578,10 @@ const SUBAGENT_LAUNCH_TOOL_NAME: &str = "Task"; pub struct ToolPipeline { tool_registry: Arc>, state_manager: Arc, - confirmation_channels: ToolConfirmationChannelStore, cancellation_tokens: ToolCancellationTokenStore, computer_use_host: Option, + permission_request_manager: Option>, + permission_plans: Arc>>, } impl ToolPipeline { @@ -418,16 +593,433 @@ impl ToolPipeline { Self { tool_registry, state_manager, - confirmation_channels: ToolConfirmationChannelStore::new(), cancellation_tokens: ToolCancellationTokenStore::new(), computer_use_host, + permission_request_manager: None, + permission_plans: Arc::new(TokioMutex::new(HashMap::new())), } } + pub fn with_permission_request_manager( + mut self, + permission_request_manager: Arc, + ) -> Self { + self.permission_request_manager = Some(permission_request_manager); + self + } + pub fn computer_use_host(&self) -> Option { self.computer_use_host.clone() } + async fn draft_permission_plan( + &self, + task: ToolTask, + tool_name: String, + intents: Vec, + context: ToolUseContext, + ) -> BitFunResult { + if intents.is_empty() { + return Ok(PermissionPlanDraft::Allowed); + } + + let project_id = permission_project_id(&context)?; + let project_path = permission_project_path(&context)?; + let permission_rules = task.options.permission_rules.clone(); + let case_sensitivity = permission_resource_case_sensitivity(&context); + let round_id = task.context.round_id.clone(); + let tool_call_id = task.tool_call.tool_id.clone(); + let session_id = task.context.session_id.clone(); + let agent_type = task.context.agent_type.clone(); + let permission_delegation = task.context.permission_delegation.clone().or_else(|| { + task.context + .subagent_parent_info + .as_ref() + .map(|parent| parent.permission_delegation_context(&agent_type)) + }); + let manager = self.permission_request_manager.clone(); + let grants = match manager { + Some(ref manager) => manager + .list_project_grants(&project_id) + .await + .map_err(|error| BitFunError::service(error.to_string()))?, + None => Vec::new(), + }; + let mut asks = Vec::new(); + + for intent in intents { + match permission_intent_effect(&intent, &permission_rules, &grants, case_sensitivity) { + PermissionEffect::Allow => {} + PermissionEffect::Ask => asks.push(intent), + PermissionEffect::Deny => { + return Ok(PermissionPlanDraft::Rejected { + reason: format!( + "Permission policy denied '{}' for {}", + intent.action, + intent.resources.join(", ") + ), + }); + } + } + } + + if asks.is_empty() { + return Ok(PermissionPlanDraft::Allowed); + } + + if manager.is_none() { + return Err(BitFunError::service( + "Permission request manager is unavailable for a file tool request".to_string(), + )); + } + + let requests = asks + .into_iter() + .map(|intent| PermissionRequest { + request_id: uuid::Uuid::new_v4().to_string(), + round_id: round_id.clone(), + order: task.tool_call_order, + tool_call_id: Some(tool_call_id.clone()), + project_path: Some(project_path.clone()), + project_id: project_id.clone(), + session_id: session_id.clone(), + agent_id: agent_type.clone(), + action: intent.action, + resources: intent.resources, + save_resources: intent.save_resources, + source: PermissionRequestSource { + kind: PermissionRequestSourceKind::ToolCall, + identity: tool_name.clone(), + }, + delegation: permission_delegation.clone(), + display_metadata: intent.display_metadata, + }) + .collect(); + + Ok(PermissionPlanDraft::Requests(requests)) + } + + async fn register_permission_requests( + &self, + requests: Vec, + auto_approve: bool, + ) -> BitFunResult> { + let manager = self.permission_request_manager.as_ref().ok_or_else(|| { + BitFunError::service( + "Permission request manager is unavailable for a file tool request".to_string(), + ) + })?; + + let receivers = if auto_approve { + manager + .register_batch_non_interactive(requests.clone()) + .await + } else { + manager.register_batch(requests.clone()).await + } + .map_err(|error| BitFunError::service(error.to_string()))?; + + if auto_approve { + for request in &requests { + if let Err(error) = manager + .reply( + &request.request_id, + PermissionReply::Once, + bitfun_runtime_ports::PermissionReplySource::AutoApprove, + ) + .await + { + self.cancel_permission_request_ids( + requests + .iter() + .map(|request| request.request_id.clone()) + .collect(), + "Automatic permission approval failed".to_string(), + ) + .await; + return Err(BitFunError::service(error.to_string())); + } + } + } + + Ok(receivers) + } + + async fn prepare_permission_plans(&self, task_ids: &[String]) -> BitFunResult<()> { + let mut drafts = Vec::with_capacity(task_ids.len()); + let mut ordered_requests = Vec::new(); + + for task_id in task_ids { + let Some(task) = self.state_manager.get_task(task_id) else { + continue; + }; + let tool_name = task.invocation.effective_tool_name.clone(); + if task.invocation_resolution_error.is_some() + || task.tool_call.tool_name.is_empty() + || task.tool_call.is_error + || recovered_write_has_potentially_truncated_marked_path( + &tool_name, + &task.invocation.effective_arguments, + task.tool_call.recovered_from_truncation, + ) + { + continue; + } + let tool = { + let registry = self.tool_registry.read().await; + if validate_tool_execution_admission(ToolExecutionAdmissionRequest { + tool_name: &tool_name, + allowed_tools: &task.context.allowed_tools, + runtime_tool_restrictions: &task.context.runtime_tool_restrictions, + invocation_is_deferred: task.invocation.is_deferred(), + deferred_tools: &task.context.deferred_tools, + loaded_deferred_tool_specs: &task.context.loaded_deferred_tool_specs, + current_catalog_generation: registry.current_snapshot_generation(), + get_tool_spec_tool_name: GET_TOOL_SPEC_TOOL_NAME, + }) + .is_err() + { + continue; + } + registry.get_tool(&tool_name) + }; + let Some(tool) = tool else { + continue; + }; + let tool_context = self.build_tool_use_context(&task, CancellationToken::new()); + let validation = tool + .validate_input(&task.invocation.effective_arguments, Some(&tool_context)) + .await; + if !validation.result { + continue; + } + let intents = + tool.permission_intents(&task.invocation.effective_arguments, &tool_context)?; + let draft = self + .draft_permission_plan( + task.clone(), + tool_name.clone(), + intents, + tool_context.clone(), + ) + .await?; + if let PermissionPlanDraft::Requests(requests) = &draft { + ordered_requests.extend( + requests + .iter() + .cloned() + .map(|request| (task_id.clone(), request)), + ); + } + drafts.push((task_id.clone(), draft)); + } + + if !ordered_requests.is_empty() { + let batch_requests = ordered_requests + .iter() + .map(|(_, request)| request.clone()) + .collect::>(); + let auto_approve = task_ids + .first() + .and_then(|task_id| self.state_manager.get_task(task_id)) + .is_some_and(|task| task.options.auto_approve_ask); + let receivers = self + .register_permission_requests(batch_requests, auto_approve) + .await?; + + let mut receivers_by_task = HashMap::>::new(); + for ((task_id, _), receiver) in ordered_requests.into_iter().zip(receivers) { + receivers_by_task.entry(task_id).or_default().push(receiver); + } + for (task_id, draft) in &drafts { + if let PermissionPlanDraft::Requests(_) = draft { + let receivers = receivers_by_task.remove(task_id).ok_or_else(|| { + BitFunError::service(format!( + "Permission plan lost its pending receivers for tool task '{task_id}'" + )) + })?; + self.permission_plans.lock().await.insert( + task_id.clone(), + PermissionExecutionPlan::Awaiting(receivers), + ); + } + } + } + + for (task_id, draft) in drafts { + match draft { + PermissionPlanDraft::Allowed => { + self.permission_plans + .lock() + .await + .insert(task_id, PermissionExecutionPlan::Allowed); + } + PermissionPlanDraft::Rejected { reason } => { + self.permission_plans + .lock() + .await + .insert(task_id, PermissionExecutionPlan::Rejected { reason }); + } + PermissionPlanDraft::Requests(_) => {} + } + } + + Ok(()) + } + + async fn await_prepared_permission_plan( + &self, + task_id: &str, + cancellation_token: &CancellationToken, + ) -> BitFunResult { + let Some(plan) = self.permission_plans.lock().await.remove(task_id) else { + return Ok(PermissionAuthorization::Allowed); + }; + + self.await_permission_execution_plan(plan, cancellation_token) + .await + } + + async fn await_permission_execution_plan( + &self, + plan: PermissionExecutionPlan, + cancellation_token: &CancellationToken, + ) -> BitFunResult { + let receivers = match plan { + PermissionExecutionPlan::Allowed => return Ok(PermissionAuthorization::Allowed), + PermissionExecutionPlan::Rejected { reason } => { + return Ok(PermissionAuthorization::PolicyDenied { reason }); + } + PermissionExecutionPlan::Awaiting(receivers) => receivers, + }; + + let mut receivers = receivers.into_iter(); + while let Some(pending) = receivers.next() { + let request_id = pending.request_id().to_string(); + let outcome = tokio::select! { + outcome = pending.wait() => outcome, + _ = cancellation_token.cancelled() => { + let remaining = std::iter::once(request_id.clone()) + .chain(receivers.map(|pending| pending.request_id().to_string())); + self.cancel_permission_request_ids( + remaining.collect(), + "Tool execution was cancelled".to_string(), + ) + .await; + return Err(BitFunError::Cancelled( + "Tool execution was cancelled while awaiting permission".to_string(), + )); + } + }; + + match outcome { + PermissionWaitOutcome::Replied(PermissionReply::Once | PermissionReply::Always) => { + } + PermissionWaitOutcome::Replied(PermissionReply::Reject { feedback }) => { + self.cancel_permission_request_ids( + receivers + .map(|pending| pending.request_id().to_string()) + .collect(), + "Another permission request for this tool was rejected".to_string(), + ) + .await; + let feedback = feedback + .map(|feedback| feedback.trim().to_string()) + .filter(|feedback| !feedback.is_empty()); + return Ok(PermissionAuthorization::UserRejected { feedback }); + } + PermissionWaitOutcome::Cancelled { reason } => { + self.cancel_permission_request_ids( + receivers + .map(|pending| pending.request_id().to_string()) + .collect(), + "Another permission request for this tool was cancelled".to_string(), + ) + .await; + return Err(BitFunError::Cancelled(reason)); + } + } + + if cancellation_token.is_cancelled() { + self.cancel_permission_request_ids( + receivers + .map(|pending| pending.request_id().to_string()) + .collect(), + "Tool execution was cancelled".to_string(), + ) + .await; + return Err(BitFunError::Cancelled( + "Tool execution was cancelled after permission reply".to_string(), + )); + } + } + + Ok(PermissionAuthorization::Allowed) + } + + async fn cancel_permission_request_ids(&self, request_ids: Vec, reason: String) { + let Some(manager) = self.permission_request_manager.as_ref() else { + return; + }; + for request_id in request_ids { + if let Err(error) = manager.cancel_request(&request_id, reason.clone()).await { + warn!( + "Failed to cancel prepared permission request: request_id={}, error={}", + request_id, error + ); + } + } + } + + async fn cleanup_permission_plans(&self, task_ids: &[String], reason: String) { + for task_id in task_ids { + let Some(plan) = self.permission_plans.lock().await.remove(task_id) else { + continue; + }; + if let PermissionExecutionPlan::Awaiting(receivers) = plan { + self.cancel_permission_request_ids( + receivers + .into_iter() + .map(|pending| pending.request_id().to_string()) + .collect(), + reason.clone(), + ) + .await; + } + } + } + + async fn authorize_permission_intents( + &self, + task: &ToolTask, + tool_name: &str, + intents: Vec, + context: &ToolUseContext, + cancellation_token: &CancellationToken, + ) -> BitFunResult { + let draft = self + .draft_permission_plan( + task.clone(), + tool_name.to_string(), + intents, + context.clone(), + ) + .await?; + let plan = match draft { + PermissionPlanDraft::Allowed => PermissionExecutionPlan::Allowed, + PermissionPlanDraft::Rejected { reason } => { + PermissionExecutionPlan::Rejected { reason } + } + PermissionPlanDraft::Requests(requests) => PermissionExecutionPlan::Awaiting( + self.register_permission_requests(requests, task.options.auto_approve_ask) + .await?, + ), + }; + + self.await_permission_execution_plan(plan, cancellation_token) + .await + } + fn pending_round_injection_tool_preemption( &self, context: &ToolExecutionContext, @@ -604,18 +1196,27 @@ impl ToolPipeline { // Create tasks for all tool calls let mut task_ids = Vec::with_capacity(resolved_tool_calls.len()); - for (tool_call, invocation, resolution_error) in resolved_tool_calls { - let task = ToolTask::new_resolved( + for (tool_call_order, (tool_call, invocation, resolution_error)) in + resolved_tool_calls.into_iter().enumerate() + { + let mut task = ToolTask::new_resolved( tool_call, invocation, resolution_error, context.clone(), options.clone(), ); + task.tool_call_order = tool_call_order as u32; let tool_id = self.state_manager.create_task(task).await; task_ids.push(tool_id); } + if let Err(error) = self.prepare_permission_plans(&task_ids).await { + self.cleanup_permission_plans(&task_ids, "Permission planning failed".to_string()) + .await; + return Err(error); + } + if !options.allow_parallel { debug!( "Tool execution plan: total_tools={}, batches=1, concurrency_safe={}, non_concurrency_safe={}, allow_parallel=false, tools={}", @@ -624,7 +1225,10 @@ impl ToolPipeline { task_ids.len().saturating_sub(concurrency_safe_count), tool_names.join(", ") ); - return self.execute_sequential(task_ids).await; + let result = self.execute_sequential(task_ids.clone()).await; + self.cleanup_permission_plans(&task_ids, "Tool execution finished".to_string()) + .await; + return result; } // Partition into batches of consecutive same-safety tool calls @@ -681,6 +1285,8 @@ impl ToolPipeline { all_results.extend(batch_results); } + self.cleanup_permission_plans(&task_ids, "Tool execution finished".to_string()) + .await; Ok(all_results) } @@ -771,7 +1377,7 @@ impl ToolPipeline { let tool_is_error = task.tool_call.is_error; let recovered_from_truncation = task.tool_call.recovered_from_truncation; let queue_wait_ms = elapsed_ms_since(task.created_at); - let mut confirmation_wait_ms = 0; + let confirmation_wait_ms = 0; debug!( "Tool task details: tool_name={}, wire_tool_name={}, tool_id={}, queue_wait_ms={}", @@ -922,170 +1528,90 @@ impl ToolPipeline { self.cancellation_tokens .insert(tool_id.clone(), cancellation_token.clone()); - debug!("Executing tool: tool_name={}", tool_name); + let has_prepared_plan = self.permission_plans.lock().await.contains_key(&tool_id); + let permission_authorization = if has_prepared_plan { + self.await_prepared_permission_plan(&tool_id, &cancellation_token) + .await + } else { + let permission_intents = tool.permission_intents(&tool_args, &tool_context)?; + self.authorize_permission_intents( + &task, + &tool_name, + permission_intents, + &tool_context, + &cancellation_token, + ) + .await + }; - let is_streaming = tool.supports_streaming(); - let preflight_ms = elapsed_ms_u64(start_time); + let rejected = match permission_authorization { + Ok(PermissionAuthorization::Allowed) => None, + Ok(PermissionAuthorization::UserRejected { feedback }) => { + let reason = user_rejection_audit_reason(&tool_name, feedback.as_deref()); + let result = build_user_rejected_tool_result( + &tool_id, + self.state_manager.get_task(&tool_id), + feedback.as_deref(), + ); + Some((reason, result)) + } + Ok(PermissionAuthorization::PolicyDenied { reason }) => { + let result = build_permission_denied_tool_result( + &tool_id, + self.state_manager.get_task(&tool_id), + &reason, + ); + Some((reason, result)) + } + Err(error) => { + self.cancellation_tokens.remove(&tool_id); + return Err(error); + } + }; - let confirmation_plan = resolve_tool_confirmation_plan(ToolConfirmationRequestFacts { - confirm_before_run: task.options.confirm_before_run, - tool_needs_permission: tool.needs_permissions(Some(&tool_args)), - confirmation_timeout_secs: task.options.confirmation_timeout_secs, - now: SystemTime::now(), - }); + if let Some((reason, result)) = rejected { + let preflight_ms = elapsed_ms_u64(start_time); + self.state_manager + .update_state( + &tool_id, + ToolExecutionState::Rejected { + reason, + duration_ms: Some(preflight_ms), + queue_wait_ms: Some(queue_wait_ms), + preflight_ms: Some(preflight_ms), + confirmation_wait_ms: Some(0), + execution_ms: None, + }, + ) + .await; + self.cancellation_tokens.remove(&tool_id); + return Ok(result); + } - if let ToolConfirmationPlan::Await { - timeout_at, - timeout_secs, - } = confirmation_plan - { - info!("Tool requires confirmation: tool_name={}", tool_name); + debug!("Executing tool: tool_name={}", tool_name); - let rx = self.confirmation_channels.register(tool_id.clone()); + let is_streaming = tool.supports_streaming(); + let preflight_ms = elapsed_ms_u64(start_time); + if cancellation_token.is_cancelled() { self.state_manager .update_state( &tool_id, - ToolExecutionState::AwaitingConfirmation { - params: tool_args.clone(), - timeout_at, + ToolExecutionState::Cancelled { + reason: "Tool was cancelled before execution".to_string(), + duration_ms: Some(elapsed_ms_u64(start_time)), + queue_wait_ms: Some(queue_wait_ms), + preflight_ms: Some(preflight_ms), + confirmation_wait_ms: Some(confirmation_wait_ms), + execution_ms: None, }, ) .await; - - debug!("Waiting for confirmation: tool_name={}", tool_name); - let confirmation_started_at = Instant::now(); - - let confirmation_result = match timeout_secs { - Some(timeout_secs) => { - debug!( - "Waiting for user confirmation with timeout: timeout_secs={}, tool_name={}", - timeout_secs, tool_name - ); - // There is a timeout limit - timeout(Duration::from_secs(timeout_secs), rx).await.ok() - } - None => { - debug!( - "Waiting for user confirmation without timeout: tool_name={}", - tool_name - ); - Some(rx.await) - } - }; - confirmation_wait_ms = elapsed_ms_u64(confirmation_started_at); - - let confirmation_wait_result = match confirmation_result { - Some(Ok(ToolConfirmationResponse::Confirmed)) => { - debug!("Tool confirmed: tool_name={}", tool_name); - ToolConfirmationWaitResult::Confirmed - } - Some(Ok(ToolConfirmationResponse::Rejected(reason))) => { - ToolConfirmationWaitResult::Rejected(reason) - } - Some(Err(_)) => ToolConfirmationWaitResult::ChannelClosed, - None => ToolConfirmationWaitResult::TimedOut, - }; - let confirmation_outcome = - resolve_confirmation_wait_result(confirmation_wait_result, &tool_name); - - if let Some(failure) = resolve_confirmation_failure(confirmation_outcome) { - if matches!( - failure.kind, - ConfirmationFailureKind::ChannelClosed | ConfirmationFailureKind::Timeout - ) { - self.confirmation_channels.cancel(&tool_id); - } - - if matches!(failure.kind, ConfirmationFailureKind::Timeout) { - warn!("{}", failure.error_message); - } - - self.state_manager - .update_state( - &tool_id, - match failure.kind { - ConfirmationFailureKind::Rejected => ToolExecutionState::Rejected { - reason: failure.state_reason, - duration_ms: Some(elapsed_ms_u64(start_time)), - queue_wait_ms: Some(queue_wait_ms), - preflight_ms: Some(preflight_ms), - confirmation_wait_ms: Some(elapsed_ms_u64(confirmation_started_at)), - execution_ms: None, - }, - ConfirmationFailureKind::ChannelClosed - | ConfirmationFailureKind::Timeout => ToolExecutionState::Cancelled { - reason: failure.state_reason, - duration_ms: Some(elapsed_ms_u64(start_time)), - queue_wait_ms: Some(queue_wait_ms), - preflight_ms: Some(preflight_ms), - confirmation_wait_ms: Some(elapsed_ms_u64(confirmation_started_at)), - execution_ms: None, - }, - }, - ) - .await; - - match failure.kind { - ConfirmationFailureKind::Rejected => { - return Ok(build_user_rejected_tool_result( - &tool_id, - self.state_manager.get_task(&tool_id), - failure.rejection_instruction.as_deref(), - )); - } - ConfirmationFailureKind::ChannelClosed => { - return Err(BitFunError::service(failure.error_message)); - } - ConfirmationFailureKind::Timeout => { - let presentation = build_tool_confirmation_timeout_presentation(&tool_name); - return Ok(ToolExecutionResult { - tool_id: tool_id.clone(), - tool_name: wire_tool_name.clone(), - effective_tool_name: tool_name.clone(), - result: ModelToolResult { - tool_id, - effective_tool_name: persisted_effective_tool_name( - &wire_tool_name, - &tool_name, - ), - tool_name: wire_tool_name, - result: presentation.result_json, - result_for_assistant: Some(presentation.result_for_assistant), - is_error: false, - duration_ms: Some(elapsed_ms_u64(start_time)), - image_attachments: None, - }, - execution_time_ms: elapsed_ms_u64(start_time), - }); - } - } - } - - self.confirmation_channels.cancel(&tool_id); - } - - let preflight_ms = elapsed_ms_u64(start_time).saturating_sub(confirmation_wait_ms); - - if cancellation_token.is_cancelled() { - self.state_manager - .update_state( - &tool_id, - ToolExecutionState::Cancelled { - reason: "Tool was cancelled before execution".to_string(), - duration_ms: Some(elapsed_ms_u64(start_time)), - queue_wait_ms: Some(queue_wait_ms), - preflight_ms: Some(preflight_ms), - confirmation_wait_ms: Some(confirmation_wait_ms), - execution_ms: None, - }, - ) - .await; - self.cancellation_tokens.remove(&tool_id); - return Err(BitFunError::Cancelled( - "Tool was cancelled before execution".to_string(), - )); - } + self.cancellation_tokens.remove(&tool_id); + return Err(BitFunError::Cancelled( + "Tool was cancelled before execution".to_string(), + )); + } // Set initial state if is_streaming { @@ -1502,13 +2028,7 @@ impl ToolPipeline { ); } - // 2. Clean up confirmation channel (if waiting for confirmation) - if self.confirmation_channels.cancel(tool_id) { - // Channel will be automatically closed, causing await rx to return Err - debug!("Cleared confirmation channel: tool_id={}", tool_id); - } - - // 3. Update state to cancelled + // 2. Update state to cancelled self.state_manager .update_state( tool_id, @@ -1566,85 +2086,6 @@ impl ToolPipeline { ); Ok(()) } - - /// Confirm tool execution - pub async fn confirm_tool( - &self, - tool_id: &str, - updated_input: Option, - ) -> BitFunResult<()> { - let task = self - .state_manager - .get_task(tool_id) - .ok_or_else(|| BitFunError::NotFound(format!("Tool task not found: {}", tool_id)))?; - - // Check if the state is waiting for confirmation - if !matches!(task.state, ToolExecutionState::AwaitingConfirmation { .. }) { - return Err(BitFunError::Validation(format!( - "Tool is not in awaiting confirmation state: {:?}", - task.state - ))); - } - - // If the user modified the parameters, update the task parameters first - if let Some(new_args) = updated_input { - debug!("User updated tool arguments: tool_id={}", tool_id); - self.state_manager.update_task_arguments(tool_id, new_args); - } - - // Get sender from map and send confirmation response - if self.confirmation_channels.confirm(tool_id) { - info!("User confirmed tool execution: tool_id={}", tool_id); - Ok(()) - } else { - Err(BitFunError::NotFound(format!( - "Confirmation channel not found: {}", - tool_id - ))) - } - } - - /// Reject tool execution - pub async fn reject_tool(&self, tool_id: &str, reason: String) -> BitFunResult<()> { - let task = self - .state_manager - .get_task(tool_id) - .ok_or_else(|| BitFunError::NotFound(format!("Tool task not found: {}", tool_id)))?; - - // Check if the state is waiting for confirmation - if !matches!(task.state, ToolExecutionState::AwaitingConfirmation { .. }) { - return Err(BitFunError::Validation(format!( - "Tool is not in awaiting confirmation state: {:?}", - task.state - ))); - } - - // Get sender from map and send rejection response - if self.confirmation_channels.reject(tool_id, reason.clone()) { - info!( - "User rejected tool execution: tool_id={}, reason={}", - tool_id, reason - ); - Ok(()) - } else { - // If the channel does not exist, mark it as rejected directly. - self.state_manager - .update_state( - tool_id, - ToolExecutionState::Rejected { - reason: format!("User rejected: {}", reason), - duration_ms: None, - queue_wait_ms: None, - preflight_ms: None, - confirmation_wait_ms: None, - execution_ms: None, - }, - ) - .await; - - Ok(()) - } - } } #[cfg(test)] @@ -1661,14 +2102,20 @@ mod tests { use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::WorkspaceBinding; use async_trait::async_trait; - use bitfun_agent_tools::{LoadedDeferredToolSpec, CALL_DEFERRED_TOOL_NAME}; + use bitfun_agent_tools::{ + LoadedDeferredToolSpec, CALL_DEFERRED_TOOL_NAME, USER_REJECTED_TOOL_MESSAGE, + }; use bitfun_runtime_ports::{ - RoundInjection, RoundInjectionExecutionPolicy, RoundInjectionKind, RoundInjectionTarget, - RoundInjectionToolPreemption, + ClockPort, PermissionAuditEvent, PermissionAuditRecord, PermissionAuditStorePort, + PermissionGrant, PermissionGrantKey, PermissionGrantStorePort, PermissionReplyStorePort, + PortResult, RoundInjection, RoundInjectionExecutionPolicy, RoundInjectionKind, + RoundInjectionTarget, RoundInjectionToolPreemption, RuntimeServiceCapability, + RuntimeServicePort, }; use serde_json::json; use std::collections::HashMap; use std::path::PathBuf; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::SystemTime; use tokio::time::{sleep, Duration}; @@ -1717,11 +2164,56 @@ mod tests { )); } + #[test] + fn bash_permission_allows_only_exact_command_grants() { + let intent = PermissionIntent::new("bash", vec!["git status && rm -rf build".to_string()]); + let wildcard_allow = vec![PermissionRule::new( + "bash", + "git *", + PermissionEffect::Allow, + )]; + assert_eq!( + permission_intent_effect( + &intent, + &wildcard_allow, + &[], + PermissionResourceCaseSensitivity::Sensitive, + ), + PermissionEffect::Ask + ); + + let exact_allow = vec![PermissionRule::new( + "bash", + "git status && rm -rf build", + PermissionEffect::Allow, + )]; + assert_eq!( + permission_intent_effect( + &intent, + &exact_allow, + &[], + PermissionResourceCaseSensitivity::Sensitive, + ), + PermissionEffect::Allow + ); + + let wildcard_deny = vec![PermissionRule::new("bash", "*", PermissionEffect::Deny)]; + assert_eq!( + permission_intent_effect( + &intent, + &wildcard_deny, + &[], + PermissionResourceCaseSensitivity::Sensitive, + ), + PermissionEffect::Deny + ); + } + struct StaticTestTool { name: String, response: serde_json::Value, delay_ms: u64, - needs_permissions: bool, + readonly: bool, } struct CapturingTestTool { @@ -1729,6 +2221,163 @@ mod tests { received_arguments: Arc>>, } + struct V2FileTestTool { + intents: Vec, + call_count: Arc, + } + + #[async_trait] + impl Tool for V2FileTestTool { + fn name(&self) -> &str { + "Write" + } + + fn is_readonly(&self) -> bool { + // Keep the test tool eligible for the parallel batch scheduler + // while its explicit permission intent still exercises permission prompts. + true + } + + async fn description(&self) -> BitFunResult { + Ok("File permission test tool".to_string()) + } + + fn short_description(&self) -> String { + "File permission test tool".to_string() + } + + fn input_schema(&self) -> serde_json::Value { + json!({ "type": "object" }) + } + + fn permission_intents( + &self, + _input: &serde_json::Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Ok(self.intents.clone()) + } + + async fn call_impl( + &self, + _input: &serde_json::Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + self.call_count.fetch_add(1, Ordering::SeqCst); + Ok(vec![ToolResult::Result { + data: json!({ "written": true }), + result_for_assistant: None, + image_attachments: None, + }]) + } + } + + #[derive(Default)] + struct MemoryPermissionStore { + grants: Mutex>, + audit: Mutex>, + } + + impl RuntimeServicePort for MemoryPermissionStore { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::Permission + } + } + + #[async_trait] + impl PermissionGrantStorePort for MemoryPermissionStore { + async fn list_project_grants(&self, project_id: &str) -> PortResult> { + Ok(self + .grants + .lock() + .expect("permission grant lock") + .iter() + .filter(|grant| grant.project_id == project_id) + .cloned() + .collect()) + } + + async fn add_project_grants(&self, grants: Vec) -> PortResult<()> { + self.grants + .lock() + .expect("permission grant lock") + .extend(grants); + Ok(()) + } + + async fn remove_project_grant(&self, key: PermissionGrantKey) -> PortResult { + let mut grants = self.grants.lock().expect("permission grant lock"); + let original_len = grants.len(); + grants.retain(|grant| grant.key() != key); + Ok(grants.len() != original_len) + } + + async fn clear_project_grants(&self, project_id: &str) -> PortResult { + let mut grants = self.grants.lock().expect("permission grant lock"); + let original_len = grants.len(); + grants.retain(|grant| grant.project_id != project_id); + Ok(original_len - grants.len()) + } + } + + #[async_trait] + impl PermissionAuditStorePort for MemoryPermissionStore { + async fn append_permission_audit(&self, record: PermissionAuditRecord) -> PortResult<()> { + self.audit + .lock() + .expect("permission audit lock") + .push(record); + Ok(()) + } + + async fn list_project_permission_audit( + &self, + project_id: &str, + ) -> PortResult> { + Ok(self + .audit + .lock() + .expect("permission audit lock") + .iter() + .filter(|record| record.request.project_id == project_id) + .cloned() + .collect()) + } + } + + #[async_trait] + impl PermissionReplyStorePort for MemoryPermissionStore { + async fn commit_permission_reply( + &self, + grants: Vec, + audit: Vec, + ) -> PortResult<()> { + self.grants + .lock() + .expect("permission grant lock") + .extend(grants); + self.audit + .lock() + .expect("permission audit lock") + .extend(audit); + Ok(()) + } + } + + struct FixedPermissionClock; + + impl RuntimeServicePort for FixedPermissionClock { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::Clock + } + } + + impl ClockPort for FixedPermissionClock { + fn now_unix_millis(&self) -> i64 { + 42 + } + } + #[async_trait] impl Tool for CapturingTestTool { fn name(&self) -> &str { @@ -1808,11 +2457,7 @@ mod tests { } fn is_readonly(&self) -> bool { - !self.needs_permissions - } - - fn needs_permissions(&self, _input: Option<&serde_json::Value>) -> bool { - self.needs_permissions + self.readonly } fn input_schema(&self) -> serde_json::Value { @@ -1881,6 +2526,7 @@ mod tests { primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), context_vars: HashMap::new(), subagent_parent_info: None, + permission_delegation: None, delegation_policy: bitfun_runtime_ports::DelegationPolicy::top_level(), deferred_tools: Vec::new(), loaded_deferred_tool_specs: Vec::new(), @@ -1956,25 +2602,7 @@ mod tests { name: name.to_string(), response, delay_ms, - needs_permissions: false, - })); - } - - async fn register_permissioned_static_test_tool( - pipeline: &ToolPipeline, - name: &str, - response: serde_json::Value, - delay_ms: u64, - ) { - pipeline - .tool_registry - .write() - .await - .register_tool(Arc::new(StaticTestTool { - name: name.to_string(), - response, - delay_ms, - needs_permissions: true, + readonly: true, })); } @@ -2001,40 +2629,788 @@ mod tests { .current_snapshot_generation() } - #[tokio::test] - async fn deferred_gateway_executes_effective_target_and_preserves_wire_identity() { - let pipeline = test_tool_pipeline(); - let received_arguments = Arc::new(Mutex::new(None)); - register_capturing_test_tool(&pipeline, "get_weather", Arc::clone(&received_arguments)) - .await; + async fn register_v2_file_test_tool( + pipeline: &ToolPipeline, + intents: Vec, + call_count: Arc, + ) { + pipeline + .tool_registry + .write() + .await + .register_tool(Arc::new(V2FileTestTool { + intents, + call_count, + })); + } + fn permission_test_context() -> ToolExecutionContext { let mut context = test_tool_execution_context(); - context.allowed_tools = vec![ - CALL_DEFERRED_TOOL_NAME.to_string(), - "get_weather".to_string(), - ]; - context.deferred_tools = vec!["get_weather".to_string()]; - context.loaded_deferred_tool_specs = vec![loaded_spec( - "get_weather", - current_registry_generation(&pipeline).await, - )]; + context.workspace = Some(WorkspaceBinding::new( + None, + std::env::temp_dir().join("bitfun-permission-test"), + )); + context + } - let mut call = test_tool_call("deferred_1", CALL_DEFERRED_TOOL_NAME); - call.arguments = json!({ - "tool_name": "get_weather", - "args": { "city": "Shanghai" } + fn subagent_permission_test_context(parent_tool_call_id: &str) -> ToolExecutionContext { + let mut context = permission_test_context(); + context.session_id = "subagent-session".to_string(); + context.dialog_turn_id = "subagent-turn".to_string(); + context.agent_type = "Explore".to_string(); + context.subagent_parent_info = Some(SubagentParentInfo { + session_id: "parent-session".to_string(), + dialog_turn_id: "parent-turn".to_string(), + tool_call_id: parent_tool_call_id.to_string(), }); + context + } + + #[tokio::test] + async fn non_readonly_tools_use_v2_custom_tool_fallback() { + let pipeline = test_tool_pipeline(); + pipeline + .tool_registry + .write() + .await + .register_tool(Arc::new(StaticTestTool { + name: "UnclassifiedMutation".to_string(), + response: json!({ "unexpected": true }), + delay_ms: 0, + readonly: false, + })); + let mut options = ToolExecutionOptions::default(); + options.permission_rules = vec![PermissionRule::new( + "custom_tool", + "UnclassifiedMutation", + PermissionEffect::Deny, + )]; let results = pipeline .execute_tools( - vec![call], - context, - ToolExecutionOptions { - confirm_before_run: false, - ..ToolExecutionOptions::default() + vec![test_tool_call("fallback-deny", "UnclassifiedMutation")], + permission_test_context(), + options, + ) + .await + .expect("fallback policy denial"); + + assert!(matches!( + pipeline + .state_manager + .get_task("fallback-deny") + .map(|task| task.state), + Some(ToolExecutionState::Rejected { .. }) + )); + assert_eq!(results[0].result.result["category"], "permission_denied"); + assert!(results[0] + .result + .result_for_assistant + .as_deref() + .is_some_and(|message| message.contains("current permission policy"))); + } + + fn permission_test_manager(store: Arc) -> Arc { + Arc::new( + PermissionRequestManager::new( + store.clone(), + store.clone(), + Arc::new(FixedPermissionClock), + ) + .with_grant_store(store), + ) + } + + async fn wait_for_permission_request( + manager: &PermissionRequestManager, + ) -> bitfun_runtime_ports::PermissionRequest { + for _ in 0..100 { + if let Some(request) = manager.pending_requests().into_iter().next() { + return request; + } + sleep(Duration::from_millis(5)).await; + } + panic!("permission request was not registered"); + } + + async fn wait_for_permission_request_count( + manager: &PermissionRequestManager, + expected: usize, + ) -> Vec { + for _ in 0..100 { + let requests = manager.pending_requests(); + if requests.len() >= expected { + return requests; + } + sleep(Duration::from_millis(5)).await; + } + panic!("expected {expected} permission requests to be registered"); + } + + #[tokio::test] + async fn v2_allow_and_deny_are_enforced_before_tool_side_effects() { + let pipeline = test_tool_pipeline(); + let calls = Arc::new(AtomicUsize::new(0)); + register_v2_file_test_tool( + &pipeline, + vec![PermissionIntent::new( + "edit", + vec!["src/main.rs".to_string(), "src/private/key.rs".to_string()], + )], + Arc::clone(&calls), + ) + .await; + + let mut allow_options = ToolExecutionOptions::default(); + allow_options.permission_rules = vec![PermissionRule::new( + "edit", + "src/*", + PermissionEffect::Allow, + )]; + let results = pipeline + .execute_tools( + vec![test_tool_call("allow", "Write")], + permission_test_context(), + allow_options, + ) + .await + .expect("allowed tool should execute"); + assert!(!results[0].result.is_error); + assert_eq!(calls.load(Ordering::SeqCst), 1); + + let mut deny_options = ToolExecutionOptions::default(); + deny_options.auto_approve_ask = true; + deny_options.permission_rules = vec![ + PermissionRule::new("edit", "src/*", PermissionEffect::Allow), + PermissionRule::new("edit", "src/private/*", PermissionEffect::Deny), + ]; + let results = pipeline + .execute_tools( + vec![test_tool_call("deny", "Write")], + permission_test_context(), + deny_options, + ) + .await + .expect("denied tool should return a structured rejection"); + assert!(!results[0].result.is_error); + assert!(matches!( + pipeline + .state_manager + .get_task("deny") + .map(|task| task.state), + Some(ToolExecutionState::Rejected { .. }) + )); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(results[0].result.result["category"], "permission_denied"); + } + + #[tokio::test] + async fn v2_rejecting_one_parallel_tool_does_not_reject_sibling() { + let store = Arc::new(MemoryPermissionStore::default()); + let manager = permission_test_manager(Arc::clone(&store)); + let pipeline = test_tool_pipeline().with_permission_request_manager(Arc::clone(&manager)); + let calls = Arc::new(AtomicUsize::new(0)); + register_v2_file_test_tool( + &pipeline, + vec![PermissionIntent::new( + "edit", + vec!["src/main.rs".to_string()], + )], + Arc::clone(&calls), + ) + .await; + + let mut permission_events = manager.subscribe(); + let running_pipeline = pipeline.clone(); + let execution = tokio::spawn(async move { + running_pipeline + .execute_tools( + vec![ + test_tool_call("reject-me", "Write"), + test_tool_call("keep-going", "Write"), + ], + permission_test_context(), + ToolExecutionOptions::default(), + ) + .await + }); + + let requests = wait_for_permission_request_count(&manager, 2).await; + assert_eq!(requests.len(), 2); + let expected_project_path = std::env::temp_dir() + .join("bitfun-permission-test") + .to_string_lossy() + .to_string(); + assert_eq!( + requests[0].project_path.as_deref(), + Some(expected_project_path.as_str()) + ); + assert_eq!(requests[0].tool_call_id.as_deref(), Some("reject-me")); + assert_eq!(requests[0].order, 0); + assert_eq!(requests[1].tool_call_id.as_deref(), Some("keep-going")); + assert_eq!(requests[1].order, 1); + for (event, expected_request) in [ + permission_events.recv().await.expect("first asked event"), + permission_events.recv().await.expect("second asked event"), + ] + .into_iter() + .zip(requests.iter()) + { + match event { + bitfun_runtime_ports::PermissionRequestEvent::Asked { request } => { + assert_eq!(request.request_id, expected_request.request_id); + } + other => panic!("expected asked event, got {other:?}"), + } + } + let rejected_request = requests + .iter() + .find(|request| request.tool_call_id.as_deref() == Some("reject-me")) + .expect("rejected tool permission request"); + let sibling_request = requests + .iter() + .find(|request| request.tool_call_id.as_deref() == Some("keep-going")) + .expect("sibling tool permission request"); + + manager + .reply( + &rejected_request.request_id, + PermissionReply::Reject { feedback: None }, + bitfun_runtime_ports::PermissionReplySource::User, + ) + .await + .expect("reject one tool"); + assert_eq!( + manager + .pending_requests() + .iter() + .map(|request| request.request_id.as_str()) + .collect::>(), + vec![sibling_request.request_id.as_str()] + ); + + manager + .reply( + &sibling_request.request_id, + PermissionReply::Once, + bitfun_runtime_ports::PermissionReplySource::User, + ) + .await + .expect("allow sibling tool"); + + let results = execution + .await + .expect("parallel tool execution join") + .expect("parallel tool execution"); + assert_eq!(results.len(), 2); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(results[0].result.result["category"], "user_rejected"); + assert!(results[0].result.result["instruction"].is_null()); + assert_eq!( + results[0].result.result_for_assistant.as_deref(), + Some(USER_REJECTED_TOOL_MESSAGE) + ); + assert!(!results[1].result.is_error); + } + + #[tokio::test] + async fn v2_rejection_feedback_is_preserved_for_the_assistant() { + let store = Arc::new(MemoryPermissionStore::default()); + let manager = permission_test_manager(Arc::clone(&store)); + let pipeline = test_tool_pipeline().with_permission_request_manager(Arc::clone(&manager)); + let calls = Arc::new(AtomicUsize::new(0)); + register_v2_file_test_tool( + &pipeline, + vec![PermissionIntent::new( + "edit", + vec!["src/main.rs".to_string()], + )], + Arc::clone(&calls), + ) + .await; + + let running_pipeline = pipeline.clone(); + let execution = tokio::spawn(async move { + running_pipeline + .execute_tools( + vec![test_tool_call("reject-with-feedback", "Write")], + permission_test_context(), + ToolExecutionOptions::default(), + ) + .await + }); + + let request = wait_for_permission_request(&manager).await; + manager + .reply( + &request.request_id, + PermissionReply::Reject { + feedback: Some("Use a read-only path".to_string()), }, + bitfun_runtime_ports::PermissionReplySource::User, + ) + .await + .expect("reject request with feedback"); + + let results = execution + .await + .expect("feedback rejection task join") + .expect("feedback rejection should return a structured result"); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(results[0].result.result["category"], "user_rejected"); + assert_eq!( + results[0].result.result["instruction"], + "Use a read-only path" + ); + assert_eq!( + results[0].result.result_for_assistant.as_deref(), + Some( + "The user rejected this tool call with the following instruction: \"Use a read-only path\". Do not retry it unless the user explicitly asks you to. If you cannot complete the task without running this tool call, stop and ask the user how to proceed." + ) + ); + } + + #[tokio::test] + async fn v2_subagent_request_projects_exact_parent_task_context() { + let store = Arc::new(MemoryPermissionStore::default()); + let manager = permission_test_manager(Arc::clone(&store)); + let pipeline = test_tool_pipeline().with_permission_request_manager(Arc::clone(&manager)); + let calls = Arc::new(AtomicUsize::new(0)); + register_v2_file_test_tool( + &pipeline, + vec![PermissionIntent::new( + "edit", + vec!["src/main.rs".to_string()], + )], + Arc::clone(&calls), + ) + .await; + + let running_pipeline = pipeline.clone(); + let execution = tokio::spawn(async move { + running_pipeline + .execute_tools( + vec![test_tool_call("child-write", "Write")], + subagent_permission_test_context("parent-task-call"), + ToolExecutionOptions::default(), + ) + .await + }); + + let request = wait_for_permission_request(&manager).await; + assert_eq!(request.session_id, "subagent-session"); + assert_eq!(request.tool_call_id.as_deref(), Some("child-write")); + let delegation = request + .delegation + .as_ref() + .expect("subagent request should project delegation context"); + assert_eq!(delegation.parent_session_id, "parent-session"); + assert_eq!( + delegation.parent_dialog_turn_id.as_deref(), + Some("parent-turn") + ); + assert_eq!(delegation.parent_tool_call_id, "parent-task-call"); + assert_eq!(delegation.subagent_type, "Explore"); + + manager + .reply( + &request.request_id, + PermissionReply::Once, + bitfun_runtime_ports::PermissionReplySource::User, + ) + .await + .expect("allow child request"); + execution + .await + .expect("child task join") + .expect("child execution"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn v2_request_routes_partial_persisted_subagent_delegation() { + let store = Arc::new(MemoryPermissionStore::default()); + let manager = permission_test_manager(Arc::clone(&store)); + let pipeline = test_tool_pipeline().with_permission_request_manager(Arc::clone(&manager)); + let calls = Arc::new(AtomicUsize::new(0)); + register_v2_file_test_tool( + &pipeline, + vec![PermissionIntent::new( + "edit", + vec!["src/main.rs".to_string()], + )], + Arc::clone(&calls), + ) + .await; + + let mut context = permission_test_context(); + context.session_id = "subagent-session".to_string(); + context.agent_type = "Explore".to_string(); + context.permission_delegation = Some(bitfun_runtime_ports::PermissionDelegationContext { + parent_session_id: "parent-session".to_string(), + parent_dialog_turn_id: None, + parent_tool_call_id: "parent-task-call".to_string(), + subagent_type: "Explore".to_string(), + }); + + let running_pipeline = pipeline.clone(); + let execution = tokio::spawn(async move { + running_pipeline + .execute_tools( + vec![test_tool_call("child-write", "Write")], + context, + ToolExecutionOptions::default(), + ) + .await + }); + + let request = wait_for_permission_request(&manager).await; + let delegation = request + .delegation + .as_ref() + .expect("partial subagent lineage should route permission requests"); + assert_eq!(delegation.parent_session_id, "parent-session"); + assert_eq!(delegation.parent_dialog_turn_id, None); + assert_eq!(delegation.parent_tool_call_id, "parent-task-call"); + + manager + .reply( + &request.request_id, + PermissionReply::Once, + bitfun_runtime_ports::PermissionReplySource::User, ) .await + .expect("allow child request"); + execution + .await + .expect("child task join") + .expect("child execution"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn once_and_always_replies_control_execution_and_remembered_grants() { + let store = Arc::new(MemoryPermissionStore::default()); + let manager = permission_test_manager(Arc::clone(&store)); + let pipeline = test_tool_pipeline().with_permission_request_manager(Arc::clone(&manager)); + let calls = Arc::new(AtomicUsize::new(0)); + register_v2_file_test_tool( + &pipeline, + vec![PermissionIntent::new( + "edit", + vec!["src/main.rs".to_string(), "src/private/key.rs".to_string()], + )], + Arc::clone(&calls), + ) + .await; + + let once_pipeline = pipeline.clone(); + let once = tokio::spawn(async move { + once_pipeline + .execute_tools( + vec![test_tool_call("once", "Write")], + permission_test_context(), + ToolExecutionOptions::default(), + ) + .await + }); + let request = wait_for_permission_request(&manager).await; + assert_eq!(request.tool_call_id.as_deref(), Some("once")); + assert!(request.delegation.is_none()); + manager + .reply( + &request.request_id, + PermissionReply::Once, + bitfun_runtime_ports::PermissionReplySource::User, + ) + .await + .expect("once reply"); + once.await.expect("once task join").expect("once execution"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + + let always_pipeline = pipeline.clone(); + let always = tokio::spawn(async move { + always_pipeline + .execute_tools( + vec![test_tool_call("always", "Write")], + permission_test_context(), + ToolExecutionOptions::default(), + ) + .await + }); + let request = wait_for_permission_request(&manager).await; + manager + .reply( + &request.request_id, + PermissionReply::Always, + bitfun_runtime_ports::PermissionReplySource::User, + ) + .await + .expect("always reply"); + always + .await + .expect("always task join") + .expect("always execution"); + assert_eq!(calls.load(Ordering::SeqCst), 2); + + pipeline + .execute_tools( + vec![test_tool_call("remembered", "Write")], + permission_test_context(), + ToolExecutionOptions::default(), + ) + .await + .expect("remembered grant should allow the same project"); + assert_eq!(calls.load(Ordering::SeqCst), 3); + + assert_eq!( + store.audit.lock().expect("permission audit lock").len(), + 4, + "once and always should each persist requested and replied audit facts" + ); + + let mut other_project_context = permission_test_context(); + other_project_context.workspace = Some(WorkspaceBinding::new( + None, + std::env::temp_dir().join("bitfun-permission-other-project"), + )); + let other_pipeline = pipeline.clone(); + let other_project = tokio::spawn(async move { + other_pipeline + .execute_tools( + vec![test_tool_call("other-project", "Write")], + other_project_context, + ToolExecutionOptions::default(), + ) + .await + }); + let other_request = wait_for_permission_request(&manager).await; + let remembered_project_id = store + .grants + .lock() + .expect("permission grant lock") + .first() + .expect("remembered grant") + .project_id + .clone(); + assert_ne!(other_request.project_id, remembered_project_id); + manager + .reply( + &other_request.request_id, + PermissionReply::Reject { feedback: None }, + bitfun_runtime_ports::PermissionReplySource::User, + ) + .await + .expect("reject other project request"); + other_project + .await + .expect("other project task join") + .expect("other project rejection"); + assert_eq!(calls.load(Ordering::SeqCst), 3); + + let mut remote_context = permission_test_context(); + let local_root = remote_context + .workspace + .as_ref() + .expect("local permission workspace") + .root_path() + .to_path_buf(); + let remote_identity = + crate::service::remote_ssh::workspace_state::workspace_session_identity( + local_root.to_string_lossy().as_ref(), + Some("permission-remote-connection"), + Some("remote.example"), + ) + .expect("remote permission identity"); + remote_context.workspace = Some(WorkspaceBinding::new_remote( + None, + local_root, + "permission-remote-connection".to_string(), + "Remote permission test".to_string(), + remote_identity, + )); + let remote_pipeline = pipeline.clone(); + let remote_execution = tokio::spawn(async move { + remote_pipeline + .execute_tools( + vec![test_tool_call("remote-project", "Write")], + remote_context, + ToolExecutionOptions::default(), + ) + .await + }); + let remote_request = wait_for_permission_request(&manager).await; + assert_ne!(remote_request.project_id, remembered_project_id); + assert!(remote_request.project_id.starts_with("remote_")); + manager + .reply( + &remote_request.request_id, + PermissionReply::Reject { feedback: None }, + bitfun_runtime_ports::PermissionReplySource::User, + ) + .await + .expect("reject remote project request"); + remote_execution + .await + .expect("remote project task join") + .expect("remote project rejection"); + assert_eq!(calls.load(Ordering::SeqCst), 3); + + let mut deny_options = ToolExecutionOptions::default(); + deny_options.permission_rules = vec![ + PermissionRule::new("edit", "src/*", PermissionEffect::Allow), + PermissionRule::new("edit", "src/private/*", PermissionEffect::Deny), + ]; + pipeline + .execute_tools( + vec![test_tool_call("deny-after-grant", "Write")], + permission_test_context(), + deny_options, + ) + .await + .expect("policy denial should be structured"); + assert_eq!(calls.load(Ordering::SeqCst), 3); + } + + #[tokio::test] + async fn v2_auto_approve_subagent_ask_preserves_lineage_without_interactive_event() { + let store = Arc::new(MemoryPermissionStore::default()); + let manager = permission_test_manager(Arc::clone(&store)); + let mut events = manager.subscribe(); + let pipeline = test_tool_pipeline().with_permission_request_manager(Arc::clone(&manager)); + let calls = Arc::new(AtomicUsize::new(0)); + register_v2_file_test_tool( + &pipeline, + vec![PermissionIntent::new( + "edit", + vec!["src/main.rs".to_string()], + )], + Arc::clone(&calls), + ) + .await; + + let mut options = ToolExecutionOptions::default(); + options.auto_approve_ask = true; + pipeline + .execute_tools( + vec![test_tool_call("auto", "Write")], + subagent_permission_test_context("background-task-call"), + options, + ) + .await + .expect("auto-approved tool should execute"); + + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(store + .grants + .lock() + .expect("permission grant lock") + .is_empty()); + let audit = store.audit.lock().expect("permission audit lock"); + assert_eq!(audit.len(), 2); + assert!(audit.iter().all(|record| { + record + .request + .delegation + .as_ref() + .is_some_and(|delegation| { + delegation.parent_tool_call_id == "background-task-call" + && delegation.subagent_type == "Explore" + }) + })); + assert!(matches!(audit[0].event, PermissionAuditEvent::Requested)); + assert!(matches!( + audit[1].event, + PermissionAuditEvent::Replied { + reply: PermissionReply::Once, + source: bitfun_runtime_ports::PermissionReplySource::AutoApprove, + } + )); + assert!(matches!( + events.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + )); + assert!(manager.pending_requests().is_empty()); + } + + #[tokio::test] + async fn v2_cancellation_clears_pending_request_without_side_effect() { + let store = Arc::new(MemoryPermissionStore::default()); + let manager = permission_test_manager(Arc::clone(&store)); + let pipeline = test_tool_pipeline().with_permission_request_manager(Arc::clone(&manager)); + let calls = Arc::new(AtomicUsize::new(0)); + register_v2_file_test_tool( + &pipeline, + vec![PermissionIntent::new( + "edit", + vec!["src/main.rs".to_string()], + )], + Arc::clone(&calls), + ) + .await; + + let running_pipeline = pipeline.clone(); + let task = tokio::spawn(async move { + running_pipeline + .execute_tools( + vec![test_tool_call("cancel", "Write")], + subagent_permission_test_context("cancelled-parent-task"), + ToolExecutionOptions::default(), + ) + .await + }); + let request = wait_for_permission_request(&manager).await; + assert_eq!( + request + .delegation + .as_ref() + .map(|delegation| delegation.parent_tool_call_id.as_str()), + Some("cancelled-parent-task") + ); + pipeline + .cancel_tool("cancel", "test cancellation".to_string()) + .await + .expect("cancel tool"); + task.await + .expect("cancel task join") + .expect("cancel result"); + assert!(manager.pending_requests().is_empty()); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert!(store + .audit + .lock() + .expect("permission audit lock") + .iter() + .any(|record| matches!(record.event, PermissionAuditEvent::Cancelled { .. }))); + } + + #[tokio::test] + async fn deferred_gateway_executes_effective_target_and_preserves_wire_identity() { + let pipeline = test_tool_pipeline(); + let received_arguments = Arc::new(Mutex::new(None)); + register_capturing_test_tool(&pipeline, "get_weather", Arc::clone(&received_arguments)) + .await; + + let mut context = test_tool_execution_context(); + context.allowed_tools = vec![ + CALL_DEFERRED_TOOL_NAME.to_string(), + "get_weather".to_string(), + ]; + context.deferred_tools = vec!["get_weather".to_string()]; + context.loaded_deferred_tool_specs = vec![loaded_spec( + "get_weather", + current_registry_generation(&pipeline).await, + )]; + + let mut call = test_tool_call("deferred_1", CALL_DEFERRED_TOOL_NAME); + call.arguments = json!({ + "tool_name": "get_weather", + "args": { "city": "Shanghai" } + }); + + let results = pipeline + .execute_tools(vec![call], context, ToolExecutionOptions::default()) + .await .expect("deferred tool execution"); assert_eq!(results.len(), 1); @@ -2198,54 +3574,6 @@ mod tests { ); } - #[tokio::test] - async fn deferred_gateway_uses_effective_target_permission_policy() { - let pipeline = test_tool_pipeline(); - register_permissioned_static_test_tool( - &pipeline, - "write_weather", - json!({ "ok": true }), - 0, - ) - .await; - - let mut context = test_tool_execution_context(); - context.allowed_tools = vec![ - CALL_DEFERRED_TOOL_NAME.to_string(), - "write_weather".to_string(), - ]; - context.deferred_tools = vec!["write_weather".to_string()]; - context.loaded_deferred_tool_specs = vec![loaded_spec( - "write_weather", - current_registry_generation(&pipeline).await, - )]; - - let mut call = test_tool_call("deferred_permission", CALL_DEFERRED_TOOL_NAME); - call.arguments = json!({ - "tool_name": "write_weather", - "args": { "city": "Shanghai" } - }); - - let results = pipeline - .execute_tools( - vec![call], - context, - ToolExecutionOptions { - confirmation_timeout_secs: Some(0), - ..ToolExecutionOptions::default() - }, - ) - .await - .expect("permission timeout should be returned as a tool result"); - - assert_eq!(results.len(), 1); - assert_eq!(results[0].tool_name, CALL_DEFERRED_TOOL_NAME); - assert_eq!(results[0].effective_tool_name, "write_weather"); - assert_eq!(results[0].result.tool_name, CALL_DEFERRED_TOOL_NAME); - assert_eq!(results[0].result.result["category"], "confirmation_timeout"); - assert_eq!(results[0].result.result["tool_name"], "write_weather"); - } - fn test_round_injection( kind: RoundInjectionKind, tool_preemption: RoundInjectionToolPreemption, @@ -2323,131 +3651,6 @@ mod tests { .contains("Raw arguments:")); } - #[tokio::test] - async fn confirmation_timeout_returns_timeout_result_without_argument_error() { - let pipeline = test_tool_pipeline(); - register_permissioned_static_test_tool( - &pipeline, - "ExecCommand", - json!({ "unexpected": true }), - 0, - ) - .await; - - let task_id = "tool_1".to_string(); - pipeline - .state_manager - .create_task(ToolTask::new( - test_tool_call(&task_id, "ExecCommand"), - test_tool_execution_context(), - ToolExecutionOptions { - confirm_before_run: true, - confirmation_timeout_secs: Some(1), - ..Default::default() - }, - )) - .await; - - // The public execute_tools path is easier to keep stable via timeout - // by not delivering confirmation. We use a second task with the same - // setup to exercise the actual pipeline path. - let results = pipeline - .execute_tools( - vec![test_tool_call("tool_1", "ExecCommand")], - test_tool_execution_context(), - ToolExecutionOptions { - confirmation_timeout_secs: Some(0), - ..Default::default() - }, - ) - .await - .expect("timeout should be returned as a tool result"); - - assert_eq!(results.len(), 1); - let result = &results[0].result; - assert!(!result.is_error); - assert_eq!(result.result["category"], json!("confirmation_timeout")); - assert_eq!(result.result["tool_name"], json!("ExecCommand")); - assert!(result.result["provided_arguments"].is_null()); - let assistant_text = result.result_for_assistant.as_deref().unwrap_or_default(); - assert!(assistant_text.contains("confirmation window expired")); - assert!(!assistant_text.contains("failed")); - assert!(!assistant_text.contains("Provided arguments")); - } - - #[tokio::test] - async fn confirmation_rejection_returns_user_rejected_result_without_argument_error() { - let pipeline = test_tool_pipeline(); - register_permissioned_static_test_tool( - &pipeline, - "ExecCommand", - json!({ "unexpected": true }), - 0, - ) - .await; - - let reject_pipeline = pipeline.clone(); - let reject_handle = tokio::spawn(async move { - for _ in 0..50 { - if reject_pipeline - .state_manager - .get_task("tool_1") - .is_some_and(|task| { - matches!(task.state, ToolExecutionState::AwaitingConfirmation { .. }) - }) - { - reject_pipeline - .reject_tool( - "tool_1", - "Use the built-in status view instead.".to_string(), - ) - .await - .expect("reject tool confirmation"); - return; - } - sleep(Duration::from_millis(10)).await; - } - panic!("tool should enter awaiting confirmation"); - }); - - let results = pipeline - .execute_tools( - vec![test_tool_call("tool_1", "ExecCommand")], - test_tool_execution_context(), - ToolExecutionOptions::default(), - ) - .await - .expect("user rejection should be returned as a tool result"); - reject_handle.await.expect("rejection task should finish"); - - assert_eq!(results.len(), 1); - let result = &results[0].result; - assert!(!result.is_error); - assert_eq!(result.result["status"], json!("rejected")); - assert_eq!(result.result["category"], json!("user_rejected")); - assert_eq!(result.result["tool_name"], json!("ExecCommand")); - assert_eq!( - result.result["instruction"], - json!("Use the built-in status view instead.") - ); - assert!(result.result["provided_arguments"].is_null()); - - let assistant_text = result.result_for_assistant.as_deref().unwrap_or_default(); - assert!(assistant_text.contains( - "The user rejected this tool call with the following instruction: \"Use the built-in status view instead.\"" - )); - assert!(assistant_text.contains("Do not retry it unless the user explicitly asks you to.")); - assert!(!assistant_text.contains("invalid_arguments")); - assert!(!assistant_text.contains("Provided arguments")); - assert!(!assistant_text.contains("failed")); - - let task = pipeline - .state_manager - .get_task("tool_1") - .expect("tool task should be retained"); - assert!(matches!(task.state, ToolExecutionState::Rejected { .. })); - } - #[tokio::test] async fn pipeline_admission_allowed_list_rejection_updates_failed_state_before_registry_lookup() { diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs index d805794e56..124f848c15 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs @@ -7,7 +7,9 @@ use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; use bitfun_agent_tools::ResolvedToolInvocation; -use bitfun_runtime_ports::{DelegationPolicy, RemoteExecPort, TerminalPort}; +use bitfun_runtime_ports::{ + DelegationPolicy, PermissionDelegationContext, PermissionRule, RemoteExecPort, TerminalPort, +}; use std::collections::HashMap; use std::sync::Arc; use std::time::SystemTime; @@ -22,9 +24,10 @@ pub struct ToolExecutionOptions { pub max_retries: usize, /// Tool execution timeout (seconds), None means infinite waiting pub timeout_secs: Option, - pub confirm_before_run: bool, - /// Tool confirmation timeout (seconds), None means infinite waiting - pub confirmation_timeout_secs: Option, + /// Ordered permission rules. An unmatched resource defaults to `ask`. + pub permission_rules: Vec, + /// Automatically reply `once` to `ask` requests through the permission manager. + pub auto_approve_ask: bool, } impl Default for ToolExecutionOptions { @@ -34,8 +37,8 @@ impl Default for ToolExecutionOptions { subagent_batch_execution_policy: SubagentBatchExecutionPolicy::default(), max_retries: 0, timeout_secs: None, // Default no timeout (infinite waiting) - confirm_before_run: true, - confirmation_timeout_secs: None, // Default no timeout (infinite waiting) + permission_rules: Vec::new(), + auto_approve_ask: false, } } } @@ -47,6 +50,20 @@ pub struct SubagentParentInfo { pub dialog_turn_id: String, } +impl SubagentParentInfo { + pub(crate) fn permission_delegation_context( + &self, + subagent_type: &str, + ) -> PermissionDelegationContext { + PermissionDelegationContext { + parent_session_id: self.session_id.clone(), + parent_dialog_turn_id: Some(self.dialog_turn_id.clone()), + parent_tool_call_id: self.tool_call_id.clone(), + subagent_type: subagent_type.to_string(), + } + } +} + impl From for EventSubagentParentInfo { fn from(info: SubagentParentInfo) -> Self { Self { @@ -70,6 +87,7 @@ pub struct ToolExecutionContext { pub primary_model_facts: PrimaryModelFacts, pub context_vars: HashMap, pub subagent_parent_info: Option, + pub permission_delegation: Option, pub(crate) delegation_policy: DelegationPolicy, pub deferred_tools: Vec, pub loaded_deferred_tool_specs: Vec, @@ -90,6 +108,9 @@ pub struct ToolExecutionContext { #[derive(Debug, Clone)] pub struct ToolTask { pub tool_call: ToolCall, + /// Position of this call in the model's tool-call array for the current + /// round. Permission requests inherit this value as their order key. + pub tool_call_order: u32, pub invocation: ResolvedToolInvocation, pub invocation_resolution_error: Option, pub context: ToolExecutionContext, @@ -122,6 +143,7 @@ impl ToolTask { ) -> Self { Self { tool_call, + tool_call_order: 0, invocation, invocation_resolution_error, context, @@ -140,12 +162,6 @@ impl ToolTask { pub fn effective_arguments(&self) -> &serde_json::Value { &self.invocation.effective_arguments } - - pub fn update_effective_arguments(&mut self, arguments: serde_json::Value) { - self.invocation - .replace_effective_arguments(arguments.clone()); - self.tool_call.arguments = self.invocation.wire_arguments.clone(); - } } /// Tool execution result wrapper diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/call_deferred_tool.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/call_deferred_tool.rs index fcfb940020..48ddd42c25 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/call_deferred_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/call_deferred_tool.rs @@ -46,10 +46,6 @@ impl Tool for CallDeferredTool { false } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { let target = input .get("tool_name") diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/get_tool_spec_tool.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/get_tool_spec_tool.rs index 7cba456f9c..a8aafe2b89 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/get_tool_spec_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/get_tool_spec_tool.rs @@ -72,10 +72,6 @@ impl Tool for GetToolSpecTool { with_runtime(|runtime| runtime.is_concurrency_safe(input)) } - fn needs_permissions(&self, input: Option<&Value>) -> bool { - with_runtime(|runtime| runtime.needs_permissions(input)) - } - fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { with_runtime(|runtime| runtime.render_tool_use_message(input)) } diff --git a/src/crates/assembly/core/src/agentic/tools/restrictions.rs b/src/crates/assembly/core/src/agentic/tools/restrictions.rs index 5769ff1f68..59cd3b4a6e 100644 --- a/src/crates/assembly/core/src/agentic/tools/restrictions.rs +++ b/src/crates/assembly/core/src/agentic/tools/restrictions.rs @@ -13,12 +13,12 @@ impl From for BitFunError { } pub fn is_local_path_within_root(path: &Path, root: &Path) -> BitFunResult { - let canonical_path = canonicalize_best_effort(path)?; - let canonical_root = canonicalize_best_effort(root)?; + let canonical_path = canonicalize_local_path_best_effort(path)?; + let canonical_root = canonicalize_local_path_best_effort(root)?; Ok(canonical_path == canonical_root || canonical_path.starts_with(&canonical_root)) } -fn canonicalize_best_effort(path: &Path) -> BitFunResult { +pub(crate) fn canonicalize_local_path_best_effort(path: &Path) -> BitFunResult { if path.exists() { return dunce::canonicalize(path).map_err(|err| { BitFunError::validation(format!( diff --git a/src/crates/assembly/core/src/agentic/tools/tool_adapter.rs b/src/crates/assembly/core/src/agentic/tools/tool_adapter.rs index d8d10fdd06..ec9d136561 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_adapter.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_adapter.rs @@ -56,10 +56,6 @@ impl ToolRegistryItem for dyn Tool { Tool::is_concurrency_safe(self, input) } - fn needs_permissions(&self, input: Option<&Value>) -> bool { - Tool::needs_permissions(self, input) - } - fn manages_own_execution_timeout(&self) -> bool { Tool::manages_own_execution_timeout(self) } diff --git a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs index 7c993eb69c..2982de6a94 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs @@ -1463,6 +1463,7 @@ mod task_context_tests { session_id: "parent_session".to_string(), dialog_turn_id: "parent_turn".to_string(), }), + permission_delegation: None, delegation_policy: DelegationPolicy::top_level().spawn_child(), deferred_tools: vec!["WebFetch".to_string()], loaded_deferred_tool_specs: vec![loaded_spec("WebFetch")], diff --git a/src/crates/assembly/core/src/external_subagents.rs b/src/crates/assembly/core/src/external_subagents.rs index 43f717d921..52ec447e0e 100644 --- a/src/crates/assembly/core/src/external_subagents.rs +++ b/src/crates/assembly/core/src/external_subagents.rs @@ -227,10 +227,10 @@ async fn gather_product_facts( name.as_str(), metadata.as_str(), if readonly { "readonly" } else { "writable" }, - if selected.needs_permissions(None) { - "permissioned" - } else { + if readonly { "unpermissioned" + } else { + "permissioned" }, ]), readonly, diff --git a/src/crates/assembly/core/src/external_tools.rs b/src/crates/assembly/core/src/external_tools.rs index e14e34d0c4..9a5b68dea2 100644 --- a/src/crates/assembly/core/src/external_tools.rs +++ b/src/crates/assembly/core/src/external_tools.rs @@ -1,7 +1,8 @@ //! Product-owned activation and contextual routing for external standalone tools. use crate::agentic::tools::framework::{ - DynamicToolInfo, Tool, ToolExposure, ToolResult, ToolUseContext, ValidationResult, + DynamicToolInfo, PermissionIntent, Tool, ToolExposure, ToolResult, ToolUseContext, + ValidationResult, }; use crate::agentic::tools::registry::get_global_tool_registry; use crate::util::errors::{BitFunError, BitFunResult}; @@ -151,6 +152,10 @@ pub(super) fn project_external_tools_read_only( pub(super) const UNRESOLVED_TOOL_CONFLICT_CHOICE: &str = "__bitfun_unresolved__"; pub(super) const TOOL_CONFLICT_RESELECTION_REQUIRED: &str = "__bitfun_reselection_required__"; +fn external_tool_permission_intent(provider_id: &str, tool_name: &str) -> PermissionIntent { + PermissionIntent::new("custom_tool", vec![format!("{provider_id}:{tool_name}")]) +} + #[derive(Clone)] struct LoadedExternalTool { descriptor: ScriptToolDescriptor, @@ -209,8 +214,15 @@ impl Tool for LoadedExternalTool { false } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - true + fn permission_intents( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Ok(vec![external_tool_permission_intent( + &self.provider_id, + &self.descriptor.name, + )]) } async fn call_impl( @@ -591,8 +603,14 @@ impl Tool for ExternalToolMux { false } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - true + fn permission_intents( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + self.selected(Some(context)) + .ok_or_else(|| BitFunError::tool(format!("tool '{}' is unavailable", self.name)))? + .permission_intents(input, context) } async fn validate_input( @@ -2058,6 +2076,14 @@ fn tool_diagnostic( mod tests { use super::*; + #[test] + fn external_tools_use_provider_qualified_v2_identity() { + let intent = external_tool_permission_intent("opencode", "release_notes"); + + assert_eq!(intent.action, "custom_tool"); + assert_eq!(intent.resources, ["opencode:release_notes".to_string()]); + } + struct TestTool { name: String, } diff --git a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs index 7eb8e8c93a..f36b085970 100644 --- a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs +++ b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs @@ -384,6 +384,12 @@ impl PathManager { .join("agent_profiles.json") } + /// Get project tool permission rules file: {project}/.bitfun/config/tool_permissions.json + pub fn project_permission_file(&self, workspace_path: &Path) -> PathBuf { + self.project_internal_config_dir(workspace_path) + .join("tool_permissions.json") + } + /// Get project mode skills file: {project}/.bitfun/config/mode_skills.json pub fn project_mode_skills_file(&self, workspace_path: &Path) -> PathBuf { self.project_internal_config_dir(workspace_path) diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index 8353c277da..0d088c5ebf 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -8,8 +8,10 @@ mod runtime_services; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::Duration; +use std::sync::OnceLock; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use bitfun_agent_runtime::permission::PermissionRequestManager; use bitfun_agent_runtime::sdk::{ AgentEventSource, AgentRuntime, AgentSessionForkAtTurnRequest, AgentSessionForkPort, AgentSessionForkRequest, AgentSessionForkResult, AgentSessionUsagePort, @@ -17,11 +19,13 @@ use bitfun_agent_runtime::sdk::{ }; use bitfun_harness::HarnessRegistry; use bitfun_runtime_ports::{ - LocalWorkspaceSnapshotPort, LocalWorkspaceSnapshotSessionRequest, LocalWorkspaceSnapshotStats, - LocalWorkspaceSnapshotTurnRequest, PortError, PortErrorKind, PortResult, - SessionStoragePathRequest, SessionStorePort, SessionViewRestoreTiming, + ClockPort, LocalWorkspaceSnapshotPort, LocalWorkspaceSnapshotSessionRequest, + LocalWorkspaceSnapshotStats, LocalWorkspaceSnapshotTurnRequest, PortError, PortErrorKind, + PortResult, RuntimeServiceCapability, RuntimeServicePort, SessionStoragePathRequest, + SessionStorePort, SessionViewRestoreTiming, }; use bitfun_runtime_services::RuntimeServices; +use bitfun_services_core::permission_store::ProjectPermissionSqliteStore; use crate::agentic::coordination::{ ConversationCoordinator, DialogScheduler, SessionMaintenancePermit, @@ -44,6 +48,53 @@ use crate::util::errors::{BitFunError, BitFunResult}; pub use bitfun_product_capabilities::ProductRuntimeAssembly as CoreProductRuntimeAssembly; pub use runtime_services::CoreRuntimeServicesProvider; +#[derive(Debug, Clone, Copy, Default)] +struct SystemPermissionClock; + +impl RuntimeServicePort for SystemPermissionClock { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::Clock + } +} + +impl ClockPort for SystemPermissionClock { + fn now_unix_millis(&self) -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)) + .unwrap_or_default() + } +} + +static PERMISSION_REQUEST_MANAGER: OnceLock> = OnceLock::new(); + +/// Returns the process-shared permission request owner used by product +/// surfaces. Pending requests remain process-local; only remembered grants and +/// audit facts are written to the user data directory. +pub fn core_permission_request_manager() -> Result, String> { + if let Some(manager) = PERMISSION_REQUEST_MANAGER.get() { + return Ok(manager.clone()); + } + + let path_manager = crate::infrastructure::PathManager::new() + .map_err(|error| format!("Failed to initialize permission path manager: {error}"))?; + let store = Arc::new(ProjectPermissionSqliteStore::new( + path_manager.user_data_dir().join("permissions"), + )); + let audit_store: Arc = store.clone(); + let reply_store: Arc = store.clone(); + let grant_store: Arc = store; + let manager = Arc::new( + PermissionRequestManager::new(audit_store, reply_store, Arc::new(SystemPermissionClock)) + .with_grant_store(grant_store), + ); + let _ = PERMISSION_REQUEST_MANAGER.set(manager); + PERMISSION_REQUEST_MANAGER + .get() + .cloned() + .ok_or_else(|| "Failed to initialize shared permission request manager".to_string()) +} + /// Serializes one compatibility mutation with Core's session lifecycle. pub struct CoreSessionMutationPermit { _guard: KeyedAsyncLockGuard, diff --git a/src/crates/assembly/core/src/service/config/mod.rs b/src/crates/assembly/core/src/service/config/mod.rs index 577feea3f0..cfae48b8d7 100644 --- a/src/crates/assembly/core/src/service/config/mod.rs +++ b/src/crates/assembly/core/src/service/config/mod.rs @@ -10,6 +10,7 @@ pub mod global; pub mod manager; #[cfg(feature = "product-full")] pub mod mode_config_canonicalizer; +pub mod project_permission_store; pub mod providers; pub mod service; pub mod types; diff --git a/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs b/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs index 6b6549f546..b07f45bf90 100644 --- a/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs +++ b/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs @@ -13,6 +13,7 @@ use crate::service::config::types::{ }; use crate::util::errors::*; use bitfun_agent_runtime::skills::normalize_user_mode_skill_overrides; +use bitfun_runtime_ports::PermissionRule; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::collections::{HashMap, HashSet}; @@ -122,6 +123,7 @@ fn stored_agent_profile_from_tool_selection( disabled_user_skills: Vec, enabled_user_skills: Vec, subagent_overrides: ParentSubagentOverrideConfig, + tool_permission_rules: Vec, default_tools: &[String], valid_tools: &HashSet, ) -> Option { @@ -151,6 +153,7 @@ fn stored_agent_profile_from_tool_selection( disabled_user_skills, enabled_user_skills, subagent_overrides, + tool_permission_rules, default_tools: &default_tools, valid_tools, }) @@ -163,6 +166,7 @@ struct StoredAgentProfileOverrides<'a> { disabled_user_skills: Vec, enabled_user_skills: Vec, subagent_overrides: ParentSubagentOverrideConfig, + tool_permission_rules: Vec, default_tools: &'a [String], valid_tools: &'a HashSet, } @@ -177,6 +181,7 @@ fn stored_agent_profile_from_overrides( disabled_user_skills, enabled_user_skills, subagent_overrides, + tool_permission_rules, default_tools, valid_tools, } = overrides; @@ -199,6 +204,7 @@ fn stored_agent_profile_from_overrides( && disabled_user_skills.is_empty() && enabled_user_skills.is_empty() && subagent_overrides.is_empty() + && tool_permission_rules.is_empty() { return None; } @@ -210,6 +216,7 @@ fn stored_agent_profile_from_overrides( disabled_user_skills, enabled_user_skills, subagent_overrides, + tool_permission_rules, }) } @@ -271,6 +278,7 @@ fn canonicalize_agent_profile( disabled_user_skills: stored.disabled_user_skills, enabled_user_skills: stored.enabled_user_skills, subagent_overrides: stored.subagent_overrides, + tool_permission_rules: stored.tool_permission_rules, default_tools, valid_tools, }, @@ -427,12 +435,35 @@ pub async fn persist_agent_profile_from_value(agent_id: &str, config: Value) -> .unwrap_or_default() }; + let tool_permission_rules = if config + .as_object() + .map(|obj| obj.contains_key("tool_permission_rules")) + .unwrap_or(false) + { + match config.get("tool_permission_rules") { + Some(Value::Null) | None => Vec::new(), + Some(value) => { + serde_json::from_value::>(value.clone()).map_err(|error| { + BitFunError::config(format!( + "Invalid tool_permission_rules for mode '{}': {}", + agent_id, error + )) + })? + } + } + } else { + current + .map(|item| item.tool_permission_rules.clone()) + .unwrap_or_default() + }; + if let Some(canonical) = stored_agent_profile_from_tool_selection( agent_id, enabled_tools, disabled_user_skills, enabled_user_skills, subagent_overrides, + tool_permission_rules, default_tools, &valid_tools, ) { @@ -458,6 +489,7 @@ pub async fn reset_agent_profile_to_default(agent_id: &str) -> BitFunResult<()> if current.disabled_user_skills.is_empty() && current.enabled_user_skills.is_empty() && current.subagent_overrides.is_empty() + && current.tool_permission_rules.is_empty() { stored_configs.remove(&profile_id); } @@ -550,6 +582,7 @@ mod tests { StoredAgentProfileOverrides, }; use crate::service::config::types::AgentSubagentOverrideState; + use bitfun_runtime_ports::{PermissionEffect, PermissionRule}; use serde_json::Value; use std::collections::HashSet; @@ -581,6 +614,7 @@ mod tests { disabled_user_skills: Vec::new(), enabled_user_skills: vec!["user::bitfun-system::pdf".to_string()], subagent_overrides: Default::default(), + tool_permission_rules: Vec::new(), default_tools: &[], valid_tools: &valid_tools, }) @@ -609,6 +643,7 @@ mod tests { disabled_user_skills: Vec::new(), enabled_user_skills: Vec::new(), subagent_overrides: subagent_overrides.clone(), + tool_permission_rules: Vec::new(), default_tools: &[], valid_tools: &valid_tools, }) @@ -618,6 +653,47 @@ mod tests { assert_eq!(stored.subagent_overrides, subagent_overrides); } + #[test] + fn stored_agent_profile_from_overrides_keeps_permission_rules() { + let valid_tools = HashSet::new(); + let rules = vec![PermissionRule::new( + "read", + "secrets/*", + PermissionEffect::Deny, + )]; + let stored = stored_agent_profile_from_overrides(StoredAgentProfileOverrides { + agent_id: "agentic", + added_tools: Vec::new(), + removed_tools: Vec::new(), + disabled_user_skills: Vec::new(), + enabled_user_skills: Vec::new(), + subagent_overrides: Default::default(), + tool_permission_rules: rules.clone(), + default_tools: &[], + valid_tools: &valid_tools, + }) + .expect("permission-only profile should be retained"); + + assert_eq!(stored.tool_permission_rules, rules); + } + + #[test] + fn canonicalize_agent_profile_preserves_permission_rules() { + let raw = serde_json::json!({ + "tool_permission_rules": [{ + "action": "edit", + "resource": "generated/*", + "effect": "allow" + }] + }); + let canonical = canonicalize_agent_profile("agentic", Some(&raw), &[], &HashSet::new()) + .expect("profile should canonicalize") + .expect("permission-only profile should be present"); + + assert_eq!(canonical.tool_permission_rules.len(), 1); + assert_eq!(canonical.tool_permission_rules[0].action, "edit"); + } + #[test] fn canonicalize_agent_profile_treats_null_as_missing() { let canonical = diff --git a/src/crates/assembly/core/src/service/config/project_permission_store.rs b/src/crates/assembly/core/src/service/config/project_permission_store.rs new file mode 100644 index 0000000000..66c9c72191 --- /dev/null +++ b/src/crates/assembly/core/src/service/config/project_permission_store.rs @@ -0,0 +1,122 @@ +//! Workspace-scoped static tool permission rules. + +use crate::infrastructure::get_path_manager_arc; +use crate::util::errors::{BitFunError, BitFunResult}; +use bitfun_runtime_ports::{PermissionRule, WorkspaceFileSystem}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::{Path, PathBuf}; + +pub const PROJECT_PERMISSION_FILE_NAME: &str = "tool_permissions.json"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct ProjectPermissionConfig { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules: Vec, +} + +pub fn project_permission_file_path(workspace_root: &Path) -> PathBuf { + get_path_manager_arc().project_permission_file(workspace_root) +} + +pub fn project_permission_file_path_for_remote(remote_root: &str) -> String { + format!( + "{}/.bitfun/config/{}", + remote_root.trim_end_matches('/'), + PROJECT_PERMISSION_FILE_NAME + ) +} + +pub fn deserialize_project_permission_config( + content: &str, +) -> BitFunResult { + let value: Value = serde_json::from_str(content).map_err(|error| { + BitFunError::config(format!( + "Failed to parse project permission config: {error}" + )) + })?; + + if value.is_array() { + let rules = serde_json::from_value(value).map_err(|error| { + BitFunError::config(format!("Invalid project permission rules: {error}")) + })?; + Ok(ProjectPermissionConfig { rules }) + } else { + serde_json::from_value(value).map_err(|error| { + BitFunError::config(format!("Invalid project permission config: {error}")) + }) + } +} + +pub async fn load_project_permission_config_local( + workspace_root: &Path, +) -> BitFunResult { + let path = project_permission_file_path(workspace_root); + match tokio::fs::read_to_string(&path).await { + Ok(content) => deserialize_project_permission_config(&content), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(ProjectPermissionConfig::default()) + } + Err(error) => Err(BitFunError::config(format!( + "Failed to read project permission config '{}': {error}", + path.display() + ))), + } +} + +pub async fn load_project_permission_config_remote( + fs: &dyn WorkspaceFileSystem, + remote_root: &str, +) -> BitFunResult { + let path = project_permission_file_path_for_remote(remote_root); + if !fs.exists(&path).await.unwrap_or(false) { + return Ok(ProjectPermissionConfig::default()); + } + + let content = fs.read_file_text(&path).await.map_err(|error| { + BitFunError::config(format!( + "Failed to read remote project permission config '{}': {error}", + path + )) + })?; + deserialize_project_permission_config(&content) +} + +#[cfg(test)] +mod tests { + use super::{deserialize_project_permission_config, project_permission_file_path_for_remote}; + use bitfun_runtime_ports::{PermissionEffect, PermissionRule}; + + #[test] + fn parses_object_permission_config() { + let config = deserialize_project_permission_config( + r#"{"rules":[{"action":"edit","resource":"src/*","effect":"deny"}]}"#, + ) + .expect("object config should parse"); + + assert_eq!( + config.rules, + vec![PermissionRule::new("edit", "src/*", PermissionEffect::Deny)] + ); + } + + #[test] + fn parses_legacy_array_permission_config() { + let config = deserialize_project_permission_config( + r#"[{"action":"read","resource":"secrets/*","effect":"deny"}]"#, + ) + .expect("array config should parse"); + + assert_eq!(config.rules.len(), 1); + assert_eq!(config.rules[0].action, "read"); + } + + #[test] + fn remote_permission_path_is_workspace_scoped() { + assert_eq!( + project_permission_file_path_for_remote("/home/user/project/"), + "/home/user/project/.bitfun/config/tool_permissions.json" + ); + } +} diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index 0956f951f8..7336938ffe 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -4,6 +4,7 @@ use crate::util::errors::*; use async_trait::async_trait; +use bitfun_runtime_ports::{PermissionRule, ToolPermissionConfig}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; @@ -55,6 +56,9 @@ pub struct GlobalConfig { pub terminal: TerminalConfig, pub workspace: WorkspaceConfig, pub ai: AIConfig, + /// User-level static tool permission policy and interaction preferences. + #[serde(default)] + pub tool_permissions: ToolPermissionConfig, #[serde(default)] pub memories: MemoriesConfig, /// Project-scoped overlays stored in the shared config document. @@ -695,14 +699,6 @@ pub struct AIConfig { #[serde(default = "default_tool_execution_timeout")] pub tool_execution_timeout_secs: Option, - /// Tool confirmation timeout in seconds; `None` means wait indefinitely. - #[serde(default = "default_tool_confirmation_timeout")] - pub tool_confirmation_timeout_secs: Option, - - /// Skip tool execution confirmation (global, applies to all modes). - #[serde(default = "default_skip_tool_confirmation")] - pub skip_tool_confirmation: bool, - /// Whether tools with deferred exposure load their schemas on demand. #[serde(default = "default_enable_deferred_tool_loading")] pub enable_deferred_tool_loading: bool, @@ -882,6 +878,10 @@ pub struct AgentProfileConfig { /// User-level subagent availability overrides for this shared profile. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub subagent_overrides: ParentSubagentOverrideConfig, + + /// Agent-level permission rules applied after project rules. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_permission_rules: Vec, } /// API view of a mode configuration. @@ -916,15 +916,6 @@ fn default_tool_execution_timeout() -> Option { None } -/// Default is no timeout (wait forever). -fn default_tool_confirmation_timeout() -> Option { - None -} - -fn default_skip_tool_confirmation() -> bool { - true -} - fn default_enable_deferred_tool_loading() -> bool { true } @@ -1569,6 +1560,7 @@ impl Default for GlobalConfig { ai: AIConfig::default(), memories: MemoriesConfig::default(), project: ProjectConfig::default(), + tool_permissions: ToolPermissionConfig::default(), mcp_servers: None, acp_clients: None, themes: Some(ThemesConfig::default()), @@ -1757,8 +1749,6 @@ impl Default for AIConfig { stream_idle_timeout_secs: default_stream_idle_timeout(), stream_ttft_timeout_secs: default_stream_ttft_timeout(), tool_execution_timeout_secs: default_tool_execution_timeout(), - tool_confirmation_timeout_secs: default_tool_confirmation_timeout(), - skip_tool_confirmation: true, enable_deferred_tool_loading: default_enable_deferred_tool_loading(), debug_mode_config: DebugModeConfig::default(), computer_use_enabled: false, @@ -2005,6 +1995,7 @@ mod tests { ModelExchangeTracingMode, ReasoningMode, SubagentBatchExecutionPolicy, SubagentModelSelection, UserSkillGroupsConfig, UserToolGroupsConfig, }; + use bitfun_runtime_ports::ToolPermissionConfig; #[test] fn agent_profile_defaults_keep_all_collections_empty() { @@ -2015,6 +2006,7 @@ mod tests { assert!(config.disabled_user_skills.is_empty()); assert!(config.enabled_user_skills.is_empty()); assert!(config.subagent_overrides.is_empty()); + assert!(config.tool_permission_rules.is_empty()); let view = AgentProfileView::default(); assert!(view.profile_id.is_empty()); @@ -2024,6 +2016,27 @@ mod tests { assert!(view.enabled_user_skills.is_empty()); } + #[test] + fn legacy_agent_profile_defaults_permission_rules_and_omits_empty_field() { + let config: AgentProfileConfig = serde_json::from_value(serde_json::json!({ + "profile_id": "coding_shared", + "added_tools": ["read"] + })) + .expect("legacy agent profile should deserialize"); + + assert!(config.tool_permission_rules.is_empty()); + let serialized = serde_json::to_value(config).expect("agent profile should serialize"); + assert!(serialized.get("tool_permission_rules").is_none()); + } + + #[test] + fn legacy_global_config_defaults_permission_settings() { + let config: GlobalConfig = serde_json::from_value(serde_json::json!({})) + .expect("legacy config should deserialize with permission defaults"); + + assert_eq!(config.tool_permissions, ToolPermissionConfig::default()); + } + #[test] fn user_tool_groups_default_to_version_one_without_persisted_groups() { let config: GlobalConfig = serde_json::from_value(serde_json::json!({})) diff --git a/src/crates/assembly/core/src/service/cron/service.rs b/src/crates/assembly/core/src/service/cron/service.rs index 126595feeb..2f75574f54 100644 --- a/src/crates/assembly/core/src/service/cron/service.rs +++ b/src/crates/assembly/core/src/service/cron/service.rs @@ -969,11 +969,7 @@ fn format_scheduled_job_user_input( } fn scheduled_job_policy() -> DialogSubmissionPolicy { - DialogSubmissionPolicy::new( - DialogTriggerSource::ScheduledJob, - DialogQueuePriority::Low, - true, - ) + DialogSubmissionPolicy::new(DialogTriggerSource::ScheduledJob, DialogQueuePriority::Low) } fn now_ms() -> i64 { diff --git a/src/crates/assembly/core/src/service/mcp/adapter/tool.rs b/src/crates/assembly/core/src/service/mcp/adapter/tool.rs index 23b1bab1e7..4af536b058 100644 --- a/src/crates/assembly/core/src/service/mcp/adapter/tool.rs +++ b/src/crates/assembly/core/src/service/mcp/adapter/tool.rs @@ -3,8 +3,8 @@ //! Wraps MCP tools as implementations of BitFun's `Tool` trait. use crate::agentic::tools::framework::{ - DynamicToolInfo, Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, - ValidationResult, + DynamicToolInfo, PermissionIntent, Tool, ToolExposure, ToolRenderOptions, ToolResult, + ToolUseContext, ValidationResult, }; use crate::service::mcp::protocol::{MCPTool, MCPToolResult}; use crate::service::mcp::server::MCPConnection; @@ -27,6 +27,10 @@ use std::sync::RwLock; const MCP_TOOL_DEFAULT_EXPOSURE: ToolExposure = ToolExposure::Deferred; +fn dynamic_mcp_permission_intent(full_name: &str) -> PermissionIntent { + PermissionIntent::new("mcp", vec![full_name.to_string()]) +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct MCPWorkspaceToolRoute { pub active_external_server_ids: BTreeSet, @@ -196,8 +200,14 @@ impl Tool for MCPToolWrapper { self.is_readonly() } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - !self.is_readonly() + fn permission_intents( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Ok(vec![dynamic_mcp_permission_intent( + &self.descriptor.full_name, + )]) } async fn validate_input( @@ -368,7 +378,8 @@ impl Default for MCPToolAdapter { #[cfg(test)] mod tests { use super::{ - MCPToolContextPolicy, MCPWorkspaceToolRoute, ToolExposure, MCP_TOOL_DEFAULT_EXPOSURE, + dynamic_mcp_permission_intent, MCPToolContextPolicy, MCPWorkspaceToolRoute, ToolExposure, + MCP_TOOL_DEFAULT_EXPOSURE, }; #[test] @@ -410,4 +421,15 @@ mod tests { fn mcp_tool_wrapper_defaults_to_deferred_exposure() { assert_eq!(MCP_TOOL_DEFAULT_EXPOSURE, ToolExposure::Deferred); } + + #[test] + fn dynamic_mcp_tools_use_the_full_server_tool_identity() { + let intent = dynamic_mcp_permission_intent("mcp__github__search_repositories"); + + assert_eq!(intent.action, "mcp"); + assert_eq!( + intent.resources, + ["mcp__github__search_repositories".to_string()] + ); + } } diff --git a/src/crates/assembly/core/src/service/mcp/server/manager/lifecycle.rs b/src/crates/assembly/core/src/service/mcp/server/manager/lifecycle.rs index dca8dfc773..e5ae3162a3 100644 --- a/src/crates/assembly/core/src/service/mcp/server/manager/lifecycle.rs +++ b/src/crates/assembly/core/src/service/mcp/server/manager/lifecycle.rs @@ -713,8 +713,7 @@ impl MCPServerManager { pub async fn retire_external_ephemeral_server(&self, server_id: &str) -> BitFunResult<()> { const RETIREMENT_GRACE: std::time::Duration = std::time::Duration::from_secs(30); const RETIREMENT_RECLAIM_ATTEMPTS: usize = 3; - const RETIREMENT_RETRY_DELAY: std::time::Duration = - std::time::Duration::from_millis(250); + const RETIREMENT_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(250); let _lifecycle_guard = self.ephemeral_lifecycle.lock().await; self.ephemeral_start_tokens.write().await.remove(server_id); if !self.runtime.contains(server_id).await { diff --git a/src/crates/assembly/core/src/service/snapshot/manager.rs b/src/crates/assembly/core/src/service/snapshot/manager.rs index 419ecf09a6..b45af79a6c 100644 --- a/src/crates/assembly/core/src/service/snapshot/manager.rs +++ b/src/crates/assembly/core/src/service/snapshot/manager.rs @@ -491,8 +491,12 @@ impl Tool for WrappedTool { self.original_tool.is_concurrency_safe(input) } - fn needs_permissions(&self, input: Option<&Value>) -> bool { - self.original_tool.needs_permissions(input) + fn permission_intents( + &self, + input: &Value, + context: &ToolUseContext, + ) -> crate::util::errors::BitFunResult> { + self.original_tool.permission_intents(input, context) } async fn validate_input( diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index bf67cb1981..3e8ee435f3 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -342,11 +342,7 @@ fn core_dialog_submission_policy(policy: RemoteDialogSubmissionPolicy) -> Dialog RemoteDialogQueuePriority::High => DialogQueuePriority::High, }; - DialogSubmissionPolicy::new( - trigger_source, - queue_priority, - policy.skip_tool_confirmation, - ) + DialogSubmissionPolicy::new(trigger_source, queue_priority) } fn remote_dialog_scheduler_outcome_fact( @@ -407,10 +403,10 @@ fn core_agent_runtime_builder( thread_goal_management: Arc, cancellation: Arc, interaction_response: Arc, -) -> AgentRuntimeBuilder { +) -> Result { let agent_registry: Arc = crate::agentic::agents::get_agent_registry(); - AgentRuntimeBuilder::new() + Ok(AgentRuntimeBuilder::new() .with_submission_port(submission) .with_session_management_port(session_management) .with_session_mode_port(session_mode) @@ -421,7 +417,8 @@ fn core_agent_runtime_builder( .with_thread_goal_management_port(thread_goal_management) .with_cancellation_port(cancellation) .with_interaction_response_port(interaction_response) - .with_agent_registry(agent_registry) + .with_permission_request_manager(crate::product_runtime::core_permission_request_manager()?) + .with_agent_registry(agent_registry)) } #[derive(Clone)] @@ -837,7 +834,7 @@ impl CoreServiceAgentRuntime { thread_goal_management, cancellation, interaction_response, - ) + )? .build() .map_err(|error| error.to_string()) } @@ -871,7 +868,7 @@ impl CoreServiceAgentRuntime { thread_goal_management, cancellation, interaction_response, - ) + )? .with_dialog_turn_port(dialog_turn) .with_lifecycle_delivery_port(lifecycle_delivery) .build() @@ -906,7 +903,7 @@ impl CoreServiceAgentRuntime { thread_goal_management, cancellation, interaction_response, - ) + )? .with_lifecycle_delivery_port(lifecycle_delivery) .build() .map_err(|error| error.to_string()) @@ -937,6 +934,9 @@ impl CoreServiceAgentRuntime { .with_interaction_response_port(interaction_response) .with_session_fork_port(session_fork) .with_session_usage_port(session_usage) + .with_permission_request_manager( + crate::product_runtime::core_permission_request_manager()?, + ) .build() .map_err(|error| error.to_string()) } @@ -970,7 +970,7 @@ impl CoreServiceAgentRuntime { thread_goal_management, cancellation, interaction_response, - ) + )? .with_dialog_turn_port(dialog_turn) .with_lifecycle_delivery_port(lifecycle_delivery) .build() @@ -1058,7 +1058,7 @@ impl CoreServiceAgentRuntime { thread_goal_management, cancellation, interaction_response, - ) + )? .with_dialog_turn_port(dialog_turn) .with_lifecycle_delivery_port(lifecycle_delivery); let builder = match event_source { @@ -1654,26 +1654,6 @@ impl RemotePollRuntimeHost for CoreRemotePollRuntimeHost<'_> { #[async_trait::async_trait] impl RemoteInteractionRuntimeHost for CoreRemoteInteractionRuntimeHost { - async fn confirm_tool( - &self, - tool_id: &str, - updated_input: Option, - ) -> Result<(), String> { - self.coordinator()? - .confirm_tool(tool_id, updated_input) - .await - .map(|_| ()) - .map_err(|error| error.to_string()) - } - - async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<(), String> { - self.coordinator()? - .reject_tool(tool_id, reason) - .await - .map(|_| ()) - .map_err(|error| error.to_string()) - } - async fn cancel_tool(&self, tool_id: &str, reason: String) -> Result<(), String> { self.coordinator()? .cancel_tool(tool_id, reason) @@ -1842,20 +1822,16 @@ mod tests { let relay = core_dialog_submission_policy(RemoteDialogSubmissionPolicy { source: RemoteConnectSubmissionSource::Relay, queue_priority: RemoteDialogQueuePriority::High, - skip_tool_confirmation: true, }); assert_eq!(relay.trigger_source, DialogTriggerSource::RemoteRelay); assert_eq!(relay.queue_priority, DialogQueuePriority::High); - assert!(relay.skip_tool_confirmation); let bot = core_dialog_submission_policy(RemoteDialogSubmissionPolicy { source: RemoteConnectSubmissionSource::Bot, queue_priority: RemoteDialogQueuePriority::Low, - skip_tool_confirmation: false, }); assert_eq!(bot.trigger_source, DialogTriggerSource::Bot); assert_eq!(bot.queue_priority, DialogQueuePriority::Low); - assert!(!bot.skip_tool_confirmation); } #[test] diff --git a/src/crates/assembly/product-capabilities/src/lib.rs b/src/crates/assembly/product-capabilities/src/lib.rs index 7b577ed2e2..1c3a692439 100644 --- a/src/crates/assembly/product-capabilities/src/lib.rs +++ b/src/crates/assembly/product-capabilities/src/lib.rs @@ -944,7 +944,6 @@ const CODE_AGENT_SERVICES: &[RuntimeServiceCapability] = &[ RuntimeServiceCapability::FileSystem, RuntimeServiceCapability::Workspace, RuntimeServiceCapability::SessionStore, - RuntimeServiceCapability::Permission, RuntimeServiceCapability::Events, RuntimeServiceCapability::Clock, RuntimeServiceCapability::Terminal, @@ -952,19 +951,16 @@ const CODE_AGENT_SERVICES: &[RuntimeServiceCapability] = &[ const DEEP_REVIEW_SERVICES: &[RuntimeServiceCapability] = &[ RuntimeServiceCapability::Workspace, RuntimeServiceCapability::Git, - RuntimeServiceCapability::Permission, RuntimeServiceCapability::Events, ]; const DEEP_RESEARCH_SERVICES: &[RuntimeServiceCapability] = &[ RuntimeServiceCapability::Workspace, RuntimeServiceCapability::Network, - RuntimeServiceCapability::Permission, RuntimeServiceCapability::Events, ]; const MINIAPP_SERVICES: &[RuntimeServiceCapability] = &[ RuntimeServiceCapability::FileSystem, RuntimeServiceCapability::Workspace, - RuntimeServiceCapability::Permission, RuntimeServiceCapability::Events, ]; const CANVAS_SERVICES: &[RuntimeServiceCapability] = &[ diff --git a/src/crates/assembly/product-capabilities/tests/product_capabilities.rs b/src/crates/assembly/product-capabilities/tests/product_capabilities.rs index 784d2362c6..2fbb30cf35 100644 --- a/src/crates/assembly/product-capabilities/tests/product_capabilities.rs +++ b/src/crates/assembly/product-capabilities/tests/product_capabilities.rs @@ -180,7 +180,6 @@ fn capability_packs_describe_service_tool_and_harness_requirements() { let service_capabilities = registry.required_service_capabilities(); assert!(service_capabilities.contains(&RuntimeServiceCapability::FileSystem)); assert!(service_capabilities.contains(&RuntimeServiceCapability::Workspace)); - assert!(service_capabilities.contains(&RuntimeServiceCapability::Permission)); assert!(service_capabilities.contains(&RuntimeServiceCapability::Events)); let harness_capabilities = registry @@ -770,7 +769,6 @@ fn default_capability_assembly_keeps_service_tool_and_harness_facts_together() { RuntimeServiceCapability::FileSystem, RuntimeServiceCapability::Workspace, RuntimeServiceCapability::SessionStore, - RuntimeServiceCapability::Permission, RuntimeServiceCapability::Events, RuntimeServiceCapability::Clock, RuntimeServiceCapability::Terminal, diff --git a/src/crates/contracts/product-domains/src/lib.rs b/src/crates/contracts/product-domains/src/lib.rs index 096f3b9108..13da542c5f 100644 --- a/src/crates/contracts/product-domains/src/lib.rs +++ b/src/crates/contracts/product-domains/src/lib.rs @@ -4,6 +4,7 @@ //! the full BitFun core runtime assembly. pub mod canvas; +pub mod tool_permissions; #[cfg(feature = "external-sources")] pub mod external_integration_policy; diff --git a/src/crates/contracts/product-domains/src/tool_permissions.rs b/src/crates/contracts/product-domains/src/tool_permissions.rs new file mode 100644 index 0000000000..9a60dd91da --- /dev/null +++ b/src/crates/contracts/product-domains/src/tool_permissions.rs @@ -0,0 +1,564 @@ +//! Pure domain contracts and evaluation rules for tool-call permissions. +//! +//! This module intentionally has no runtime, persistence, or interaction +//! responsibilities. Product assembly and execution owners may consume these +//! decisions in later integration phases. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::fmt; + +/// The effect produced by a matching permission rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PermissionEffect { + Allow, + Ask, + Deny, +} + +/// An ordered action/resource permission rule. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PermissionRule { + pub action: String, + pub resource: String, + pub effect: PermissionEffect, +} + +impl PermissionRule { + pub fn new( + action: impl Into, + resource: impl Into, + effect: PermissionEffect, + ) -> Self { + Self { + action: action.into(), + resource: resource.into(), + effect, + } + } +} + +/// A rule list whose order is significant: later matching rules win. +pub type PermissionRuleset = Vec; + +/// A validated runtime restriction inherited by a delegated child agent. +/// +/// A ceiling is intentionally unable to carry `allow` rules: delegation may +/// preserve or tighten the parent's runtime restrictions, but it must never +/// widen the child's independently resolved policy. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PermissionRuntimeCeiling { + rules: PermissionRuleset, +} + +impl PermissionRuntimeCeiling { + pub fn try_new( + rules: PermissionRuleset, + ) -> Result { + if let Some((rule_index, rule)) = rules + .iter() + .enumerate() + .find(|(_, rule)| rule.effect == PermissionEffect::Allow) + { + return Err(PermissionRuntimeCeilingValidationError { + rule_index, + action: rule.action.clone(), + resource: rule.resource.clone(), + }); + } + + Ok(Self { rules }) + } + + pub fn rules(&self) -> &[PermissionRule] { + &self.rules + } + + pub fn into_rules(self) -> PermissionRuleset { + self.rules + } + + pub fn is_empty(&self) -> bool { + self.rules.is_empty() + } +} + +/// Validation failure returned when a runtime ceiling attempts to widen +/// delegated permissions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PermissionRuntimeCeilingValidationError { + pub rule_index: usize, + pub action: String, + pub resource: String, +} + +impl fmt::Display for PermissionRuntimeCeilingValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "permission runtime ceiling rule {} cannot allow action '{}' on resource '{}'", + self.rule_index, self.action, self.resource + ) + } +} + +impl std::error::Error for PermissionRuntimeCeilingValidationError {} + +/// Product-facing baseline for static tool permission rules. +/// +/// Presets expand into ordinary rules and never bypass permission evaluation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PermissionPolicyPreset { + #[default] + Ask, + FullAccess, +} + +impl PermissionPolicyPreset { + pub fn baseline_rules(self) -> PermissionRuleset { + match self { + // Later matches win, so sensitive read exceptions follow the broad + // low-risk allow and .env.example follows the .env.* guard. + Self::Ask => vec![ + PermissionRule::new("*", "*", PermissionEffect::Ask), + PermissionRule::new("read", "*", PermissionEffect::Allow), + PermissionRule::new("read", "*/.env", PermissionEffect::Ask), + PermissionRule::new("read", "*/.env.*", PermissionEffect::Ask), + PermissionRule::new("read", "*/.env.example", PermissionEffect::Allow), + PermissionRule::new("websearch", "*", PermissionEffect::Allow), + PermissionRule::new("webfetch", "*", PermissionEffect::Allow), + PermissionRule::new("task", "*", PermissionEffect::Allow), + PermissionRule::new("skill", "*", PermissionEffect::Allow), + PermissionRule::new("git", "git status *", PermissionEffect::Allow), + PermissionRule::new("git", "git diff *", PermissionEffect::Allow), + PermissionRule::new("git", "git log *", PermissionEffect::Allow), + PermissionRule::new("git", "git show *", PermissionEffect::Allow), + PermissionRule::new("git", "git blame *", PermissionEffect::Allow), + PermissionRule::new("git", "git rev-parse *", PermissionEffect::Allow), + PermissionRule::new("git", "git describe *", PermissionEffect::Allow), + PermissionRule::new("git", "git shortlog *", PermissionEffect::Allow), + PermissionRule::new("git", "git branch", PermissionEffect::Allow), + ], + Self::FullAccess => vec![PermissionRule::new("*", "*", PermissionEffect::Allow)], + } + } +} + +/// Static user-level policy. Custom rules are evaluated after the preset. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct PermissionPolicyConfig { + pub preset: PermissionPolicyPreset, + pub rules: PermissionRuleset, +} + +/// User interaction preferences applied only after static policy evaluation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct PermissionInteractionConfig { + pub auto_approve_ask: bool, +} + +/// Root configuration contract for the `tool_permissions` config section. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ToolPermissionConfig { + pub policy: PermissionPolicyConfig, + pub interaction: PermissionInteractionConfig, +} + +/// Ordered inputs used to resolve one effective static permission ruleset. +/// +/// Product defaults are the initial baseline. The user preset and global +/// rules follow, then project and agent overrides. Enforced rules remain last +/// so user-level full access cannot loosen product or organization limits. +#[derive(Debug, Clone, Copy)] +pub struct PermissionPolicyLayers<'a> { + pub product_defaults: &'a [PermissionRule], + pub global: &'a PermissionPolicyConfig, + pub project: &'a [PermissionRule], + pub agent: &'a [PermissionRule], + pub enforced: &'a [PermissionRule], +} + +/// Ordered inputs used to derive a delegated child agent's permission policy. +/// +/// The child keeps the ordinary global, project, and child-profile layers. +/// The parent's validated runtime ceiling follows the child profile, while +/// product or organization enforced rules remain authoritative at the end. +#[derive(Debug, Clone, Copy)] +pub struct ChildPermissionPolicyLayers<'a> { + pub product_defaults: &'a [PermissionRule], + pub global: &'a PermissionPolicyConfig, + pub project: &'a [PermissionRule], + pub child_agent: &'a [PermissionRule], + pub parent_runtime_ceiling: &'a PermissionRuntimeCeiling, + pub enforced: &'a [PermissionRule], +} + +/// Expands the configured preset and merges every static rule layer in its +/// security-significant evaluation order. +pub fn resolve_permission_policy(layers: PermissionPolicyLayers<'_>) -> PermissionRuleset { + let baseline = layers.global.preset.baseline_rules(); + merge_permission_rule_layers(&[ + layers.product_defaults, + &baseline, + &layers.global.rules, + layers.project, + layers.agent, + layers.enforced, + ]) +} + +/// Resolves a delegated child policy without allowing parent policy to widen +/// the child's own capabilities. +pub fn resolve_child_permission_policy( + layers: ChildPermissionPolicyLayers<'_>, +) -> PermissionRuleset { + let baseline = layers.global.preset.baseline_rules(); + merge_permission_rule_layers(&[ + layers.product_defaults, + &baseline, + &layers.global.rules, + layers.project, + layers.child_agent, + layers.parent_runtime_ceiling.rules(), + layers.enforced, + ]) +} + +/// Identifies the boundary that originated a permission request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PermissionRequestSourceKind { + ToolCall, + Provider, + Extension, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionRequestSource { + pub kind: PermissionRequestSourceKind, + pub identity: String, +} + +/// Identifies the parent Task invocation that owns interaction for a +/// permission request raised by a subagent. +/// +/// The request's own session and tool-call IDs continue to identify the +/// concrete child execution. These fields only project the existing +/// delegation relationship to interactive surfaces and audit consumers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionDelegationContext { + pub parent_session_id: String, + /// The parent dialog turn when it is available from the persisted + /// subagent lineage. Older child sessions may retain only the parent + /// session and Task call identity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_dialog_turn_id: Option, + pub parent_tool_call_id: String, + pub subagent_type: String, +} + +/// A process-local permission request projected to an interactive surface. +/// +/// Resource and display values stored here must already be safe for user +/// presentation and audit persistence. Raw secrets and unrestricted command +/// payloads must remain outside this DTO. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionRequest { + pub request_id: String, + /// Model round that owns this permission request. + /// + /// BitFun-created tool requests always provide this value so interactive + /// surfaces can keep requests from one model round together. + pub round_id: String, + /// Stable permission order within `round_id`. + /// + /// This is derived from the model tool-call order before tools are + /// scheduled. Requests produced by one tool share the same order when + /// they cannot be counted before execution; registration order remains a + /// deterministic tie-breaker in that case. + pub order: u32, + /// Provider/tool-stream call ID used to correlate this request with one + /// concrete tool invocation in interactive surfaces. + /// + /// This is deliberately separate from `request_id`: one tool invocation + /// may produce more than one permission request, while some providers or + /// extensions may not expose a call ID at all. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, + /// User-presentable logical workspace root for this permission request. + /// + /// This is display-only and must not be used as a persistence or grant + /// identity. `project_id` remains the stable authorization scope key. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_path: Option, + pub project_id: String, + pub session_id: String, + pub agent_id: String, + pub action: String, + pub resources: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub save_resources: Vec, + pub source: PermissionRequestSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delegation: Option, + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub display_metadata: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "reply", rename_all = "snake_case")] +pub enum PermissionReply { + Once, + Always, + Reject { + #[serde(default, skip_serializing_if = "Option::is_none")] + feedback: Option, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PermissionReplySource { + User, + AutoApprove, + System, +} + +/// Process-local lifecycle event projected to interactive permission surfaces. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde( + tag = "event", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum PermissionRequestEvent { + Asked { + request: PermissionRequest, + }, + Replied { + request_id: String, + reply: PermissionReply, + source: PermissionReplySource, + }, + Cancelled { + request_id: String, + reason: String, + }, +} + +/// A remembered allow scoped by project, action, and resource. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionGrant { + pub project_id: String, + pub action: String, + pub resource: String, + pub created_at_ms: i64, +} + +impl PermissionGrant { + pub fn key(&self) -> PermissionGrantKey { + PermissionGrantKey { + project_id: self.project_id.clone(), + action: self.action.clone(), + resource: self.resource.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionGrantKey { + pub project_id: String, + pub action: String, + pub resource: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum PermissionAuditEvent { + Requested, + Replied { + reply: PermissionReply, + source: PermissionReplySource, + }, + Cancelled { + reason: String, + }, +} + +/// An append-only audit fact containing only presentation-safe request data. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionAuditRecord { + pub audit_id: String, + pub request: PermissionRequest, + pub event: PermissionAuditEvent, + pub timestamp_ms: i64, +} + +/// Controls resource matching for local or remote workspace path semantics. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PermissionResourceCaseSensitivity { + Sensitive, + Insensitive, +} + +/// Pure evaluator for ordered tool permission rules. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PermissionEvaluator { + resource_case_sensitivity: PermissionResourceCaseSensitivity, +} + +impl PermissionEvaluator { + pub const fn new(resource_case_sensitivity: PermissionResourceCaseSensitivity) -> Self { + Self { + resource_case_sensitivity, + } + } + + pub const fn case_sensitive() -> Self { + Self::new(PermissionResourceCaseSensitivity::Sensitive) + } + + pub const fn windows_compatible() -> Self { + Self::new(PermissionResourceCaseSensitivity::Insensitive) + } + + pub const fn for_current_platform() -> Self { + if cfg!(windows) { + Self::windows_compatible() + } else { + Self::case_sensitive() + } + } + + /// Returns the effect of the last rule matching both action and resource. + /// Unmatched requests default to `ask`. + pub fn evaluate_resource( + &self, + action: &str, + resource: &str, + rules: &[PermissionRule], + ) -> PermissionEffect { + rules + .iter() + .rev() + .find(|rule| { + wildcard_matches( + action, + &rule.action, + PermissionResourceCaseSensitivity::Sensitive, + ) && wildcard_matches(resource, &rule.resource, self.resource_case_sensitivity) + }) + .map(|rule| rule.effect) + .unwrap_or(PermissionEffect::Ask) + } + + /// Evaluates every resource in one tool call atomically. + /// + /// Any denied resource denies the call. Otherwise any resource that still + /// requires confirmation makes the call ask. Only an all-allow result is + /// allowed. A request without resources fails closed as `ask`. + pub fn evaluate_resources( + &self, + action: &str, + resources: &[String], + rules: &[PermissionRule], + ) -> PermissionEffect { + if resources.is_empty() { + return PermissionEffect::Ask; + } + + let mut aggregate = PermissionEffect::Allow; + for resource in resources { + match self.evaluate_resource(action, resource, rules) { + PermissionEffect::Deny => return PermissionEffect::Deny, + PermissionEffect::Ask => aggregate = PermissionEffect::Ask, + PermissionEffect::Allow => {} + } + } + aggregate + } +} + +impl Default for PermissionEvaluator { + fn default() -> Self { + Self::for_current_platform() + } +} + +/// 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(); + let mut merged = Vec::with_capacity(capacity); + for layer in layers { + merged.extend_from_slice(layer); + } + merged +} + +/// Matches `*` and `?` wildcards after normalizing path separators. +/// +/// Like the OpenCode V2 reference, a pattern ending in ` *` also matches the +/// prefix without a trailing argument (for example, `git *` matches `git`). +pub fn wildcard_matches( + input: &str, + pattern: &str, + case_sensitivity: PermissionResourceCaseSensitivity, +) -> bool { + let input = normalize_wildcard_value(input, case_sensitivity); + let pattern = normalize_wildcard_value(pattern, case_sensitivity); + + if pattern + .strip_suffix(" *") + .is_some_and(|prefix| input == prefix) + { + return true; + } + + glob_matches(&input, &pattern) +} + +fn normalize_wildcard_value( + value: &str, + case_sensitivity: PermissionResourceCaseSensitivity, +) -> String { + let normalized = value.replace('\\', "/"); + match case_sensitivity { + PermissionResourceCaseSensitivity::Sensitive => normalized, + PermissionResourceCaseSensitivity::Insensitive => normalized.to_lowercase(), + } +} + +fn glob_matches(input: &str, pattern: &str) -> bool { + let input: Vec = input.chars().collect(); + let mut previous = vec![false; input.len() + 1]; + previous[0] = true; + + for pattern_char in pattern.chars() { + let mut current = vec![false; input.len() + 1]; + if pattern_char == '*' { + current[0] = previous[0]; + } + + for (index, input_char) in input.iter().enumerate() { + current[index + 1] = match pattern_char { + '*' => previous[index + 1] || current[index], + '?' => previous[index], + literal => previous[index] && literal == *input_char, + }; + } + previous = current; + } + + previous[input.len()] +} diff --git a/src/crates/contracts/product-domains/tests/tool_permission_contracts.rs b/src/crates/contracts/product-domains/tests/tool_permission_contracts.rs new file mode 100644 index 0000000000..ca5c356ccb --- /dev/null +++ b/src/crates/contracts/product-domains/tests/tool_permission_contracts.rs @@ -0,0 +1,614 @@ +use bitfun_product_domains::tool_permissions::{ + merge_permission_rule_layers, resolve_child_permission_policy, resolve_permission_policy, + wildcard_matches, ChildPermissionPolicyLayers, PermissionDelegationContext, PermissionEffect, + PermissionEvaluator, PermissionPolicyConfig, PermissionPolicyLayers, PermissionPolicyPreset, + PermissionReply, PermissionReplySource, PermissionRequest, PermissionRequestEvent, + PermissionRequestSource, PermissionRequestSourceKind, PermissionResourceCaseSensitivity, + PermissionRule, PermissionRuntimeCeiling, ToolPermissionConfig, +}; +use serde_json::json; +use serde_json::Map; + +fn rule(action: &str, resource: &str, effect: PermissionEffect) -> PermissionRule { + PermissionRule::new(action, resource, effect) +} + +fn policy(preset: PermissionPolicyPreset, rules: Vec) -> PermissionPolicyConfig { + PermissionPolicyConfig { preset, rules } +} + +#[test] +fn tool_permission_config_defaults_to_ask_with_auto_approve_disabled() { + let config = ToolPermissionConfig::default(); + + assert_eq!(config.policy.preset, PermissionPolicyPreset::Ask); + assert!(config.policy.rules.is_empty()); + assert!(!config.interaction.auto_approve_ask); + assert_eq!( + serde_json::to_value(config).expect("serialize tool permission config"), + json!({ + "policy": { + "preset": "ask", + "rules": [], + }, + "interaction": { + "auto_approve_ask": false, + }, + }) + ); +} + +#[test] +fn policy_presets_expand_into_ordinary_baseline_rules() { + let ask = policy(PermissionPolicyPreset::Ask, Vec::new()); + let full_access = policy(PermissionPolicyPreset::FullAccess, Vec::new()); + let evaluator = PermissionEvaluator::case_sensitive(); + + let ask_rules = resolve_permission_policy(PermissionPolicyLayers { + product_defaults: &[], + global: &ask, + project: &[], + agent: &[], + enforced: &[], + }); + let full_access_rules = resolve_permission_policy(PermissionPolicyLayers { + product_defaults: &[], + global: &full_access, + project: &[], + agent: &[], + enforced: &[], + }); + + assert_eq!( + ask_rules, + vec![ + rule("*", "*", PermissionEffect::Ask), + rule("read", "*", PermissionEffect::Allow), + rule("read", "*/.env", PermissionEffect::Ask), + rule("read", "*/.env.*", PermissionEffect::Ask), + rule("read", "*/.env.example", PermissionEffect::Allow), + rule("websearch", "*", PermissionEffect::Allow), + rule("webfetch", "*", PermissionEffect::Allow), + rule("task", "*", PermissionEffect::Allow), + rule("skill", "*", PermissionEffect::Allow), + rule("git", "git status *", PermissionEffect::Allow), + rule("git", "git diff *", PermissionEffect::Allow), + rule("git", "git log *", PermissionEffect::Allow), + rule("git", "git show *", PermissionEffect::Allow), + rule("git", "git blame *", PermissionEffect::Allow), + rule("git", "git rev-parse *", PermissionEffect::Allow), + rule("git", "git describe *", PermissionEffect::Allow), + rule("git", "git shortlog *", PermissionEffect::Allow), + rule("git", "git branch", PermissionEffect::Allow), + ] + ); + assert_eq!( + full_access_rules, + vec![rule("*", "*", PermissionEffect::Allow)] + ); + assert_eq!( + evaluator.evaluate_resource("edit", "src/main.rs", &ask_rules), + PermissionEffect::Ask + ); + assert_eq!( + evaluator.evaluate_resource("edit", "src/main.rs", &full_access_rules), + PermissionEffect::Allow + ); +} + +#[test] +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()), + project: &[], + agent: &[], + enforced: &[], + }); + let evaluator = PermissionEvaluator::case_sensitive(); + + for (action, resource) in [ + ("read", "C:/repo/README.md"), + ("read", "C:/repo/.env.example"), + ("websearch", "BitFun permission model"), + ("webfetch", "https://example.com/docs"), + ("task", "general"), + ("task", "send_input:session-1"), + ("skill", "pdf"), + ("git", "git status"), + ("git", "git diff --staged"), + ("git", "git log --oneline -10"), + ("git", "git show HEAD"), + ("git", "git blame src/main.rs"), + ("git", "git rev-parse HEAD"), + ("git", "git describe --tags"), + ("git", "git shortlog -sn"), + ("git", "git branch"), + ] { + assert_eq!( + evaluator.evaluate_resource(action, resource, &rules), + PermissionEffect::Allow, + "{action} {resource}" + ); + } + + for (action, resource) in [ + ("read", "C:/repo/.env"), + ("read", "C:/repo/.env.local"), + ("external_directory", "C:/outside"), + ("edit", "C:/repo/src/main.rs"), + ("bash", "cargo test"), + ("git", "git branch feature/new"), + ("git", "git add src/main.rs"), + ("git", "git commit -m change"), + ("git", "git push origin main"), + ("mcp", "server/tool"), + ("future_action", "resource"), + ] { + assert_eq!( + evaluator.evaluate_resource(action, resource, &rules), + PermissionEffect::Ask, + "{action} {resource}" + ); + } +} + +#[test] +fn resolved_policy_preserves_layer_order_and_enforced_limits() { + let product_defaults = vec![rule("read", "*", PermissionEffect::Allow)]; + let global = policy( + PermissionPolicyPreset::FullAccess, + vec![rule("bash", "rm *", PermissionEffect::Ask)], + ); + let project = vec![rule("edit", "generated/*", PermissionEffect::Deny)]; + let agent = vec![rule("edit", "generated/review.md", PermissionEffect::Allow)]; + let enforced = vec![rule("edit", "generated/*", PermissionEffect::Deny)]; + + let resolved = resolve_permission_policy(PermissionPolicyLayers { + product_defaults: &product_defaults, + global: &global, + project: &project, + agent: &agent, + enforced: &enforced, + }); + + assert_eq!( + resolved, + [ + product_defaults, + PermissionPolicyPreset::FullAccess.baseline_rules(), + global.rules, + project, + agent, + enforced, + ] + .concat() + ); + + let evaluator = PermissionEvaluator::case_sensitive(); + assert_eq!( + evaluator.evaluate_resource("bash", "rm -rf target", &resolved), + PermissionEffect::Ask + ); + assert_eq!( + evaluator.evaluate_resource("edit", "generated/review.md", &resolved), + PermissionEffect::Deny + ); + assert_eq!( + evaluator.evaluate_resource("webfetch", "https://example.com", &resolved), + PermissionEffect::Allow + ); +} + +#[test] +fn runtime_ceiling_accepts_empty_ask_and_deny_rules() { + assert!(PermissionRuntimeCeiling::try_new(Vec::new()) + .expect("empty ceiling should be valid") + .is_empty()); + + let rules = vec![ + rule("read", "secrets/*", PermissionEffect::Ask), + rule("bash", "rm *", PermissionEffect::Deny), + ]; + let ceiling = PermissionRuntimeCeiling::try_new(rules.clone()) + .expect("ask and deny rules should be valid ceiling restrictions"); + assert_eq!(ceiling.rules(), rules); +} + +#[test] +fn runtime_ceiling_rejects_allow_rules_with_typed_context() { + let error = PermissionRuntimeCeiling::try_new(vec![ + rule("read", "secrets/*", PermissionEffect::Ask), + rule("bash", "cargo test", PermissionEffect::Allow), + ]) + .expect_err("allow must not enter a runtime ceiling"); + + assert_eq!(error.rule_index, 1); + assert_eq!(error.action, "bash"); + assert_eq!(error.resource, "cargo test"); +} + +#[test] +fn child_policy_preserves_exact_layer_order_and_security_precedence() { + let product_defaults = vec![rule("read", "*", PermissionEffect::Allow)]; + let global = policy( + PermissionPolicyPreset::Ask, + vec![rule("edit", "generated/*", PermissionEffect::Ask)], + ); + let project = vec![rule("edit", "generated/*", PermissionEffect::Deny)]; + let child_agent = vec![rule("edit", "generated/review.md", PermissionEffect::Allow)]; + let ceiling_rules = vec![rule("edit", "generated/review.md", PermissionEffect::Ask)]; + let ceiling = PermissionRuntimeCeiling::try_new(ceiling_rules.clone()) + .expect("ask ceiling should be valid"); + let enforced = vec![rule("edit", "generated/review.md", PermissionEffect::Deny)]; + + let resolved = resolve_child_permission_policy(ChildPermissionPolicyLayers { + product_defaults: &product_defaults, + global: &global, + project: &project, + child_agent: &child_agent, + parent_runtime_ceiling: &ceiling, + enforced: &enforced, + }); + + assert_eq!( + resolved, + [ + product_defaults, + PermissionPolicyPreset::Ask.baseline_rules(), + global.rules, + project, + child_agent, + ceiling_rules, + enforced, + ] + .concat() + ); + + let evaluator = PermissionEvaluator::case_sensitive(); + assert_eq!( + evaluator.evaluate_resource("edit", "generated/review.md", &resolved), + PermissionEffect::Deny, + "enforced rules must remain later than the parent ceiling" + ); +} + +#[test] +fn parent_ceiling_overrides_child_agent_allow() { + let global = policy(PermissionPolicyPreset::FullAccess, Vec::new()); + let child_agent = vec![rule("read", "secrets/*", PermissionEffect::Allow)]; + let ceiling = + PermissionRuntimeCeiling::try_new(vec![rule("read", "secrets/*", PermissionEffect::Deny)]) + .expect("deny ceiling should be valid"); + + let resolved = resolve_child_permission_policy(ChildPermissionPolicyLayers { + product_defaults: &[], + global: &global, + project: &[], + child_agent: &child_agent, + parent_runtime_ceiling: &ceiling, + enforced: &[], + }); + + assert_eq!( + PermissionEvaluator::case_sensitive().evaluate_resource( + "read", + "secrets/token.txt", + &resolved, + ), + PermissionEffect::Deny + ); +} + +#[test] +fn task_and_skill_default_allow_do_not_authorize_child_tools() { + let global = policy(PermissionPolicyPreset::Ask, Vec::new()); + let ceiling = PermissionRuntimeCeiling::default(); + let resolved = resolve_child_permission_policy(ChildPermissionPolicyLayers { + product_defaults: &[], + global: &global, + project: &[], + child_agent: &[], + parent_runtime_ceiling: &ceiling, + enforced: &[], + }); + let evaluator = PermissionEvaluator::case_sensitive(); + + assert_eq!( + evaluator.evaluate_resource("task", "Explore", &resolved), + PermissionEffect::Allow + ); + assert_eq!( + evaluator.evaluate_resource("skill", "pdf", &resolved), + PermissionEffect::Allow + ); + assert_eq!( + evaluator.evaluate_resource("edit", "src/main.rs", &resolved), + PermissionEffect::Ask + ); +} + +#[test] +fn legacy_skip_confirmation_field_does_not_enable_access_or_auto_approve() { + let config: ToolPermissionConfig = serde_json::from_value(json!({ + "skip_tool_confirmation": true, + })) + .expect("deserialize legacy-shaped permission config"); + + assert_eq!(config, ToolPermissionConfig::default()); +} + +#[test] +fn permission_rule_uses_stable_wire_values() { + let value = serde_json::to_value(rule("read", "src/*", PermissionEffect::Ask)) + .expect("serialize permission rule"); + + assert_eq!( + value, + json!({ + "action": "read", + "resource": "src/*", + "effect": "ask", + }) + ); + assert_eq!( + serde_json::from_value::(value).expect("deserialize permission rule"), + rule("read", "src/*", PermissionEffect::Ask) + ); +} + +#[test] +fn permission_reply_uses_stable_tagged_wire_values() { + assert_eq!( + serde_json::to_value(PermissionReply::Once).expect("serialize once reply"), + json!({ "reply": "once" }) + ); + assert_eq!( + serde_json::to_value(PermissionReply::Always).expect("serialize always reply"), + json!({ "reply": "always" }) + ); + assert_eq!( + serde_json::to_value(PermissionReply::Reject { + feedback: Some("Use a read-only path".to_string()), + }) + .expect("serialize reject reply"), + json!({ + "reply": "reject", + "feedback": "Use a read-only path", + }) + ); +} + +#[test] +fn permission_request_correlation_fields_use_stable_wire_shape() { + let request = PermissionRequest { + request_id: "request-1".to_string(), + round_id: "round-1".to_string(), + order: 2, + tool_call_id: Some("call-1".to_string()), + project_path: Some("/workspace/project".to_string()), + project_id: "project-1".to_string(), + session_id: "session-1".to_string(), + agent_id: "agentic".to_string(), + action: "read".to_string(), + resources: vec!["README.md".to_string()], + save_resources: Vec::new(), + source: PermissionRequestSource { + kind: PermissionRequestSourceKind::ToolCall, + identity: "Read".to_string(), + }, + delegation: Some(PermissionDelegationContext { + parent_session_id: "parent-session-1".to_string(), + parent_dialog_turn_id: Some("parent-turn-1".to_string()), + parent_tool_call_id: "parent-task-call-1".to_string(), + subagent_type: "Explore".to_string(), + }), + display_metadata: Map::new(), + }; + let value = serde_json::to_value(&request).expect("serialize permission request"); + assert_eq!(value["roundId"], "round-1"); + assert_eq!(value["order"], 2); + assert_eq!(value["toolCallId"], "call-1"); + assert_eq!(value["projectPath"], "/workspace/project"); + assert_eq!( + value["delegation"], + json!({ + "parentSessionId": "parent-session-1", + "parentDialogTurnId": "parent-turn-1", + "parentToolCallId": "parent-task-call-1", + "subagentType": "Explore", + }) + ); + + let top_level = PermissionRequest { + delegation: None, + ..request.clone() + }; + let top_level_value = + serde_json::to_value(top_level).expect("serialize top-level permission request"); + assert!(top_level_value.get("delegation").is_none()); + + let partial_delegation = PermissionRequest { + delegation: Some(PermissionDelegationContext { + parent_dialog_turn_id: None, + ..request.delegation.expect("delegation should exist") + }), + ..request + }; + let partial_value = + serde_json::to_value(partial_delegation).expect("serialize partial permission delegation"); + assert_eq!( + partial_value["delegation"]["parentSessionId"], + "parent-session-1" + ); + assert!(partial_value["delegation"] + .get("parentDialogTurnId") + .is_none()); +} + +#[test] +fn permission_request_events_use_camel_case_fields() { + assert_eq!( + serde_json::to_value(PermissionRequestEvent::Replied { + request_id: "request-1".to_string(), + reply: PermissionReply::Once, + source: PermissionReplySource::AutoApprove, + }) + .expect("serialize replied permission event"), + json!({ + "event": "replied", + "requestId": "request-1", + "reply": { "reply": "once" }, + "source": "auto_approve", + }) + ); + assert_eq!( + serde_json::to_value(PermissionRequestEvent::Cancelled { + request_id: "request-2".to_string(), + reason: "session closed".to_string(), + }) + .expect("serialize cancelled permission event"), + json!({ + "event": "cancelled", + "requestId": "request-2", + "reason": "session closed", + }) + ); +} + +#[test] +fn wildcard_matching_supports_star_question_and_normalized_separators() { + let sensitive = PermissionResourceCaseSensitivity::Sensitive; + + assert!(wildcard_matches("src/main.rs", "src/*.rs", sensitive)); + assert!(wildcard_matches("src/main.rs", "src/mai?.rs", sensitive)); + assert!(wildcard_matches( + r"src\nested\main.rs", + "src/*/main.rs", + sensitive + )); + assert!(wildcard_matches("git", "git *", sensitive)); + assert!(wildcard_matches("git status", "git *", sensitive)); + assert!(!wildcard_matches("src/main.ts", "src/*.rs", sensitive)); + assert!(!wildcard_matches( + "src/deep/main.rs", + "src/????.rs", + sensitive + )); +} + +#[test] +fn windows_compatible_matching_is_case_insensitive_for_resources() { + let evaluator = PermissionEvaluator::windows_compatible(); + let rules = vec![rule( + "read", + r"C:\Users\Developer\Project\*", + PermissionEffect::Allow, + )]; + + assert_eq!( + evaluator.evaluate_resource("read", r"c:\users\developer\project\SRC\main.rs", &rules,), + PermissionEffect::Allow + ); + assert_eq!( + PermissionEvaluator::case_sensitive().evaluate_resource( + "read", + r"c:\users\developer\project\SRC\main.rs", + &rules, + ), + PermissionEffect::Ask + ); +} + +#[test] +fn last_matching_action_and_resource_rule_wins() { + let evaluator = PermissionEvaluator::case_sensitive(); + let rules = vec![ + rule("*", "*", PermissionEffect::Ask), + rule("read", "src/*", PermissionEffect::Allow), + rule("read", "src/private/*", PermissionEffect::Deny), + rule("read", "src/private/public.txt", PermissionEffect::Allow), + ]; + + assert_eq!( + evaluator.evaluate_resource("read", "src/lib.rs", &rules), + PermissionEffect::Allow + ); + assert_eq!( + evaluator.evaluate_resource("read", "src/private/key.txt", &rules), + PermissionEffect::Deny + ); + assert_eq!( + evaluator.evaluate_resource("read", "src/private/public.txt", &rules), + PermissionEffect::Allow + ); + assert_eq!( + evaluator.evaluate_resource("edit", "src/lib.rs", &rules), + PermissionEffect::Ask + ); +} + +#[test] +fn merged_layers_preserve_global_project_agent_override_order() { + let global = vec![rule("*", "*", PermissionEffect::Ask)]; + let project = vec![rule("read", "*", PermissionEffect::Allow)]; + let agent = vec![rule("read", "secrets/*", PermissionEffect::Deny)]; + let merged = merge_permission_rule_layers(&[&global, &project, &agent]); + let evaluator = PermissionEvaluator::case_sensitive(); + + assert_eq!(merged, [global, project, agent].concat()); + assert_eq!( + evaluator.evaluate_resource("read", "README.md", &merged), + PermissionEffect::Allow + ); + assert_eq!( + evaluator.evaluate_resource("read", "secrets/token.txt", &merged), + PermissionEffect::Deny + ); +} + +#[test] +fn unmatched_and_empty_resource_requests_default_to_ask() { + let evaluator = PermissionEvaluator::case_sensitive(); + let rules = vec![rule("read", "src/*", PermissionEffect::Allow)]; + + assert_eq!( + evaluator.evaluate_resource("edit", "src/lib.rs", &rules), + PermissionEffect::Ask + ); + assert_eq!( + evaluator.evaluate_resources("read", &[], &rules), + PermissionEffect::Ask + ); +} + +#[test] +fn multi_resource_decision_is_atomic_with_deny_then_ask_precedence() { + let evaluator = PermissionEvaluator::case_sensitive(); + let rules = vec![ + rule("edit", "src/*", PermissionEffect::Allow), + rule("edit", "src/generated/*", PermissionEffect::Ask), + rule("edit", "src/secrets/*", PermissionEffect::Deny), + ]; + + assert_eq!( + evaluator.evaluate_resources("edit", &["src/lib.rs".into(), "src/main.rs".into()], &rules,), + PermissionEffect::Allow + ); + assert_eq!( + evaluator.evaluate_resources( + "edit", + &["src/lib.rs".into(), "src/generated/api.rs".into()], + &rules, + ), + PermissionEffect::Ask + ); + assert_eq!( + evaluator.evaluate_resources( + "edit", + &["src/generated/api.rs".into(), "src/secrets/key.rs".into(),], + &rules, + ), + PermissionEffect::Deny + ); +} diff --git a/src/crates/contracts/runtime-ports/Cargo.toml b/src/crates/contracts/runtime-ports/Cargo.toml index b25e741a84..4d9d67f974 100644 --- a/src/crates/contracts/runtime-ports/Cargo.toml +++ b/src/crates/contracts/runtime-ports/Cargo.toml @@ -12,12 +12,17 @@ crate-type = ["rlib"] [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +bitfun-product-domains = { path = "../product-domains", default-features = false, optional = true } bitfun-core-types = { path = "../core-types" } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } +[features] +default = [] +permission = ["dep:bitfun-product-domains"] + [dev-dependencies] tokio = { workspace = true } diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index a59b546ced..f722a3398f 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -12,12 +12,30 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; mod local_workspace_snapshot; +#[cfg(feature = "permission")] +mod permission; 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, + PermissionDelegationContext, PermissionEffect, PermissionEvaluator, PermissionGrant, + PermissionGrantKey, PermissionInteractionConfig, PermissionPolicyConfig, + PermissionPolicyLayers, PermissionPolicyPreset, PermissionReply, PermissionReplySource, + PermissionRequest, PermissionRequestEvent, PermissionRequestSource, + PermissionRequestSourceKind, PermissionResourceCaseSensitivity, PermissionRule, + PermissionRuleset, PermissionRuntimeCeiling, PermissionRuntimeCeilingValidationError, + ToolPermissionConfig, +}; pub use local_workspace_snapshot::{ LocalWorkspaceSnapshotPort, LocalWorkspaceSnapshotSessionRequest, LocalWorkspaceSnapshotStats, LocalWorkspaceSnapshotTurnRequest, }; +#[cfg(feature = "permission")] +pub use permission::{ + PermissionAuditStorePort, PermissionGrantStorePort, PermissionReplyStorePort, +}; pub use plugin::{ validate_plugin_dispatch_response, validate_plugin_runtime_read_response, DisabledPluginRuntimeClient, ExtensionCapabilityAvailability, PermissionPromptDenyState, @@ -672,30 +690,6 @@ impl std::fmt::Debug for ToolRuntimeHandles { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PermissionRequest { - pub scope: String, - pub action: String, - #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] - pub metadata: serde_json::Map, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PermissionDecision { - Allow, - Deny { reason: String }, -} - -#[async_trait::async_trait] -pub trait PermissionPort: RuntimeServicePort { - async fn request_permission( - &self, - request: PermissionRequest, - ) -> PortResult; -} - pub trait ClockPort: RuntimeServicePort { fn now_unix_millis(&self) -> i64; } @@ -1294,49 +1288,37 @@ pub enum DialogQueuePriority { pub struct DialogSubmissionPolicy { pub trigger_source: DialogTriggerSource, pub queue_priority: DialogQueuePriority, - pub skip_tool_confirmation: bool, } impl DialogSubmissionPolicy { pub const fn new( trigger_source: DialogTriggerSource, queue_priority: DialogQueuePriority, - skip_tool_confirmation: bool, ) -> Self { Self { trigger_source, queue_priority, - skip_tool_confirmation, } } pub const fn for_source(trigger_source: DialogTriggerSource) -> Self { - let (queue_priority, skip_tool_confirmation) = match trigger_source { - DialogTriggerSource::AgentSession => (DialogQueuePriority::Low, true), - DialogTriggerSource::ScheduledJob => (DialogQueuePriority::Low, true), + let queue_priority = match trigger_source { + DialogTriggerSource::AgentSession => DialogQueuePriority::Low, + DialogTriggerSource::ScheduledJob => DialogQueuePriority::Low, DialogTriggerSource::DesktopUi | DialogTriggerSource::DesktopApi - | DialogTriggerSource::Cli => (DialogQueuePriority::Normal, false), + | DialogTriggerSource::Cli => DialogQueuePriority::Normal, DialogTriggerSource::RemoteRelay | DialogTriggerSource::Bot => { - (DialogQueuePriority::Normal, true) + DialogQueuePriority::Normal } }; - Self::new(trigger_source, queue_priority, skip_tool_confirmation) + Self::new(trigger_source, queue_priority) } pub const fn with_queue_priority(mut self, queue_priority: DialogQueuePriority) -> Self { self.queue_priority = queue_priority; self } - - pub const fn with_skip_tool_confirmation(mut self, skip_tool_confirmation: bool) -> Self { - self.skip_tool_confirmation = skip_tool_confirmation; - self - } - - pub const fn requires_tool_confirmation(self) -> bool { - matches!(self.trigger_source, DialogTriggerSource::Cli) && !self.skip_tool_confirmation - } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -2402,38 +2384,15 @@ mod tests { fn dialog_submission_policy_preserves_current_surface_queue_defaults() { let remote = DialogSubmissionPolicy::for_source(DialogTriggerSource::RemoteRelay); assert_eq!(remote.queue_priority, DialogQueuePriority::Normal); - assert!(remote.skip_tool_confirmation); let bot = DialogSubmissionPolicy::for_source(DialogTriggerSource::Bot); assert_eq!(bot.queue_priority, DialogQueuePriority::Normal); - assert!(bot.skip_tool_confirmation); let agent_session = DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession); assert_eq!(agent_session.queue_priority, DialogQueuePriority::Low); - assert!(agent_session.skip_tool_confirmation); let cli = DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli); assert_eq!(cli.queue_priority, DialogQueuePriority::Normal); - assert!(!cli.skip_tool_confirmation); - assert!(cli.requires_tool_confirmation()); - let cli_json = serde_json::to_value(cli).expect("serialize cli policy"); - assert!(cli_json.get("requireToolConfirmation").is_none()); - - let auto = cli.with_skip_tool_confirmation(true); - assert!(auto.skip_tool_confirmation); - assert!(!auto.requires_tool_confirmation()); - } - - #[test] - fn legacy_cli_policy_without_require_field_still_requires_confirmation() { - let policy: DialogSubmissionPolicy = serde_json::from_value(serde_json::json!({ - "triggerSource": "cli", - "queuePriority": "normal", - "skipToolConfirmation": false - })) - .expect("legacy policy"); - - assert!(policy.requires_tool_confirmation()); } #[test] @@ -2853,7 +2812,6 @@ mod tests { policy: DialogSubmissionPolicy::new( AgentSubmissionSource::RemoteRelay, DialogQueuePriority::High, - true, ), reply_route: Some(AgentSessionReplyRoute { source_session_id: "source_session".to_string(), @@ -2885,7 +2843,7 @@ mod tests { assert_eq!(json["remoteSshHost"], "host-1"); assert_eq!(json["policy"]["triggerSource"], "remote_relay"); assert_eq!(json["policy"]["queuePriority"], "high"); - assert_eq!(json["policy"]["skipToolConfirmation"], true); + assert!(json["policy"].get("skipToolConfirmation").is_none()); assert_eq!(json["replyRoute"]["sourceSessionId"], "source_session"); assert_eq!(json["replyRoute"]["sourceRemoteConnectionId"], "conn-1"); assert_eq!(json["replyRoute"]["sourceRemoteSshHost"], "host-1"); diff --git a/src/crates/contracts/runtime-ports/src/permission.rs b/src/crates/contracts/runtime-ports/src/permission.rs new file mode 100644 index 0000000000..f938d50b17 --- /dev/null +++ b/src/crates/contracts/runtime-ports/src/permission.rs @@ -0,0 +1,38 @@ +use crate::{PortResult, RuntimeServicePort}; +use async_trait::async_trait; +use bitfun_product_domains::tool_permissions::{ + PermissionAuditRecord, PermissionGrant, PermissionGrantKey, +}; + +/// Persistent remembered-grant storage. Pending requests do not belong here. +#[async_trait] +pub trait PermissionGrantStorePort: RuntimeServicePort { + async fn list_project_grants(&self, project_id: &str) -> PortResult>; + + async fn add_project_grants(&self, grants: Vec) -> PortResult<()>; + + async fn remove_project_grant(&self, key: PermissionGrantKey) -> PortResult; + + async fn clear_project_grants(&self, project_id: &str) -> PortResult; +} + +/// Append-only permission audit persistence over presentation-safe DTOs. +#[async_trait] +pub trait PermissionAuditStorePort: RuntimeServicePort { + async fn append_permission_audit(&self, record: PermissionAuditRecord) -> PortResult<()>; + + async fn list_project_permission_audit( + &self, + project_id: &str, + ) -> PortResult>; +} + +/// Atomically commits the durable effects of one permission reply. +#[async_trait] +pub trait PermissionReplyStorePort: RuntimeServicePort { + async fn commit_permission_reply( + &self, + grants: Vec, + audit: Vec, + ) -> PortResult<()>; +} diff --git a/src/crates/execution/agent-runtime/Cargo.toml b/src/crates/execution/agent-runtime/Cargo.toml index 7c8c174847..3bb0bd1f97 100644 --- a/src/crates/execution/agent-runtime/Cargo.toml +++ b/src/crates/execution/agent-runtime/Cargo.toml @@ -19,7 +19,7 @@ bitfun-agent-tools = { path = "../tool-contracts" } bitfun-core-types = { path = "../../contracts/core-types" } bitfun-events = { path = "../../contracts/events" } bitfun-harness = { path = "../harness" } -bitfun-runtime-ports = { path = "../../contracts/runtime-ports" } +bitfun-runtime-ports = { path = "../../contracts/runtime-ports", features = ["permission"] } bitfun-runtime-services = { path = "../runtime-services" } dashmap = { workspace = true } log = { workspace = true } diff --git a/src/crates/execution/agent-runtime/src/deep_review/manifest.rs b/src/crates/execution/agent-runtime/src/deep_review/manifest.rs index 0e477b39f5..c6ab37e047 100644 --- a/src/crates/execution/agent-runtime/src/deep_review/manifest.rs +++ b/src/crates/execution/agent-runtime/src/deep_review/manifest.rs @@ -349,10 +349,6 @@ impl DeepReviewEvidencePack { pub(crate) fn content_boundary(&self) -> &str { &self.content_boundary } - - pub(crate) fn requires_tool_confirmation(&self) -> bool { - true - } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1282,7 +1278,6 @@ mod tests { ); assert_eq!(pack.hunk_hint_count(), 1); assert_eq!(pack.contract_hint_count(), 1); - assert!(pack.requires_tool_confirmation()); } #[test] diff --git a/src/crates/execution/agent-runtime/src/lib.rs b/src/crates/execution/agent-runtime/src/lib.rs index a462a8d7b7..ee14058af4 100644 --- a/src/crates/execution/agent-runtime/src/lib.rs +++ b/src/crates/execution/agent-runtime/src/lib.rs @@ -19,6 +19,7 @@ pub mod events; pub mod evidence_ledger; pub mod file_read_state; pub mod output_surface; +pub mod permission; pub mod post_call_hooks; pub mod prompt; pub mod prompt_cache; @@ -37,6 +38,5 @@ pub mod skill_agent_snapshot; pub mod skills; pub mod thread_goal; pub mod thread_goal_tools; -pub mod tool_confirmation; pub mod turn_cancellation; pub mod user_questions; diff --git a/src/crates/execution/agent-runtime/src/permission.rs b/src/crates/execution/agent-runtime/src/permission.rs new file mode 100644 index 0000000000..cb54f803b1 --- /dev/null +++ b/src/crates/execution/agent-runtime/src/permission.rs @@ -0,0 +1,720 @@ +//! Process-local pending permission requests and reply coordination. +//! +//! This owner is intentionally not connected to the legacy tool confirmation +//! pipeline yet. It persists remembered grants and audit facts only when an +//! explicit permission reply is delivered through this standalone contract. + +use bitfun_runtime_ports::{ + ClockPort, PermissionAuditEvent, PermissionAuditRecord, PermissionAuditStorePort, + PermissionGrant, PermissionGrantStorePort, PermissionReply, PermissionReplySource, + PermissionReplyStorePort, PermissionRequest, PermissionRequestEvent, PortError, +}; +use dashmap::DashMap; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use tokio::sync::{broadcast, oneshot, Mutex}; + +const PERMISSION_EVENT_CAPACITY: usize = 128; + +/// Per-submission override for the interactive ask handling policy. +/// +/// Product surfaces may set this in dialog metadata to keep invocation-scoped +/// policies (such as CLI `--auto`) separate from persisted user preferences. +pub const AUTO_APPROVE_ASK_CONTEXT_KEY: &str = "auto_approve_ask"; + +pub type PermissionRequestEventReceiver = broadcast::Receiver; + +#[derive(Debug, Clone, PartialEq)] +pub enum PermissionWaitOutcome { + Replied(PermissionReply), + Cancelled { reason: String }, +} + +#[derive(Debug)] +pub struct PendingPermissionReceiver { + request_id: String, + receiver: oneshot::Receiver, +} + +impl PendingPermissionReceiver { + pub fn request_id(&self) -> &str { + &self.request_id + } + + pub async fn wait(self) -> PermissionWaitOutcome { + self.receiver + .await + .unwrap_or_else(|_| PermissionWaitOutcome::Cancelled { + reason: "Permission request channel closed".to_string(), + }) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PermissionReplyResolution { + pub request: PermissionRequest, + pub reply: PermissionReply, + pub saved_grants: Vec, + pub resolved_request_ids: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum PermissionRequestManagerError { + #[error("Duplicate pending permission request: {0}")] + DuplicateRequest(String), + #[error("Pending permission request not found: {0}")] + RequestNotFound(String), + #[error("Failed to persist permission reply: {0}")] + ReplyStore(#[source] PortError), + #[error("Failed to load remembered permission grants: {0}")] + GrantStore(#[source] PortError), + #[error("Failed to persist permission audit: {0}")] + AuditStore(#[source] PortError), +} + +#[derive(Debug)] +struct PendingPermission { + request: PermissionRequest, + sender: oneshot::Sender, + interactive: bool, + registration_sequence: u64, +} + +#[derive(Clone)] +pub struct PermissionRequestManager { + pending: Arc>, + next_registration_sequence: Arc, + operations: Arc>, + audit_store: Arc, + reply_store: Arc, + grant_store: Option>, + clock: Arc, + events: broadcast::Sender, +} + +impl std::fmt::Debug for PermissionRequestManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PermissionRequestManager") + .field("pending_count", &self.pending.len()) + .finish_non_exhaustive() + } +} + +impl PermissionRequestManager { + pub fn new( + audit_store: Arc, + reply_store: Arc, + clock: Arc, + ) -> Self { + let (events, _) = broadcast::channel(PERMISSION_EVENT_CAPACITY); + Self { + pending: Arc::new(DashMap::new()), + next_registration_sequence: Arc::new(AtomicU64::new(0)), + operations: Arc::new(Mutex::new(())), + audit_store, + reply_store, + grant_store: None, + clock, + events, + } + } + + pub fn with_grant_store(mut self, grant_store: Arc) -> Self { + self.grant_store = Some(grant_store); + self + } + + pub async fn list_project_grants( + &self, + project_id: &str, + ) -> Result, PermissionRequestManagerError> { + let Some(grant_store) = &self.grant_store else { + return Ok(Vec::new()); + }; + grant_store + .list_project_grants(project_id) + .await + .map_err(PermissionRequestManagerError::GrantStore) + } + + pub async fn remove_project_grant( + &self, + key: bitfun_runtime_ports::PermissionGrantKey, + ) -> Result { + let Some(grant_store) = &self.grant_store else { + return Ok(false); + }; + grant_store + .remove_project_grant(key) + .await + .map_err(PermissionRequestManagerError::GrantStore) + } + + pub async fn clear_project_grants( + &self, + project_id: &str, + ) -> Result { + let Some(grant_store) = &self.grant_store else { + return Ok(0); + }; + grant_store + .clear_project_grants(project_id) + .await + .map_err(PermissionRequestManagerError::GrantStore) + } + + pub async fn list_project_permission_audit( + &self, + project_id: &str, + ) -> Result, PermissionRequestManagerError> + { + self.audit_store + .list_project_permission_audit(project_id) + .await + .map_err(PermissionRequestManagerError::AuditStore) + } + + pub fn subscribe(&self) -> PermissionRequestEventReceiver { + self.events.subscribe() + } + + pub async fn register( + &self, + request: PermissionRequest, + ) -> Result { + let mut pending = self.register_batch(vec![request]).await?; + Ok(pending + .pop() + .expect("a single-request batch must return one receiver")) + } + + /// Registers a request for internal coordination and audit without exposing + /// it to interactive subscribers or pending-request snapshots. + pub async fn register_non_interactive( + &self, + request: PermissionRequest, + ) -> Result { + let mut pending = self.register_batch_non_interactive(vec![request]).await?; + Ok(pending + .pop() + .expect("a single-request batch must return one receiver")) + } + + pub async fn register_batch( + &self, + requests: Vec, + ) -> Result, PermissionRequestManagerError> { + self.register_batch_with_visibility(requests, true).await + } + + pub async fn register_batch_non_interactive( + &self, + requests: Vec, + ) -> Result, PermissionRequestManagerError> { + self.register_batch_with_visibility(requests, false).await + } + + async fn register_batch_with_visibility( + &self, + requests: Vec, + interactive: bool, + ) -> Result, PermissionRequestManagerError> { + if requests.is_empty() { + return Ok(Vec::new()); + } + + let _operation = self.operations.lock().await; + let mut request_ids = HashSet::with_capacity(requests.len()); + for request in &requests { + if !request_ids.insert(request.request_id.clone()) { + return Err(PermissionRequestManagerError::DuplicateRequest( + request.request_id.clone(), + )); + } + if self.pending.contains_key(&request.request_id) { + return Err(PermissionRequestManagerError::DuplicateRequest( + request.request_id.clone(), + )); + } + } + + let timestamp_ms = self.clock.now_unix_millis(); + let mut receivers = Vec::with_capacity(requests.len()); + for request in &requests { + let (sender, receiver) = oneshot::channel(); + let registration_sequence = self + .next_registration_sequence + .fetch_add(1, Ordering::Relaxed); + self.pending.insert( + request.request_id.clone(), + PendingPermission { + request: request.clone(), + sender, + interactive, + registration_sequence, + }, + ); + receivers.push(PendingPermissionReceiver { + request_id: request.request_id.clone(), + receiver, + }); + } + + for request in &requests { + if let Err(error) = self + .audit_store + .append_permission_audit(PermissionAuditRecord { + audit_id: audit_id(&request.request_id, "requested"), + request: request.clone(), + event: PermissionAuditEvent::Requested, + timestamp_ms, + }) + .await + { + for request in &requests { + self.pending.remove(&request.request_id); + } + return Err(PermissionRequestManagerError::AuditStore(error)); + } + } + + if interactive { + for request in requests { + let _ = self.events.send(PermissionRequestEvent::Asked { request }); + } + } + + Ok(receivers) + } + + pub fn pending_requests(&self) -> Vec { + self.ordered_pending_requests(|_| true) + } + + pub fn interactive_pending_requests(&self) -> Vec { + self.ordered_pending_requests(|pending| pending.interactive) + } + + fn ordered_pending_requests( + &self, + include: impl Fn(&PendingPermission) -> bool, + ) -> Vec { + let mut first_registration_by_round = HashMap::<(String, String), u64>::new(); + for entry in self.pending.iter().filter(|entry| include(entry.value())) { + let batch_id = ( + entry.request.session_id.clone(), + entry.request.round_id.clone(), + ); + first_registration_by_round + .entry(batch_id) + .and_modify(|first| *first = (*first).min(entry.registration_sequence)) + .or_insert(entry.registration_sequence); + } + + let mut requests = self + .pending + .iter() + .filter_map(|entry| { + include(entry.value()).then(|| { + ( + first_registration_by_round + .get(&( + entry.request.session_id.clone(), + entry.request.round_id.clone(), + )) + .copied() + .unwrap_or(entry.registration_sequence), + entry.request.order, + entry.registration_sequence, + entry.request.request_id.clone(), + entry.request.clone(), + ) + }) + }) + .collect::>(); + requests.sort_by(|left, right| { + (left.0, left.1, left.2, &left.3).cmp(&(right.0, right.1, right.2, &right.3)) + }); + requests + .into_iter() + .map(|(_, _, _, _, request)| request) + .collect() + } + + pub async fn reply( + &self, + request_id: &str, + reply: PermissionReply, + source: PermissionReplySource, + ) -> Result { + self.reply_from(request_id, false, reply, source).await + } + + pub async fn reply_from( + &self, + request_id: &str, + include_following: bool, + reply: PermissionReply, + source: PermissionReplySource, + ) -> Result { + let _operation = self.operations.lock().await; + let request = self + .pending + .get(request_id) + .map(|entry| entry.request.clone()) + .ok_or_else(|| { + PermissionRequestManagerError::RequestNotFound(request_id.to_string()) + })?; + let timestamp_ms = self.clock.now_unix_millis(); + let resolution_requests = if include_following { + self.ordered_pending_requests(|pending| { + pending.request.session_id == request.session_id + && pending.request.round_id == request.round_id + && pending.request.order >= request.order + }) + } else { + vec![request.clone()] + }; + let resolutions = resolution_requests + .into_iter() + .map(|request| (request, reply.clone())) + .collect::>(); + let grants = resolutions + .iter() + .flat_map(|(request, reply)| grants_for_reply(request, reply, timestamp_ms)) + .collect::>(); + + let audit = resolutions + .iter() + .map(|(pending_request, pending_reply)| PermissionAuditRecord { + audit_id: audit_id(&pending_request.request_id, "replied"), + request: pending_request.clone(), + event: PermissionAuditEvent::Replied { + reply: pending_reply.clone(), + source, + }, + timestamp_ms, + }) + .collect::>(); + self.reply_store + .commit_permission_reply(grants.clone(), audit) + .await + .map_err(PermissionRequestManagerError::ReplyStore)?; + + let resolved_request_ids = resolutions + .iter() + .map(|(pending_request, _)| pending_request.request_id.clone()) + .collect::>(); + for (pending_request, pending_reply) in resolutions { + if let Some((_, pending)) = self.pending.remove(&pending_request.request_id) { + let _ = pending + .sender + .send(PermissionWaitOutcome::Replied(pending_reply.clone())); + if pending.interactive { + let _ = self.events.send(PermissionRequestEvent::Replied { + request_id: pending_request.request_id, + reply: pending_reply, + source, + }); + } + } + } + + Ok(PermissionReplyResolution { + request, + reply, + saved_grants: grants, + resolved_request_ids, + }) + } + + pub async fn cancel_request( + &self, + request_id: &str, + reason: impl Into, + ) -> Result { + let _operation = self.operations.lock().await; + let Some(request) = self + .pending + .get(request_id) + .map(|entry| entry.request.clone()) + else { + return Ok(false); + }; + self.cancel_requests(vec![request], reason.into()).await?; + Ok(true) + } + + pub async fn cancel_session( + &self, + session_id: &str, + reason: impl Into, + ) -> Result, PermissionRequestManagerError> { + let _operation = self.operations.lock().await; + let requests = + self.ordered_pending_requests(|pending| pending.request.session_id == session_id); + let request_ids = requests + .iter() + .map(|request| request.request_id.clone()) + .collect(); + self.cancel_requests(requests, reason.into()).await?; + Ok(request_ids) + } + + async fn cancel_requests( + &self, + requests: Vec, + reason: String, + ) -> Result<(), PermissionRequestManagerError> { + let timestamp_ms = self.clock.now_unix_millis(); + for request in &requests { + self.audit_store + .append_permission_audit(PermissionAuditRecord { + audit_id: audit_id(&request.request_id, "cancelled"), + request: request.clone(), + event: PermissionAuditEvent::Cancelled { + reason: reason.clone(), + }, + timestamp_ms, + }) + .await + .map_err(PermissionRequestManagerError::AuditStore)?; + } + + for request in requests { + if let Some((_, pending)) = self.pending.remove(&request.request_id) { + let _ = pending.sender.send(PermissionWaitOutcome::Cancelled { + reason: reason.clone(), + }); + if pending.interactive { + let _ = self.events.send(PermissionRequestEvent::Cancelled { + request_id: request.request_id, + reason: reason.clone(), + }); + } + } + } + Ok(()) + } +} + +fn grants_for_reply( + request: &PermissionRequest, + reply: &PermissionReply, + created_at_ms: i64, +) -> Vec { + if !matches!(reply, PermissionReply::Always) { + return Vec::new(); + } + + let mut unique = HashSet::new(); + request + .save_resources + .iter() + .filter(|resource| unique.insert((*resource).clone())) + .map(|resource| PermissionGrant { + project_id: request.project_id.clone(), + action: request.action.clone(), + resource: resource.clone(), + created_at_ms, + }) + .collect() +} + +fn audit_id(request_id: &str, event: &str) -> String { + format!("{request_id}:{event}") +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_runtime_ports::{ + PermissionAuditStorePort, PermissionReplyStorePort, PortResult, RuntimeServiceCapability, + RuntimeServicePort, + }; + use serde_json::Map; + use std::sync::Mutex as StdMutex; + + #[derive(Debug, Default)] + struct MemoryPermissionStore { + audit: StdMutex>, + } + + impl RuntimeServicePort for MemoryPermissionStore { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::Permission + } + } + + #[async_trait::async_trait] + impl PermissionAuditStorePort for MemoryPermissionStore { + async fn append_permission_audit(&self, record: PermissionAuditRecord) -> PortResult<()> { + self.audit.lock().unwrap().push(record); + Ok(()) + } + + async fn list_project_permission_audit( + &self, + project_id: &str, + ) -> PortResult> { + Ok(self + .audit + .lock() + .unwrap() + .iter() + .filter(|record| record.request.project_id == project_id) + .cloned() + .collect()) + } + } + + #[async_trait::async_trait] + impl PermissionReplyStorePort for MemoryPermissionStore { + async fn commit_permission_reply( + &self, + _grants: Vec, + audit: Vec, + ) -> PortResult<()> { + self.audit.lock().unwrap().extend(audit); + Ok(()) + } + } + + #[derive(Debug)] + struct FixedClock; + + impl RuntimeServicePort for FixedClock { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::Clock + } + } + + impl ClockPort for FixedClock { + fn now_unix_millis(&self) -> i64 { + 42 + } + } + + fn request() -> PermissionRequest { + PermissionRequest { + request_id: "request-1".to_string(), + round_id: "round-1".to_string(), + order: 0, + tool_call_id: None, + project_path: None, + project_id: "project-1".to_string(), + session_id: "session-1".to_string(), + agent_id: "agentic".to_string(), + action: "edit".to_string(), + resources: vec!["src/main.rs".to_string()], + save_resources: vec!["src/main.rs".to_string()], + source: bitfun_runtime_ports::PermissionRequestSource { + kind: bitfun_runtime_ports::PermissionRequestSourceKind::ToolCall, + identity: "write_file".to_string(), + }, + delegation: None, + display_metadata: Map::new(), + } + } + + #[tokio::test] + async fn request_events_project_asked_and_replied_lifecycle() { + let store = Arc::new(MemoryPermissionStore::default()); + let manager = PermissionRequestManager::new(store.clone(), store, Arc::new(FixedClock)); + let mut events = manager.subscribe(); + + let pending = manager.register(request()).await.expect("register request"); + assert_eq!( + events.recv().await.expect("asked event"), + PermissionRequestEvent::Asked { request: request() } + ); + assert_eq!(manager.pending_requests(), vec![request()]); + + manager + .reply( + "request-1", + PermissionReply::Once, + PermissionReplySource::User, + ) + .await + .expect("reply request"); + assert_eq!( + events.recv().await.expect("replied event"), + PermissionRequestEvent::Replied { + request_id: "request-1".to_string(), + reply: PermissionReply::Once, + source: PermissionReplySource::User, + } + ); + assert_eq!( + pending.wait().await, + PermissionWaitOutcome::Replied(PermissionReply::Once) + ); + assert!(manager.pending_requests().is_empty()); + + let cancelled = manager + .register(PermissionRequest { + request_id: "request-2".to_string(), + ..request() + }) + .await + .expect("register second request"); + let _ = events.recv().await.expect("second asked event"); + manager + .cancel_request("request-2", "session closed") + .await + .expect("cancel request"); + assert!(matches!( + events.recv().await.expect("cancelled event"), + PermissionRequestEvent::Cancelled { request_id, reason } + if request_id == "request-2" && reason == "session closed" + )); + assert_eq!( + cancelled.wait().await, + PermissionWaitOutcome::Cancelled { + reason: "session closed".to_string() + } + ); + } + + #[tokio::test] + async fn non_interactive_request_is_audited_without_entering_interactive_surfaces() { + let store = Arc::new(MemoryPermissionStore::default()); + let manager = + PermissionRequestManager::new(store.clone(), store.clone(), Arc::new(FixedClock)); + let mut events = manager.subscribe(); + + let pending = manager + .register_non_interactive(request()) + .await + .expect("register non-interactive request"); + + assert_eq!(manager.pending_requests(), vec![request()]); + assert!(manager.interactive_pending_requests().is_empty()); + assert!(matches!( + events.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + )); + + manager + .reply( + "request-1", + PermissionReply::Once, + PermissionReplySource::AutoApprove, + ) + .await + .expect("auto-approve request"); + + assert_eq!( + pending.wait().await, + PermissionWaitOutcome::Replied(PermissionReply::Once) + ); + assert!(manager.pending_requests().is_empty()); + assert!(matches!( + events.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + )); + assert_eq!(store.audit.lock().unwrap().len(), 2); + } +} diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index c94a055ace..c88e52212a 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -24,14 +24,17 @@ use bitfun_runtime_ports::{ AgentThreadGoalCreateRequest, AgentThreadGoalDeliveryRequest, AgentThreadGoalGetRequest, AgentThreadGoalManagementPort, AgentThreadGoalUpdateStatusRequest, AgentTurnCancellationPort, AgentTurnCancellationRequest, AgentTurnCancellationResult, AgentTurnSettlementPort, - AgentTurnSettlementRequest, DialogSubmitOutcome, PluginRuntimeBinding, PortError, - PortErrorKind, PortResult, RuntimeEventEnvelope, SessionTranscript, SessionTranscriptReader, - SessionTranscriptRequest, ThreadGoal, + AgentTurnSettlementRequest, DialogSubmitOutcome, PermissionAuditRecord, PermissionGrant, + PermissionGrantKey, PluginRuntimeBinding, PortError, PortErrorKind, PortResult, + RuntimeEventEnvelope, SessionTranscript, SessionTranscriptReader, SessionTranscriptRequest, + ThreadGoal, }; use bitfun_runtime_services::RuntimeServices; use crate::event_source::{AgentEventReceiver, AgentEventSource, AgentSessionEventReceiver}; +use crate::permission::{PermissionRequestEventReceiver, PermissionRequestManager}; use crate::post_call_hooks::RuntimeHookRegistry; +use bitfun_runtime_ports::{PermissionReply, PermissionReplySource, PermissionRequest}; #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum RuntimeBuildError { @@ -39,6 +42,8 @@ pub enum RuntimeBuildError { MissingSubmissionPort, #[error("plugin runtime client binding must report executable host availability")] UnsupportedPluginRuntimeHostBinding, + #[error("permission request manager is unavailable: {0}")] + PermissionRequestManagerUnavailable(String), } #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] @@ -65,6 +70,10 @@ pub enum RuntimeError { MissingEventSink, #[error("agent event source is not registered")] MissingEventSource, + #[error("permission request manager is not registered")] + MissingPermissionRequestManager, + #[error("permission request failed: {0}")] + PermissionRequest(String), #[error(transparent)] Port(#[from] PortError), } @@ -107,23 +116,6 @@ pub trait AgentSessionRestorePort: Send + Sync { ) -> PortResult; } -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -/// Confirms a pending tool call, optionally replacing its input before execution. -pub struct AgentToolConfirmationRequest { - pub tool_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub updated_input: Option, -} - -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -/// Rejects a pending tool call with the user's reason. -pub struct AgentToolRejectionRequest { - pub tool_id: String, - pub reason: String, -} - #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] /// Delivers answers to a pending user-question tool call. @@ -133,12 +125,10 @@ pub struct AgentUserAnswersRequest { } #[async_trait::async_trait] -/// Routes product responses to the existing tool and user-input owners. +/// Routes product responses to the existing user-input owner. /// /// Implementations do not own approval policy or interaction lifecycle state. pub trait AgentInteractionResponsePort: Send + Sync { - async fn confirm_tool(&self, request: AgentToolConfirmationRequest) -> PortResult<()>; - async fn reject_tool(&self, request: AgentToolRejectionRequest) -> PortResult<()>; async fn submit_user_answers(&self, request: AgentUserAnswersRequest) -> PortResult<()>; } @@ -207,6 +197,7 @@ pub struct AgentRuntime { lifecycle_delivery: Option>, cancellation: Option>, interaction_response: Option>, + permission_requests: Option>, services: Option, event_stream: Option, event_source: Option, @@ -382,6 +373,7 @@ pub struct AgentRuntimeBuilder { lifecycle_delivery: Option>, cancellation: Option>, interaction_response: Option>, + permission_requests: Option>, services: Option, event_stream: Option, event_source: Option, @@ -490,6 +482,14 @@ impl AgentRuntimeBuilder { self } + pub fn with_permission_request_manager( + mut self, + manager: Arc, + ) -> Self { + self.permission_requests = Some(manager); + self + } + pub fn with_services(mut self, services: RuntimeServices) -> Self { self.services = Some(services); self @@ -547,6 +547,7 @@ impl AgentRuntimeBuilder { lifecycle_delivery, cancellation, interaction_response, + permission_requests, services, event_stream, event_source, @@ -577,6 +578,7 @@ impl AgentRuntimeBuilder { lifecycle_delivery, cancellation, interaction_response, + permission_requests, services, event_stream, event_source, @@ -703,39 +705,109 @@ impl AgentRuntime { .ok_or(RuntimeError::MissingEventSource) } - pub fn services(&self) -> Option<&RuntimeServices> { - self.services.as_ref() + pub fn pending_permission_requests(&self) -> Result, RuntimeError> { + self.permission_requests + .as_ref() + .map(|manager| manager.interactive_pending_requests()) + .ok_or(RuntimeError::MissingPermissionRequestManager) } - pub fn registered_tool_names(&self) -> Vec { - self.tool_registry + pub fn subscribe_permission_requests( + &self, + ) -> Result { + self.permission_requests .as_ref() - .map(|registry| registry.tool_names()) - .unwrap_or_default() + .map(|manager| manager.subscribe()) + .ok_or(RuntimeError::MissingPermissionRequestManager) } - pub async fn confirm_tool( + pub async fn respond_permission( &self, - request: AgentToolConfirmationRequest, + request_id: &str, + reply: PermissionReply, + source: PermissionReplySource, ) -> Result<(), RuntimeError> { - self.interaction_response + self.permission_requests .as_ref() - .ok_or(RuntimeError::MissingInteractionResponsePort)? - .confirm_tool(request) - .await?; - Ok(()) + .ok_or(RuntimeError::MissingPermissionRequestManager)? + .reply(request_id, reply, source) + .await + .map(|_| ()) + .map_err(|error| RuntimeError::PermissionRequest(error.to_string())) } - pub async fn reject_tool( + pub async fn respond_permission_batch( &self, - request: AgentToolRejectionRequest, - ) -> Result<(), RuntimeError> { - self.interaction_response + request_id: &str, + reply: PermissionReply, + source: PermissionReplySource, + ) -> Result, RuntimeError> { + self.permission_requests .as_ref() - .ok_or(RuntimeError::MissingInteractionResponsePort)? - .reject_tool(request) - .await?; - Ok(()) + .ok_or(RuntimeError::MissingPermissionRequestManager)? + .reply_from(request_id, true, reply, source) + .await + .map(|resolution| resolution.resolved_request_ids) + .map_err(|error| RuntimeError::PermissionRequest(error.to_string())) + } + + pub async fn list_project_permission_grants( + &self, + project_id: &str, + ) -> Result, RuntimeError> { + self.permission_requests + .as_ref() + .ok_or(RuntimeError::MissingPermissionRequestManager)? + .list_project_grants(project_id) + .await + .map_err(|error| RuntimeError::PermissionRequest(error.to_string())) + } + + pub async fn remove_project_permission_grant( + &self, + key: PermissionGrantKey, + ) -> Result { + self.permission_requests + .as_ref() + .ok_or(RuntimeError::MissingPermissionRequestManager)? + .remove_project_grant(key) + .await + .map_err(|error| RuntimeError::PermissionRequest(error.to_string())) + } + + pub async fn clear_project_permission_grants( + &self, + project_id: &str, + ) -> Result { + self.permission_requests + .as_ref() + .ok_or(RuntimeError::MissingPermissionRequestManager)? + .clear_project_grants(project_id) + .await + .map_err(|error| RuntimeError::PermissionRequest(error.to_string())) + } + + pub async fn list_project_permission_audit( + &self, + project_id: &str, + ) -> Result, RuntimeError> { + self.permission_requests + .as_ref() + .ok_or(RuntimeError::MissingPermissionRequestManager)? + .list_project_permission_audit(project_id) + .await + .map_err(|error| RuntimeError::PermissionRequest(error.to_string())) + } + + pub fn services(&self) -> Option<&RuntimeServices> { + self.services.as_ref() + } + + pub fn registered_tool_names(&self) -> Vec { + self.tool_registry + .as_ref() + .map(|registry| registry.tool_names()) + .unwrap_or_default() } pub async fn submit_user_answers( @@ -1217,12 +1289,11 @@ mod tests { AgentSessionSummary, AgentSessionWorkspaceRequest, AgentSubmissionResult, AgentThreadGoalDeliveryKind, AgentThreadGoalDeliveryRequest, AgentThreadGoalManagementPort, AgentTurnCancellationResult, ClockPort, DialogQueuePriority, DialogSubmissionPolicy, - DialogSubmitOutcome, FileSystemPort, PermissionPort, PluginDispatchEnvelope, - PluginResponseEnvelope, PluginRuntimeAvailability, PluginRuntimeClient, - PluginRuntimeUnavailableReason, PortErrorKind, PortResult, RuntimeEventSink, - RuntimeEventType, RuntimeServiceCapability, SessionStorePort, SessionTranscript, - SessionTranscriptReader, SessionTranscriptRequest, ThreadGoal, ThreadGoalStatus, - TranscriptContent, TranscriptMessage, WorkspacePort, + DialogSubmitOutcome, FileSystemPort, PluginDispatchEnvelope, PluginResponseEnvelope, + PluginRuntimeAvailability, PluginRuntimeClient, PluginRuntimeUnavailableReason, + PortErrorKind, PortResult, RuntimeEventSink, RuntimeEventType, RuntimeServiceCapability, + SessionStorePort, SessionTranscript, SessionTranscriptReader, SessionTranscriptRequest, + ThreadGoal, ThreadGoalStatus, TranscriptContent, TranscriptMessage, WorkspacePort, }; use bitfun_runtime_services::{test_support::FakeRuntimePort, RuntimeServicesBuilder}; @@ -1577,8 +1648,6 @@ mod tests { Arc::new(FakeRuntimePort::new(RuntimeServiceCapability::Workspace)); let session_store: Arc = Arc::new(FakeRuntimePort::new(RuntimeServiceCapability::SessionStore)); - let permission: Arc = - Arc::new(FakeRuntimePort::new(RuntimeServiceCapability::Permission)); let clock: Arc = Arc::new(FakeRuntimePort::new(RuntimeServiceCapability::Clock)); @@ -1586,7 +1655,6 @@ mod tests { .with_filesystem(filesystem) .with_workspace(workspace) .with_session_store(session_store) - .with_permission(permission) .with_events(events) .with_clock(clock) .build() @@ -2287,7 +2355,6 @@ mod tests { policy: DialogSubmissionPolicy::new( AgentSubmissionSource::RemoteRelay, DialogQueuePriority::Normal, - true, ), reply_route: None, prepended_reminders: Vec::new(), @@ -2342,7 +2409,6 @@ mod tests { policy: DialogSubmissionPolicy::new( AgentSubmissionSource::RemoteRelay, DialogQueuePriority::High, - true, ), reply_route: None, prepended_reminders: Vec::new(), diff --git a/src/crates/execution/agent-runtime/src/scheduler.rs b/src/crates/execution/agent-runtime/src/scheduler.rs index aef69b3d54..3cb6f9e1a3 100644 --- a/src/crates/execution/agent-runtime/src/scheduler.rs +++ b/src/crates/execution/agent-runtime/src/scheduler.rs @@ -390,10 +390,7 @@ pub struct BackgroundDeliveryFacts { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BackgroundDeliveryAction { InjectIntoRunningTurn, - SubmitAgentSessionFollowUp { - queue_priority: DialogQueuePriority, - skip_tool_confirmation: bool, - }, + SubmitAgentSessionFollowUp { queue_priority: DialogQueuePriority }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -429,14 +426,9 @@ impl BackgroundDeliveryAction { pub const fn follow_up_submission_policy(self) -> Option { match self { Self::InjectIntoRunningTurn => None, - Self::SubmitAgentSessionFollowUp { - queue_priority, - skip_tool_confirmation, - } => Some(DialogSubmissionPolicy::new( - DialogTriggerSource::AgentSession, - queue_priority, - skip_tool_confirmation, - )), + Self::SubmitAgentSessionFollowUp { queue_priority } => Some( + DialogSubmissionPolicy::new(DialogTriggerSource::AgentSession, queue_priority), + ), } } } @@ -682,7 +674,6 @@ pub const fn resolve_background_delivery_action( let policy = DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession); BackgroundDeliveryAction::SubmitAgentSessionFollowUp { queue_priority: policy.queue_priority, - skip_tool_confirmation: policy.skip_tool_confirmation, } } } diff --git a/src/crates/execution/agent-runtime/src/sdk.rs b/src/crates/execution/agent-runtime/src/sdk.rs index 22f54071eb..426b93db2c 100644 --- a/src/crates/execution/agent-runtime/src/sdk.rs +++ b/src/crates/execution/agent-runtime/src/sdk.rs @@ -36,6 +36,10 @@ impl AgentRuntimeSdkCompatibility { pub use crate::context_profile::{ContextProfile, ContextProfilePolicy, ModelCapabilityProfile}; pub use crate::event_source::{AgentEventReceiver, AgentEventSource, AgentSessionEventReceiver}; +pub use crate::permission::{ + PermissionReplyResolution, PermissionRequestEventReceiver, PermissionRequestManager, + PermissionRequestManagerError, AUTO_APPROVE_ASK_CONTEXT_KEY, +}; pub use crate::post_call_hooks::{ RuntimeHookErrorPolicy, RuntimeHookKind, RuntimeHookPlan, RuntimeHookRegistry, RuntimeHookRegistryBuildError, @@ -43,9 +47,8 @@ pub use crate::post_call_hooks::{ pub use crate::runtime::{ AgentEventStream, AgentInteractionResponsePort, AgentRunHandle, AgentRunRequest, AgentSessionRestorePort, AgentSessionRestoreRequest, AgentSessionRestoreResult, - AgentToolConfirmationRequest, AgentToolRejectionRequest, AgentUserAnswersRequest, - RuntimeAgentRegistry, RuntimeAgentRegistryQuery, RuntimeBuildError, RuntimeError, - RuntimeToolRegistry, SessionSelector, + AgentUserAnswersRequest, RuntimeAgentRegistry, RuntimeAgentRegistryQuery, RuntimeBuildError, + RuntimeError, RuntimeToolRegistry, SessionSelector, }; pub use crate::session_state::{session_state_label_for_state, ProcessingPhase, SessionState}; pub use bitfun_agent_tools::{ToolRegistry, ToolRegistryItem}; @@ -70,10 +73,13 @@ pub use bitfun_runtime_ports::{ AgentThreadGoalManagementPort, AgentThreadGoalUpdateStatusRequest, AgentTurnCancellationPort, AgentTurnCancellationRequest, AgentTurnCancellationResult, AgentTurnSettlementPort, AgentTurnSettlementRequest, ClockPort, DialogSubmissionPolicy, DialogSubmitOutcome, - FileSystemPort, GitPort, McpCatalogPort, NetworkPort, PermissionDecision, PermissionPort, - PermissionRequest, PortError, PortErrorKind, PortResult, RemoteAssistantWorkspaceFacts, - RemoteCapabilityPort, RemoteConnectionPort, RemoteProjectionPort, RemoteRecentWorkspaceFacts, - RemoteWorkspaceFacts, RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind, RemoteWorkspacePort, + FileSystemPort, GitPort, McpCatalogPort, NetworkPort, PermissionAuditRecord, + PermissionDelegationContext, PermissionGrant, PermissionGrantKey, PermissionReply, + PermissionReplySource, PermissionRequest, PermissionRequestEvent, PermissionRequestSource, + PermissionRequestSourceKind, PortError, PortErrorKind, PortResult, + RemoteAssistantWorkspaceFacts, RemoteCapabilityPort, RemoteConnectionPort, + RemoteProjectionPort, RemoteRecentWorkspaceFacts, RemoteWorkspaceFacts, + RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind, RemoteWorkspacePort, RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, RuntimeEventEnvelope, RuntimeEventSink, RuntimeEventType, RuntimeServiceCapability, RuntimeServicePort, SessionStorageKind, SessionStoragePathRequest, SessionStoragePathResolution, SessionStorePort, SessionTranscript, @@ -201,6 +207,14 @@ impl AgentRuntimeBuilder { self } + pub fn with_permission_request_manager( + mut self, + manager: Arc, + ) -> Self { + self.inner = self.inner.with_permission_request_manager(manager); + self + } + pub fn with_services(mut self, services: RuntimeServices) -> Self { self.inner = self.inner.with_services(services); self @@ -253,6 +267,75 @@ impl AgentRuntime { self.inner.subscribe_session_events(session_id) } + pub fn pending_permission_requests(&self) -> Result, RuntimeError> { + self.inner.pending_permission_requests() + } + + pub fn subscribe_permission_requests( + &self, + ) -> Result { + self.inner.subscribe_permission_requests() + } + + pub async fn respond_permission( + &self, + request_id: &str, + reply: PermissionReply, + ) -> Result<(), RuntimeError> { + self.inner + .respond_permission(request_id, reply, PermissionReplySource::User) + .await + } + + pub async fn respond_permission_with_source( + &self, + request_id: &str, + reply: PermissionReply, + source: PermissionReplySource, + ) -> Result<(), RuntimeError> { + self.inner + .respond_permission(request_id, reply, source) + .await + } + + pub async fn respond_permission_batch( + &self, + request_id: &str, + reply: PermissionReply, + ) -> Result, RuntimeError> { + self.inner + .respond_permission_batch(request_id, reply, PermissionReplySource::User) + .await + } + + pub async fn list_project_permission_grants( + &self, + project_id: &str, + ) -> Result, RuntimeError> { + self.inner.list_project_permission_grants(project_id).await + } + + pub async fn remove_project_permission_grant( + &self, + key: PermissionGrantKey, + ) -> Result { + self.inner.remove_project_permission_grant(key).await + } + + pub async fn clear_project_permission_grants( + &self, + project_id: &str, + ) -> Result { + self.inner.clear_project_permission_grants(project_id).await + } + + pub async fn list_project_permission_audit( + &self, + project_id: &str, + ) -> Result, RuntimeError> { + self.inner.list_project_permission_audit(project_id).await + } + pub fn services(&self) -> Option<&RuntimeServices> { self.inner.services() } @@ -458,20 +541,6 @@ impl AgentRuntime { self.inner.cancel_turn(request).await } - pub async fn confirm_tool( - &self, - request: AgentToolConfirmationRequest, - ) -> Result<(), RuntimeError> { - self.inner.confirm_tool(request).await - } - - pub async fn reject_tool( - &self, - request: AgentToolRejectionRequest, - ) -> Result<(), RuntimeError> { - self.inner.reject_tool(request).await - } - pub async fn submit_user_answers( &self, request: AgentUserAnswersRequest, diff --git a/src/crates/execution/agent-runtime/src/tool_confirmation.rs b/src/crates/execution/agent-runtime/src/tool_confirmation.rs deleted file mode 100644 index 4ef7584951..0000000000 --- a/src/crates/execution/agent-runtime/src/tool_confirmation.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! Tool confirmation planning and failure mapping. - -use dashmap::DashMap; -use std::sync::Arc; -use std::time::{Duration, SystemTime}; -use tokio::sync::oneshot; - -pub const CONFIRMATION_NO_TIMEOUT_SECS: u64 = 365 * 24 * 60 * 60; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ToolConfirmationRequestFacts { - pub confirm_before_run: bool, - pub tool_needs_permission: bool, - pub confirmation_timeout_secs: Option, - pub now: SystemTime, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ToolConfirmationGateFacts { - pub global_skip_tool_confirmation: bool, - pub context_skip_tool_confirmation: bool, - pub any_tool_needs_permission: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ToolConfirmationPolicyGateFacts { - pub global_skip_tool_confirmation: bool, - pub context_policy: ToolConfirmationContextPolicy, - pub any_tool_needs_permission: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ToolConfirmationContextPolicy { - Inherit, - Require, - Skip, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ToolConfirmationGatePlan { - SkipByPolicy, - SkipNoPermissionedTool, - AwaitPermissionedTool, -} - -impl ToolConfirmationGatePlan { - pub const fn confirm_before_run(self) -> bool { - matches!(self, Self::AwaitPermissionedTool) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ToolConfirmationPlan { - Skip, - Await { - timeout_at: SystemTime, - timeout_secs: Option, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ToolConfirmationOutcome { - Confirmed, - Rejected { reason: String }, - ChannelClosed, - Timeout { tool_name: String }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ToolConfirmationWaitResult { - Confirmed, - Rejected(String), - ChannelClosed, - TimedOut, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ToolConfirmationResponse { - Confirmed, - Rejected(String), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConfirmationFailureKind { - Rejected, - ChannelClosed, - Timeout, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ToolConfirmationFailure { - pub kind: ConfirmationFailureKind, - pub state_reason: String, - pub error_message: String, - pub rejection_instruction: Option, -} - -#[derive(Debug, Clone, Default)] -pub struct ToolConfirmationChannelStore { - channels: Arc>>, -} - -impl ToolConfirmationChannelStore { - pub fn new() -> Self { - Self::default() - } - - pub fn register(&self, tool_id: String) -> oneshot::Receiver { - let (sender, receiver) = oneshot::channel(); - self.channels.insert(tool_id, sender); - receiver - } - - pub fn confirm(&self, tool_id: &str) -> bool { - self.send(tool_id, ToolConfirmationResponse::Confirmed) - } - - pub fn reject(&self, tool_id: &str, reason: String) -> bool { - self.send(tool_id, ToolConfirmationResponse::Rejected(reason)) - } - - pub fn cancel(&self, tool_id: &str) -> bool { - self.channels.remove(tool_id).is_some() - } - - pub fn has_pending(&self, tool_id: &str) -> bool { - self.channels.contains_key(tool_id) - } - - fn send(&self, tool_id: &str, response: ToolConfirmationResponse) -> bool { - let Some((_, sender)) = self.channels.remove(tool_id) else { - return false; - }; - let _ = sender.send(response); - true - } -} - -pub fn resolve_tool_confirmation_gate( - facts: ToolConfirmationGateFacts, -) -> ToolConfirmationGatePlan { - resolve_tool_confirmation_gate_from_skip( - facts.global_skip_tool_confirmation || facts.context_skip_tool_confirmation, - facts.any_tool_needs_permission, - ) -} - -pub fn resolve_tool_confirmation_policy_gate( - facts: ToolConfirmationPolicyGateFacts, -) -> ToolConfirmationGatePlan { - let skip_by_policy = match facts.context_policy { - ToolConfirmationContextPolicy::Require => false, - ToolConfirmationContextPolicy::Skip => true, - ToolConfirmationContextPolicy::Inherit => facts.global_skip_tool_confirmation, - }; - - resolve_tool_confirmation_gate_from_skip(skip_by_policy, facts.any_tool_needs_permission) -} - -fn resolve_tool_confirmation_gate_from_skip( - skip_by_policy: bool, - any_tool_needs_permission: bool, -) -> ToolConfirmationGatePlan { - if skip_by_policy { - return ToolConfirmationGatePlan::SkipByPolicy; - } - - if any_tool_needs_permission { - ToolConfirmationGatePlan::AwaitPermissionedTool - } else { - ToolConfirmationGatePlan::SkipNoPermissionedTool - } -} - -pub fn resolve_tool_confirmation_plan( - request: ToolConfirmationRequestFacts, -) -> ToolConfirmationPlan { - if !(request.confirm_before_run && request.tool_needs_permission) { - return ToolConfirmationPlan::Skip; - } - - let timeout_secs = request - .confirmation_timeout_secs - .unwrap_or(CONFIRMATION_NO_TIMEOUT_SECS); - - ToolConfirmationPlan::Await { - timeout_at: request.now + Duration::from_secs(timeout_secs), - timeout_secs: request.confirmation_timeout_secs, - } -} - -pub fn resolve_confirmation_failure( - outcome: ToolConfirmationOutcome, -) -> Option { - match outcome { - ToolConfirmationOutcome::Confirmed => None, - ToolConfirmationOutcome::Rejected { reason } => { - let rejection_instruction = normalize_rejection_instruction(&reason); - Some(ToolConfirmationFailure { - kind: ConfirmationFailureKind::Rejected, - state_reason: format!("User rejected: {reason}"), - error_message: format!("Tool was rejected by user: {reason}"), - rejection_instruction, - }) - } - ToolConfirmationOutcome::ChannelClosed => Some(ToolConfirmationFailure { - kind: ConfirmationFailureKind::ChannelClosed, - state_reason: "Confirmation channel closed".to_string(), - error_message: "Confirmation channel closed".to_string(), - rejection_instruction: None, - }), - ToolConfirmationOutcome::Timeout { tool_name } => Some(ToolConfirmationFailure { - kind: ConfirmationFailureKind::Timeout, - state_reason: "Confirmation timeout".to_string(), - error_message: format!("Confirmation timeout: {tool_name}"), - rejection_instruction: None, - }), - } -} - -fn normalize_rejection_instruction(reason: &str) -> Option { - let trimmed = reason.trim(); - if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("User rejected") { - None - } else { - Some(trimmed.to_string()) - } -} - -pub fn resolve_confirmation_wait_result( - result: ToolConfirmationWaitResult, - tool_name: &str, -) -> ToolConfirmationOutcome { - match result { - ToolConfirmationWaitResult::Confirmed => ToolConfirmationOutcome::Confirmed, - ToolConfirmationWaitResult::Rejected(reason) => { - ToolConfirmationOutcome::Rejected { reason } - } - ToolConfirmationWaitResult::ChannelClosed => ToolConfirmationOutcome::ChannelClosed, - ToolConfirmationWaitResult::TimedOut => ToolConfirmationOutcome::Timeout { - tool_name: tool_name.to_string(), - }, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn maps_confirmation_wait_timeout_to_tool_named_outcome() { - let outcome = - resolve_confirmation_wait_result(ToolConfirmationWaitResult::TimedOut, "Bash"); - - assert_eq!( - outcome, - ToolConfirmationOutcome::Timeout { - tool_name: "Bash".to_string() - } - ); - let failure = resolve_confirmation_failure(outcome).unwrap(); - assert_eq!(failure.kind, ConfirmationFailureKind::Timeout); - assert_eq!(failure.error_message, "Confirmation timeout: Bash"); - } - - #[test] - fn preserves_rejection_reason_in_confirmation_failure() { - let outcome = resolve_confirmation_wait_result( - ToolConfirmationWaitResult::Rejected("no".to_string()), - "Edit", - ); - - let failure = resolve_confirmation_failure(outcome).unwrap(); - assert_eq!(failure.kind, ConfirmationFailureKind::Rejected); - assert_eq!(failure.error_message, "Tool was rejected by user: no"); - } - - #[tokio::test] - async fn confirmation_channel_store_delivers_confirmation_once() { - let store = ToolConfirmationChannelStore::new(); - let receiver = store.register("tool-1".to_string()); - - assert!(store.has_pending("tool-1")); - assert!(store.confirm("tool-1")); - assert!(!store.has_pending("tool-1")); - assert_eq!( - receiver.await.expect("confirmation should be delivered"), - ToolConfirmationResponse::Confirmed - ); - assert!(!store.confirm("tool-1")); - } - - #[tokio::test] - async fn confirmation_channel_store_delivers_rejection_reason() { - let store = ToolConfirmationChannelStore::new(); - let receiver = store.register("tool-1".to_string()); - - assert!(store.reject("tool-1", "unsafe".to_string())); - assert_eq!( - receiver.await.expect("rejection should be delivered"), - ToolConfirmationResponse::Rejected("unsafe".to_string()) - ); - } - - #[tokio::test] - async fn confirmation_channel_store_cancel_closes_receiver() { - let store = ToolConfirmationChannelStore::new(); - let receiver = store.register("tool-1".to_string()); - - assert!(store.cancel("tool-1")); - assert!(receiver.await.is_err()); - } -} diff --git a/src/crates/execution/agent-runtime/tests/interaction_response_contracts.rs b/src/crates/execution/agent-runtime/tests/interaction_response_contracts.rs index a484c392e7..94db17950a 100644 --- a/src/crates/execution/agent-runtime/tests/interaction_response_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/interaction_response_contracts.rs @@ -3,8 +3,7 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use bitfun_agent_runtime::sdk::{ AgentInteractionResponsePort, AgentRuntimeBuilder, AgentSubmissionPort, AgentSubmissionRequest, - AgentSubmissionResult, AgentToolConfirmationRequest, AgentToolRejectionRequest, - AgentUserAnswersRequest, PortError, PortResult, RuntimeError, + AgentSubmissionResult, AgentUserAnswersRequest, PortError, PortResult, RuntimeError, }; use bitfun_runtime_ports::PortErrorKind; use serde_json::json; @@ -35,8 +34,6 @@ impl AgentSubmissionPort for FakeSubmissionPort { #[derive(Debug, Clone, PartialEq)] enum RecordedResponse { - Confirm(AgentToolConfirmationRequest), - Reject(AgentToolRejectionRequest), Answers(AgentUserAnswersRequest), } @@ -47,22 +44,6 @@ struct RecordingInteractionResponsePort { #[async_trait] impl AgentInteractionResponsePort for RecordingInteractionResponsePort { - async fn confirm_tool(&self, request: AgentToolConfirmationRequest) -> PortResult<()> { - self.responses - .lock() - .unwrap() - .push(RecordedResponse::Confirm(request)); - Ok(()) - } - - async fn reject_tool(&self, request: AgentToolRejectionRequest) -> PortResult<()> { - self.responses - .lock() - .unwrap() - .push(RecordedResponse::Reject(request)); - Ok(()) - } - async fn submit_user_answers(&self, request: AgentUserAnswersRequest) -> PortResult<()> { self.responses .lock() @@ -73,7 +54,7 @@ impl AgentInteractionResponsePort for RecordingInteractionResponsePort { } #[tokio::test] -async fn sdk_forwards_typed_interaction_responses_without_losing_payloads() { +async fn sdk_forwards_typed_user_answers_without_losing_payloads() { let responses = Arc::new(RecordingInteractionResponsePort::default()); let runtime = AgentRuntimeBuilder::new() .with_submission_port(Arc::new(FakeSubmissionPort)) @@ -81,27 +62,11 @@ async fn sdk_forwards_typed_interaction_responses_without_losing_payloads() { .build() .expect("runtime with interaction response port"); - let confirmation = AgentToolConfirmationRequest { - tool_id: "tool-1".to_string(), - updated_input: Some(json!({ "path": "updated.txt" })), - }; - let rejection = AgentToolRejectionRequest { - tool_id: "tool-2".to_string(), - reason: "Use the read-only path".to_string(), - }; let answers = AgentUserAnswersRequest { tool_id: "tool-3".to_string(), answers: json!({ "choice": "continue", "notes": ["keep history"] }), }; - runtime - .confirm_tool(confirmation.clone()) - .await - .expect("confirm tool"); - runtime - .reject_tool(rejection.clone()) - .await - .expect("reject tool"); runtime .submit_user_answers(answers.clone()) .await @@ -109,11 +74,7 @@ async fn sdk_forwards_typed_interaction_responses_without_losing_payloads() { assert_eq!( *responses.responses.lock().unwrap(), - vec![ - RecordedResponse::Confirm(confirmation), - RecordedResponse::Reject(rejection), - RecordedResponse::Answers(answers), - ] + vec![RecordedResponse::Answers(answers)] ); } @@ -125,9 +86,9 @@ async fn sdk_reports_a_missing_interaction_response_port() { .expect("runtime without optional interaction response port"); let error = runtime - .confirm_tool(AgentToolConfirmationRequest { - tool_id: "tool-1".to_string(), - updated_input: None, + .submit_user_answers(AgentUserAnswersRequest { + tool_id: "tool-3".to_string(), + answers: json!({ "choice": "continue" }), }) .await .expect_err("missing port must be explicit"); @@ -137,28 +98,6 @@ async fn sdk_reports_a_missing_interaction_response_port() { #[test] fn interaction_response_requests_keep_camel_case_wire_fields() { - assert_eq!( - serde_json::to_value(AgentToolConfirmationRequest { - tool_id: "tool-1".to_string(), - updated_input: Some(json!({ "command": "safe" })), - }) - .expect("serialize confirmation request"), - json!({ - "toolId": "tool-1", - "updatedInput": { "command": "safe" }, - }) - ); - assert_eq!( - serde_json::to_value(AgentToolRejectionRequest { - tool_id: "tool-2".to_string(), - reason: "User rejected".to_string(), - }) - .expect("serialize rejection request"), - json!({ - "toolId": "tool-2", - "reason": "User rejected", - }) - ); assert_eq!( serde_json::to_value(AgentUserAnswersRequest { tool_id: "tool-3".to_string(), diff --git a/src/crates/execution/agent-runtime/tests/permission_contracts.rs b/src/crates/execution/agent-runtime/tests/permission_contracts.rs new file mode 100644 index 0000000000..a562b4ca23 --- /dev/null +++ b/src/crates/execution/agent-runtime/tests/permission_contracts.rs @@ -0,0 +1,772 @@ +use async_trait::async_trait; +use bitfun_agent_runtime::permission::{ + PermissionRequestManager, PermissionRequestManagerError, PermissionWaitOutcome, +}; +use bitfun_runtime_ports::{ + ClockPort, PermissionAuditRecord, PermissionAuditStorePort, PermissionGrant, + PermissionGrantKey, PermissionGrantStorePort, PermissionReply, PermissionReplySource, + PermissionReplyStorePort, PermissionRequest, PermissionRequestEvent, PermissionRequestSource, + PermissionRequestSourceKind, PortError, PortErrorKind, PortResult, RuntimeServiceCapability, + RuntimeServicePort, +}; +use serde_json::Map; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Default)] +struct RecordingPermissionStore { + grants: Mutex>, + audit: Mutex>, + fail_grants: Mutex, + fail_audit: Mutex, +} + +impl RuntimeServicePort for RecordingPermissionStore { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::Permission + } +} + +#[async_trait] +impl PermissionGrantStorePort for RecordingPermissionStore { + async fn list_project_grants(&self, project_id: &str) -> PortResult> { + Ok(self + .grants + .lock() + .unwrap() + .iter() + .filter(|grant| grant.project_id == project_id) + .cloned() + .collect()) + } + + async fn add_project_grants(&self, grants: Vec) -> PortResult<()> { + if *self.fail_grants.lock().unwrap() { + return Err(PortError::new( + PortErrorKind::Backend, + "grant store unavailable", + )); + } + let mut stored = self.grants.lock().unwrap(); + for grant in grants { + if !stored.iter().any(|existing| existing.key() == grant.key()) { + stored.push(grant); + } + } + Ok(()) + } + + async fn remove_project_grant(&self, key: PermissionGrantKey) -> PortResult { + let mut stored = self.grants.lock().unwrap(); + let previous_len = stored.len(); + stored.retain(|grant| grant.key() != key); + Ok(stored.len() != previous_len) + } + + async fn clear_project_grants(&self, project_id: &str) -> PortResult { + let mut stored = self.grants.lock().unwrap(); + let previous_len = stored.len(); + stored.retain(|grant| grant.project_id != project_id); + Ok(previous_len - stored.len()) + } +} + +#[async_trait] +impl PermissionAuditStorePort for RecordingPermissionStore { + async fn append_permission_audit(&self, record: PermissionAuditRecord) -> PortResult<()> { + if *self.fail_audit.lock().unwrap() { + return Err(PortError::new( + PortErrorKind::Backend, + "audit store unavailable", + )); + } + let mut stored = self.audit.lock().unwrap(); + if !stored + .iter() + .any(|existing| existing.audit_id == record.audit_id) + { + stored.push(record); + } + Ok(()) + } + + async fn list_project_permission_audit( + &self, + project_id: &str, + ) -> PortResult> { + Ok(self + .audit + .lock() + .unwrap() + .iter() + .filter(|record| record.request.project_id == project_id) + .cloned() + .collect()) + } +} + +#[async_trait] +impl PermissionReplyStorePort for RecordingPermissionStore { + async fn commit_permission_reply( + &self, + grants: Vec, + audit: Vec, + ) -> PortResult<()> { + if *self.fail_grants.lock().unwrap() { + return Err(PortError::new( + PortErrorKind::Backend, + "reply store unavailable", + )); + } + if *self.fail_audit.lock().unwrap() { + return Err(PortError::new( + PortErrorKind::Backend, + "audit store unavailable", + )); + } + self.add_project_grants(grants).await?; + for record in audit { + self.append_permission_audit(record).await?; + } + Ok(()) + } +} + +#[derive(Debug)] +struct FixedClock(i64); + +impl RuntimeServicePort for FixedClock { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::Clock + } +} + +impl ClockPort for FixedClock { + fn now_unix_millis(&self) -> i64 { + self.0 + } +} + +fn request(request_id: &str, session_id: &str) -> PermissionRequest { + PermissionRequest { + request_id: request_id.to_string(), + round_id: format!("synthetic:{request_id}"), + order: 0, + tool_call_id: None, + project_path: None, + project_id: "project-a".to_string(), + session_id: session_id.to_string(), + agent_id: "agentic".to_string(), + action: "edit".to_string(), + resources: vec!["src/lib.rs".to_string()], + save_resources: vec!["src/*".to_string(), "src/*".to_string()], + source: PermissionRequestSource { + kind: PermissionRequestSourceKind::ToolCall, + identity: format!("tool-{request_id}"), + }, + delegation: None, + display_metadata: Map::new(), + } +} + +fn manager() -> (PermissionRequestManager, Arc) { + let store = Arc::new(RecordingPermissionStore::default()); + ( + PermissionRequestManager::new(store.clone(), store.clone(), Arc::new(FixedClock(123))) + .with_grant_store(store.clone()), + store, + ) +} + +#[tokio::test] +async fn once_releases_only_the_selected_request_and_records_audit() { + let (manager, store) = manager(); + let receiver = manager + .register(request("request-1", "session-a")) + .await + .expect("register request"); + + let resolution = manager + .reply( + "request-1", + PermissionReply::Once, + PermissionReplySource::User, + ) + .await + .expect("reply once"); + + assert_eq!( + receiver.wait().await, + PermissionWaitOutcome::Replied(PermissionReply::Once) + ); + assert_eq!(resolution.resolved_request_ids, vec!["request-1"]); + assert!(resolution.saved_grants.is_empty()); + assert!(manager.pending_requests().is_empty()); + assert_eq!(store.audit.lock().unwrap().len(), 2); +} + +#[tokio::test] +async fn always_persists_unique_project_grants_without_releasing_other_pending_requests() { + let (manager, store) = manager(); + let receiver = manager + .register(request("request-1", "session-a")) + .await + .expect("register request"); + let other = manager + .register(request("request-2", "session-b")) + .await + .expect("register other request"); + + let resolution = manager + .reply( + "request-1", + PermissionReply::Always, + PermissionReplySource::User, + ) + .await + .expect("reply always"); + + assert_eq!( + receiver.wait().await, + PermissionWaitOutcome::Replied(PermissionReply::Always) + ); + assert_eq!(resolution.saved_grants.len(), 1); + assert_eq!(store.grants.lock().unwrap().len(), 1); + assert_eq!( + manager + .pending_requests() + .iter() + .map(|request| request.request_id.as_str()) + .collect::>(), + vec!["request-2"] + ); + + manager + .cancel_request("request-2", "test cleanup") + .await + .expect("cancel other request"); + assert_eq!( + other.wait().await, + PermissionWaitOutcome::Cancelled { + reason: "test cleanup".to_string() + } + ); +} + +#[tokio::test] +async fn reject_releases_only_the_selected_request() { + let (manager, _) = manager(); + let target = manager + .register(request("request-1", "session-a")) + .await + .expect("register target"); + let sibling = manager + .register(request("request-2", "session-a")) + .await + .expect("register sibling"); + let other_session = manager + .register(request("request-3", "session-b")) + .await + .expect("register other session"); + + let reply = PermissionReply::Reject { + feedback: Some("Use a read-only path".to_string()), + }; + let resolution = manager + .reply("request-1", reply.clone(), PermissionReplySource::User) + .await + .expect("reject request"); + + assert_eq!(target.wait().await, PermissionWaitOutcome::Replied(reply)); + assert_eq!(resolution.resolved_request_ids, vec!["request-1"]); + assert_eq!( + manager + .pending_requests() + .iter() + .map(|request| request.request_id.as_str()) + .collect::>(), + vec!["request-2", "request-3"] + ); + + assert!( + tokio::time::timeout(std::time::Duration::from_millis(20), sibling.wait()) + .await + .is_err(), + "a sibling request must keep waiting after the target is rejected" + ); + + manager + .cancel_session("session-b", "disconnected") + .await + .expect("cancel other session"); + assert_eq!( + other_session.wait().await, + PermissionWaitOutcome::Cancelled { + reason: "disconnected".to_string() + } + ); + manager + .cancel_session("session-a", "test cleanup") + .await + .expect("cancel sibling request"); +} + +#[tokio::test] +async fn batch_reply_resolves_only_the_anchor_and_following_requests_in_one_round() { + let (manager, _) = manager(); + let requests = vec![ + PermissionRequest { + round_id: "round-a".to_string(), + order: 0, + ..request("earlier", "session-a") + }, + PermissionRequest { + round_id: "round-a".to_string(), + order: 1, + ..request("anchor", "session-a") + }, + PermissionRequest { + round_id: "round-a".to_string(), + order: 2, + ..request("following", "session-a") + }, + PermissionRequest { + round_id: "round-b".to_string(), + order: 2, + ..request("other-round", "session-a") + }, + PermissionRequest { + round_id: "round-a".to_string(), + order: 2, + ..request("other-session", "session-b") + }, + ]; + let mut receivers = manager + .register_batch(requests) + .await + .expect("register batch reply fixtures"); + let earlier = receivers.remove(0); + let anchor = receivers.remove(0); + let following = receivers.remove(0); + let other_round = receivers.remove(0); + let other_session = receivers.remove(0); + + let resolution = manager + .reply_from( + "anchor", + true, + PermissionReply::Once, + PermissionReplySource::User, + ) + .await + .expect("reply to current and following requests"); + + assert_eq!(resolution.resolved_request_ids, vec!["anchor", "following"]); + for receiver in [anchor, following] { + assert_eq!( + receiver.wait().await, + PermissionWaitOutcome::Replied(PermissionReply::Once) + ); + } + assert_eq!( + manager + .pending_requests() + .into_iter() + .map(|request| request.request_id) + .collect::>(), + vec!["earlier", "other-round", "other-session"] + ); + + manager + .cancel_session("session-a", "test cleanup") + .await + .expect("cancel remaining session-a requests"); + manager + .cancel_session("session-b", "test cleanup") + .await + .expect("cancel remaining session-b request"); + for receiver in [earlier, other_round, other_session] { + assert!(matches!( + receiver.wait().await, + PermissionWaitOutcome::Cancelled { .. } + )); + } +} + +#[tokio::test] +async fn batch_always_persists_each_request_grant_atomically() { + let (manager, store) = manager(); + let receivers = manager + .register_batch(vec![ + PermissionRequest { + round_id: "round-a".to_string(), + order: 0, + save_resources: vec!["src/a.rs".to_string()], + ..request("request-a", "session-a") + }, + PermissionRequest { + round_id: "round-a".to_string(), + order: 1, + save_resources: vec!["src/b.rs".to_string()], + ..request("request-b", "session-a") + }, + ]) + .await + .expect("register always batch"); + + let resolution = manager + .reply_from( + "request-a", + true, + PermissionReply::Always, + PermissionReplySource::User, + ) + .await + .expect("always allow batch"); + + assert_eq!( + resolution.resolved_request_ids, + vec!["request-a", "request-b"] + ); + assert_eq!(resolution.saved_grants.len(), 2); + assert_eq!(store.grants.lock().unwrap().len(), 2); + for receiver in receivers { + assert_eq!( + receiver.wait().await, + PermissionWaitOutcome::Replied(PermissionReply::Always) + ); + } +} + +#[tokio::test] +async fn pending_snapshots_and_session_cancellation_preserve_registration_order() { + let (manager, _) = manager(); + let first = manager + .register(request("request-z", "session-a")) + .await + .expect("register first request"); + let second = manager + .register_non_interactive(request("request-a", "session-a")) + .await + .expect("register second request"); + let third = manager + .register(request("request-m", "session-a")) + .await + .expect("register third request"); + + let request_ids = |requests: Vec| { + requests + .into_iter() + .map(|request| request.request_id) + .collect::>() + }; + assert_eq!( + request_ids(manager.pending_requests()), + vec!["request-z", "request-a", "request-m"] + ); + assert_eq!( + request_ids(manager.interactive_pending_requests()), + vec!["request-z", "request-m"] + ); + + assert_eq!( + manager + .cancel_session("session-a", "session closed") + .await + .expect("cancel session"), + vec!["request-z", "request-a", "request-m"] + ); + for receiver in [first, second, third] { + assert_eq!( + receiver.wait().await, + PermissionWaitOutcome::Cancelled { + reason: "session closed".to_string() + } + ); + } +} + +#[tokio::test] +async fn pending_snapshots_order_requests_within_each_round() { + let (manager, _) = manager(); + let first_round_late = manager + .register(PermissionRequest { + round_id: "round-1".to_string(), + order: 2, + ..request("request-round-1-late", "session-a") + }) + .await + .expect("register first round late request"); + let second_round = manager + .register(PermissionRequest { + round_id: "round-2".to_string(), + order: 0, + ..request("request-round-2", "session-a") + }) + .await + .expect("register second round request"); + let first_round_early = manager + .register(PermissionRequest { + round_id: "round-1".to_string(), + order: 0, + ..request("request-round-1-early", "session-a") + }) + .await + .expect("register first round early request"); + + assert_eq!( + manager + .pending_requests() + .iter() + .map(|request| request.request_id.as_str()) + .collect::>(), + vec![ + "request-round-1-early", + "request-round-1-late", + "request-round-2", + ] + ); + + manager + .cancel_session("session-a", "test cleanup") + .await + .expect("cancel ordered requests"); + for receiver in [first_round_late, second_round, first_round_early] { + assert!(matches!( + receiver.wait().await, + PermissionWaitOutcome::Cancelled { .. } + )); + } +} + +#[tokio::test] +async fn register_batch_publishes_asked_events_in_batch_order() { + let (manager, _) = manager(); + let mut events = manager.subscribe(); + let requests = vec![ + PermissionRequest { + round_id: "round-1".to_string(), + order: 0, + ..request("request-first", "session-a") + }, + PermissionRequest { + round_id: "round-1".to_string(), + order: 1, + ..request("request-second", "session-a") + }, + ]; + let receivers = manager + .register_batch(requests.clone()) + .await + .expect("register permission batch"); + + for request in requests { + assert_eq!( + events.recv().await.expect("asked event"), + PermissionRequestEvent::Asked { request } + ); + } + assert_eq!(receivers.len(), 2); + + manager + .cancel_session("session-a", "test cleanup") + .await + .expect("cancel permission batch"); + for receiver in receivers { + assert!(matches!( + receiver.wait().await, + PermissionWaitOutcome::Cancelled { .. } + )); + } +} + +#[tokio::test] +async fn register_batch_rolls_back_pending_requests_when_audit_fails() { + let (manager, store) = manager(); + *store.fail_audit.lock().unwrap() = true; + let error = manager + .register_batch(vec![ + request("request-first", "session-a"), + request("request-second", "session-a"), + ]) + .await + .expect_err("audit failure should reject the whole batch"); + + assert!(matches!( + error, + PermissionRequestManagerError::AuditStore(_) + )); + assert!(manager.pending_requests().is_empty()); +} + +#[tokio::test] +async fn grant_persistence_failure_keeps_the_request_pending_and_waiting() { + let (manager, store) = manager(); + let _receiver = manager + .register(request("request-1", "session-a")) + .await + .expect("register request"); + *store.fail_grants.lock().unwrap() = true; + + let error = manager + .reply( + "request-1", + PermissionReply::Always, + PermissionReplySource::User, + ) + .await + .expect_err("grant failure must fail closed"); + + assert!(matches!( + error, + PermissionRequestManagerError::ReplyStore(_) + )); + assert_eq!(manager.pending_requests().len(), 1); +} + +#[tokio::test] +async fn audit_persistence_failure_keeps_the_request_pending_and_waiting() { + let (manager, store) = manager(); + let _receiver = manager + .register(request("request-1", "session-a")) + .await + .expect("register request"); + *store.fail_audit.lock().unwrap() = true; + + let error = manager + .reply( + "request-1", + PermissionReply::Once, + PermissionReplySource::User, + ) + .await + .expect_err("audit failure must fail closed"); + + assert!(matches!( + error, + PermissionRequestManagerError::ReplyStore(_) + )); + assert_eq!(manager.pending_requests().len(), 1); +} + +#[tokio::test] +async fn batch_reply_persistence_failure_keeps_every_request_pending() { + let (manager, store) = manager(); + let _receivers = manager + .register_batch(vec![ + PermissionRequest { + round_id: "round-a".to_string(), + order: 0, + ..request("request-a", "session-a") + }, + PermissionRequest { + round_id: "round-a".to_string(), + order: 1, + ..request("request-b", "session-a") + }, + ]) + .await + .expect("register failing batch"); + *store.fail_audit.lock().unwrap() = true; + + let error = manager + .reply_from( + "request-a", + true, + PermissionReply::Once, + PermissionReplySource::User, + ) + .await + .expect_err("batch persistence failure must fail closed"); + + assert!(matches!( + error, + PermissionRequestManagerError::ReplyStore(_) + )); + assert_eq!( + manager + .pending_requests() + .into_iter() + .map(|request| request.request_id) + .collect::>(), + vec!["request-a", "request-b"] + ); +} + +#[tokio::test] +async fn pending_requests_are_process_local_and_not_restored_by_a_new_manager() { + let (manager, store) = manager(); + let _receiver = manager + .register(request("request-1", "session-a")) + .await + .expect("register request"); + + let restarted = PermissionRequestManager::new(store.clone(), store, Arc::new(FixedClock(456))); + assert!(restarted.pending_requests().is_empty()); +} + +#[tokio::test] +async fn grant_management_is_project_scoped_and_audit_remains_append_only() { + let (manager, store) = manager(); + store + .add_project_grants(vec![ + PermissionGrant { + project_id: "project-a".to_string(), + action: "edit".to_string(), + resource: "src/main.rs".to_string(), + created_at_ms: 1, + }, + PermissionGrant { + project_id: "project-b".to_string(), + action: "read".to_string(), + resource: "README.md".to_string(), + created_at_ms: 2, + }, + ]) + .await + .expect("seed grants"); + let pending = manager + .register(request("request-audit", "session-a")) + .await + .expect("register audited request"); + + assert_eq!( + manager + .list_project_grants("project-a") + .await + .expect("list project grants") + .len(), + 1 + ); + assert!(manager + .remove_project_grant(PermissionGrantKey { + project_id: "project-a".to_string(), + action: "edit".to_string(), + resource: "src/main.rs".to_string(), + }) + .await + .expect("remove project grant")); + assert_eq!( + manager + .clear_project_grants("project-b") + .await + .expect("clear project grants"), + 1 + ); + assert_eq!( + manager + .list_project_permission_audit("project-a") + .await + .expect("list project audit") + .len(), + 1 + ); + + manager + .cancel_request("request-audit", "test cleanup") + .await + .expect("cancel request"); + assert!(matches!( + pending.wait().await, + PermissionWaitOutcome::Cancelled { .. } + )); +} diff --git a/src/crates/execution/agent-runtime/tests/scheduler_contracts.rs b/src/crates/execution/agent-runtime/tests/scheduler_contracts.rs index 21c7259a79..8d36501b94 100644 --- a/src/crates/execution/agent-runtime/tests/scheduler_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/scheduler_contracts.rs @@ -40,7 +40,6 @@ fn background_delivery_starts_agent_session_follow_up_when_session_is_not_proces action, BackgroundDeliveryAction::SubmitAgentSessionFollowUp { queue_priority: DialogQueuePriority::Low, - skip_tool_confirmation: true, } ); } @@ -58,7 +57,6 @@ fn background_delivery_follow_up_uses_agent_session_source_semantics() { assert_eq!(policy.trigger_source, DialogTriggerSource::AgentSession); assert_eq!(policy.queue_priority, DialogQueuePriority::Low); - assert!(policy.skip_tool_confirmation); } #[test] diff --git a/src/crates/execution/agent-runtime/tests/sdk_smoke.rs b/src/crates/execution/agent-runtime/tests/sdk_smoke.rs index 93a6368e91..bc75d0f30d 100644 --- a/src/crates/execution/agent-runtime/tests/sdk_smoke.rs +++ b/src/crates/execution/agent-runtime/tests/sdk_smoke.rs @@ -7,12 +7,12 @@ use bitfun_agent_runtime::sdk::{ AgentRuntimeSdkCompatibility, AgentRuntimeSdkStability, AgentSessionCreateRequest, AgentSessionCreateResult, AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, AgentSubmissionSource, ClockPort, FileSystemPort, HarnessCapability, HarnessProviderDescriptor, - HarnessWorkflow, PermissionDecision, PermissionPort, PermissionRequest, PortResult, - RuntimeAgentRegistry, RuntimeAgentRegistryQuery, RuntimeEventEnvelope, RuntimeEventSink, - RuntimeEventType, RuntimeHookErrorPolicy, RuntimeHookKind, RuntimeHookPlan, - RuntimeHookRegistry, RuntimeServiceCapability, RuntimeServicePort, RuntimeServices, - RuntimeServicesBuilder, SessionSelector, SessionStorageKind, SessionStoragePathRequest, - SessionStoragePathResolution, SessionStorePort, ToolRegistry, ToolRegistryItem, WorkspacePort, + HarnessWorkflow, PortResult, RuntimeAgentRegistry, RuntimeAgentRegistryQuery, + RuntimeEventEnvelope, RuntimeEventSink, RuntimeEventType, RuntimeHookErrorPolicy, + RuntimeHookKind, RuntimeHookPlan, RuntimeHookRegistry, RuntimeServiceCapability, + RuntimeServicePort, RuntimeServices, RuntimeServicesBuilder, SessionSelector, + SessionStorageKind, SessionStoragePathRequest, SessionStoragePathResolution, SessionStorePort, + ToolRegistry, ToolRegistryItem, WorkspacePort, }; use serde_json::{json, Value}; @@ -88,16 +88,6 @@ impl SessionStorePort for FakeSdkRuntimePort { } } -#[async_trait] -impl PermissionPort for FakeSdkRuntimePort { - async fn request_permission( - &self, - _request: PermissionRequest, - ) -> PortResult { - Ok(PermissionDecision::Allow) - } -} - impl ClockPort for FakeSdkRuntimePort { fn now_unix_millis(&self) -> i64 { 0 @@ -122,9 +112,6 @@ fn fake_sdk_services() -> RuntimeServices { .with_session_store(Arc::new(FakeSdkRuntimePort::new( RuntimeServiceCapability::SessionStore, ))) - .with_permission(Arc::new(FakeSdkRuntimePort::new( - RuntimeServiceCapability::Permission, - ))) .with_events(Arc::new(FakeSdkRuntimeEventSink)) .with_clock(Arc::new(FakeSdkRuntimePort::new( RuntimeServiceCapability::Clock, diff --git a/src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs b/src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs deleted file mode 100644 index 035ef1c38e..0000000000 --- a/src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs +++ /dev/null @@ -1,183 +0,0 @@ -use bitfun_agent_runtime::tool_confirmation::{ - resolve_confirmation_failure, resolve_confirmation_wait_result, resolve_tool_confirmation_gate, - resolve_tool_confirmation_plan, resolve_tool_confirmation_policy_gate, ConfirmationFailureKind, - ToolConfirmationContextPolicy, ToolConfirmationGateFacts, ToolConfirmationGatePlan, - ToolConfirmationOutcome, ToolConfirmationPlan, ToolConfirmationPolicyGateFacts, - ToolConfirmationRequestFacts, ToolConfirmationWaitResult, -}; -use std::time::{Duration, UNIX_EPOCH}; - -#[test] -fn confirmation_gate_preserves_skip_policy_precedence() { - assert_eq!( - resolve_tool_confirmation_gate(ToolConfirmationGateFacts { - global_skip_tool_confirmation: true, - context_skip_tool_confirmation: false, - any_tool_needs_permission: true, - }), - ToolConfirmationGatePlan::SkipByPolicy - ); - assert_eq!( - resolve_tool_confirmation_gate(ToolConfirmationGateFacts { - global_skip_tool_confirmation: false, - context_skip_tool_confirmation: true, - any_tool_needs_permission: true, - }), - ToolConfirmationGatePlan::SkipByPolicy - ); -} - -#[test] -fn invocation_require_policy_overrides_the_global_skip_default() { - let plan = resolve_tool_confirmation_policy_gate(ToolConfirmationPolicyGateFacts { - global_skip_tool_confirmation: true, - context_policy: ToolConfirmationContextPolicy::Require, - any_tool_needs_permission: true, - }); - - assert_eq!(plan, ToolConfirmationGatePlan::AwaitPermissionedTool); - assert!(plan.confirm_before_run()); -} - -#[test] -fn confirmation_gate_requires_confirmation_only_for_permissioned_tools() { - let permissioned = resolve_tool_confirmation_gate(ToolConfirmationGateFacts { - global_skip_tool_confirmation: false, - context_skip_tool_confirmation: false, - any_tool_needs_permission: true, - }); - - assert_eq!( - permissioned, - ToolConfirmationGatePlan::AwaitPermissionedTool - ); - assert!(permissioned.confirm_before_run()); - - let readonly = resolve_tool_confirmation_gate(ToolConfirmationGateFacts { - global_skip_tool_confirmation: false, - context_skip_tool_confirmation: false, - any_tool_needs_permission: false, - }); - - assert_eq!(readonly, ToolConfirmationGatePlan::SkipNoPermissionedTool); - assert!(!readonly.confirm_before_run()); -} - -#[test] -fn confirmation_plan_requires_permission_only_when_both_flags_are_true() { - let plan = resolve_tool_confirmation_plan(ToolConfirmationRequestFacts { - confirm_before_run: true, - tool_needs_permission: true, - confirmation_timeout_secs: Some(30), - now: UNIX_EPOCH + Duration::from_secs(1_000), - }); - - assert_eq!( - plan, - ToolConfirmationPlan::Await { - timeout_at: UNIX_EPOCH + Duration::from_secs(1_030), - timeout_secs: Some(30), - } - ); - - assert_eq!( - resolve_tool_confirmation_plan(ToolConfirmationRequestFacts { - confirm_before_run: false, - tool_needs_permission: true, - confirmation_timeout_secs: Some(30), - now: UNIX_EPOCH + Duration::from_secs(1_000), - }), - ToolConfirmationPlan::Skip - ); - assert_eq!( - resolve_tool_confirmation_plan(ToolConfirmationRequestFacts { - confirm_before_run: true, - tool_needs_permission: false, - confirmation_timeout_secs: None, - now: UNIX_EPOCH + Duration::from_secs(1_000), - }), - ToolConfirmationPlan::Skip - ); -} - -#[test] -fn confirmation_plan_preserves_legacy_no_timeout_one_year_deadline() { - assert_eq!( - resolve_tool_confirmation_plan(ToolConfirmationRequestFacts { - confirm_before_run: true, - tool_needs_permission: true, - confirmation_timeout_secs: None, - now: UNIX_EPOCH + Duration::from_secs(1_000), - }), - ToolConfirmationPlan::Await { - timeout_at: UNIX_EPOCH + Duration::from_secs(31_537_000), - timeout_secs: None, - } - ); -} - -#[test] -fn confirmation_failure_mapping_preserves_legacy_reasons_and_errors() { - assert_eq!( - resolve_confirmation_failure(ToolConfirmationOutcome::Confirmed), - None - ); - - let rejected = resolve_confirmation_failure(ToolConfirmationOutcome::Rejected { - reason: "unsafe".to_string(), - }) - .expect("rejection should produce failure"); - assert_eq!(rejected.kind, ConfirmationFailureKind::Rejected); - assert_eq!(rejected.state_reason, "User rejected: unsafe"); - assert_eq!(rejected.error_message, "Tool was rejected by user: unsafe"); - assert_eq!(rejected.rejection_instruction.as_deref(), Some("unsafe")); - - let default_rejected = resolve_confirmation_failure(ToolConfirmationOutcome::Rejected { - reason: "User rejected".to_string(), - }) - .expect("rejection should produce failure"); - assert_eq!(default_rejected.kind, ConfirmationFailureKind::Rejected); - assert_eq!(default_rejected.rejection_instruction, None); - - let closed = resolve_confirmation_failure(ToolConfirmationOutcome::ChannelClosed) - .expect("closed channel should produce failure"); - assert_eq!(closed.kind, ConfirmationFailureKind::ChannelClosed); - assert_eq!(closed.state_reason, "Confirmation channel closed"); - assert_eq!(closed.error_message, "Confirmation channel closed"); - - let timeout = resolve_confirmation_failure(ToolConfirmationOutcome::Timeout { - tool_name: "Bash".to_string(), - }) - .expect("timeout should produce failure"); - assert_eq!(timeout.kind, ConfirmationFailureKind::Timeout); - assert_eq!(timeout.state_reason, "Confirmation timeout"); - assert_eq!(timeout.error_message, "Confirmation timeout: Bash"); - assert_eq!(timeout.rejection_instruction, None); -} - -#[test] -fn confirmation_wait_result_mapping_preserves_legacy_timeout_and_rejection() { - assert_eq!( - resolve_confirmation_wait_result(ToolConfirmationWaitResult::Confirmed, "Bash"), - ToolConfirmationOutcome::Confirmed - ); - assert_eq!( - resolve_confirmation_wait_result( - ToolConfirmationWaitResult::Rejected("unsafe".to_string()), - "Edit" - ), - ToolConfirmationOutcome::Rejected { - reason: "unsafe".to_string(), - } - ); - assert_eq!( - resolve_confirmation_wait_result(ToolConfirmationWaitResult::ChannelClosed, "Read"), - ToolConfirmationOutcome::ChannelClosed - ); - assert_eq!( - resolve_confirmation_wait_result(ToolConfirmationWaitResult::TimedOut, "Bash"), - ToolConfirmationOutcome::Timeout { - tool_name: "Bash".to_string(), - } - ); -} diff --git a/src/crates/execution/runtime-services/src/lib.rs b/src/crates/execution/runtime-services/src/lib.rs index 7ae64f6ac9..5b9c9476ca 100644 --- a/src/crates/execution/runtime-services/src/lib.rs +++ b/src/crates/execution/runtime-services/src/lib.rs @@ -3,10 +3,10 @@ use std::sync::Arc; use bitfun_runtime_ports::{ - ClockPort, FileSystemPort, GitPort, McpCatalogPort, NetworkPort, PermissionPort, - RemoteCapabilityPort, RemoteConnectionPort, RemoteExecPort, RemoteProjectionPort, - RemoteWorkspacePort, RuntimeEventSink, RuntimeServiceCapability, RuntimeServicePort, - SessionStorePort, TerminalPort, WorkspacePort, + ClockPort, FileSystemPort, GitPort, McpCatalogPort, NetworkPort, RemoteCapabilityPort, + RemoteConnectionPort, RemoteExecPort, RemoteProjectionPort, RemoteWorkspacePort, + RuntimeEventSink, RuntimeServiceCapability, RuntimeServicePort, SessionStorePort, TerminalPort, + WorkspacePort, }; pub mod backend_events; @@ -73,7 +73,6 @@ pub struct RuntimeServices { pub filesystem: Arc, pub workspace: Arc, pub session_store: Arc, - pub permission: Arc, pub events: Arc, pub clock: Arc, pub terminal: Option>, @@ -93,7 +92,6 @@ impl std::fmt::Debug for RuntimeServices { .field("filesystem", &self.filesystem.capability()) .field("workspace", &self.workspace.capability()) .field("session_store", &self.session_store.capability()) - .field("permission", &self.permission.capability()) .field("events", &RuntimeServiceCapability::Events) .field("clock", &self.clock.capability()) .field( @@ -148,9 +146,9 @@ impl RuntimeServices { RuntimeServiceCapability::FileSystem | RuntimeServiceCapability::Workspace | RuntimeServiceCapability::SessionStore - | RuntimeServiceCapability::Permission | RuntimeServiceCapability::Events | RuntimeServiceCapability::Clock => true, + RuntimeServiceCapability::Permission => false, RuntimeServiceCapability::Terminal => self.terminal.is_some(), RuntimeServiceCapability::RemoteExec => self.remote_exec.is_some(), RuntimeServiceCapability::Network => self.network.is_some(), @@ -190,7 +188,6 @@ pub struct RuntimeServicesBuilder { filesystem: Option>, workspace: Option>, session_store: Option>, - permission: Option>, events: Option>, clock: Option>, terminal: Option>, @@ -224,11 +221,6 @@ impl RuntimeServicesBuilder { self } - pub fn with_permission(mut self, port: Arc) -> Self { - self.permission = Some(port); - self - } - pub fn with_events(mut self, port: Arc) -> Self { self.events = Some(port); self @@ -307,10 +299,6 @@ impl RuntimeServicesBuilder { self.session_store, RuntimeServiceCapability::SessionStore, )?, - permission: Self::required_service( - self.permission, - RuntimeServiceCapability::Permission, - )?, events: Self::required(self.events, RuntimeServiceCapability::Events)?, clock: Self::required_service(self.clock, RuntimeServiceCapability::Clock)?, terminal: Self::optional_service(self.terminal, RuntimeServiceCapability::Terminal)?, diff --git a/src/crates/execution/runtime-services/src/test_support.rs b/src/crates/execution/runtime-services/src/test_support.rs index b7ee1ebae8..1570f53a5c 100644 --- a/src/crates/execution/runtime-services/src/test_support.rs +++ b/src/crates/execution/runtime-services/src/test_support.rs @@ -1,9 +1,8 @@ use std::sync::Arc; use bitfun_runtime_ports::{ - ClockPort, FileSystemPort, GitPort, McpCatalogPort, NetworkPort, PermissionDecision, - PermissionPort, PermissionRequest, PortError, PortErrorKind, PortResult, - RemoteAssistantWorkspaceFacts, RemoteCapabilityPort, RemoteConnectionPort, + ClockPort, FileSystemPort, GitPort, McpCatalogPort, NetworkPort, PortError, PortErrorKind, + PortResult, RemoteAssistantWorkspaceFacts, RemoteCapabilityPort, RemoteConnectionPort, RemoteExecCommandRequest, RemoteExecCommandResponse, RemoteExecControlRequest, RemoteExecOneShotCommandRequest, RemoteExecOneShotCommandResponse, RemoteExecPort, RemoteExecStreamingOutputSink, RemoteProjectionPort, RemoteRecentWorkspaceFacts, @@ -232,16 +231,6 @@ impl RemoteWorkspaceFileRuntimeHost for FakeRuntimePort { } } -#[async_trait::async_trait] -impl PermissionPort for FakeRuntimePort { - async fn request_permission( - &self, - _request: PermissionRequest, - ) -> PortResult { - Ok(PermissionDecision::Allow) - } -} - impl ClockPort for FakeRuntimePort { fn now_unix_millis(&self) -> i64 { 0 @@ -296,8 +285,6 @@ impl RuntimeServicesProvider for FakeRuntimeServicesProvider { Arc::new(FakeRuntimePort::new(RuntimeServiceCapability::Workspace)); let session_store: Arc = Arc::new(FakeRuntimePort::new(RuntimeServiceCapability::SessionStore)); - let permission: Arc = - Arc::new(FakeRuntimePort::new(RuntimeServiceCapability::Permission)); let events: Arc = Arc::new(FakeRuntimeEventSink); let clock: Arc = Arc::new(FakeRuntimePort::new(RuntimeServiceCapability::Clock)); @@ -306,7 +293,6 @@ impl RuntimeServicesProvider for FakeRuntimeServicesProvider { .with_filesystem(filesystem) .with_workspace(workspace) .with_session_store(session_store) - .with_permission(permission) .with_events(events) .with_clock(clock); diff --git a/src/crates/execution/runtime-services/tests/runtime_services_contracts.rs b/src/crates/execution/runtime-services/tests/runtime_services_contracts.rs index 1f514684d5..ba2a2870a4 100644 --- a/src/crates/execution/runtime-services/tests/runtime_services_contracts.rs +++ b/src/crates/execution/runtime-services/tests/runtime_services_contracts.rs @@ -33,7 +33,7 @@ fn fake_provider_registers_required_and_remote_services_through_registry() { assert!(services.has_capability(RuntimeServiceCapability::FileSystem)); assert!(services.has_capability(RuntimeServiceCapability::Workspace)); assert!(services.has_capability(RuntimeServiceCapability::SessionStore)); - assert!(services.has_capability(RuntimeServiceCapability::Permission)); + assert!(!services.has_capability(RuntimeServiceCapability::Permission)); assert!(services.has_capability(RuntimeServiceCapability::Events)); assert!(services.has_capability(RuntimeServiceCapability::Clock)); assert!(services.has_capability(RuntimeServiceCapability::RemoteConnection)); diff --git a/src/crates/execution/tool-contracts/src/framework.rs b/src/crates/execution/tool-contracts/src/framework.rs index 594da9d979..3de63aad3c 100644 --- a/src/crates/execution/tool-contracts/src/framework.rs +++ b/src/crates/execution/tool-contracts/src/framework.rs @@ -426,10 +426,6 @@ pub fn get_tool_spec_is_concurrency_safe(_input: Option<&Value>) -> bool { true } -pub fn get_tool_spec_needs_permissions(_input: Option<&Value>) -> bool { - false -} - pub fn render_get_tool_spec_tool_use_message(input: &Value) -> String { let tool_name = input .get("tool_name") @@ -696,10 +692,6 @@ pub trait ToolRegistryItem: Send + Sync { self.is_readonly() } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - !self.is_readonly() - } - fn manages_own_execution_timeout(&self) -> bool { false } @@ -1044,10 +1036,6 @@ impl<'a, Tool: ?Sized, Context, Provider: ?Sized> GetToolSpecRuntime<'a, Tool, C get_tool_spec_is_concurrency_safe(input) } - pub fn needs_permissions(&self, input: Option<&Value>) -> bool { - get_tool_spec_needs_permissions(input) - } - pub fn render_tool_use_message(&self, input: &Value) -> String { render_get_tool_spec_tool_use_message(input) } diff --git a/src/crates/execution/tool-contracts/src/lib.rs b/src/crates/execution/tool-contracts/src/lib.rs index 874d5622b0..6114173074 100644 --- a/src/crates/execution/tool-contracts/src/lib.rs +++ b/src/crates/execution/tool-contracts/src/lib.rs @@ -13,6 +13,7 @@ pub mod file_read_freshness; pub mod framework; pub mod input_validator; pub mod mcp_tool_bridge; +pub mod permission_intent; pub mod tool_execution_presentation; pub mod tool_result_storage; pub mod tool_snapshot; @@ -56,9 +57,9 @@ pub use framework::{ build_tool_manifest_policy_tools, build_tool_path_policy_denial_message, build_tool_runtime_artifact_reference, build_tool_session_runtime_artifact_reference, collect_loaded_deferred_tool_specs, get_tool_spec_input_schema, - get_tool_spec_is_concurrency_safe, get_tool_spec_is_readonly, get_tool_spec_needs_permissions, - get_tool_spec_short_description, is_bitfun_current_session_uri, is_bitfun_runtime_uri, - is_bitfun_tool_uri, is_miniapp_headless_agent_run, is_remote_posix_path_within_root, + get_tool_spec_is_concurrency_safe, get_tool_spec_is_readonly, get_tool_spec_short_description, + is_bitfun_current_session_uri, is_bitfun_runtime_uri, is_bitfun_tool_uri, + is_miniapp_headless_agent_run, is_remote_posix_path_within_root, is_tool_path_allowed_by_resolved_roots, materialize_static_tool_provider_groups, miniapp_headless_agent_tool_restrictions, normalize_absolute_posix_path, normalize_host_path, normalize_runtime_relative_path, parse_bitfun_current_session_uri, parse_bitfun_runtime_uri, @@ -99,16 +100,16 @@ pub use mcp_tool_bridge::{ McpToolBridgeDefinition, McpToolBridgeDefinitionInput, McpToolBridgeToolInfo, MCP_TOOL_DELIMITER, MCP_TOOL_PREFIX, }; +pub use permission_intent::PermissionIntent; pub use tool_execution_presentation::{ - build_invalid_tool_call_error_message, build_tool_call_truncation_recovery_notice, - build_tool_confirmation_timeout_presentation, build_tool_execution_error_presentation, + build_invalid_tool_call_error_message, build_permission_denied_tool_presentation, + build_tool_call_truncation_recovery_notice, build_tool_execution_error_presentation, build_tool_execution_timeout_presentation, build_user_rejected_tool_presentation, build_user_rejected_tool_presentation_with_instruction, build_user_steering_interrupted_presentation, is_write_like_tool_name, render_tool_result_for_assistant, truncate_raw_tool_arguments_preview, truncate_raw_tool_arguments_preview_to, truncate_tool_arguments_preview, - ToolExecutionErrorPresentation, TOOL_CONFIRMATION_TIMEOUT_MESSAGE, - TOOL_ERROR_ARGUMENTS_PREVIEW_BYTES, USER_REJECTED_TOOL_MESSAGE, + ToolExecutionErrorPresentation, TOOL_ERROR_ARGUMENTS_PREVIEW_BYTES, USER_REJECTED_TOOL_MESSAGE, USER_STEERING_INTERRUPTED_MESSAGE, }; pub use tool_result_storage::{ diff --git a/src/crates/execution/tool-contracts/src/permission_intent.rs b/src/crates/execution/tool-contracts/src/permission_intent.rs new file mode 100644 index 0000000000..4a70c3e9a5 --- /dev/null +++ b/src/crates/execution/tool-contracts/src/permission_intent.rs @@ -0,0 +1,27 @@ +//! Provider-neutral permission intents emitted by tool preflight. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +/// A side-effect-free description of the resources a tool call intends to use. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionIntent { + pub action: String, + pub resources: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub save_resources: Vec, + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub display_metadata: Map, +} + +impl PermissionIntent { + pub fn new(action: impl Into, resources: Vec) -> Self { + Self { + action: action.into(), + save_resources: resources.clone(), + resources, + display_metadata: Map::new(), + } + } +} diff --git a/src/crates/execution/tool-contracts/src/tool_execution_presentation.rs b/src/crates/execution/tool-contracts/src/tool_execution_presentation.rs index 99cea7d080..651d18bd19 100644 --- a/src/crates/execution/tool-contracts/src/tool_execution_presentation.rs +++ b/src/crates/execution/tool-contracts/src/tool_execution_presentation.rs @@ -4,8 +4,6 @@ pub const TOOL_ERROR_ARGUMENTS_PREVIEW_BYTES: usize = 1024; pub const USER_STEERING_INTERRUPTED_MESSAGE: &str = "Tool execution skipped because the user sent a new steering message for the running turn. Stop the remaining old tool plan and handle the new user message next."; pub const USER_REJECTED_TOOL_MESSAGE: &str = "The user rejected this tool call. Do not retry it unless the user explicitly asks you to. If you cannot complete the task without running this tool call, stop and ask the user how to proceed."; -pub const TOOL_CONFIRMATION_TIMEOUT_MESSAGE: &str = - "The tool confirmation window expired before the user responded. Do not retry the same tool call unless the user explicitly asks you to. If you still need this tool to complete the task, stop and ask the user how to proceed."; #[derive(Debug, Clone, PartialEq)] pub struct ToolExecutionErrorPresentation { @@ -102,20 +100,6 @@ pub fn build_user_steering_interrupted_presentation( } } -pub fn build_tool_confirmation_timeout_presentation( - tool_name: &str, -) -> ToolExecutionErrorPresentation { - ToolExecutionErrorPresentation { - result_json: serde_json::json!({ - "status": "cancelled", - "category": "confirmation_timeout", - "tool_name": tool_name, - "message": TOOL_CONFIRMATION_TIMEOUT_MESSAGE, - }), - result_for_assistant: TOOL_CONFIRMATION_TIMEOUT_MESSAGE.to_string(), - } -} - pub fn build_tool_execution_timeout_presentation( tool_name: &str, timeout_secs: Option, @@ -176,6 +160,27 @@ pub fn build_user_rejected_tool_presentation_with_instruction( } } +pub fn build_permission_denied_tool_presentation( + tool_name: &str, + reason: &str, +) -> ToolExecutionErrorPresentation { + let reason = reason.trim(); + let message = format!( + "This tool call was blocked by the current permission policy: \"{reason}\". Do not retry it. If you cannot complete the task without running this tool call, stop and ask the user how to proceed." + ); + + ToolExecutionErrorPresentation { + result_json: serde_json::json!({ + "status": "rejected", + "category": "permission_denied", + "tool_name": tool_name, + "reason": reason, + "message": message, + }), + result_for_assistant: message, + } +} + pub fn build_invalid_tool_call_error_message( tool_name: &str, tool_is_error: bool, diff --git a/src/crates/execution/tool-contracts/src/tool_snapshot.rs b/src/crates/execution/tool-contracts/src/tool_snapshot.rs index f34fe83593..d6edfd8485 100644 --- a/src/crates/execution/tool-contracts/src/tool_snapshot.rs +++ b/src/crates/execution/tool-contracts/src/tool_snapshot.rs @@ -65,7 +65,6 @@ pub enum ToolEffectFactsSource { pub struct ToolEffectFacts { pub source: ToolEffectFactsSource, pub readonly_by_default: bool, - pub needs_permissions_by_default: bool, pub concurrency_safe_by_default: bool, } @@ -79,14 +78,12 @@ pub struct ToolCancellationContract { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ToolEffectFilter { pub readonly_default_only: bool, - pub include_default_permissioned: bool, } impl ToolEffectFilter { pub fn readonly_only() -> Self { Self { readonly_default_only: true, - include_default_permissioned: false, } } @@ -94,9 +91,6 @@ impl ToolEffectFilter { if self.readonly_default_only && !effects.readonly_by_default { return false; } - if !self.include_default_permissioned && effects.needs_permissions_by_default { - return false; - } true } } @@ -246,7 +240,6 @@ pub async fn materialize_tool_snapshot( effects: ToolEffectFacts { source: ToolEffectFactsSource::NoInputDefault, readonly_by_default: tool.is_readonly(), - needs_permissions_by_default: tool.needs_permissions(None), concurrency_safe_by_default: tool.is_concurrency_safe(None), }, cancellation: ToolCancellationContract { diff --git a/src/crates/execution/tool-contracts/tests/tool_contracts.rs b/src/crates/execution/tool-contracts/tests/tool_contracts.rs index 81f9bf6a76..005ed54f95 100644 --- a/src/crates/execution/tool-contracts/tests/tool_contracts.rs +++ b/src/crates/execution/tool-contracts/tests/tool_contracts.rs @@ -14,8 +14,8 @@ use bitfun_agent_tools::{ build_tool_runtime_artifact_reference, build_tool_session_runtime_artifact_reference, call_deferred_tool_description, call_deferred_tool_input_schema, collect_loaded_deferred_tool_specs, effective_tool_invocation, get_tool_spec_input_schema, - get_tool_spec_is_concurrency_safe, get_tool_spec_is_readonly, get_tool_spec_needs_permissions, - get_tool_spec_short_description, is_bitfun_runtime_uri, is_remote_posix_path_within_root, + get_tool_spec_is_concurrency_safe, get_tool_spec_is_readonly, get_tool_spec_short_description, + is_bitfun_runtime_uri, is_remote_posix_path_within_root, is_tool_path_allowed_by_resolved_roots, normalize_host_path, normalize_runtime_relative_path, parse_bitfun_current_session_uri, parse_bitfun_runtime_uri, posix_resolve_path_with_workspace, posix_style_path_is_absolute, render_get_tool_spec_tool_use_message, @@ -38,14 +38,13 @@ use bitfun_agent_tools::{ ToolWorkspaceKind, ValidationResult, CALL_DEFERRED_TOOL_NAME, GET_TOOL_SPEC_TOOL_NAME, }; use bitfun_agent_tools::{ - build_invalid_tool_call_error_message, build_tool_call_truncation_recovery_notice, - build_tool_confirmation_timeout_presentation, build_tool_execution_error_presentation, + build_invalid_tool_call_error_message, build_permission_denied_tool_presentation, + build_tool_call_truncation_recovery_notice, build_tool_execution_error_presentation, build_user_rejected_tool_presentation, build_user_rejected_tool_presentation_with_instruction, build_user_steering_interrupted_presentation, is_write_like_tool_name, render_tool_result_for_assistant, truncate_raw_tool_arguments_preview_to, - truncate_tool_arguments_preview, TOOL_CONFIRMATION_TIMEOUT_MESSAGE, - TOOL_ERROR_ARGUMENTS_PREVIEW_BYTES, USER_REJECTED_TOOL_MESSAGE, - USER_STEERING_INTERRUPTED_MESSAGE, + truncate_tool_arguments_preview, TOOL_ERROR_ARGUMENTS_PREVIEW_BYTES, + USER_REJECTED_TOOL_MESSAGE, USER_STEERING_INTERRUPTED_MESSAGE, }; use bitfun_agent_tools::{ build_mcp_tool_bridge_definition, build_mcp_tool_bridge_name, build_mcp_tool_bridge_result, @@ -504,28 +503,6 @@ fn steering_interrupted_presentation_preserves_current_contract() { ); } -#[test] -fn tool_confirmation_timeout_presentation_is_not_an_execution_failure() { - let presentation = build_tool_confirmation_timeout_presentation("ExecCommand"); - - assert_eq!(presentation.result_json["status"], "cancelled"); - assert_eq!(presentation.result_json["category"], "confirmation_timeout"); - assert_eq!(presentation.result_json["tool_name"], "ExecCommand"); - assert_eq!( - presentation.result_json["message"], - TOOL_CONFIRMATION_TIMEOUT_MESSAGE - ); - assert!(presentation.result_json["provided_arguments"].is_null()); - assert_eq!( - presentation.result_for_assistant, - TOOL_CONFIRMATION_TIMEOUT_MESSAGE - ); - assert!(!presentation.result_for_assistant.contains("failed")); - assert!(!presentation - .result_for_assistant - .contains("Provided arguments")); -} - #[test] fn tool_execution_timeout_presentation_includes_timeout_seconds() { let presentation = build_tool_execution_timeout_presentation("ExecCommand", Some(120)); @@ -582,6 +559,27 @@ fn user_rejected_tool_presentation_is_not_an_argument_error() { ); } +#[test] +fn permission_denied_tool_presentation_is_not_a_user_rejection() { + let presentation = build_permission_denied_tool_presentation( + "ExecCommand", + "Global permission policy denies bash commands.", + ); + + assert_eq!(presentation.result_json["status"], "rejected"); + assert_eq!(presentation.result_json["category"], "permission_denied"); + assert_eq!(presentation.result_json["tool_name"], "ExecCommand"); + assert_eq!( + presentation.result_json["reason"], + "Global permission policy denies bash commands." + ); + assert_eq!( + presentation.result_for_assistant, + "This tool call was blocked by the current permission policy: \"Global permission policy denies bash commands.\". Do not retry it. If you cannot complete the task without running this tool call, stop and ask the user how to proceed." + ); + assert!(!presentation.result_for_assistant.contains("user rejected")); +} + #[test] fn invalid_tool_call_error_message_preserves_current_contract() { let message = @@ -1881,9 +1879,6 @@ fn get_tool_spec_contract_preserves_static_metadata_and_use_message() { assert!(get_tool_spec_is_concurrency_safe(Some(&json!({ "tool_name": "WebFetch" })))); - assert!(!get_tool_spec_needs_permissions(Some(&json!({ - "tool_name": "WebFetch" - })))); assert_eq!( render_get_tool_spec_tool_use_message(&json!({ "tool_name": "Git" })), "Reading tool spec for 'Git'." @@ -2131,15 +2126,6 @@ impl ToolRegistryItem for InputSensitiveEffectTool { false } - fn needs_permissions(&self, input: Option<&serde_json::Value>) -> bool { - !matches!( - input - .and_then(|value| value.get("action")) - .and_then(serde_json::Value::as_str), - Some("status") - ) - } - fn is_concurrency_safe(&self, input: Option<&serde_json::Value>) -> bool { matches!( input @@ -3249,7 +3235,6 @@ fn get_tool_spec_runtime_facade_owns_static_tool_surface() { assert_eq!(runtime.input_schema(), get_tool_spec_input_schema()); assert!(runtime.is_readonly()); assert!(runtime.is_concurrency_safe(None)); - assert!(!runtime.needs_permissions(None)); assert_eq!( runtime.render_tool_use_message(&json!({ "tool_name": "WebFetch" })), "Reading tool spec for 'WebFetch'." @@ -3457,10 +3442,9 @@ async fn generic_tool_registry_snapshot_preserves_static_provider_identity_after } #[tokio::test] -async fn generic_tool_registry_snapshot_labels_effects_as_no_input_defaults() { +async fn generic_tool_registry_snapshot_labels_execution_effects_as_no_input_defaults() { let mut registry: ToolRegistry = ToolRegistry::new(); let tool = Arc::new(InputSensitiveEffectTool); - assert!(!tool.needs_permissions(Some(&json!({ "action": "status" })))); assert!(tool.is_concurrency_safe(Some(&json!({ "action": "status" })))); registry.register_tool(tool); @@ -3475,7 +3459,6 @@ async fn generic_tool_registry_snapshot_labels_effects_as_no_input_defaults() { assert_eq!(tool.effects.source, ToolEffectFactsSource::NoInputDefault); assert!(!tool.effects.readonly_by_default); - assert!(tool.effects.needs_permissions_by_default); assert!(!tool.effects.concurrency_safe_by_default); } diff --git a/src/crates/execution/tool-execution/src/pipeline.rs b/src/crates/execution/tool-execution/src/pipeline.rs index e74380fdd0..1cf18bd9bf 100644 --- a/src/crates/execution/tool-execution/src/pipeline.rs +++ b/src/crates/execution/tool-execution/src/pipeline.rs @@ -38,7 +38,6 @@ pub enum ToolTaskStateKind { Waiting, Running, Streaming, - AwaitingConfirmation, Completed, Failed, Rejected, @@ -54,10 +53,7 @@ impl ToolTaskStateKind { } pub fn is_cancellable(self) -> bool { - matches!( - self, - Self::Queued | Self::Waiting | Self::Running | Self::AwaitingConfirmation - ) + matches!(self, Self::Queued | Self::Waiting | Self::Running) } pub fn starts_execution_timer(self) -> bool { @@ -76,7 +72,6 @@ pub struct ToolStateCounts { pub waiting: usize, pub running: usize, pub streaming: usize, - pub awaiting_confirmation: usize, pub completed: usize, pub failed: usize, pub rejected: usize, @@ -104,10 +99,6 @@ pub enum ToolStateEventKind { Streaming { chunks_received: usize, }, - AwaitingConfirmation { - params: serde_json::Value, - timeout_at: Option, - }, Completed { result: serde_json::Value, result_for_assistant: Option, @@ -257,7 +248,6 @@ pub fn count_tool_states(states: impl IntoIterator) -> ToolTaskStateKind::Waiting => counts.waiting += 1, ToolTaskStateKind::Running => counts.running += 1, ToolTaskStateKind::Streaming => counts.streaming += 1, - ToolTaskStateKind::AwaitingConfirmation => counts.awaiting_confirmation += 1, ToolTaskStateKind::Completed => counts.completed += 1, ToolTaskStateKind::Failed => counts.failed += 1, ToolTaskStateKind::Rejected => counts.rejected += 1, @@ -295,13 +285,6 @@ pub fn tool_state_event_data(facts: ToolStateEventFacts) -> ToolEventData { identity, chunks_received, }, - ToolStateEventKind::AwaitingConfirmation { params, timeout_at } => { - ToolEventData::ConfirmationNeeded { - identity, - params, - timeout_at, - } - } ToolStateEventKind::Completed { result, result_for_assistant, diff --git a/src/crates/execution/tool-execution/tests/tool_pipeline_planning.rs b/src/crates/execution/tool-execution/tests/tool_pipeline_planning.rs index f58f1d6343..7533107ffc 100644 --- a/src/crates/execution/tool-execution/tests/tool_pipeline_planning.rs +++ b/src/crates/execution/tool-execution/tests/tool_pipeline_planning.rs @@ -109,9 +109,6 @@ fn cancellation_policy_preserves_cancellable_and_terminal_state_contract() { assert!(should_cancel_tool_state(ToolTaskStateKind::Queued)); assert!(should_cancel_tool_state(ToolTaskStateKind::Waiting)); assert!(should_cancel_tool_state(ToolTaskStateKind::Running)); - assert!(should_cancel_tool_state( - ToolTaskStateKind::AwaitingConfirmation - )); assert!(!should_cancel_tool_state(ToolTaskStateKind::Streaming)); assert!(!should_cancel_tool_state(ToolTaskStateKind::Completed)); assert!(!should_cancel_tool_state(ToolTaskStateKind::Failed)); @@ -161,19 +158,17 @@ fn state_counts_preserve_pipeline_stats_contract() { ToolTaskStateKind::Waiting, ToolTaskStateKind::Running, ToolTaskStateKind::Streaming, - ToolTaskStateKind::AwaitingConfirmation, ToolTaskStateKind::Completed, ToolTaskStateKind::Failed, ToolTaskStateKind::Rejected, ToolTaskStateKind::Cancelled, ]); - assert_eq!(counts.total, 10); + assert_eq!(counts.total, 9); assert_eq!(counts.queued, 2); assert_eq!(counts.waiting, 1); assert_eq!(counts.running, 1); assert_eq!(counts.streaming, 1); - assert_eq!(counts.awaiting_confirmation, 1); assert_eq!(counts.completed, 1); assert_eq!(counts.failed, 1); assert_eq!(counts.rejected, 1); diff --git a/src/crates/interfaces/acp/src/client/tool.rs b/src/crates/interfaces/acp/src/client/tool.rs index 0f71adfc9f..3d62b997e1 100644 --- a/src/crates/interfaces/acp/src/client/tool.rs +++ b/src/crates/interfaces/acp/src/client/tool.rs @@ -86,10 +86,6 @@ impl Tool for AcpAgentTool { false } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - !self.definition.read_only - } - async fn validate_input( &self, input: &Value, diff --git a/src/crates/interfaces/acp/src/runtime/prompt.rs b/src/crates/interfaces/acp/src/runtime/prompt.rs index 52bdb3c600..5b300dd70f 100644 --- a/src/crates/interfaces/acp/src/runtime/prompt.rs +++ b/src/crates/interfaces/acp/src/runtime/prompt.rs @@ -7,8 +7,8 @@ use agent_client_protocol::schema::{ use agent_client_protocol::{Client, ConnectionTo, Error, Result}; use bitfun_agent_runtime::sdk::{ AgentDialogTurnRequest, AgentSessionEventReceiver, AgentSubmissionSource, - AgentToolConfirmationRequest, AgentToolRejectionRequest, AgentTurnCancellationRequest, - DialogSubmissionPolicy, DialogSubmitOutcome, + AgentTurnCancellationRequest, DialogSubmissionPolicy, DialogSubmitOutcome, PermissionReply, + PermissionRequest, PermissionRequestEvent, PermissionRequestEventReceiver, }; use bitfun_events::AgenticEvent as CoreEvent; use log::warn; @@ -43,6 +43,10 @@ impl BitfunAcpRuntime { .agent_runtime .subscribe_session_events(&acp_session.bitfun_session_id) .map_err(|error| Self::session_runtime_error(&session_id, error))?; + let mut permission_rx = self + .agent_runtime + .subscribe_permission_requests() + .map_err(|error| Self::session_runtime_error(&session_id, error))?; let outcome = self .agent_runtime .submit_dialog_turn(dialog_turn_request(&acp_session, parsed_prompt)) @@ -68,6 +72,7 @@ impl BitfunAcpRuntime { let stop_reason = wait_for_prompt_completion( self, &mut event_rx, + &mut permission_rx, &connection, &acp_session.acp_session_id, &acp_session.bitfun_session_id, @@ -144,9 +149,18 @@ fn acp_user_message_metadata() -> serde_json::Map { .expect("ACP metadata must be an object") } +fn permission_request_targets_session(request: &PermissionRequest, session_id: &str) -> bool { + request.session_id == session_id + || request + .delegation + .as_ref() + .is_some_and(|delegation| delegation.parent_session_id == session_id) +} + async fn wait_for_prompt_completion( runtime: &BitfunAcpRuntime, event_rx: &mut AgentSessionEventReceiver, + permission_rx: &mut PermissionRequestEventReceiver, connection: &ConnectionTo, acp_session_id: &str, bitfun_session_id: &str, @@ -156,7 +170,49 @@ async fn wait_for_prompt_completion( let mut inline_think = InlineThinkRouter::new(); loop { - let event = match event_rx.recv().await { + let event_result = tokio::select! { + event = event_rx.recv() => event, + permission = permission_rx.recv() => { + match permission { + Ok(PermissionRequestEvent::Asked { request }) + if permission_request_targets_session(&request, bitfun_session_id) => + { + handle_permission_request( + runtime, + connection, + acp_session_id, + request, + ) + .await?; + } + Ok(_) => {} + Err(broadcast::error::RecvError::Lagged(count)) => { + cancel_turn_after_event_stream_failure( + runtime, + bitfun_session_id, + turn_id, + "acp_permission_stream_lagged", + ) + .await; + return Err(Error::internal_error().data(format!( + "permission event stream lagged after skipping {count} events" + ))); + } + Err(broadcast::error::RecvError::Closed) => { + cancel_turn_after_event_stream_failure( + runtime, + bitfun_session_id, + turn_id, + "acp_permission_stream_closed", + ) + .await; + return Err(Error::internal_error().data("permission event stream closed")); + } + } + continue; + } + }; + let event = match event_result { Ok(envelope) => envelope.event, Err(broadcast::error::RecvError::Lagged(count)) => { let message = format!( @@ -209,23 +265,6 @@ async fn wait_for_prompt_completion( for update in tool_event_updates(&tool_event, &mut seen_tool_calls) { send_update(connection, acp_session_id, update)?; } - - if let bitfun_events::ToolEventData::ConfirmationNeeded { - identity, params, .. - } = tool_event - { - let (effective_tool_name, effective_params) = - bitfun_agent_tools::effective_tool_invocation(&identity.tool_name, ¶ms); - handle_permission_request( - runtime, - connection, - acp_session_id, - &identity.tool_id, - effective_tool_name, - effective_params, - ) - .await?; - } } CoreEvent::DialogTurnCompleted { .. } => { send_inline_think_segments(connection, acp_session_id, inline_think.flush())?; @@ -309,86 +348,66 @@ async fn handle_permission_request( runtime: &BitfunAcpRuntime, connection: &ConnectionTo, acp_session_id: &str, - tool_id: &str, - tool_name: &str, - params: &serde_json::Value, + permission: PermissionRequest, ) -> Result<()> { - let request = permission_request(acp_session_id, tool_id, tool_name, params); + let request_id = permission.request_id.clone(); + let tool_name = permission.source.identity.clone(); + let params = json!({ + "action": permission.action, + "resources": permission.resources, + "saveResources": permission.save_resources, + "source": permission.source, + "displayMetadata": permission.display_metadata, + }); + let request = permission_request(acp_session_id, &request_id, &tool_name, ¶ms); let response = match connection.send_request(request).block_task().await { Ok(response) => response, Err(error) => { - let reason = format!("ACP permission request failed: {}", error); let _ = runtime .agent_runtime - .reject_tool(AgentToolRejectionRequest { - tool_id: tool_id.to_string(), - reason: reason.clone(), - }) + .respond_permission( + &request_id, + PermissionReply::Reject { + feedback: Some(format!("ACP permission request failed: {error}")), + }, + ) .await; return Err(error); } }; - match response.outcome { + let reply = match response.outcome { RequestPermissionOutcome::Selected(selected) if selected.option_id.to_string() == PERMISSION_ALLOW_ONCE => { - runtime - .agent_runtime - .confirm_tool(AgentToolConfirmationRequest { - tool_id: tool_id.to_string(), - updated_input: None, - }) - .await - .map_err(BitfunAcpRuntime::runtime_error)?; + PermissionReply::Once } RequestPermissionOutcome::Selected(selected) if selected.option_id.to_string() == PERMISSION_REJECT_ONCE => { - runtime - .agent_runtime - .reject_tool(AgentToolRejectionRequest { - tool_id: tool_id.to_string(), - reason: "Rejected by ACP client".to_string(), - }) - .await - .map_err(BitfunAcpRuntime::runtime_error)?; - } - RequestPermissionOutcome::Cancelled => { - runtime - .agent_runtime - .reject_tool(AgentToolRejectionRequest { - tool_id: tool_id.to_string(), - reason: "ACP permission request cancelled".to_string(), - }) - .await - .map_err(BitfunAcpRuntime::runtime_error)?; + PermissionReply::Reject { + feedback: Some("Rejected by ACP client".to_string()), + } } - RequestPermissionOutcome::Selected(selected) => { - let reason = format!( + RequestPermissionOutcome::Cancelled => PermissionReply::Reject { + feedback: Some("ACP permission request cancelled".to_string()), + }, + RequestPermissionOutcome::Selected(selected) => PermissionReply::Reject { + feedback: Some(format!( "Unknown ACP permission option selected: {}", selected.option_id - ); - runtime - .agent_runtime - .reject_tool(AgentToolRejectionRequest { - tool_id: tool_id.to_string(), - reason, - }) - .await - .map_err(BitfunAcpRuntime::runtime_error)?; - } - _ => { - runtime - .agent_runtime - .reject_tool(AgentToolRejectionRequest { - tool_id: tool_id.to_string(), - reason: "Unsupported ACP permission outcome".to_string(), - }) - .await - .map_err(BitfunAcpRuntime::runtime_error)?; - } - } + )), + }, + _ => PermissionReply::Reject { + feedback: Some("Unsupported ACP permission outcome".to_string()), + }, + }; + + runtime + .agent_runtime + .respond_permission(&request_id, reply) + .await + .map_err(BitfunAcpRuntime::runtime_error)?; Ok(()) } @@ -397,12 +416,14 @@ async fn handle_permission_request( mod tests { use bitfun_agent_runtime::sdk::{ AgentInputAttachment, AgentSubmissionSource, DialogSubmitOutcome, + PermissionDelegationContext, PermissionRequest, PermissionRequestSource, + PermissionRequestSourceKind, }; use bitfun_events::AgenticEvent; use super::{ - dialog_turn_request, prompt_event_matches_turn, resolve_started_prompt_turn, - turn_cancellation_request, AcpSessionState, ParsedPrompt, + dialog_turn_request, permission_request_targets_session, prompt_event_matches_turn, + resolve_started_prompt_turn, turn_cancellation_request, AcpSessionState, ParsedPrompt, }; fn session() -> AcpSessionState { @@ -417,6 +438,33 @@ mod tests { } } + fn permission_request(session_id: &str, parent_session_id: Option<&str>) -> PermissionRequest { + PermissionRequest { + request_id: "request-1".to_string(), + round_id: "synthetic:request-1".to_string(), + order: 0, + tool_call_id: Some("tool-call".to_string()), + project_path: Some("/workspace".to_string()), + project_id: "project".to_string(), + session_id: session_id.to_string(), + agent_id: "Explore".to_string(), + action: "read".to_string(), + resources: vec!["README.md".to_string()], + save_resources: Vec::new(), + source: PermissionRequestSource { + kind: PermissionRequestSourceKind::ToolCall, + identity: "Read".to_string(), + }, + delegation: parent_session_id.map(|parent_session_id| PermissionDelegationContext { + parent_session_id: parent_session_id.to_string(), + parent_dialog_turn_id: Some("parent-turn".to_string()), + parent_tool_call_id: "parent-task".to_string(), + subagent_type: "Explore".to_string(), + }), + display_metadata: serde_json::Map::new(), + } + } + #[test] fn dialog_request_preserves_cli_confirmation_and_acp_metadata() { let request = dialog_turn_request( @@ -435,7 +483,6 @@ mod tests { assert_eq!(request.session_id, "bitfun-session"); assert_eq!(request.workspace_path.as_deref(), Some("/workspace")); assert_eq!(request.policy.trigger_source, AgentSubmissionSource::Cli); - assert!(request.policy.requires_tool_confirmation()); assert_eq!(request.metadata["acp_transport"], true); assert_eq!(request.attachments.len(), 1); } @@ -490,4 +537,20 @@ mod tests { assert!(prompt_event_matches_turn(¤t, "turn-current")); assert!(!prompt_event_matches_turn(&other, "turn-current")); } + + #[test] + fn permission_requests_target_direct_and_delegated_acp_sessions() { + assert!(permission_request_targets_session( + &permission_request("bitfun-session", None), + "bitfun-session", + )); + assert!(permission_request_targets_session( + &permission_request("child-session", Some("bitfun-session")), + "bitfun-session", + )); + assert!(!permission_request_targets_session( + &permission_request("child-session", Some("other-session")), + "bitfun-session", + )); + } } diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index 9d2e8b2362..39a0ccf354 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -30,6 +30,7 @@ sha2 = { workspace = true } which = { workspace = true } similar = { workspace = true } regex = { workspace = true } +rusqlite = { version = "0.32", features = ["bundled"], optional = true } [target.'cfg(windows)'.dependencies] win32job = { workspace = true } @@ -48,6 +49,7 @@ default = ["lsp"] lsp = ["dep:anyhow", "dep:notify", "dep:zip"] markdown = ["dep:serde_yaml"] workspace-runtime = ["dep:anyhow", "dep:async-trait", "dep:bitfun-runtime-ports"] +permission = ["dep:async-trait", "dep:bitfun-runtime-ports", "dep:rusqlite", "bitfun-runtime-ports/permission"] [dev-dependencies] filetime = { workspace = true } diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index 62255a45f0..f652159759 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -12,6 +12,8 @@ pub mod lsp; pub mod managed_runtime; #[cfg(feature = "markdown")] pub mod markdown; +#[cfg(feature = "permission")] +pub mod permission_store; pub mod persistence; pub mod process_manager; pub mod process_tree; diff --git a/src/crates/services/services-core/src/permission_store.rs b/src/crates/services/services-core/src/permission_store.rs new file mode 100644 index 0000000000..d38f7e3edd --- /dev/null +++ b/src/crates/services/services-core/src/permission_store.rs @@ -0,0 +1,505 @@ +//! SQLite-backed project permission grants and bounded audit facts. + +use bitfun_runtime_ports::{ + PermissionAuditEvent, PermissionAuditRecord, PermissionAuditStorePort, PermissionGrant, + PermissionGrantKey, PermissionGrantStorePort, PermissionReplyStorePort, PermissionRequest, + PortError, PortErrorKind, PortResult, RuntimeServiceCapability, RuntimeServicePort, +}; +use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const PERMISSION_STORE_FILE_NAME: &str = "tool-permissions.sqlite"; +const PERMISSION_STORE_SCHEMA_VERSION: i64 = 1; +const DEFAULT_AUDIT_LIMIT_PER_PROJECT: usize = 1_000; + +#[derive(Debug, Clone)] +struct PreparedAuditRecord { + record: PermissionAuditRecord, + request_json: String, + event_json: String, +} + +/// User-level permission persistence. Grants and audit records are scoped by +/// project ID inside one database under the user data directory. +#[derive(Debug, Clone)] +pub struct ProjectPermissionSqliteStore { + path: PathBuf, + audit_limit_per_project: usize, +} + +impl ProjectPermissionSqliteStore { + pub fn new(base_dir: impl Into) -> Self { + Self { + path: base_dir.into().join(PERMISSION_STORE_FILE_NAME), + audit_limit_per_project: DEFAULT_AUDIT_LIMIT_PER_PROJECT, + } + } + + pub fn with_audit_limit(mut self, audit_limit_per_project: usize) -> Self { + self.audit_limit_per_project = audit_limit_per_project.max(1); + self + } + + pub fn path(&self) -> &Path { + &self.path + } + + async fn execute( + &self, + operation: impl FnOnce(&mut Connection, usize) -> PortResult + Send + 'static, + ) -> PortResult { + let path = self.path.clone(); + let audit_limit_per_project = self.audit_limit_per_project; + tokio::task::spawn_blocking(move || { + let mut connection = open_connection(&path)?; + operation(&mut connection, audit_limit_per_project) + }) + .await + .map_err(|error| { + PortError::new( + PortErrorKind::Backend, + format!("Permission store worker failed: {error}"), + ) + })? + } +} + +impl RuntimeServicePort for ProjectPermissionSqliteStore { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::Permission + } +} + +#[async_trait::async_trait] +impl PermissionGrantStorePort for ProjectPermissionSqliteStore { + async fn list_project_grants(&self, project_id: &str) -> PortResult> { + let project_id = project_id.to_string(); + self.execute(move |connection, _| list_grants(connection, &project_id)) + .await + } + + async fn add_project_grants(&self, grants: Vec) -> PortResult<()> { + if grants.is_empty() { + return Ok(()); + } + validate_grants(&grants)?; + + self.execute(move |connection, _| { + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(store_error)?; + insert_grants(&transaction, &grants)?; + transaction.commit().map_err(store_error) + }) + .await + } + + async fn remove_project_grant(&self, key: PermissionGrantKey) -> PortResult { + self.execute(move |connection, _| { + let removed = connection + .execute( + "DELETE FROM grants WHERE project_id = ?1 AND action = ?2 AND resource = ?3", + params![key.project_id, key.action, key.resource], + ) + .map_err(store_error)?; + Ok(removed > 0) + }) + .await + } + + async fn clear_project_grants(&self, project_id: &str) -> PortResult { + if project_id.trim().is_empty() { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "Permission grant project ID must be non-empty", + )); + } + let project_id = project_id.to_string(); + + self.execute(move |connection, _| { + connection + .execute( + "DELETE FROM grants WHERE project_id = ?1", + params![project_id], + ) + .map_err(store_error) + }) + .await + } +} + +#[async_trait::async_trait] +impl PermissionReplyStorePort for ProjectPermissionSqliteStore { + async fn commit_permission_reply( + &self, + grants: Vec, + audit: Vec, + ) -> PortResult<()> { + validate_grants(&grants)?; + let audit = prepare_audit_records(audit)?; + + self.execute(move |connection, audit_limit_per_project| { + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(store_error)?; + ensure_audit_records_compatible(&transaction, &audit)?; + insert_grants(&transaction, &grants)?; + insert_audit_records(&transaction, &audit)?; + prune_audit_records(&transaction, &audit, audit_limit_per_project)?; + transaction.commit().map_err(store_error) + }) + .await + } +} + +#[async_trait::async_trait] +impl PermissionAuditStorePort for ProjectPermissionSqliteStore { + async fn append_permission_audit(&self, record: PermissionAuditRecord) -> PortResult<()> { + let record = prepare_audit_record(record)?; + + self.execute(move |connection, audit_limit_per_project| { + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(store_error)?; + ensure_audit_record_compatible(&transaction, &record)?; + insert_audit_records(&transaction, std::slice::from_ref(&record))?; + prune_audit_records( + &transaction, + std::slice::from_ref(&record), + audit_limit_per_project, + )?; + transaction.commit().map_err(store_error) + }) + .await + } + + async fn list_project_permission_audit( + &self, + project_id: &str, + ) -> PortResult> { + let project_id = project_id.to_string(); + self.execute(move |connection, _| list_audit_records(connection, &project_id)) + .await + } +} + +fn open_connection(path: &Path) -> PortResult { + let parent = path.parent().ok_or_else(|| { + PortError::new( + PortErrorKind::Backend, + format!( + "Permission store database path has no parent: {}", + path.display() + ), + ) + })?; + fs::create_dir_all(parent).map_err(store_error)?; + + let mut connection = Connection::open(path).map_err(store_error)?; + connection + .busy_timeout(Duration::from_secs(5)) + .map_err(store_error)?; + connection + .execute_batch( + r#" + PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + "#, + ) + .map_err(store_error)?; + initialize_schema(&mut connection)?; + Ok(connection) +} + +fn initialize_schema(connection: &mut Connection) -> PortResult<()> { + let schema_version = connection + .query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0)) + .map_err(store_error)?; + if schema_version == PERMISSION_STORE_SCHEMA_VERSION { + return Ok(()); + } + if schema_version != 0 { + return Err(PortError::new( + PortErrorKind::Backend, + format!( + "Unsupported permission store schema version: {schema_version}; expected at most {PERMISSION_STORE_SCHEMA_VERSION}" + ), + )); + } + + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(store_error)?; + transaction + .execute_batch( + r#" + CREATE TABLE IF NOT EXISTS grants ( + project_id TEXT NOT NULL, + action TEXT NOT NULL, + resource TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + PRIMARY KEY (project_id, action, resource) + ); + + CREATE TABLE IF NOT EXISTS audit ( + audit_id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + request_json TEXT NOT NULL, + event_json TEXT NOT NULL, + timestamp_ms INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_audit_project_timestamp + ON audit (project_id, timestamp_ms, audit_id); + "#, + ) + .map_err(store_error)?; + transaction + .pragma_update(None, "user_version", PERMISSION_STORE_SCHEMA_VERSION) + .map_err(store_error)?; + transaction.commit().map_err(store_error) +} + +fn list_grants(connection: &Connection, project_id: &str) -> PortResult> { + let mut statement = connection + .prepare( + "SELECT project_id, action, resource, created_at_ms + FROM grants + WHERE project_id = ?1 + ORDER BY action ASC, resource ASC", + ) + .map_err(store_error)?; + let rows = statement + .query_map(params![project_id], |row| { + Ok(PermissionGrant { + project_id: row.get(0)?, + action: row.get(1)?, + resource: row.get(2)?, + created_at_ms: row.get(3)?, + }) + }) + .map_err(store_error)?; + collect_rows(rows) +} + +fn list_audit_records( + connection: &Connection, + project_id: &str, +) -> PortResult> { + let mut statement = connection + .prepare( + "SELECT audit_id, request_json, event_json, timestamp_ms + FROM audit + WHERE project_id = ?1 + ORDER BY timestamp_ms ASC, audit_id ASC", + ) + .map_err(store_error)?; + let rows = statement + .query_map(params![project_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + )) + }) + .map_err(store_error)?; + + let mut records = Vec::new(); + for row in rows { + let (audit_id, request_json, event_json, timestamp_ms) = row.map_err(store_error)?; + records.push(PermissionAuditRecord { + audit_id, + request: deserialize_audit_value(&request_json, "request")?, + event: deserialize_audit_value(&event_json, "event")?, + timestamp_ms, + }); + } + Ok(records) +} + +fn collect_rows( + rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result>, +) -> PortResult> { + rows.collect::, _>>().map_err(store_error) +} + +fn insert_grants(transaction: &Transaction<'_>, grants: &[PermissionGrant]) -> PortResult<()> { + let mut statement = transaction + .prepare( + "INSERT OR IGNORE INTO grants (project_id, action, resource, created_at_ms) + VALUES (?1, ?2, ?3, ?4)", + ) + .map_err(store_error)?; + for grant in grants { + statement + .execute(params![ + grant.project_id, + grant.action, + grant.resource, + grant.created_at_ms, + ]) + .map_err(store_error)?; + } + Ok(()) +} + +fn ensure_audit_records_compatible( + transaction: &Transaction<'_>, + records: &[PreparedAuditRecord], +) -> PortResult<()> { + for record in records { + ensure_audit_record_compatible(transaction, record)?; + } + Ok(()) +} + +fn ensure_audit_record_compatible( + transaction: &Transaction<'_>, + record: &PreparedAuditRecord, +) -> PortResult<()> { + let existing = transaction + .query_row( + "SELECT request_json, event_json FROM audit WHERE audit_id = ?1", + params![record.record.audit_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional() + .map_err(store_error)?; + + if let Some((request_json, event_json)) = existing { + let existing_request: PermissionRequest = + deserialize_audit_value(&request_json, "request")?; + let existing_event: PermissionAuditEvent = deserialize_audit_value(&event_json, "event")?; + if existing_request != record.record.request || existing_event != record.record.event { + return Err(audit_conflict(&record.record.audit_id)); + } + } + Ok(()) +} + +fn insert_audit_records( + transaction: &Transaction<'_>, + records: &[PreparedAuditRecord], +) -> PortResult<()> { + let mut statement = transaction + .prepare( + "INSERT OR IGNORE INTO audit + (audit_id, project_id, request_json, event_json, timestamp_ms) + VALUES (?1, ?2, ?3, ?4, ?5)", + ) + .map_err(store_error)?; + for record in records { + statement + .execute(params![ + record.record.audit_id, + record.record.request.project_id, + record.request_json, + record.event_json, + record.record.timestamp_ms, + ]) + .map_err(store_error)?; + } + Ok(()) +} + +fn prune_audit_records( + transaction: &Transaction<'_>, + records: &[PreparedAuditRecord], + audit_limit_per_project: usize, +) -> PortResult<()> { + let project_ids = records + .iter() + .map(|record| record.record.request.project_id.as_str()) + .collect::>(); + let offset = i64::try_from(audit_limit_per_project).map_err(|_| { + PortError::new( + PortErrorKind::InvalidRequest, + "Permission audit retention limit exceeds SQLite integer range", + ) + })?; + + for project_id in project_ids { + transaction + .execute( + "DELETE FROM audit + WHERE project_id = ?1 + AND audit_id IN ( + SELECT audit_id + FROM audit + WHERE project_id = ?2 + ORDER BY timestamp_ms DESC, audit_id DESC + LIMIT -1 OFFSET ?3 + )", + params![project_id, project_id, offset], + ) + .map_err(store_error)?; + } + Ok(()) +} + +fn prepare_audit_records( + records: Vec, +) -> PortResult> { + records.into_iter().map(prepare_audit_record).collect() +} + +fn prepare_audit_record(record: PermissionAuditRecord) -> PortResult { + if record.audit_id.trim().is_empty() || record.request.project_id.trim().is_empty() { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "Permission audit ID and project ID must be non-empty", + )); + } + + let request_json = serde_json::to_string(&record.request).map_err(store_error)?; + let event_json = serde_json::to_string(&record.event).map_err(store_error)?; + Ok(PreparedAuditRecord { + record, + request_json, + event_json, + }) +} + +fn deserialize_audit_value( + value: &str, + field: &str, +) -> PortResult { + serde_json::from_str(value).map_err(|error| { + PortError::new( + PortErrorKind::Backend, + format!("Permission audit {field} data is invalid: {error}"), + ) + }) +} + +fn validate_grants(grants: &[PermissionGrant]) -> PortResult<()> { + if grants.iter().any(|grant| { + grant.project_id.trim().is_empty() + || grant.action.trim().is_empty() + || grant.resource.trim().is_empty() + }) { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "Permission grant project, action, and resource must be non-empty", + )); + } + Ok(()) +} + +fn audit_conflict(audit_id: &str) -> PortError { + PortError::new( + PortErrorKind::InvalidRequest, + format!("Permission audit ID already exists with different content: {audit_id}"), + ) +} + +fn store_error(error: impl std::fmt::Display) -> PortError { + PortError::new( + PortErrorKind::Backend, + format!("Permission store operation failed: {error}"), + ) +} diff --git a/src/crates/services/services-core/tests/permission_store_contracts.rs b/src/crates/services/services-core/tests/permission_store_contracts.rs new file mode 100644 index 0000000000..9070b146a4 --- /dev/null +++ b/src/crates/services/services-core/tests/permission_store_contracts.rs @@ -0,0 +1,300 @@ +#![cfg(feature = "permission")] + +use bitfun_runtime_ports::{ + PermissionAuditEvent, PermissionAuditRecord, PermissionAuditStorePort, PermissionGrant, + PermissionGrantKey, PermissionGrantStorePort, PermissionReply, PermissionReplySource, + PermissionReplyStorePort, PermissionRequest, PermissionRequestSource, + PermissionRequestSourceKind, +}; +use bitfun_services_core::permission_store::ProjectPermissionSqliteStore; +use serde_json::Map; + +fn request(request_id: &str, project_id: &str) -> PermissionRequest { + PermissionRequest { + request_id: request_id.to_string(), + round_id: format!("synthetic:{request_id}"), + order: 0, + tool_call_id: None, + project_path: None, + project_id: project_id.to_string(), + session_id: "session-1".to_string(), + agent_id: "agentic".to_string(), + action: "read".to_string(), + resources: vec!["README.md".to_string()], + save_resources: vec!["README.md".to_string()], + source: PermissionRequestSource { + kind: PermissionRequestSourceKind::ToolCall, + identity: "tool-1".to_string(), + }, + delegation: None, + display_metadata: Map::new(), + } +} + +fn audit_record( + audit_id: &str, + request_id: &str, + project_id: &str, + timestamp_ms: i64, +) -> PermissionAuditRecord { + PermissionAuditRecord { + audit_id: audit_id.to_string(), + request: request(request_id, project_id), + event: PermissionAuditEvent::Requested, + timestamp_ms, + } +} + +#[tokio::test] +async fn project_grants_are_idempotent_isolated_and_survive_store_recreation() { + let root = tempfile::tempdir().expect("temp permission store"); + let store = ProjectPermissionSqliteStore::new(root.path()); + assert_eq!( + store.path(), + root.path().join("tool-permissions.sqlite").as_path() + ); + let project_a = PermissionGrant { + project_id: "project-a".to_string(), + action: "read".to_string(), + resource: "README.md".to_string(), + created_at_ms: 10, + }; + let project_b = PermissionGrant { + project_id: "project-b".to_string(), + action: "edit".to_string(), + resource: "src/*".to_string(), + created_at_ms: 20, + }; + + store + .add_project_grants(vec![project_a.clone(), project_a.clone(), project_b]) + .await + .expect("persist grants"); + assert!(store.path().is_file()); + let connection = rusqlite::Connection::open(store.path()).expect("open permission database"); + let schema_version = connection + .query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0)) + .expect("read permission database schema version"); + assert_eq!(schema_version, 1); + + let reopened = ProjectPermissionSqliteStore::new(root.path()); + assert_eq!( + reopened + .list_project_grants("project-a") + .await + .expect("list project grants"), + vec![project_a.clone()] + ); + assert_eq!( + reopened + .list_project_grants("project-b") + .await + .expect("list other project grants") + .len(), + 1 + ); + + assert!(reopened + .remove_project_grant(PermissionGrantKey { + project_id: "project-a".to_string(), + action: "read".to_string(), + resource: "README.md".to_string(), + }) + .await + .expect("remove grant")); + assert!(reopened + .list_project_grants("project-a") + .await + .expect("list removed project grants") + .is_empty()); +} + +#[tokio::test] +async fn clearing_grants_only_removes_the_selected_project() { + let root = tempfile::tempdir().expect("temp permission store"); + let store = ProjectPermissionSqliteStore::new(root.path()); + store + .add_project_grants(vec![ + PermissionGrant { + project_id: "project-a".to_string(), + action: "read".to_string(), + resource: "README.md".to_string(), + created_at_ms: 10, + }, + PermissionGrant { + project_id: "project-b".to_string(), + action: "edit".to_string(), + resource: "src/*".to_string(), + created_at_ms: 20, + }, + ]) + .await + .expect("persist grants"); + + assert_eq!( + store + .clear_project_grants("project-a") + .await + .expect("clear project grants"), + 1 + ); + assert!(store + .list_project_grants("project-a") + .await + .expect("list cleared project grants") + .is_empty()); + assert_eq!( + store + .list_project_grants("project-b") + .await + .expect("list retained project grants") + .len(), + 1 + ); +} + +#[tokio::test] +async fn audit_records_are_idempotent_project_scoped_and_persistent() { + let root = tempfile::tempdir().expect("temp permission store"); + let store = ProjectPermissionSqliteStore::new(root.path()); + let record = PermissionAuditRecord { + audit_id: "request-1:replied".to_string(), + request: request("request-1", "project-a"), + event: PermissionAuditEvent::Replied { + reply: PermissionReply::Once, + source: PermissionReplySource::User, + }, + timestamp_ms: 100, + }; + + store + .append_permission_audit(record.clone()) + .await + .expect("append audit"); + store + .append_permission_audit(record.clone()) + .await + .expect("repeat audit idempotently"); + store + .append_permission_audit(PermissionAuditRecord { + timestamp_ms: 101, + ..record.clone() + }) + .await + .expect("repeat audit after retry timestamp change"); + store + .append_permission_audit(PermissionAuditRecord { + audit_id: "request-2:requested".to_string(), + request: request("request-2", "project-b"), + event: PermissionAuditEvent::Requested, + timestamp_ms: 90, + }) + .await + .expect("append other project audit"); + + let reopened = ProjectPermissionSqliteStore::new(root.path()); + assert_eq!( + reopened + .list_project_permission_audit("project-a") + .await + .expect("list project audit"), + vec![record] + ); + assert_eq!( + reopened + .list_project_permission_audit("project-b") + .await + .expect("list other project audit") + .len(), + 1 + ); +} + +#[tokio::test] +async fn reply_transaction_persists_grants_and_audit_in_one_state_update() { + let root = tempfile::tempdir().expect("temp permission store"); + let store = ProjectPermissionSqliteStore::new(root.path()); + let grant = PermissionGrant { + project_id: "project-a".to_string(), + action: "read".to_string(), + resource: "README.md".to_string(), + created_at_ms: 100, + }; + let audit = PermissionAuditRecord { + audit_id: "request-1:replied".to_string(), + request: request("request-1", "project-a"), + event: PermissionAuditEvent::Replied { + reply: PermissionReply::Always, + source: PermissionReplySource::User, + }, + timestamp_ms: 100, + }; + + store + .commit_permission_reply(vec![grant.clone()], vec![audit.clone()]) + .await + .expect("commit reply transaction"); + + let reopened = ProjectPermissionSqliteStore::new(root.path()); + assert_eq!( + reopened + .list_project_grants("project-a") + .await + .expect("list committed grants"), + vec![grant] + ); + assert_eq!( + reopened + .list_project_permission_audit("project-a") + .await + .expect("list committed audit"), + vec![audit] + ); +} + +#[tokio::test] +async fn audit_retention_keeps_the_newest_records_for_each_project() { + let root = tempfile::tempdir().expect("temp permission store"); + let store = ProjectPermissionSqliteStore::new(root.path()).with_audit_limit(2); + + store + .append_permission_audit(audit_record("project-a:1", "request-1", "project-a", 10)) + .await + .expect("append first project audit"); + store + .append_permission_audit(audit_record("project-a:2", "request-2", "project-a", 20)) + .await + .expect("append second project audit"); + store + .append_permission_audit(audit_record("project-b:1", "request-3", "project-b", 30)) + .await + .expect("append other project audit"); + store + .commit_permission_reply( + Vec::new(), + vec![audit_record("project-a:3", "request-4", "project-a", 40)], + ) + .await + .expect("commit bounded project audit"); + + assert_eq!( + store + .list_project_permission_audit("project-a") + .await + .expect("list retained project audit") + .into_iter() + .map(|record| record.audit_id) + .collect::>(), + vec!["project-a:2", "project-a:3"] + ); + assert_eq!( + store + .list_project_permission_audit("project-b") + .await + .expect("list isolated project audit") + .into_iter() + .map(|record| record.audit_id) + .collect::>(), + vec!["project-b:1"] + ); +} diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index 82d860084a..445ba0249d 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -331,7 +331,6 @@ pub enum RemoteDialogQueuePriority { pub struct RemoteDialogSubmissionPolicy { pub source: RemoteConnectSubmissionSource, pub queue_priority: RemoteDialogQueuePriority, - pub skip_tool_confirmation: bool, } impl RemoteDialogSubmissionPolicy { @@ -339,7 +338,6 @@ impl RemoteDialogSubmissionPolicy { Self { source, queue_priority: RemoteDialogQueuePriority::Normal, - skip_tool_confirmation: true, } } } @@ -1475,12 +1473,6 @@ where #[async_trait::async_trait] pub trait RemoteInteractionRuntimeHost: Send + Sync { - async fn confirm_tool( - &self, - tool_id: &str, - updated_input: Option, - ) -> Result<(), String>; - async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<(), String>; async fn cancel_tool(&self, tool_id: &str, reason: String) -> Result<(), String>; fn answer_question(&self, tool_id: &str, answers: serde_json::Value) -> Result<(), String>; } @@ -1493,24 +1485,6 @@ where H: RemoteInteractionRuntimeHost + ?Sized, { match command { - RemoteCommand::ConfirmTool { - tool_id, - updated_input, - } => remote_interaction_accepted_response( - "confirm_tool", - tool_id.clone(), - host.confirm_tool(tool_id, updated_input.clone()).await, - ), - RemoteCommand::RejectTool { tool_id, reason } => { - let reject_reason = reason - .clone() - .unwrap_or_else(|| "User rejected".to_string()); - remote_interaction_accepted_response( - "reject_tool", - tool_id.clone(), - host.reject_tool(tool_id, reject_reason).await, - ) - } RemoteCommand::CancelTool { tool_id, reason } => { let cancel_reason = reason .clone() @@ -2113,14 +2087,6 @@ pub enum RemoteCommand { DeleteSession { session_id: String, }, - ConfirmTool { - tool_id: String, - updated_input: Option, - }, - RejectTool { - tool_id: String, - reason: Option, - }, CancelTool { tool_id: String, reason: Option, @@ -2446,10 +2412,9 @@ where | RemoteCommand::ReadFileChunk { .. } | RemoteCommand::GetFileInfo { .. } => host.handle_workspace_file_command(command).await, - RemoteCommand::ConfirmTool { .. } - | RemoteCommand::RejectTool { .. } - | RemoteCommand::CancelTool { .. } - | RemoteCommand::AnswerQuestion { .. } => host.handle_interaction_command(command).await, + RemoteCommand::CancelTool { .. } | RemoteCommand::AnswerQuestion { .. } => { + host.handle_interaction_command(command).await + } RemoteCommand::SendMessage { session_id, @@ -3723,28 +3688,10 @@ mod tests { } #[derive(Default)] - struct FakeInteractionHost { - rejected: Mutex>, - } + struct FakeInteractionHost; #[async_trait::async_trait] impl RemoteInteractionRuntimeHost for FakeInteractionHost { - async fn confirm_tool( - &self, - _tool_id: &str, - _updated_input: Option, - ) -> Result<(), String> { - Ok(()) - } - - async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<(), String> { - self.rejected - .lock() - .unwrap() - .push((tool_id.to_string(), reason)); - Ok(()) - } - async fn cancel_tool(&self, _tool_id: &str, _reason: String) -> Result<(), String> { Ok(()) } @@ -3757,30 +3704,4 @@ mod tests { Ok(()) } } - - #[tokio::test] - async fn remote_interaction_handler_preserves_default_reject_reason() { - let host = FakeInteractionHost::default(); - - let response = handle_remote_interaction_command( - &host, - &RemoteCommand::RejectTool { - tool_id: "tool-1".to_string(), - reason: None, - }, - ) - .await; - - assert_eq!( - response, - RemoteResponse::InteractionAccepted { - action: "reject_tool".to_string(), - target_id: "tool-1".to_string(), - } - ); - assert_eq!( - host.rejected.lock().unwrap().as_slice(), - [("tool-1".to_string(), "User rejected".to_string())] - ); - } } diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index efe27ef67b..bd64f7f433 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -885,7 +885,7 @@ impl RemoteCommandRuntimeHost for RecordingCommandHost { async fn handle_interaction_command(&self, _command: &RemoteCommand) -> RemoteResponse { self.events.lock().unwrap().push("interaction".to_string()); RemoteResponse::InteractionAccepted { - action: "confirm_tool".to_string(), + action: "cancel_tool".to_string(), target_id: "tool-1".to_string(), } } @@ -1018,9 +1018,9 @@ async fn remote_connect_command_owner_preserves_cancel_and_group_routing() { let interaction = handle_remote_command( &host, - &RemoteCommand::ConfirmTool { + &RemoteCommand::CancelTool { tool_id: "tool-1".to_string(), - updated_input: None, + reason: None, }, RemoteConnectSubmissionSource::Relay, ) @@ -1117,7 +1117,6 @@ async fn remote_connect_dialog_runtime_owns_restore_prewarm_and_submit_order() { submitted.policy.queue_priority, RemoteDialogQueuePriority::Normal ); - assert!(submitted.policy.skip_tool_confirmation); } #[tokio::test] @@ -1620,9 +1619,9 @@ fn remote_connect_execution_response_helpers_preserve_wire_shape() { } ); assert_eq!( - remote_interaction_accepted_response("confirm_tool", "tool-1", Ok(())), + remote_interaction_accepted_response("cancel_tool", "tool-1", Ok(())), RemoteResponse::InteractionAccepted { - action: "confirm_tool".to_string(), + action: "cancel_tool".to_string(), target_id: "tool-1".to_string(), } ); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 06037ade73..51bfc81c2e 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -1,6 +1,6 @@ import React, { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; -import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch, Bot, Link2, ListChecks, Loader2, Clock3 } from 'lucide-react'; +import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch, Bot, Link2, ListChecks, Loader2, Clock3, ShieldCheck } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { DotMatrixArrowRightIcon } from './DotMatrixArrowRightIcon'; import { Button, ConfirmDialog, Modal, Tooltip } from '@/component-library'; @@ -47,6 +47,7 @@ import { } from './workspaceGitRefreshOptions'; const WorkspaceRelatedPathsDialog = lazy(() => import('./WorkspaceRelatedPathsDialog')); +const WorkspaceProjectPermissionsDialog = lazy(() => import('./WorkspaceProjectPermissionsDialog')); const WorkspaceSessionBatchModal = lazy(() => import('./WorkspaceSessionBatchModal')); const ScheduledJobsModal = lazy(() => import('@/app/components/scheduled-jobs/ScheduledJobsModal')); @@ -111,6 +112,7 @@ const WorkspaceItem: React.FC = ({ const [deleteWorktreeDialogOpen, setDeleteWorktreeDialogOpen] = useState(false); const [resetDialogOpen, setResetDialogOpen] = useState(false); const [relatedPathsDialogOpen, setRelatedPathsDialogOpen] = useState(false); + const [projectPermissionsDialogOpen, setProjectPermissionsDialogOpen] = useState(false); const [isDeletingAssistant, setIsDeletingAssistant] = useState(false); const [isDeletingWorktree, setIsDeletingWorktree] = useState(false); const [isResettingWorkspace, setIsResettingWorkspace] = useState(false); @@ -485,6 +487,11 @@ const WorkspaceItem: React.FC = ({ setScheduledJobsModalOpen(true); }, []); + const handleOpenProjectPermissions = useCallback(() => { + setMenuOpen(false); + setProjectPermissionsDialogOpen(true); + }, []); + const handleRequestDeleteAssistant = useCallback(() => { setMenuOpen(false); setDeleteDialogOpen(true); @@ -871,6 +878,17 @@ const WorkspaceItem: React.FC = ({ {t('nav.scheduledJobs.open')} +
); } @@ -1286,6 +1313,17 @@ const WorkspaceItem: React.FC = ({ {t('nav.workspaces.actions.manageRelatedPaths')} + + ) : null} + + +
+ {grantsLoading && permissionGrants.length === 0 ? ( +
{t('loading.text')}
+ ) : permissionGrants.length === 0 ? ( +
{t('projectPermissions.grantsEmpty')}
+ ) : permissionGrants.map((grant) => { + const key = `${grant.action}\n${grant.resource}`; + return ( +
+
+ {grant.action} + {grant.resource} + {formatDate(grant.createdAtMs, { dateStyle: 'medium', timeStyle: 'short' })} +
+ void handleRemovePermissionGrant(grant)} + > + + +
+ ); + })} +
+ + +
+
+ {t('projectPermissions.rulesTitle')} + +
+ + {rulesLoading ? ( +
{t('loading.text')}
+ ) : draftRules.length === 0 ? ( +
{t('projectPermissions.rulesEmpty')}
+ ) : ( +
+ + {draftRules.map((rule, index) => ( +
+ updateDraftRule(rule.localId, { action: value as string })} + /> + updateDraftRule(rule.localId, { resource: event.target.value })} + /> +
+ moveDraftRule(index, -1)} + > + + + moveDraftRule(index, 1)} + > + + + setDraftRules((rules) => rules.filter(({ localId }) => localId !== rule.localId))} + > + + +
+
+ ))} +
+ )} + + {rulesDirty ? ( +
+ + +
+ ) : null} +
+ + + ); +}; + +export default WorkspaceProjectPermissionsDialog; diff --git a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx index 34292c85c4..560ae4aaf9 100644 --- a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx +++ b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx @@ -133,7 +133,7 @@ const CreateAgentPage: React.FC = () => { name: toolName, description: typeof tool?.description === 'string' ? tool.description : '', isReadonly: Boolean(tool?.is_readonly), - needsPermissions: Boolean(tool?.needs_permissions), + needsPermissions: !tool?.is_readonly, dynamicInfo: tool?.dynamic_info, }; }) diff --git a/src/web-ui/src/app/scenes/session/ChatPane.tsx b/src/web-ui/src/app/scenes/session/ChatPane.tsx index 9b9a92c6cf..273643aad0 100644 --- a/src/web-ui/src/app/scenes/session/ChatPane.tsx +++ b/src/web-ui/src/app/scenes/session/ChatPane.tsx @@ -146,6 +146,7 @@ const ChatPaneInner: React.FC = ({ > { log.info('Opening visualization', { type, data }); }} diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index c6ff5faf8e..bddd9cc4d7 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.scss +++ b/src/web-ui/src/flow_chat/components/ChatInput.scss @@ -1466,6 +1466,7 @@ display: flex; align-items: center; gap: $size-gap-1; + min-width: 0; animation: bitfun-stacked-reveal 0.28s cubic-bezier(0.4, 0, 0.2, 1) 0.17s both; } diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index b4175bf622..b12d88b620 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -63,7 +63,7 @@ import { import { isReviewSlashCommand } from '../deep-review/launch/commandParser'; import { createLogger } from '@/shared/utils/logger'; import { isTauriRuntime } from '@/infrastructure/runtime'; -import { Tooltip, IconButton, confirmWarning } from '@/component-library'; +import { Tooltip, IconButton, confirmDanger, confirmWarning } from '@/component-library'; import { PendingQueuePanel } from './PendingQueuePanel'; import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; import { openBtwSessionInAuxPane, selectActiveBtwSessionTab } from '../services/btwSessionPane'; @@ -85,11 +85,20 @@ import { useSceneStore } from '@/app/stores/sceneStore'; import type { SceneTabId } from '@/app/components/SceneBar/types'; import { useAgentsStore } from '@/app/scenes/agents/agentsStore'; import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; -import { configManager } from '@/infrastructure/config'; +import { + configManager, + DEFAULT_TOOL_PERMISSION_CONFIG, + normalizeToolPermissionConfig, + permissionConfigService, +} from '@/infrastructure/config'; +import type { ToolPermissionConfig } from '@/infrastructure/config/types'; import type { ModeSkillInfo } from '@/infrastructure/config/types'; import { SubagentAPI, type SubagentInfo } from '@/infrastructure/api/service-api/SubagentAPI'; import MCPAPI, { type MCPPrompt, type MCPPromptMessage, type MCPServerInfo } from '@/infrastructure/api/service-api/MCPAPI'; -import { ChatInputWorkspaceStrip } from './ChatInputWorkspaceStrip'; +import { + ChatInputWorkspaceStrip, + type ChatInputPermissionMode, +} from './ChatInputWorkspaceStrip'; import { expandWidgetPromptReferenceTokens } from '@/tools/generative-widget/widgetPromptReference'; import { appendSkillPromptReferenceToken, @@ -288,6 +297,10 @@ export const ChatInput: React.FC = ({ const [historyIndex, setHistoryIndex] = useState(-1); const [savedDraft, setSavedDraft] = useState(''); const [inputTarget, setInputTarget] = useState('main'); + const [toolPermissionConfig, setToolPermissionConfig] = useState( + DEFAULT_TOOL_PERMISSION_CONFIG, + ); + const [permissionModeSaving, setPermissionModeSaving] = useState(false); const { addMessage: addToHistory, getSessionHistory } = useInputHistoryStore(); const contexts = useContextStore(state => state.contexts); @@ -677,6 +690,13 @@ export const ChatInput: React.FC = ({ [effectiveTargetSession] ); const isAcpTargetSession = Boolean(acpTargetAgentType); + const permissionMode: ChatInputPermissionMode = isAcpTargetSession + ? 'acp' + : toolPermissionConfig.policy.preset === 'full_access' + ? 'full_access' + : toolPermissionConfig.interaction.auto_approve_ask + ? 'auto' + : 'ask'; const activeSessionMode = effectiveTargetSessionId ? acpTargetAgentType || flowChatState.sessions.get(effectiveTargetSessionId)?.mode : undefined; @@ -1360,6 +1380,73 @@ export const ChatInput: React.FC = ({ }; }, []); + React.useEffect(() => { + let cancelled = false; + const applyConfig = (config: ToolPermissionConfig) => { + if (!cancelled) { + setToolPermissionConfig(config); + } + }; + const loadConfig = async () => { + applyConfig(await permissionConfigService.getConfig()); + }; + const handlePermissionConfigUpdated = (value?: ToolPermissionConfig) => { + if (value) { + applyConfig(normalizeToolPermissionConfig(value)); + } else { + void loadConfig(); + } + }; + + void loadConfig(); + globalEventBus.on('permission:config:updated', handlePermissionConfigUpdated); + return () => { + cancelled = true; + globalEventBus.off('permission:config:updated', handlePermissionConfigUpdated); + }; + }, []); + + 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; + } + + const previousConfig = toolPermissionConfig; + const nextConfig: ToolPermissionConfig = { + policy: { + ...previousConfig.policy, + preset: nextMode === 'full_access' ? 'full_access' : 'ask', + }, + interaction: { + ...previousConfig.interaction, + auto_approve_ask: nextMode === 'auto', + }, + }; + setToolPermissionConfig(nextConfig); + setPermissionModeSaving(true); + try { + const saved = await permissionConfigService.saveConfig(nextConfig); + setToolPermissionConfig(saved); + } catch (error) { + log.error('Failed to change permission mode', error); + setToolPermissionConfig(previousConfig); + notificationService.error(t('chatInput.permissionMode.changeFailed')); + } finally { + setPermissionModeSaving(false); + } + }, [isAcpTargetSession, permissionModeSaving, t, toolPermissionConfig]); + React.useEffect(() => { if (!slashCommandState.isActive || slashCommandState.kind !== 'all' || derivedState?.isProcessing) { return; @@ -4260,30 +4347,32 @@ export const ChatInput: React.FC = ({ - {((chatStripRepositoryPath || chatStripWorkspaceLabel) || - (effectiveTargetSessionId && effectiveTargetSession)) && ( - { - void threadGoalController.openGoalEntry(); - }, - } - : undefined - } - /> - )} + { + void threadGoalController.openGoalEntry(); + }, + } + : undefined + } + /> {effectiveTargetSession && !isBtwSession ? ( svg { + color: var(--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 { + color: var(--color-text-primary); + font-size: var(--flowchat-font-size-xs); + font-weight: 500; + line-height: 1.25; + } + + &__permission-option-description { + color: var(--color-text-muted); + font-size: var(--flowchat-font-size-xxs); + font-weight: 400; + line-height: 1.35; + white-space: normal; + } + &__goal-btn.icon-btn { width: 16px; height: 16px; @@ -210,3 +400,17 @@ font-weight: 400; } } + +@media (max-width: 560px) { + .bitfun-chat-input-workspace-strip { + &__permission-trigger--auto, + &__permission-trigger--full_access { + width: 18px; + padding: 0; + } + + &__permission-label { + display: none; + } + } +} 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 371c500a43..44e74731ae 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx @@ -97,4 +97,50 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { refreshOnActive: false, })); }); + + it('keeps an ask-mode permission entry visible and switches from its menu', async () => { + const onChange = vi.fn(); + await act(async () => { + root.render( + + ); + }); + + const trigger = container.querySelector('[data-testid="chat-input-permission-trigger"]'); + expect(trigger?.dataset.permissionMode).toBe('ask'); + + await act(async () => { + trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(container.querySelector('[data-testid="chat-input-permission-menu"]')).not.toBeNull(); + + await act(async () => { + container + .querySelector('[data-testid="chat-input-permission-option-auto"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(onChange).toHaveBeenCalledWith('auto'); + expect(container.querySelector('[data-testid="chat-input-permission-menu"]')).toBeNull(); + }); + + it('shows ACP ownership without exposing native permission choices', async () => { + await act(async () => { + root.render( + + ); + }); + + const trigger = container.querySelector('[data-testid="chat-input-permission-trigger"]'); + expect(trigger?.disabled).toBe(true); + expect(trigger?.dataset.permissionMode).toBe('acp'); + expect(container.querySelector('[data-testid="chat-input-permission-menu"]')).toBeNull(); + }); }); diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx index 8e2e8e0cff..1fd71d5576 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx @@ -2,9 +2,9 @@ * Workspace label + Git branch (left) and optional usage report control (right). */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { GitBranch, Activity } from 'lucide-react'; +import { Activity, Check, GitBranch, Shield, ShieldAlert, ShieldCheck } from 'lucide-react'; import { ThreadGoalStripButton } from './thread-goal/ThreadGoalStripButton'; import type { ThreadGoalSnapshot } from '../services/goalService'; import { Tooltip, IconButton } from '@/component-library'; @@ -27,18 +27,35 @@ export interface ChatInputWorkspaceStripProps { goal: ThreadGoalSnapshot | null; onOpen: () => void; }; + /** Global native-tool permission mode exposed as a compact strip control. */ + permissionControl?: { + mode: ChatInputPermissionMode; + saving?: boolean; + onChange?: (mode: Exclude) => void | Promise; + }; /** Keep the strip on cached Git state while historical content is still restoring. */ deferPassiveGitRefresh?: boolean; } +export type ChatInputPermissionMode = 'ask' | 'auto' | 'full_access' | 'acp'; + +const NATIVE_PERMISSION_MODES: Array> = [ + 'ask', + 'auto', + 'full_access', +]; + export const ChatInputWorkspaceStrip: React.FC = ({ repositoryPath, workspaceLabel, usageReport, threadGoal, + permissionControl, deferPassiveGitRefresh = false, }) => { const { t } = useTranslation('flow-chat'); + const permissionRootRef = useRef(null); + const [permissionMenuOpen, setPermissionMenuOpen] = useState(false); const trimmedPath = repositoryPath.trim(); const label = workspaceLabel.trim(); @@ -53,7 +70,48 @@ export const ChatInputWorkspaceStrip: React.FC = ( const showUsage = usageReport?.visible && !!usageReport.onOpen; const showGoal = threadGoal?.visible && !!threadGoal.onOpen; - const showRightActions = showUsage || showGoal; + const showPermission = !!permissionControl; + const showRightActions = showPermission || showUsage || showGoal; + const permissionCopy = { + ask: { + label: t('chatInput.permissionMode.ask.label'), + description: t('chatInput.permissionMode.ask.description'), + }, + auto: { + label: t('chatInput.permissionMode.auto.label'), + description: t('chatInput.permissionMode.auto.description'), + }, + full_access: { + label: t('chatInput.permissionMode.fullAccess.label'), + description: t('chatInput.permissionMode.fullAccess.description'), + }, + acp: { + label: t('chatInput.permissionMode.acp.label'), + description: t('chatInput.permissionMode.acp.tooltip'), + }, + } satisfies Record; + + useEffect(() => { + if (!permissionMenuOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + if (!permissionRootRef.current?.contains(event.target as Node)) { + setPermissionMenuOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setPermissionMenuOpen(false); + } + }; + + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [permissionMenuOpen]); const branchTooltipContent = useMemo( () => @@ -73,6 +131,17 @@ export const ChatInputWorkspaceStrip: React.FC = ( : '—'; const workspaceTooltipContent = trimmedPath || label; + const permissionMode = permissionControl?.mode ?? 'ask'; + const permissionModeLabel = permissionCopy[permissionMode].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; + const showPermissionLabel = permissionMode === 'auto' || permissionMode === 'full_access'; const split = !!label && showRightActions; const actionsOnly = !label && showRightActions; @@ -114,6 +183,95 @@ export const ChatInputWorkspaceStrip: React.FC = ( {showRightActions ? (
+ {showPermission ? ( +
+ + + + + {permissionMenuOpen && permissionMode !== 'acp' ? ( +
+
+ {t('chatInput.permissionMode.menuLabel')} + {t('chatInput.permissionMode.globalScope')} +
+
+ {NATIVE_PERMISSION_MODES.map(mode => { + const selected = permissionMode === mode; + const copy = permissionCopy[mode]; + return ( + + ); + })} +
+
+ ) : null} +
+ ) : null} {showGoal ? ( { it('keeps the session usage action visible without overpowering the strip', () => { const stylesheet = readWorkspaceStripStylesheet(); - expect(stylesheet).toContain('max-width: calc(100% - 24px);'); + expect(stylesheet).toContain('max-width: 100%;'); expect(stylesheet).toContain('width: 16px;'); expect(stylesheet).toContain('height: 16px;'); expect(stylesheet).toContain('min-width: 16px;'); @@ -23,4 +23,15 @@ describe('ChatInputWorkspaceStrip layout styles', () => { expect(stylesheet).toContain('color: color-mix(in srgb, var(--color-accent-500) 62%, var(--color-text-secondary));'); expect(stylesheet).toContain('color: color-mix(in srgb, var(--color-accent-500) 86%, var(--color-text-primary));'); }); + + it('keeps the permission control compact and collapses labels on narrow screens', () => { + const stylesheet = readWorkspaceStripStylesheet(); + + expect(stylesheet).toContain('&__permission-trigger'); + expect(stylesheet).toContain('min-width: 18px;'); + expect(stylesheet).toContain('width: min(286px, calc(100vw - 24px));'); + 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/components/FlowItemRenderer.tsx b/src/web-ui/src/flow_chat/components/FlowItemRenderer.tsx index 553730d908..3a41a47c8e 100644 --- a/src/web-ui/src/flow_chat/components/FlowItemRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/FlowItemRenderer.tsx @@ -15,7 +15,7 @@ interface FlowItemRendererProps { item: FlowItem; onFileViewRequest?: (filePath: string) => void; onTabOpen?: (tabInfo: any) => void; - onConfirm?: (toolId: string, updatedInput?: any, permissionOptionId?: string, approve?: boolean) => void; + onConfirm?: (toolId: string, permissionOptionId?: string, approve?: boolean) => void; onReject?: (toolId: string, options?: ToolRejectOptions) => void; sessionId?: string; } diff --git a/src/web-ui/src/flow_chat/components/FlowToolCard.test.tsx b/src/web-ui/src/flow_chat/components/FlowToolCard.test.tsx index 446652021d..270d74eccf 100644 --- a/src/web-ui/src/flow_chat/components/FlowToolCard.test.tsx +++ b/src/web-ui/src/flow_chat/components/FlowToolCard.test.tsx @@ -38,6 +38,14 @@ vi.mock('./FlowToolCardErrorBoundary', () => ({ vi.mock('./ToolApprovalBar', () => ({ ToolApprovalBar: () => null })); +const permissionContextMock = vi.hoisted(() => ({ + pendingPermissionToolCallIds: new Set(), +})); + +vi.mock('./modern/FlowChatContext', () => ({ + useFlowChatContext: () => permissionContextMock, +})); + import { FlowToolCard } from './FlowToolCard'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -55,6 +63,7 @@ describe('FlowToolCard deferred identity', () => { afterEach(() => { act(() => root.unmount()); container.remove(); + permissionContextMock.pendingPermissionToolCallIds.clear(); }); it('switches from the gateway card to the effective card when wire input completes', () => { @@ -89,4 +98,57 @@ describe('FlowToolCard deferred identity', () => { expect(container.querySelector('[data-card-tool-name="CreatePlan"]')).not.toBeNull(); expect(container.querySelector('[data-tool-name="CreatePlan"]')).not.toBeNull(); }); + + it('marks permission-pending cards for the shared warning highlight', () => { + act(() => root.render( + , + )); + + expect(container.querySelector('.flow-tool-card-wrapper--permission-pending')).not.toBeNull(); + }); + + it('does not highlight a same-name card without a matching permission call ID', () => { + act(() => root.render( + , + )); + + expect(container.querySelector('.flow-tool-card-wrapper--permission-pending')).toBeNull(); + }); + + it('highlights only the tool card matching a permission call ID', () => { + permissionContextMock.pendingPermissionToolCallIds.add('pending-call'); + + const tool = (id: string): FlowToolItem => ({ + id, + type: 'tool', + toolName: 'ExecCommand', + toolCall: { id, input: { cmd: 'cargo check' } }, + status: 'running', + timestamp: 1, + }); + + act(() => root.render()); + expect(container.querySelector('.flow-tool-card-wrapper--permission-pending')).toBeNull(); + + act(() => root.render()); + expect(container.querySelector('.flow-tool-card-wrapper--permission-pending')).not.toBeNull(); + }); }); diff --git a/src/web-ui/src/flow_chat/components/FlowToolCard.tsx b/src/web-ui/src/flow_chat/components/FlowToolCard.tsx index 34a67687bd..f4cf483d20 100644 --- a/src/web-ui/src/flow_chat/components/FlowToolCard.tsx +++ b/src/web-ui/src/flow_chat/components/FlowToolCard.tsx @@ -13,12 +13,13 @@ import { useTranslation } from 'react-i18next'; import { getToolInterruptionNote } from '../utils/toolInterruption'; import { ToolApprovalBar } from './ToolApprovalBar'; import { projectEffectiveToolItem } from '../utils/toolInvocationIdentity'; +import { useFlowChatContext } from './modern/FlowChatContext'; const log = createLogger('FlowToolCard'); interface FlowToolCardProps { toolItem: FlowToolItem; - onConfirm?: (toolId: string, updatedInput?: any, permissionOptionId?: string, approve?: boolean) => void; + onConfirm?: (toolId: string, permissionOptionId?: string, approve?: boolean) => void; onReject?: (toolId: string, options?: ToolRejectOptions) => void; onOpenInEditor?: (filePath: string) => void; onOpenInPanel?: (panelType: string, data: any) => void; @@ -42,6 +43,7 @@ export const FlowToolCard: React.FC = React.memo(({ }) => { const { t } = useTranslation('flow-chat'); const effectiveToolItem = projectEffectiveToolItem(toolItem); + const { pendingPermissionToolCallIds } = useFlowChatContext(); const config = getToolCardConfig(effectiveToolItem.toolName); const CardComponent = getToolCardComponent(effectiveToolItem.toolName); const interruptionNote = getToolInterruptionNote(effectiveToolItem, t); @@ -52,17 +54,18 @@ export const FlowToolCard: React.FC = React.memo(({ : effectiveToolItem.toolName === 'WebFetch' ? 'chat-browser-tool-card' : undefined; + const permissionPending = + effectiveToolItem.status === 'pending_confirmation' || + pendingPermissionToolCallIds?.has(toolItem.toolCall.id) === true; - const handleConfirm = React.useCallback((updatedInput?: any, permissionOptionId?: string, approve?: boolean) => { + const handleConfirm = React.useCallback((permissionOptionId?: string, approve?: boolean) => { log.debug('handleConfirm called', { toolId: toolItem.id, toolName: effectiveToolItem.toolName, - hasUpdatedInput: updatedInput !== undefined, - updatedInputKeys: updatedInput ? Object.keys(updatedInput) : [], hasPermissionOption: Boolean(permissionOptionId), approve }); - onConfirm?.(toolItem.id, updatedInput, permissionOptionId, approve); + onConfirm?.(toolItem.id, permissionOptionId, approve); }, [effectiveToolItem.toolName, toolItem.id, onConfirm]); const handleReject = React.useCallback((options?: ToolRejectOptions) => { @@ -75,7 +78,7 @@ export const FlowToolCard: React.FC = React.memo(({ return (
= { - 'toolCards.approval.waiting': 'Waiting for confirmation', - 'toolCards.approval.confirm': 'Allow', - 'toolCards.approval.reject': 'Reject', - 'toolCards.approval.rejectWithInstruction': 'Reject with instruction', - 'toolCards.approval.enableAutoExecute': 'Enable tool auto execute', - 'toolCards.approval.confirmTooltip': 'Allow this tool run', - 'toolCards.approval.enableAutoExecuteTooltip': '开启工具自动执行', - 'toolCards.approval.autoExecuteEnabled': 'Tool auto execute enabled', - 'toolCards.approval.autoExecuteEnableFailed': 'Failed to enable tool auto execute', - 'toolCards.approval.rejectTooltip': 'Reject this tool run', - 'toolCards.approval.rejectWithInstructionTooltip': 'Reject and tell the assistant what to do next', - 'toolCards.approval.rejectInstructionLabel': 'Rejection instruction', - 'toolCards.approval.rejectInstructionPlaceholder': 'Tell the assistant what to do instead...', - 'toolCards.approval.rejectWithInstructionSubmit': 'Reject', - 'toolCards.approval.emptyInputTooltip': 'This tool has no executable input', 'toolCards.approval.ariaLabel': 'Tool approval', - 'toolCards.approval.remaining': 'remaining {{time}}', + 'toolCards.approval.waiting': 'Waiting for permission', + 'toolCards.acpPermission.allowOnce': 'Allow once', + 'toolCards.acpPermission.allowAlways': 'Always allow', + 'toolCards.acpPermission.reject': 'Reject', + 'toolCards.acpPermission.rejectAlways': 'Always reject', + 'toolCards.acpPermission.selectOption': 'Select option', }; vi.mock('react-i18next', async () => { @@ -34,70 +23,21 @@ vi.mock('react-i18next', async () => { return { ...actual, useTranslation: () => ({ - t: (key: string, options?: Record) => { - const template = messages[key] ?? (typeof options?.defaultValue === 'string' ? options.defaultValue : key); - return template.replace(/\{\{(\w+)\}\}/g, (_match, token) => String(options?.[token] ?? `{{${token}}}`)); - }, + t: (key: string) => messages[key] ?? key, }), }; }); -vi.mock('@/component-library', () => ({ - IconButton: ({ - children, - tooltip, - ...props - }: React.ButtonHTMLAttributes & { tooltip?: React.ReactNode }) => ( - - ), -})); - -const setConfigMock = vi.hoisted(() => vi.fn()); -const notificationSuccessMock = vi.hoisted(() => vi.fn()); -const notificationErrorMock = vi.hoisted(() => vi.fn()); -const eventBusEmitMock = vi.hoisted(() => vi.fn()); - -vi.mock('@/infrastructure/config', () => ({ - configManager: { - setConfig: setConfigMock, - }, -})); - -vi.mock('@/shared/notification-system', () => ({ - notificationService: { - success: notificationSuccessMock, - error: notificationErrorMock, - }, -})); - -vi.mock('@/shared/utils/logger', () => ({ - createLogger: () => ({ - error: vi.fn(), - }), -})); - -vi.mock('@/infrastructure/event-bus', () => ({ - globalEventBus: { - emit: eventBusEmitMock, - }, -})); - -function execCommandItem(cmd: string, status: FlowToolItem['status'] = 'pending_confirmation'): FlowToolItem { +function toolItem(status: FlowToolItem['status'] = 'pending_confirmation'): FlowToolItem { return { - id: 'tool-exec-1', + id: 'tool-1', type: 'tool', - toolName: 'ExecCommand', + toolName: 'ExternalTool', status, timestamp: Date.now(), toolCall: { - id: 'call-exec-1', - input: { cmd }, + id: 'call-1', + input: {}, }, }; } @@ -108,169 +48,71 @@ describe('ToolApprovalBar', () => { let root: Root; beforeEach(() => { - setConfigMock.mockResolvedValue(undefined); - notificationSuccessMock.mockClear(); - notificationErrorMock.mockClear(); - eventBusEmitMock.mockClear(); - dom = new JSDOM('
', { pretendToBeVisual: true, }); vi.stubGlobal('window', dom.window); vi.stubGlobal('document', dom.window.document); vi.stubGlobal('HTMLElement', dom.window.HTMLElement); - (dom.window.HTMLElement.prototype as any).attachEvent = vi.fn(); - (dom.window.HTMLElement.prototype as any).detachEvent = vi.fn(); container = dom.window.document.getElementById('root') as HTMLDivElement; root = createRoot(container); }); afterEach(() => { - act(() => { - root.unmount(); - }); + act(() => root.unmount()); vi.unstubAllGlobals(); }); - it('renders shared approval actions for pending ExecCommand tools', () => { - const onConfirm = vi.fn(); - const onReject = vi.fn(); - const input = { cmd: 'npm test' }; - - act(() => { - root.render( - , - ); - }); - - expect(container.textContent).toContain('Waiting for confirmation'); - - const allowButton = container.querySelector('button[aria-label="Allow"]') as HTMLButtonElement; - const rejectButton = container.querySelector('button[aria-label="Reject"]') as HTMLButtonElement; + it('does not render the retired confirmation controls without ACP options', () => { + act(() => root.render()); - act(() => { - allowButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); - rejectButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); - }); - - expect(onConfirm).toHaveBeenCalledWith(input); - expect(onReject).toHaveBeenCalledWith(); + expect(container.textContent).toBe(''); }); - it('submits a rejection instruction from the shared approval bar', () => { + it('routes ACP option identities through the approval callbacks', () => { + const onConfirm = vi.fn(); const onReject = vi.fn(); + const item = toolItem(); + item.acpPermission = { + permissionId: 'permission-1', + requestedAt: Date.now(), + options: [ + { optionId: 'reject-once', name: 'Reject this request', kind: 'reject_once' }, + { optionId: 'allow-always', name: 'Trust this tool', kind: 'allow_always' }, + ], + }; act(() => { root.render( - , + , ); }); - const rejectWithInstructionButton = container.querySelector( - 'button[aria-label="Reject with instruction"]', - ) as HTMLButtonElement; - act(() => { - rejectWithInstructionButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); - }); - - const input = container.querySelector('input[aria-label="Rejection instruction"]') as HTMLInputElement; - act(() => { - input.value = 'Use the status panel instead'; - input.dispatchEvent(new dom.window.Event('change', { bubbles: true })); - }); + expect(container.textContent).toContain('Waiting for permission'); + const buttons = Array.from(container.querySelectorAll('button')); + const allowButton = buttons.find((button) => button.textContent === 'Always allow'); + const rejectButton = buttons.find((button) => button.textContent === 'Reject'); - const submitButton = Array.from(container.querySelectorAll('button')) - .find((button) => button.textContent === 'Reject') as HTMLButtonElement; act(() => { - submitButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + allowButton?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + rejectButton?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); }); - expect(onReject).toHaveBeenCalledWith({ instruction: 'Use the status panel instead' }); + expect(onConfirm).toHaveBeenCalledWith('allow-always', true); + expect(onReject).toHaveBeenCalledWith({ permissionOptionId: 'reject-once' }); }); - it('disables approval for an empty ExecCommand input', () => { - act(() => { - root.render(); - }); - - const allowButton = container.querySelector('button[aria-label="Allow"]') as HTMLButtonElement; - expect(allowButton.disabled).toBe(true); - expect(allowButton.title).toBe('This tool has no executable input'); - }); - - it('enables tool auto execute from the shared approval bar', async () => { - await act(async () => { - root.render(); - }); - - const autoExecuteButton = container.querySelector( - 'button[aria-label="Enable tool auto execute"]', - ) as HTMLButtonElement; - - expect(autoExecuteButton.title).toBe('开启工具自动执行'); - - await act(async () => { - autoExecuteButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); - }); + it('does not render ACP options after the tool leaves the pending state', () => { + const item = toolItem('running'); + item.acpPermission = { + permissionId: 'permission-1', + requestedAt: Date.now(), + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }], + }; - expect(setConfigMock).toHaveBeenCalledWith('ai.skip_tool_confirmation', true); - expect(notificationSuccessMock).toHaveBeenCalledWith('Tool auto execute enabled', { duration: 2000 }); - expect(eventBusEmitMock).toHaveBeenCalledWith('mode:config:updated'); - }); - - it('does not render for non-confirmation statuses', () => { - act(() => { - root.render(); - }); + act(() => root.render()); expect(container.textContent).toBe(''); }); - - it('shows remaining confirmation time when confirmation timeout is set', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-06-27T12:00:00.000Z')); - - try { - act(() => { - root.render( - , - ); - }); - - expect(container.textContent).toContain('Waiting for confirmation'); - expect(container.textContent).toContain('1m 5s'); - } finally { - vi.useRealTimers(); - } - }); - - it('hides remaining confirmation time when timeout is longer than ten minutes', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-06-27T12:00:00.000Z')); - - try { - act(() => { - root.render( - , - ); - }); - - expect(container.textContent).toContain('Waiting for confirmation'); - expect(container.textContent).not.toContain('remaining'); - } finally { - vi.useRealTimers(); - } - }); }); diff --git a/src/web-ui/src/flow_chat/components/ToolApprovalBar.tsx b/src/web-ui/src/flow_chat/components/ToolApprovalBar.tsx index 0039efb001..176b593b25 100644 --- a/src/web-ui/src/flow_chat/components/ToolApprovalBar.tsx +++ b/src/web-ui/src/flow_chat/components/ToolApprovalBar.tsx @@ -1,258 +1,39 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { Check, MessageSquareX, ShieldCheck, X } from 'lucide-react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; -import { IconButton } from '@/component-library'; -import { configManager } from '@/infrastructure/config'; -import { notificationService } from '@/shared/notification-system'; -import { createLogger } from '@/shared/utils/logger'; import { AcpPermissionActions } from '../tool-cards/AcpPermissionActions'; import { hasAcpPermissionOptions } from '../tool-cards/AcpPermissionActions.utils'; import type { FlowToolItem, ToolRejectOptions } from '../types/flow-chat'; import './ToolApprovalBar.scss'; -const log = createLogger('ToolApprovalBar'); - interface ToolApprovalBarProps { toolItem: FlowToolItem; - onConfirm?: (updatedInput?: any, permissionOptionId?: string, approve?: boolean) => void; + onConfirm?: (permissionOptionId?: string, approve?: boolean) => void; onReject?: (options?: ToolRejectOptions) => void; } -function hasPendingToolConfirmation(toolItem: FlowToolItem): boolean { - return toolItem.status === 'pending_confirmation'; -} - -function formatRemainingConfirmationTime(remainingMs: number): string { - if (remainingMs < 1000) return '1s'; - const totalSeconds = Math.ceil(remainingMs / 1000); - if (totalSeconds < 60) return `${totalSeconds}s`; - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; -} - export const ToolApprovalBar: React.FC = ({ toolItem, onConfirm, onReject, }) => { const { t } = useTranslation('flow-chat'); - const [nowMs, setNowMs] = useState(() => Date.now()); - const [showInstructionInput, setShowInstructionInput] = useState(false); - const [instruction, setInstruction] = useState(''); - const [isEnablingAutoExecute, setIsEnablingAutoExecute] = useState(false); - const instructionInputRef = useRef(null); - const input = toolItem.toolCall?.input; - const hasPermissionOptions = hasAcpPermissionOptions(toolItem); - const canConfirm = useMemo(() => { - if (toolItem.toolName === 'Bash') { - const command = typeof input?.command === 'string' ? input.command : ''; - return Boolean(command.trim()); - } - - if (toolItem.toolName === 'ExecCommand') { - const command = typeof input?.cmd === 'string' ? input.cmd : ''; - return Boolean(command.trim()); - } - return true; - }, [input, toolItem.toolName]); - const confirmationTimeoutAt = toolItem.confirmationTimeoutAt; - const remainingConfirmationMs = useMemo(() => { - if (typeof confirmationTimeoutAt !== 'number') { - return null; - } - return Math.max(0, confirmationTimeoutAt - nowMs); - }, [confirmationTimeoutAt, nowMs]); - const confirmationCountdownLabel = useMemo(() => { - if (remainingConfirmationMs == null) { - return null; - } - if (remainingConfirmationMs > 10 * 60 * 1000) { - return null; - } - return formatRemainingConfirmationTime(remainingConfirmationMs); - }, [remainingConfirmationMs]); - - useEffect(() => { - if (typeof confirmationTimeoutAt !== 'number') { - return undefined; - } - - const tick = () => setNowMs(Date.now()); - tick(); - const handle = window.setInterval(tick, 1000); - return () => window.clearInterval(handle); - }, [confirmationTimeoutAt]); - - if (!hasPendingToolConfirmation(toolItem)) { + if (toolItem.status !== 'pending_confirmation' || !hasAcpPermissionOptions(toolItem)) { return null; } - const handleConfirm = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - - if (!canConfirm) { - return; - } - - onConfirm?.(input); - }; - - const handleReject = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onReject?.(); - }; - - const handleEnableAutoExecute = async (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - - if (isEnablingAutoExecute) { - return; - } - - setIsEnablingAutoExecute(true); - try { - await configManager.setConfig('ai.skip_tool_confirmation', true); - notificationService.success(t('toolCards.approval.autoExecuteEnabled'), { duration: 2000 }); - const { globalEventBus } = await import('@/infrastructure/event-bus'); - globalEventBus.emit('mode:config:updated'); - } catch (error) { - log.error('Failed to enable tool auto execute', { error }); - notificationService.error(t('toolCards.approval.autoExecuteEnableFailed')); - } finally { - setIsEnablingAutoExecute(false); - } - }; - - const handleRejectWithInstruction = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - - const trimmedInstruction = (instructionInputRef.current?.value ?? instruction).trim(); - onReject?.(trimmedInstruction ? { instruction: trimmedInstruction } : undefined); - }; - - const handleToggleInstructionInput = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - setShowInstructionInput((value) => !value); - }; - - const handleInstructionKeyDown = (event: React.KeyboardEvent) => { - event.stopPropagation(); - - if (event.key === 'Enter') { - event.preventDefault(); - const trimmedInstruction = event.currentTarget.value.trim(); - onReject?.(trimmedInstruction ? { instruction: trimmedInstruction } : undefined); - } else if (event.key === 'Escape') { - event.preventDefault(); - setShowInstructionInput(false); - setInstruction(''); - } - }; - return (
- - {t('toolCards.approval.waiting')} - {confirmationCountdownLabel ? ( - - {' '} - · {t('toolCards.approval.remaining', { time: confirmationCountdownLabel })} - - ) : null} - - - {hasPermissionOptions ? ( - - ) : ( - <> - - - - - - - - - - + {t('toolCards.approval.waiting')} +
- {showInstructionInput && !hasPermissionOptions && ( -
- setInstruction(event.target.value)} - onClick={(event) => event.stopPropagation()} - onKeyDown={handleInstructionKeyDown} - placeholder={t('toolCards.approval.rejectInstructionPlaceholder')} - aria-label={t('toolCards.approval.rejectInstructionLabel')} - /> - -
- )}
); }; diff --git a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx index 81fc228c48..fb1d42d291 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx @@ -307,9 +307,9 @@ const ExploreItemRenderer = React.memo(({ item, turnId sessionId, } = useFlowChatContext(); - const handleConfirm = useCallback(async (toolId: string, updatedInput?: any, permissionOptionId?: string, approve?: boolean) => { + const handleConfirm = useCallback(async (toolId: string, permissionOptionId?: string, approve?: boolean) => { if (onToolConfirm) { - await onToolConfirm(toolId, updatedInput, permissionOptionId, approve); + await onToolConfirm(toolId, permissionOptionId, approve); } }, [onToolConfirm]); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx index e19f4fbe5f..ad8e3ae9a6 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx @@ -17,8 +17,10 @@ export interface FlowChatContextValue { onSwitchToChatPanel?: () => void; // Tool actions - onToolConfirm?: (toolId: string, updatedInput?: any, permissionOptionId?: string, approve?: boolean) => Promise; + onToolConfirm?: (toolId: string, permissionOptionId?: string, approve?: boolean) => Promise; onToolReject?: (toolId: string, options?: ToolRejectOptions) => Promise; + /** Tool-call IDs highlighted for active permission requests, including delegated parent Task calls. */ + pendingPermissionToolCallIds?: ReadonlySet; // Session info sessionId?: string; diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index 3c9d43828b..99c064519a 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -918,9 +918,9 @@ const FlowItemRenderer: React.FC = ({
{ + onConfirm={async (toolId: string, permissionOptionId?: string, approve?: boolean) => { if (onToolConfirm) { - await onToolConfirm(toolId, updatedInput, permissionOptionId, approve); + await onToolConfirm(toolId, permissionOptionId, approve); } }} onReject={async (_toolId: string, options?: ToolRejectOptions) => { diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx index 7e339f03c4..905659d5b2 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx @@ -60,6 +60,9 @@ const virtualListPropsMock = vi.hoisted(() => ({ })); const agentApiMock = vi.hoisted(() => ({ listBackgroundCommandActivities: vi.fn(() => Promise.resolve({ activities: [] })), + onPermissionRequestEvent: vi.fn(() => vi.fn()), + subscribePermissionRequests: vi.fn(() => Promise.resolve()), + listPendingPermissionRequests: vi.fn(() => Promise.resolve([])), })); vi.mock('react-i18next', () => ({ diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index 806dc76b70..df21d6c539 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -76,12 +76,16 @@ import { type BackgroundSubagentActivityItem, } from '../../utils/backgroundSubagentActivity'; import './ModernFlowChatContainer.scss'; +import { PermissionRequestPanel } from './PermissionRequestPanel'; +import { pendingPermissionToolCallIdsForSession } from './permissionRequestRouting'; +import { usePermissionRequests } from './usePermissionRequests'; const log = createLogger('ModernFlowChatContainer'); interface ModernFlowChatContainerProps { className?: string; config?: Partial; + permissionPanelAboveChatInput?: boolean; // Callbacks compatible with the legacy version. onFileViewRequest?: (filePath: string, fileName: string, lineRange?: LineRange) => void; @@ -220,6 +224,7 @@ function backgroundCommandSummaryFromActivity(activity: BackgroundCommandActivit export const ModernFlowChatContainer: React.FC = ({ className = '', config, + permissionPanelAboveChatInput = false, onFileViewRequest, onTabOpen, onOpenVisualization, @@ -228,6 +233,14 @@ export const ModernFlowChatContainer: React.FC = ( const { t } = useTranslation('flow-chat'); const virtualItems = useVirtualItems(); const activeSession = useActiveSession(); + const { + requests: permissionRequests, + activeBatch: activePermissionBatch, + respond: respondPermission, + respondBatch: respondPermissionBatch, + } = usePermissionRequests( + activeSession?.sessionId, + ); const visibleTurnInfo = useVisibleTurnInfo(); const [pendingHeaderTurnId, setPendingHeaderTurnId] = useState(null); const [queuedHeaderTurnPinId, setQueuedHeaderTurnPinId] = useState(null); @@ -393,6 +406,10 @@ export const ModernFlowChatContainer: React.FC = ( onSwitchToChatPanel, onToolConfirm: handleToolConfirm, onToolReject: handleToolReject, + pendingPermissionToolCallIds: pendingPermissionToolCallIdsForSession( + permissionRequests, + activeSession?.sessionId, + ), sessionId: activeSession?.sessionId, activeSessionOverride: activeSession, allowUserMessageRollback, @@ -421,6 +438,7 @@ export const ModernFlowChatContainer: React.FC = ( onSwitchToChatPanel, handleToolConfirm, handleToolReject, + permissionRequests, activeSession, allowUserMessageRollback, config, @@ -1483,6 +1501,15 @@ export const ModernFlowChatContainer: React.FC = ( onSend={handleSendBackgroundCommandInput} /> + {activePermissionBatch && ( + + )} +
= { + 'permission.actions.read': 'Read files', + 'permission.actions.edit': 'Edit files', + 'permission.actions.bash': 'Run command', + 'permission.actions.git': 'Git action', + 'permission.actions.computerUse': 'Control device', + 'permission.actions.webSearch': 'Search web', + 'permission.actions.webFetch': 'Access web', + 'permission.actions.mcp': 'MCP tool', + 'permission.actions.task': 'Run task', + 'permission.actions.skill': 'Run skill', + 'permission.actions.customTool': 'External tool', + 'permission.actions.externalDirectory': 'Access external directory', + 'permission.actions.other': 'Other action', +}; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => { + if (key === 'permission.subagentOwner') { + return `${values?.subagent} subagent`; + } + if (key === 'permission.allowAlwaysTooltip') { + return `Always allow saves matching access for ${values?.projectPath}`; + } + return TRANSLATIONS[key] ?? key; + }, + }), +})); + +vi.mock('@/component-library', () => ({ + Tooltip: ({ content, children }: { content: string; children: React.ReactElement }) => ( + {children} + ), +})); + +vi.mock('../../store/chatInputStateStore', () => ({ + useChatInputState: () => 0, +})); + +function request(delegated: boolean): PermissionRequest { + return { + requestId: delegated ? 'child-request' : 'direct-request', + roundId: delegated ? 'round-child' : 'round-parent', + order: 0, + sessionId: delegated ? 'child-session' : 'parent-session', + toolCallId: delegated ? 'child-tool' : 'direct-tool', + projectPath: '/workspace/BitFun', + projectId: 'project-1', + agentId: delegated ? 'Explore' : 'agentic', + action: 'edit', + resources: ['src/main.rs'], + saveResources: ['src/main.rs'], + source: { kind: 'tool_call', identity: 'Write' }, + delegation: delegated + ? { + parentSessionId: 'parent-session', + parentDialogTurnId: 'parent-turn', + parentToolCallId: 'parent-task', + subagentType: 'Explore', + } + : undefined, + }; +} + +describe('PermissionRequestPanel', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('names the subagent that owns a delegated permission request', () => { + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).toContain('Explore subagent'); + expect(container.querySelector('.permission-request-panel__heading h2')?.textContent) + .toBe('permission.title'); + }); + + it('keeps direct request details in the request row and scopes always allow to the project path', () => { + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).toContain('Edit files'); + expect(container.textContent).toContain('Write'); + expect(container.textContent).not.toContain('edit'); + expect(container.textContent).not.toContain('subagent'); + const tooltips = [...container.querySelectorAll('[data-tooltip]')] + .map((node) => node.getAttribute('data-tooltip')); + expect(tooltips).toContain('Always allow saves matching access for /workspace/BitFun'); + expect(tooltips).not.toContain('project-1'); + }); + + it('keeps resources to one ellipsized summary with the complete value in a tooltip', () => { + const longResource = 'src/a-very-long-directory-name/another-long-directory/file-with-a-long-name.ts'; + const bashRequest = { + ...request(false), + action: 'bash', + resources: [longResource, 'pnpm run type-check:web'], + }; + act(() => { + root.render( + , + ); + }); + + const resourceSummary = container.querySelector('.permission-request-panel__resource-summary'); + expect(resourceSummary?.textContent).toBe(`${longResource}, pnpm run type-check:web`); + expect(resourceSummary?.parentElement?.getAttribute('data-tooltip')) + .toBe(`${longResource}, pnpm run type-check:web`); + expect(container.textContent).toContain('Run command'); + }); + + it('uses friendly labels for every recognized permission action', () => { + const expectedActions = [ + ['read', 'Read files'], + ['edit', 'Edit files'], + ['bash', 'Run command'], + ['git', 'Git action'], + ['computer_use', 'Control device'], + ['websearch', 'Search web'], + ['webfetch', 'Access web'], + ['mcp', 'MCP tool'], + ['task', 'Run task'], + ['skill', 'Run skill'], + ['custom_tool', 'External tool'], + ['external_directory', 'Access external directory'], + ['future_action', 'Other action'], + ] as const; + + act(() => { + root.render( + ({ + ...request(false), + requestId: `request-${index}`, + action, + }))} + onRespond={vi.fn()} + onRespondBatch={vi.fn()} + />, + ); + }); + + expect([...container.querySelectorAll('.permission-request-panel__action')] + .map((element) => element.textContent)) + .toEqual(expectedActions.map(([, label]) => label)); + }); + + it('shows one ordered batch and responds to the current and following requests once', async () => { + const first = request(false); + const second = { ...request(false), requestId: 'second-request', order: 1 }; + const onRespondBatch = vi.fn(() => Promise.resolve()); + await act(async () => { + root.render( + , + ); + }); + + const batchButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent?.includes('permission.allowCurrentAndFollowing'), + ); + expect(batchButton).toBeDefined(); + await act(async () => { + batchButton?.click(); + await Promise.resolve(); + }); + + expect(onRespondBatch).toHaveBeenCalledWith(first.requestId, 'once', undefined); + expect(container.querySelectorAll('[role="listitem"]')).toHaveLength(2); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/PermissionRequestPanel.tsx b/src/web-ui/src/flow_chat/components/modern/PermissionRequestPanel.tsx new file mode 100644 index 0000000000..d0bc5ff19b --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/PermissionRequestPanel.tsx @@ -0,0 +1,191 @@ +import { useState, type CSSProperties } from 'react'; +import { Check, ShieldAlert, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Tooltip } from '@/component-library'; +import type { + PermissionReplyKind, + PermissionRequest, +} from '@/infrastructure/api/service-api/AgentAPI'; +import { useChatInputState } from '../../store/chatInputStateStore'; +import { CHAT_INPUT_DROP_ZONE_BOTTOM_PX } from '../../utils/flowChatScrollLayout'; +import './PermissionRequestPanel.scss'; + +const PERMISSION_PANEL_INPUT_GAP_PX = 16; + +interface PermissionRequestPanelProps { + requests: PermissionRequest[]; + onRespond: (requestId: string, reply: PermissionReplyKind, feedback?: string) => Promise; + onRespondBatch: (requestId: string, reply: PermissionReplyKind, feedback?: string) => Promise; + aboveChatInput?: boolean; +} + +const PERMISSION_ACTION_LABEL_KEYS: Record = { + read: 'permission.actions.read', + edit: 'permission.actions.edit', + bash: 'permission.actions.bash', + git: 'permission.actions.git', + computer_use: 'permission.actions.computerUse', + websearch: 'permission.actions.webSearch', + webfetch: 'permission.actions.webFetch', + mcp: 'permission.actions.mcp', + task: 'permission.actions.task', + skill: 'permission.actions.skill', + custom_tool: 'permission.actions.customTool', + external_directory: 'permission.actions.externalDirectory', +}; + +function permissionActionLabel(action: string, t: (key: string) => string): string { + return t(PERMISSION_ACTION_LABEL_KEYS[action] ?? 'permission.actions.other'); +} + +export function PermissionRequestPanel({ + requests, + onRespond, + onRespondBatch, + aboveChatInput = false, +}: PermissionRequestPanelProps) { + const { t } = useTranslation('flow-chat'); + const [feedback, setFeedback] = useState(''); + const [responding, setResponding] = useState(false); + const [error, setError] = useState(false); + const inputHeight = useChatInputState((state) => state.inputHeight); + const request = requests[0]; + const risk = [request?.displayMetadata?.riskDescription, request?.displayMetadata?.risk].find( + (value): value is string => typeof value === 'string' && value.trim().length > 0, + ); + + const alwaysAllowTooltip = request?.saveResources?.length + ? request.projectPath?.trim() + ? t('permission.allowAlwaysTooltip', { projectPath: request.projectPath.trim() }) + : t('permission.allowAlwaysTooltipCurrentProject') + : t('permission.allowAlwaysTooltipNoGrant'); + + const panelStyle = aboveChatInput && inputHeight > 0 + ? { + '--permission-request-panel-bottom': `${ + inputHeight + CHAT_INPUT_DROP_ZONE_BOTTOM_PX + PERMISSION_PANEL_INPUT_GAP_PX + }px`, + } as CSSProperties + : undefined; + + const respond = async (reply: PermissionReplyKind) => { + setResponding(true); + setError(false); + try { + await onRespond(request.requestId, reply, reply === 'reject' ? feedback : undefined); + } catch { + setError(true); + } finally { + setResponding(false); + } + }; + + const respondBatch = async (reply: PermissionReplyKind) => { + setResponding(true); + setError(false); + try { + await onRespondBatch(request.requestId, reply, reply === 'reject' ? feedback : undefined); + } catch { + setError(true); + } finally { + setResponding(false); + } + }; + + if (!request) return null; + + return ( +
+
+
+
+ + {t('permission.batchCount', { count: requests.length })} + +
+
+ {requests.map((item, index) => ( +
+
+
+ {item.source.identity} + {item.delegation && ( + + {t('permission.subagentOwner', { subagent: item.delegation.subagentType })} + + )} +
+ {index === 0 ? t('permission.current') : t('permission.pending')} +
+
+ + {permissionActionLabel(item.action, t)} + + + + + {item.resources.join(', ')} + + +
+
+ ))} +
+ {risk &&

{risk}

} + {error &&

{t('permission.responseFailed')}

} +