From 69b56cd9893db77c92efdcd0a9a720ddc562005c Mon Sep 17 00:00:00 2001 From: wsp Date: Sun, 19 Apr 2026 21:01:02 +0800 Subject: [PATCH 1/4] feat(agentic): add shared-context fork execution foundation - add fork domain types for parent context snapshots and fork execution requests/results - extract hidden subagent execution into a shared coordinator path - add coordinator APIs to capture parent context and execute forked agents - preserve inherited runtime context while rebuilding child session config cleanly - keep existing fresh subagent flow by migrating it onto the new execution foundation --- .../src/agentic/coordination/coordinator.rs | 220 ++++++++++++++---- src/crates/core/src/agentic/fork/mod.rs | 169 ++++++++++++++ src/crates/core/src/agentic/mod.rs | 4 + 3 files changed, 350 insertions(+), 43 deletions(-) create mode 100644 src/crates/core/src/agentic/fork/mod.rs diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index b18b82530..417aebccf 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -12,6 +12,7 @@ use crate::agentic::events::{ AgenticEvent, EventPriority, EventQueue, EventRouter, EventSubscriber, }; use crate::agentic::execution::{ContextCompactionOutcome, ExecutionContext, ExecutionEngine}; +use crate::agentic::fork::{ForkContextSnapshot, ForkExecutionRequest, ForkExecutionResult}; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::round_preempt::DialogRoundPreemptSource; use crate::agentic::session::SessionManager; @@ -22,6 +23,7 @@ use crate::service::bootstrap::{ }; use crate::util::errors::{BitFunError, BitFunResult}; use log::{debug, error, info, warn}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::OnceLock; @@ -41,6 +43,16 @@ pub struct SubagentResult { pub text: String, } +struct HiddenSubagentExecutionRequest { + session_name: String, + agent_type: String, + session_config: SessionConfig, + initial_messages: Vec, + created_by: Option, + subagent_parent_info: Option, + context: HashMap, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DialogTriggerSource { DesktopUi, @@ -683,16 +695,16 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } } - /// Create a subagent session for internal AI execution. + /// Create a hidden subagent session for internal AI execution. /// Unlike `create_session`, this does NOT emit `SessionCreated` to the transport layer, - /// because subagent sessions are internal implementation details of the execution engine + /// because hidden child sessions are internal implementation details of the execution engine /// and must never appear as top-level items in the UI. - async fn create_subagent_session( + async fn create_hidden_subagent_session( &self, session_name: String, agent_type: String, config: SessionConfig, - parent_info: &SubagentParentInfo, + created_by: Option, ) -> BitFunResult { self.session_manager .create_session_with_id_and_details( @@ -700,12 +712,45 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_name, agent_type, config, - Some(format!("session-{}", parent_info.session_id)), + created_by, SessionKind::Subagent, ) .await } + async fn load_session_context_messages(&self, session: &Session) -> BitFunResult> { + let session_id = &session.session_id; + let mut context_messages = self + .session_manager + .get_context_messages(session_id) + .await?; + + if context_messages.is_empty() && !session.dialog_turn_ids.is_empty() { + if let Some(workspace_path) = session.config.workspace_path.as_deref() { + match self + .session_manager + .restore_session(Path::new(workspace_path), session_id) + .await + { + Ok(_) => { + context_messages = self + .session_manager + .get_context_messages(session_id) + .await?; + } + Err(e) => { + debug!( + "Failed to restore parent session context for fork capture: session_id={}, error={}", + session_id, e + ); + } + } + } + } + + Ok(context_messages) + } + async fn wrap_user_input( &self, agent_type: &str, @@ -1871,24 +1916,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.tool_pipeline.cancel_tool(tool_id, reason).await } - /// Execute subagent task directly - /// DialogTurnStarted event not needed for now - /// - /// Parameters: - /// - agent_type: Agent type - /// - task_description: Task description - /// - subagent_parent_info: Parent info (tool call context) - /// - context: Additional context - /// - cancel_token: Optional cancel token (for async cancellation) - /// - /// Returns SubagentResult with the final text response - pub async fn execute_subagent( + async fn execute_hidden_subagent_internal( &self, - agent_type: String, - task_description: String, - subagent_parent_info: SubagentParentInfo, - workspace_path: Option, - context: Option>, + request: HiddenSubagentExecutionRequest, cancel_token: Option<&CancellationToken>, ) -> BitFunResult { // Check cancel token (before creating session) @@ -1901,25 +1931,22 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } } - // Create independent subagent session. - // Use create_subagent_session (not create_session) so that no SessionCreated - // event is emitted to the transport layer — subagent sessions are internal - // implementation details and must not appear in the UI session list. - let workspace_path = workspace_path.ok_or_else(|| { - BitFunError::Validation( - "workspace_path is required when creating a subagent session".to_string(), - ) - })?; - let subagent_config = SessionConfig { - workspace_path: Some(workspace_path), - ..SessionConfig::default() - }; + let HiddenSubagentExecutionRequest { + session_name, + agent_type, + session_config, + initial_messages, + created_by, + subagent_parent_info, + context, + } = request; + let session = self - .create_subagent_session( - format!("Subagent: {}", task_description), + .create_hidden_subagent_session( + session_name, agent_type.clone(), - subagent_config, - &subagent_parent_info, + session_config, + created_by, ) .await?; @@ -1973,8 +2000,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet turn_index: 0, agent_type: agent_type.clone(), workspace: subagent_workspace, - context: context.unwrap_or_default(), - subagent_parent_info: Some(subagent_parent_info), + context, + subagent_parent_info: subagent_parent_info.clone(), // 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. @@ -1983,8 +2010,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet round_preempt: self.round_preempt_source.get().cloned(), }; - let initial_messages = vec![Message::user(task_description)]; - let result = self .execution_engine .execute_dialog_turn(agent_type, initial_messages, execution_context) @@ -2039,6 +2064,115 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }) } + pub async fn capture_fork_context_snapshot( + &self, + parent_session_id: &str, + ) -> BitFunResult { + let parent_session = self + .session_manager + .get_session(parent_session_id) + .ok_or_else(|| { + BitFunError::NotFound(format!("Parent session not found: {}", parent_session_id)) + })?; + let context_messages = self.load_session_context_messages(&parent_session).await?; + ForkContextSnapshot::from_parent_session(&parent_session, context_messages) + } + + /// Execute a hidden child agent that inherits the parent session's current + /// model-visible context. + pub async fn execute_forked_agent( + &self, + request: ForkExecutionRequest, + cancel_token: Option<&CancellationToken>, + ) -> BitFunResult { + if request.agent_type.trim().is_empty() { + return Err(BitFunError::Validation( + "ForkExecutionRequest.agent_type is required".to_string(), + )); + } + if request.description.trim().is_empty() { + return Err(BitFunError::Validation( + "ForkExecutionRequest.description is required".to_string(), + )); + } + if request.prompt_messages.is_empty() { + return Err(BitFunError::Validation( + "ForkExecutionRequest.prompt_messages must not be empty".to_string(), + )); + } + + let inherited_message_count = request.snapshot.inherited_message_count(); + let prompt_message_count = request.prompt_messages.len(); + let agent_type = request.agent_type.clone(); + let session_config = request.child_session_config(); + let initial_messages = request.composed_initial_messages(); + let created_by = Some(format!("session-{}", request.snapshot.parent_session_id)); + let child_result = self + .execute_hidden_subagent_internal( + HiddenSubagentExecutionRequest { + session_name: format!("Fork: {}", request.description), + agent_type, + session_config, + initial_messages, + created_by, + subagent_parent_info: None, + context: request.context, + }, + cancel_token, + ) + .await?; + + Ok(ForkExecutionResult { + text: child_result.text, + inherited_message_count, + prompt_message_count, + }) + } + + /// Execute subagent task directly + /// DialogTurnStarted event not needed for now + /// + /// Parameters: + /// - agent_type: Agent type + /// - task_description: Task description + /// - subagent_parent_info: Parent info (tool call context) + /// - context: Additional context + /// - cancel_token: Optional cancel token (for async cancellation) + /// + /// Returns SubagentResult with the final text response + pub async fn execute_subagent( + &self, + agent_type: String, + task_description: String, + subagent_parent_info: SubagentParentInfo, + workspace_path: Option, + context: Option>, + cancel_token: Option<&CancellationToken>, + ) -> BitFunResult { + let workspace_path = workspace_path.ok_or_else(|| { + BitFunError::Validation( + "workspace_path is required when creating a subagent session".to_string(), + ) + })?; + + self.execute_hidden_subagent_internal( + HiddenSubagentExecutionRequest { + session_name: format!("Subagent: {}", task_description), + agent_type, + session_config: SessionConfig { + workspace_path: Some(workspace_path), + ..SessionConfig::default() + }, + initial_messages: vec![Message::user(task_description)], + created_by: Some(format!("session-{}", subagent_parent_info.session_id)), + subagent_parent_info: Some(subagent_parent_info), + context: context.unwrap_or_default(), + }, + cancel_token, + ) + .await + } + /// Clean up subagent session resources /// /// Release resources occupied by subagent session (sandbox, etc.) and delete session diff --git a/src/crates/core/src/agentic/fork/mod.rs b/src/crates/core/src/agentic/fork/mod.rs new file mode 100644 index 000000000..fb658d0cc --- /dev/null +++ b/src/crates/core/src/agentic/fork/mod.rs @@ -0,0 +1,169 @@ +//! Shared-context fork execution primitives. +//! +//! A fork is a hidden child execution that inherits the parent session's +//! model-visible message context, but still runs as an isolated session with +//! its own rounds, tools, cancellation, and cleanup lifecycle. + +use crate::agentic::core::{Message, Session, SessionConfig}; +use crate::util::errors::{BitFunError, BitFunResult}; +use std::collections::HashMap; + +/// Immutable snapshot of a parent session's runtime context at fork time. +#[derive(Debug, Clone)] +pub struct ForkContextSnapshot { + pub parent_session_id: String, + pub parent_agent_type: String, + pub workspace_path: String, + pub remote_connection_id: Option, + pub remote_ssh_host: Option, + pub storage_scope: Option, + pub session_model_id: Option, + pub session_config: SessionConfig, + pub messages: Vec, +} + +impl ForkContextSnapshot { + pub fn from_parent_session( + parent_session: &Session, + messages: Vec, + ) -> BitFunResult { + let workspace_path = parent_session + .config + .workspace_path + .clone() + .ok_or_else(|| { + BitFunError::Validation(format!( + "workspace_path is required when forking session: {}", + parent_session.session_id + )) + })?; + + Ok(Self { + parent_session_id: parent_session.session_id.clone(), + parent_agent_type: parent_session.agent_type.clone(), + workspace_path, + remote_connection_id: parent_session.config.remote_connection_id.clone(), + remote_ssh_host: parent_session.config.remote_ssh_host.clone(), + storage_scope: parent_session.config.storage_scope, + session_model_id: parent_session.config.model_id.clone(), + session_config: parent_session.config.clone(), + messages, + }) + } + + pub fn inherited_message_count(&self) -> usize { + self.messages.len() + } + + pub fn build_child_session_config(&self, max_turns_override: Option) -> SessionConfig { + let mut config = self.session_config.clone(); + config.workspace_path = Some(self.workspace_path.clone()); + config.remote_connection_id = self.remote_connection_id.clone(); + config.remote_ssh_host = self.remote_ssh_host.clone(); + config.storage_scope = self.storage_scope; + config.model_id = self.session_model_id.clone(); + if let Some(max_turns) = max_turns_override { + config.max_turns = max_turns; + } + config + } + + pub fn compose_initial_messages(&self, prompt_messages: &[Message]) -> Vec { + let mut messages = self.messages.clone(); + messages.extend(prompt_messages.iter().cloned()); + messages + } +} + +/// Semantic fork request. +#[derive(Debug, Clone)] +pub struct ForkExecutionRequest { + pub snapshot: ForkContextSnapshot, + pub agent_type: String, + pub description: String, + pub prompt_messages: Vec, + pub context: HashMap, + pub max_turns: Option, +} + +impl ForkExecutionRequest { + pub fn composed_initial_messages(&self) -> Vec { + self.snapshot + .compose_initial_messages(&self.prompt_messages) + } + + pub fn child_session_config(&self) -> SessionConfig { + self.snapshot.build_child_session_config(self.max_turns) + } +} + +/// Result returned by a completed semantic fork. +#[derive(Debug, Clone)] +pub struct ForkExecutionResult { + pub text: String, + pub inherited_message_count: usize, + pub prompt_message_count: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::core::{Message, Session, SessionConfig, SessionStorageScope}; + + fn parent_session() -> Session { + let config = SessionConfig { + workspace_path: Some("/workspace/project".to_string()), + remote_connection_id: Some("remote-1".to_string()), + remote_ssh_host: Some("prod-box".to_string()), + storage_scope: Some(SessionStorageScope::AgenticOs), + model_id: Some("primary".to_string()), + max_turns: 42, + ..SessionConfig::default() + }; + Session::new("Parent".to_string(), "agentic".to_string(), config) + } + + #[test] + fn snapshot_composes_parent_and_prompt_messages_in_order() { + let parent = parent_session(); + let inherited = vec![Message::user("hello".to_string())]; + let prompt = vec![Message::user("fork directive".to_string())]; + let snapshot = + ForkContextSnapshot::from_parent_session(&parent, inherited.clone()).expect("snapshot"); + + let combined = snapshot.compose_initial_messages(&prompt); + + assert_eq!(combined.len(), 2); + assert!(matches!( + combined[0].content, + crate::agentic::core::MessageContent::Text(_) + )); + assert_eq!(combined[0].id, inherited[0].id); + assert_eq!(combined[1].id, prompt[0].id); + } + + #[test] + fn snapshot_builds_child_session_config_from_parent() { + let parent = parent_session(); + let snapshot = + ForkContextSnapshot::from_parent_session(&parent, Vec::new()).expect("snapshot"); + + let child_config = snapshot.build_child_session_config(Some(7)); + + assert_eq!( + child_config.workspace_path.as_deref(), + Some("/workspace/project") + ); + assert_eq!( + child_config.remote_connection_id.as_deref(), + Some("remote-1") + ); + assert_eq!(child_config.remote_ssh_host.as_deref(), Some("prod-box")); + assert_eq!( + child_config.storage_scope, + Some(SessionStorageScope::AgenticOs) + ); + assert_eq!(child_config.model_id.as_deref(), Some("primary")); + assert_eq!(child_config.max_turns, 7); + } +} diff --git a/src/crates/core/src/agentic/mod.rs b/src/crates/core/src/agentic/mod.rs index b199b3ede..6759f7f71 100644 --- a/src/crates/core/src/agentic/mod.rs +++ b/src/crates/core/src/agentic/mod.rs @@ -19,6 +19,9 @@ pub mod tools; // Coordination module pub mod coordination; +// Shared-context fork execution module +pub mod fork; + /// Round-boundary yield when user queues a message during an active turn pub mod round_preempt; @@ -42,6 +45,7 @@ pub use coordination::*; pub use core::*; pub use events::{queue, router, types as event_types}; pub use execution::*; +pub use fork::*; pub use image_analysis::{ImageAnalyzer, MessageEnhancer}; pub use persistence::PersistenceManager; pub use round_preempt::{ From 7b45b8196692655485270946fec35e85d17fb756 Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Sat, 25 Apr 2026 15:14:16 +0800 Subject: [PATCH 2/4] fix(agentic): adapt fork foundation to main session config --- src/crates/core/src/agentic/fork/mod.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/crates/core/src/agentic/fork/mod.rs b/src/crates/core/src/agentic/fork/mod.rs index fb658d0cc..4a64f3c5d 100644 --- a/src/crates/core/src/agentic/fork/mod.rs +++ b/src/crates/core/src/agentic/fork/mod.rs @@ -16,7 +16,6 @@ pub struct ForkContextSnapshot { pub workspace_path: String, pub remote_connection_id: Option, pub remote_ssh_host: Option, - pub storage_scope: Option, pub session_model_id: Option, pub session_config: SessionConfig, pub messages: Vec, @@ -44,7 +43,6 @@ impl ForkContextSnapshot { workspace_path, remote_connection_id: parent_session.config.remote_connection_id.clone(), remote_ssh_host: parent_session.config.remote_ssh_host.clone(), - storage_scope: parent_session.config.storage_scope, session_model_id: parent_session.config.model_id.clone(), session_config: parent_session.config.clone(), messages, @@ -60,7 +58,6 @@ impl ForkContextSnapshot { config.workspace_path = Some(self.workspace_path.clone()); config.remote_connection_id = self.remote_connection_id.clone(); config.remote_ssh_host = self.remote_ssh_host.clone(); - config.storage_scope = self.storage_scope; config.model_id = self.session_model_id.clone(); if let Some(max_turns) = max_turns_override { config.max_turns = max_turns; @@ -108,14 +105,13 @@ pub struct ForkExecutionResult { #[cfg(test)] mod tests { use super::*; - use crate::agentic::core::{Message, Session, SessionConfig, SessionStorageScope}; + use crate::agentic::core::{Message, Session, SessionConfig}; fn parent_session() -> Session { let config = SessionConfig { workspace_path: Some("/workspace/project".to_string()), remote_connection_id: Some("remote-1".to_string()), remote_ssh_host: Some("prod-box".to_string()), - storage_scope: Some(SessionStorageScope::AgenticOs), model_id: Some("primary".to_string()), max_turns: 42, ..SessionConfig::default() @@ -159,10 +155,6 @@ mod tests { Some("remote-1") ); assert_eq!(child_config.remote_ssh_host.as_deref(), Some("prod-box")); - assert_eq!( - child_config.storage_scope, - Some(SessionStorageScope::AgenticOs) - ); assert_eq!(child_config.model_id.as_deref(), Some("primary")); assert_eq!(child_config.max_turns, 7); } From f94c7d8507a29c555afa262eb8d43ae776100028 Mon Sep 17 00:00:00 2001 From: wsp Date: Sun, 19 Apr 2026 21:01:02 +0800 Subject: [PATCH 3/4] feat(agentic): enforce runtime restrictions for tool execution - add runtime tool restriction and path policy models with local and remote path root checks - propagate tool restrictions through execution, fork, coordinator, and pipeline contexts - validate and enforce write, edit, and delete path permissions in file tools --- .../src/agentic/coordination/coordinator.rs | 7 + .../src/agentic/execution/execution_engine.rs | 6 +- .../src/agentic/execution/round_executor.rs | 1 + .../core/src/agentic/execution/types.rs | 3 + src/crates/core/src/agentic/fork/mod.rs | 2 + .../core/src/agentic/tools/framework.rs | 61 +++++ .../tools/implementations/control_hub_tool.rs | 1 + .../tools/implementations/delete_file_tool.rs | 13 + .../tools/implementations/file_edit_tool.rs | 65 ++++- .../tools/implementations/file_write_tool.rs | 16 +- .../tools/implementations/web_tools.rs | 1 + src/crates/core/src/agentic/tools/mod.rs | 2 + .../agentic/tools/pipeline/tool_pipeline.rs | 22 ++ .../core/src/agentic/tools/pipeline/types.rs | 2 + .../core/src/agentic/tools/restrictions.rs | 235 ++++++++++++++++++ 15 files changed, 434 insertions(+), 3 deletions(-) create mode 100644 src/crates/core/src/agentic/tools/restrictions.rs diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 417aebccf..6a4dd46b5 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -16,6 +16,7 @@ use crate::agentic::fork::{ForkContextSnapshot, ForkExecutionRequest, ForkExecut use crate::agentic::image_analysis::ImageContextData; use crate::agentic::round_preempt::DialogRoundPreemptSource; use crate::agentic::session::SessionManager; +use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::tools::pipeline::{SubagentParentInfo, ToolPipeline}; use crate::agentic::WorkspaceBinding; use crate::service::bootstrap::{ @@ -51,6 +52,7 @@ struct HiddenSubagentExecutionRequest { created_by: Option, subagent_parent_info: Option, context: HashMap, + runtime_tool_restrictions: ToolRuntimeRestrictions, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1483,6 +1485,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet context: context_vars, subagent_parent_info: None, skip_tool_confirmation: submission_policy.skip_tool_confirmation, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), workspace_services, round_preempt: self.round_preempt_source.get().cloned(), }; @@ -1939,6 +1942,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet created_by, subagent_parent_info, context, + runtime_tool_restrictions, } = request; let session = self @@ -2006,6 +2010,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet // 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, round_preempt: self.round_preempt_source.get().cloned(), }; @@ -2117,6 +2122,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet created_by, subagent_parent_info: None, context: request.context, + runtime_tool_restrictions: request.runtime_tool_restrictions, }, cancel_token, ) @@ -2167,6 +2173,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet created_by: Some(format!("session-{}", subagent_parent_info.session_id)), subagent_parent_info: Some(subagent_parent_info), context: context.unwrap_or_default(), + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), }, cancel_token, ) diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index 70103105d..754abf398 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -17,7 +17,9 @@ use crate::agentic::image_analysis::{ ImageLimits, }; use crate::agentic::session::{CompressionTailPolicy, ContextCompressor, SessionManager}; -use crate::agentic::tools::{get_all_registered_tools, SubagentParentInfo}; +use crate::agentic::tools::{ + get_all_registered_tools, SubagentParentInfo, ToolRuntimeRestrictions, +}; use crate::agentic::util::build_remote_workspace_layout_preview; use crate::agentic::{WorkspaceBackend, WorkspaceBinding}; use crate::infrastructure::ai::get_global_ai_client_factory; @@ -1432,6 +1434,7 @@ impl ExecutionEngine { model_name: ai_client.config.model.clone(), agent_type: agent_type.clone(), context_vars: round_context_vars, + runtime_tool_restrictions: context.runtime_tool_restrictions.clone(), cancellation_token: CancellationToken::new(), workspace_services: context.workspace_services.clone(), }; @@ -1702,6 +1705,7 @@ impl ExecutionEngine { custom_data: tool_opts_custom, computer_use_host: None, cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), workspace_services: None, }; for tool in &all_tools { diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index af8f223c7..3a083b951 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -353,6 +353,7 @@ impl RoundExecutor { context_vars: context.context_vars.clone(), subagent_parent_info, allowed_tools: context.available_tools.clone(), + runtime_tool_restrictions: context.runtime_tool_restrictions.clone(), workspace_services: context.workspace_services.clone(), }; diff --git a/src/crates/core/src/agentic/execution/types.rs b/src/crates/core/src/agentic/execution/types.rs index 68e6581c9..601bc7c1b 100644 --- a/src/crates/core/src/agentic/execution/types.rs +++ b/src/crates/core/src/agentic/execution/types.rs @@ -2,6 +2,7 @@ use crate::agentic::core::Message; use crate::agentic::round_preempt::DialogRoundPreemptSource; +use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::tools::pipeline::SubagentParentInfo; use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; @@ -21,6 +22,7 @@ pub struct ExecutionContext { pub context: HashMap, pub subagent_parent_info: Option, pub skip_tool_confirmation: bool, + pub runtime_tool_restrictions: ToolRuntimeRestrictions, /// Workspace I/O services (filesystem + shell) injected into tools pub workspace_services: Option, /// When set, engine may end the turn after a full model round if a user message was queued. @@ -41,6 +43,7 @@ pub struct RoundContext { pub model_name: String, pub agent_type: String, pub context_vars: HashMap, + pub runtime_tool_restrictions: ToolRuntimeRestrictions, pub cancellation_token: CancellationToken, pub workspace_services: Option, } diff --git a/src/crates/core/src/agentic/fork/mod.rs b/src/crates/core/src/agentic/fork/mod.rs index 4a64f3c5d..d1671a505 100644 --- a/src/crates/core/src/agentic/fork/mod.rs +++ b/src/crates/core/src/agentic/fork/mod.rs @@ -5,6 +5,7 @@ //! its own rounds, tools, cancellation, and cleanup lifecycle. use crate::agentic::core::{Message, Session, SessionConfig}; +use crate::agentic::tools::ToolRuntimeRestrictions; use crate::util::errors::{BitFunError, BitFunResult}; use std::collections::HashMap; @@ -80,6 +81,7 @@ pub struct ForkExecutionRequest { pub description: String, pub prompt_messages: Vec, pub context: HashMap, + pub runtime_tool_restrictions: ToolRuntimeRestrictions, pub max_turns: Option, } diff --git a/src/crates/core/src/agentic/tools/framework.rs b/src/crates/core/src/agentic/tools/framework.rs index ba74e5d6e..67a5bd8ab 100644 --- a/src/crates/core/src/agentic/tools/framework.rs +++ b/src/crates/core/src/agentic/tools/framework.rs @@ -1,4 +1,8 @@ //! Tool framework - Tool interface definition and execution context +use crate::agentic::tools::restrictions::{ + is_local_path_within_root, is_remote_posix_path_within_root, ToolPathOperation, + ToolRuntimeRestrictions, +}; use crate::agentic::tools::workspace_paths::{ build_bitfun_runtime_uri, is_bitfun_runtime_uri, normalize_runtime_relative_path, parse_bitfun_runtime_uri, @@ -65,6 +69,7 @@ pub struct ToolUseContext { pub computer_use_host: Option, // Cancel tool execution more timely, especially for tools like TaskTool that need to run for a long time pub cancellation_token: Option, + pub runtime_tool_restrictions: ToolRuntimeRestrictions, /// Workspace I/O services (filesystem + shell) — use these instead of /// checking `get_remote_workspace_manager()` inside individual tools. pub workspace_services: Option, @@ -90,6 +95,62 @@ impl ToolUseContext { self.workspace_services.as_ref().map(|s| s.shell.as_ref()) } + pub fn enforce_tool_runtime_restrictions(&self, tool_name: &str) -> BitFunResult<()> { + self.runtime_tool_restrictions.ensure_tool_allowed(tool_name) + } + + pub fn enforce_path_operation( + &self, + operation: ToolPathOperation, + resolution: &ToolPathResolution, + ) -> BitFunResult<()> { + let allowed_roots = self + .runtime_tool_restrictions + .path_policy + .roots_for(operation); + if allowed_roots.is_empty() { + return Ok(()); + } + + let mut resolved_roots = Vec::with_capacity(allowed_roots.len()); + for root in allowed_roots { + resolved_roots.push(self.resolve_tool_path(root)?); + } + + let mut is_allowed = false; + for root in &resolved_roots { + if root.backend != resolution.backend { + continue; + } + + let matches_root = match resolution.backend { + ToolPathBackend::Local => is_local_path_within_root( + Path::new(&resolution.resolved_path), + Path::new(&root.resolved_path), + )?, + ToolPathBackend::RemoteWorkspace => { + is_remote_posix_path_within_root(&resolution.resolved_path, &root.resolved_path) + } + }; + + if matches_root { + is_allowed = true; + break; + } + } + + if is_allowed { + return Ok(()); + } + + Err(crate::util::errors::BitFunError::validation(format!( + "Path '{}' is not allowed for {}. Allowed roots: {}", + resolution.logical_path, + operation.verb(), + allowed_roots.join(", ") + ))) + } + /// Whether the session primary model accepts image inputs (from tool-definition / pipeline context). /// Defaults to **true** when unset (e.g. API listings without model metadata). pub fn primary_model_supports_image_understanding(&self) -> bool { diff --git a/src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs b/src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs index 6e61d283f..9be30f7dd 100644 --- a/src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs @@ -1450,6 +1450,7 @@ mod control_hub_tests { custom_data: std::collections::HashMap::new(), computer_use_host: None, cancellation_token: None, + runtime_tool_restrictions: Default::default(), workspace_services: None, } } diff --git a/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs b/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs index 03daf3ee5..8ca403580 100644 --- a/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs @@ -1,6 +1,7 @@ use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; +use crate::agentic::tools::ToolPathOperation; use crate::agentic::tools::workspace_paths::is_bitfun_runtime_uri; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; @@ -188,6 +189,17 @@ Important notes: } }; + if let Some(ctx) = context { + if let Err(err) = ctx.enforce_path_operation(ToolPathOperation::Delete, &resolved) { + return ValidationResult { + result: false, + message: Some(err.to_string()), + error_code: Some(400), + meta: None, + }; + } + } + if !resolved.uses_remote_workspace_backend() { let local_path = Path::new(&resolved.resolved_path); if !local_path.exists() { @@ -281,6 +293,7 @@ Important notes: .unwrap_or(false); let resolved = context.resolve_tool_path(path_str)?; + context.enforce_path_operation(ToolPathOperation::Delete, &resolved)?; // Remote workspace path: delete via shell command if resolved.uses_remote_workspace_backend() { diff --git a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs index 479e1e392..c5240be0b 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs @@ -1,4 +1,5 @@ -use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; +use crate::agentic::tools::ToolPathOperation; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use serde_json::{json, Value}; @@ -79,6 +80,67 @@ Usage: false } + async fn validate_input( + &self, + input: &Value, + context: Option<&ToolUseContext>, + ) -> ValidationResult { + let file_path = match input.get("file_path").and_then(|v| v.as_str()) { + Some(path) if !path.is_empty() => path, + _ => { + return ValidationResult { + result: false, + message: Some("file_path is required and cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }; + } + }; + + if input.get("old_string").is_none() { + return ValidationResult { + result: false, + message: Some("old_string is required".to_string()), + error_code: Some(400), + meta: None, + }; + } + + if input.get("new_string").is_none() { + return ValidationResult { + result: false, + message: Some("new_string is required".to_string()), + error_code: Some(400), + meta: None, + }; + } + + if let Some(ctx) = context { + let resolved = match ctx.resolve_tool_path(file_path) { + Ok(resolved) => resolved, + Err(err) => { + return ValidationResult { + result: false, + message: Some(err.to_string()), + error_code: Some(400), + meta: None, + }; + } + }; + + if let Err(err) = ctx.enforce_path_operation(ToolPathOperation::Edit, &resolved) { + return ValidationResult { + result: false, + message: Some(err.to_string()), + error_code: Some(400), + meta: None, + }; + } + } + + ValidationResult::default() + } + async fn call_impl( &self, input: &Value, @@ -105,6 +167,7 @@ Usage: .unwrap_or(false); let resolved = context.resolve_tool_path(file_path)?; + context.enforce_path_operation(ToolPathOperation::Edit, &resolved)?; // For remote workspace paths, use the abstract FS to read → edit in memory → write back. if resolved.uses_remote_workspace_backend() { diff --git a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs index 24977a0f5..97977fa91 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs @@ -1,6 +1,7 @@ use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; +use crate::agentic::tools::ToolPathOperation; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use serde_json::{json, Value}; @@ -97,7 +98,19 @@ Usage: } if let Some(ctx) = context { - if let Err(err) = ctx.resolve_tool_path(file_path) { + let resolved = match ctx.resolve_tool_path(file_path) { + Ok(resolved) => resolved, + Err(err) => { + return ValidationResult { + result: false, + message: Some(err.to_string()), + error_code: Some(400), + meta: None, + }; + } + }; + + if let Err(err) = ctx.enforce_path_operation(ToolPathOperation::Write, &resolved) { return ValidationResult { result: false, message: Some(err.to_string()), @@ -138,6 +151,7 @@ Usage: .ok_or_else(|| BitFunError::tool("file_path is required".to_string()))?; let resolved = context.resolve_tool_path(file_path)?; + context.enforce_path_operation(ToolPathOperation::Write, &resolved)?; let content = input .get("content") diff --git a/src/crates/core/src/agentic/tools/implementations/web_tools.rs b/src/crates/core/src/agentic/tools/implementations/web_tools.rs index ca63a9fa7..7af9dbd85 100644 --- a/src/crates/core/src/agentic/tools/implementations/web_tools.rs +++ b/src/crates/core/src/agentic/tools/implementations/web_tools.rs @@ -649,6 +649,7 @@ mod tests { custom_data: std::collections::HashMap::new(), computer_use_host: None, cancellation_token: None, + runtime_tool_restrictions: Default::default(), workspace_services: None, } } diff --git a/src/crates/core/src/agentic/tools/mod.rs b/src/crates/core/src/agentic/tools/mod.rs index 6609f6ff8..f2402862c 100644 --- a/src/crates/core/src/agentic/tools/mod.rs +++ b/src/crates/core/src/agentic/tools/mod.rs @@ -10,6 +10,7 @@ pub mod image_context; pub mod implementations; pub mod input_validator; pub mod pipeline; +pub mod restrictions; pub mod registry; pub mod user_input_manager; pub mod workspace_paths; @@ -18,6 +19,7 @@ pub use framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; pub use image_context::{ImageContextData, ImageContextProvider, ImageContextProviderRef}; pub use input_validator::InputValidator; pub use pipeline::*; +pub use restrictions::{ToolPathOperation, ToolPathPolicy, ToolRuntimeRestrictions}; pub use registry::{ create_tool_registry, get_all_registered_tool_names, get_all_registered_tools, get_all_tools, get_readonly_tools, diff --git a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs index 4d59473f4..ac65cfc06 100644 --- a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -511,6 +511,27 @@ impl ToolPipeline { return Err(BitFunError::Validation(error_msg)); } + if let Err(err) = task + .context + .runtime_tool_restrictions + .ensure_tool_allowed(&tool_name) + { + let error_msg = err.to_string(); + warn!("Tool rejected by runtime restrictions: {}", error_msg); + + self.state_manager + .update_state( + &tool_id, + ToolExecutionState::Failed { + error: error_msg, + is_retryable: false, + }, + ) + .await; + + return Err(err); + } + // Create cancellation token let cancellation_token = CancellationToken::new(); self.cancellation_tokens @@ -844,6 +865,7 @@ impl ToolPipeline { }, computer_use_host: self.computer_use_host.clone(), cancellation_token: Some(cancellation_token), + runtime_tool_restrictions: task.context.runtime_tool_restrictions.clone(), workspace_services: task.context.workspace_services.clone(), }; diff --git a/src/crates/core/src/agentic/tools/pipeline/types.rs b/src/crates/core/src/agentic/tools/pipeline/types.rs index 04130b976..7458c15e9 100644 --- a/src/crates/core/src/agentic/tools/pipeline/types.rs +++ b/src/crates/core/src/agentic/tools/pipeline/types.rs @@ -2,6 +2,7 @@ use crate::agentic::core::{ToolCall, ToolExecutionState}; use crate::agentic::events::SubagentParentInfo as EventSubagentParentInfo; +use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; use std::collections::HashMap; @@ -61,6 +62,7 @@ pub struct ToolExecutionContext { /// If empty, allow all registered tools /// If not empty, only allow tools in the list to be executed pub allowed_tools: Vec, + pub runtime_tool_restrictions: ToolRuntimeRestrictions, pub workspace_services: Option, } diff --git a/src/crates/core/src/agentic/tools/restrictions.rs b/src/crates/core/src/agentic/tools/restrictions.rs new file mode 100644 index 000000000..63f1582dc --- /dev/null +++ b/src/crates/core/src/agentic/tools/restrictions.rs @@ -0,0 +1,235 @@ +use crate::util::errors::{BitFunError, BitFunResult}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ToolPathOperation { + Write, + Edit, + Delete, +} + +impl ToolPathOperation { + pub fn verb(self) -> &'static str { + match self { + Self::Write => "write", + Self::Edit => "edit", + Self::Delete => "delete", + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolPathPolicy { + #[serde(default)] + pub write_roots: Vec, + #[serde(default)] + pub edit_roots: Vec, + #[serde(default)] + pub delete_roots: Vec, +} + +impl ToolPathPolicy { + pub fn roots_for(&self, operation: ToolPathOperation) -> &[String] { + match operation { + ToolPathOperation::Write => &self.write_roots, + ToolPathOperation::Edit => &self.edit_roots, + ToolPathOperation::Delete => &self.delete_roots, + } + } + + pub fn is_restricted(&self, operation: ToolPathOperation) -> bool { + !self.roots_for(operation).is_empty() + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolRuntimeRestrictions { + #[serde(default)] + pub allowed_tool_names: BTreeSet, + #[serde(default)] + pub denied_tool_names: BTreeSet, + #[serde(default)] + pub path_policy: ToolPathPolicy, +} + +impl ToolRuntimeRestrictions { + pub fn is_tool_allowed(&self, tool_name: &str) -> bool { + (self.allowed_tool_names.is_empty() || self.allowed_tool_names.contains(tool_name)) + && !self.denied_tool_names.contains(tool_name) + } + + pub fn ensure_tool_allowed(&self, tool_name: &str) -> BitFunResult<()> { + if self.denied_tool_names.contains(tool_name) { + return Err(BitFunError::validation(format!( + "Tool '{}' is denied by runtime restrictions", + tool_name + ))); + } + + if !self.allowed_tool_names.is_empty() && !self.allowed_tool_names.contains(tool_name) { + return Err(BitFunError::validation(format!( + "Tool '{}' is not allowed by runtime restrictions", + tool_name + ))); + } + + Ok(()) + } +} + +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)?; + Ok(canonical_path == canonical_root || canonical_path.starts_with(&canonical_root)) +} + +pub fn is_remote_posix_path_within_root(path: &str, root: &str) -> bool { + let normalized_path = normalize_absolute_posix_path(path); + let normalized_root = normalize_absolute_posix_path(root); + + if !normalized_path.starts_with('/') || !normalized_root.starts_with('/') { + return false; + } + + if normalized_root == "/" { + return true; + } + + normalized_path == normalized_root + || normalized_path + .strip_prefix(&normalized_root) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +fn canonicalize_best_effort(path: &Path) -> BitFunResult { + if path.exists() { + return dunce::canonicalize(path).map_err(|err| { + BitFunError::validation(format!( + "Failed to canonicalize path '{}': {}", + path.display(), + err + )) + }); + } + + let mut missing_tail: Vec = Vec::new(); + let mut current = path; + + loop { + if current.exists() { + let mut canonical = dunce::canonicalize(current).map_err(|err| { + BitFunError::validation(format!( + "Failed to canonicalize path '{}': {}", + current.display(), + err + )) + })?; + + for suffix in missing_tail.iter().rev() { + canonical.push(suffix); + } + + return Ok(canonical); + } + + let file_name = current.file_name().ok_or_else(|| { + BitFunError::validation(format!( + "Path '{}' cannot be normalized for restriction checks", + path.display() + )) + })?; + missing_tail.push(PathBuf::from(file_name)); + + current = current.parent().ok_or_else(|| { + BitFunError::validation(format!( + "Path '{}' cannot be normalized for restriction checks", + path.display() + )) + })?; + } +} + +fn normalize_absolute_posix_path(path: &str) -> String { + let normalized = path.trim().replace('\\', "/"); + let is_absolute = normalized.starts_with('/'); + let mut segments = Vec::new(); + + for segment in normalized.split('/') { + match segment { + "" | "." => {} + ".." => { + if !segments.is_empty() { + segments.pop(); + } + } + value => segments.push(value.to_string()), + } + } + + let body = segments.join("/"); + if is_absolute { + if body.is_empty() { + "/".to_string() + } else { + format!("/{}", body) + } + } else { + body + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_restrictions_allow_all_when_empty() { + let restrictions = ToolRuntimeRestrictions::default(); + + assert!(restrictions.is_tool_allowed("Write")); + assert!(restrictions.ensure_tool_allowed("Write").is_ok()); + } + + #[test] + fn denied_tool_names_override_allow_list() { + let restrictions = ToolRuntimeRestrictions { + allowed_tool_names: ["Write", "Edit"] + .into_iter() + .map(str::to_string) + .collect(), + denied_tool_names: ["Write"].into_iter().map(str::to_string).collect(), + path_policy: ToolPathPolicy::default(), + }; + + assert!(!restrictions.is_tool_allowed("Write")); + assert!(restrictions.is_tool_allowed("Edit")); + } + + #[test] + fn remote_posix_roots_require_true_containment() { + assert!(is_remote_posix_path_within_root( + "/workspace/src/lib.rs", + "/workspace/src" + )); + assert!(!is_remote_posix_path_within_root( + "/workspace/src2/lib.rs", + "/workspace/src" + )); + } + + #[test] + fn local_path_containment_handles_missing_children() { + let root = std::env::temp_dir().join(format!("bitfun-restrictions-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(root.join("allowed")).expect("create temp root"); + + let allowed_child = root.join("allowed").join("nested").join("file.txt"); + let sibling = root.join("blocked").join("file.txt"); + + assert!(is_local_path_within_root(&allowed_child, &root.join("allowed")).unwrap()); + assert!(!is_local_path_within_root(&sibling, &root.join("allowed")).unwrap()); + + let _ = std::fs::remove_dir_all(&root); + } +} From 2df6d6f8b5656f21d829a222c3f588ce331f5245 Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Sat, 25 Apr 2026 15:30:11 +0800 Subject: [PATCH 4/4] fix(agentic): align runtime tool restrictions with main callers --- src/apps/cli/src/acp/handlers.rs | 3 ++- src/apps/desktop/src/api/tool_api.rs | 1 + .../src/agentic/tools/implementations/session_control_tool.rs | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/apps/cli/src/acp/handlers.rs b/src/apps/cli/src/acp/handlers.rs index bf8fc837f..55104868c 100644 --- a/src/apps/cli/src/acp/handlers.rs +++ b/src/apps/cli/src/acp/handlers.rs @@ -628,6 +628,7 @@ async fn handle_tools_call( custom_data: std::collections::HashMap::new(), computer_use_host: None, cancellation_token: None, + runtime_tool_restrictions: Default::default(), workspace_services: None, }; @@ -678,4 +679,4 @@ fn handle_set_config_option(_request: &JsonRpcRequest) -> Result Result { // TODO: Implement mode switching Ok(serde_json::json!({ "success": true })) -} \ No newline at end of file +} diff --git a/src/apps/desktop/src/api/tool_api.rs b/src/apps/desktop/src/api/tool_api.rs index 6eef04b25..0c1f61960 100644 --- a/src/apps/desktop/src/api/tool_api.rs +++ b/src/apps/desktop/src/api/tool_api.rs @@ -97,6 +97,7 @@ fn build_tool_context(workspace_path: Option<&str>) -> ToolUseContext { custom_data: HashMap::new(), computer_use_host: None, cancellation_token: None, + runtime_tool_restrictions: Default::default(), workspace_services: None, } } diff --git a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs index ecf20134b..704d09114 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs @@ -702,6 +702,7 @@ mod tests { custom_data: HashMap::new(), computer_use_host: None, cancellation_token: None, + runtime_tool_restrictions: Default::default(), workspace_services: None, } }