diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index dbc9d38b9c..565ee07048 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -17,7 +17,7 @@ use crate::agent::routine_engine::{RoutineEngine, spawn_cron_ticker}; use crate::agent::self_repair::{DefaultSelfRepair, RepairResult, SelfRepair}; use crate::agent::session_manager::SessionManager; use crate::agent::submission::{Submission, SubmissionParser, SubmissionResult}; -use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, Router, Scheduler}; +use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, Router, Scheduler, SchedulerDeps}; use crate::channels::{ChannelManager, IncomingMessage, OutgoingResponse}; use crate::config::{AgentConfig, HeartbeatConfig, RoutineConfig, SkillsConfig}; use crate::context::ContextManager; @@ -227,9 +227,12 @@ impl Agent { context_manager.clone(), deps.llm.clone(), deps.safety.clone(), - deps.tools.clone(), - deps.store.clone(), - deps.hooks.clone(), + SchedulerDeps { + tools: deps.tools.clone(), + extension_manager: deps.extension_manager.clone(), + store: deps.store.clone(), + hooks: deps.hooks.clone(), + }, ); if let Some(ref tx) = deps.sse_tx { scheduler.set_sse_sender(tx.clone()); @@ -600,6 +603,7 @@ impl Agent { Arc::clone(workspace), notify_tx, Some(self.scheduler.clone()), + self.deps.extension_manager.clone(), self.tools().clone(), self.safety().clone(), self.deps.sandbox_readiness, diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 81c56dad6a..84155666fd 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -40,7 +40,7 @@ pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_hea pub use router::{MessageIntent, Router}; pub use routine::{Routine, RoutineAction, RoutineRun, Trigger}; pub use routine_engine::{RoutineEngine, SandboxReadiness}; -pub use scheduler::Scheduler; +pub use scheduler::{Scheduler, SchedulerDeps}; pub use self_repair::{BrokenTool, RepairResult, RepairTask, SelfRepair, StuckJob}; pub use session::{PendingApproval, PendingAuth, Session, Thread, ThreadState, Turn, TurnState}; pub use session_manager::SessionManager; diff --git a/src/agent/routine.rs b/src/agent/routine.rs index 2178db0cc1..296c1ff00b 100644 --- a/src/agent/routine.rs +++ b/src/agent/routine.rs @@ -17,7 +17,7 @@ //! └──────────────┘ //! ``` -use std::collections::{HashSet, hash_map::DefaultHasher}; +use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::str::FromStr; use std::time::Duration; @@ -28,171 +28,6 @@ use uuid::Uuid; use crate::error::RoutineError; -pub const FULL_JOB_OWNER_ALLOWED_TOOLS_SETTING_KEY: &str = "routines.full_job_owner_allowed_tools"; -pub const FULL_JOB_DEFAULT_PERMISSION_MODE_SETTING_KEY: &str = - "routines.full_job_default_permission_mode"; - -/// Persisted per-routine permission mode for autonomous `full_job` routines. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -pub enum FullJobPermissionMode { - /// Only use the routine's stored `tool_permissions`. - #[default] - Explicit, - /// Union the owner-scoped allowlist with the routine's `tool_permissions`. - InheritOwner, -} - -impl FullJobPermissionMode { - pub fn as_str(self) -> &'static str { - match self { - Self::Explicit => "explicit", - Self::InheritOwner => "inherit_owner", - } - } -} - -impl FromStr for FullJobPermissionMode { - type Err = (); - - fn from_str(s: &str) -> Result { - match s { - "explicit" => Ok(Self::Explicit), - "inherit_owner" => Ok(Self::InheritOwner), - _ => Err(()), - } - } -} - -/// Owner-scoped default behavior for newly-created `full_job` routines. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum FullJobPermissionDefaultMode { - Explicit, - #[default] - InheritOwner, - CopyOwner, -} - -impl FullJobPermissionDefaultMode { - pub fn as_str(self) -> &'static str { - match self { - Self::Explicit => "explicit", - Self::InheritOwner => "inherit_owner", - Self::CopyOwner => "copy_owner", - } - } -} - -impl FromStr for FullJobPermissionDefaultMode { - type Err = (); - - fn from_str(s: &str) -> Result { - match s { - "explicit" => Ok(Self::Explicit), - "inherit_owner" => Ok(Self::InheritOwner), - "copy_owner" => Ok(Self::CopyOwner), - _ => Err(()), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct FullJobPermissionSettings { - pub owner_allowed_tools: Vec, - pub default_mode: FullJobPermissionDefaultMode, -} - -pub fn normalize_tool_names(tools: I) -> Vec -where - I: IntoIterator, -{ - let mut seen = HashSet::new(); - let mut normalized = Vec::new(); - for tool in tools { - let trimmed = tool.trim(); - if trimmed.is_empty() { - continue; - } - let normalized_name = trimmed.to_string(); - if seen.insert(normalized_name.clone()) { - normalized.push(normalized_name); - } - } - normalized -} - -pub fn parse_full_job_permission_mode(value: &serde_json::Value) -> FullJobPermissionMode { - value - .get("permission_mode") - .and_then(|v| v.as_str()) - .and_then(|mode| FullJobPermissionMode::from_str(mode).ok()) - .unwrap_or_default() -} - -fn parse_owner_allowed_tools_setting(value: Option) -> Vec { - match value { - Some(serde_json::Value::Array(values)) => normalize_tool_names( - values - .into_iter() - .filter_map(|value| value.as_str().map(ToOwned::to_owned)), - ), - Some(serde_json::Value::String(csv)) => normalize_tool_names( - csv.split([',', '\n']) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned), - ), - _ => Vec::new(), - } -} - -fn parse_default_permission_mode_setting( - value: Option, -) -> FullJobPermissionDefaultMode { - value - .and_then(|v| v.as_str().map(ToOwned::to_owned)) - .and_then(|mode| FullJobPermissionDefaultMode::from_str(&mode).ok()) - .unwrap_or_default() -} - -pub async fn load_full_job_permission_settings( - store: &(dyn crate::db::SettingsStore + Sync), - user_id: &str, -) -> Result { - let owner_allowed_tools = parse_owner_allowed_tools_setting( - store - .get_setting(user_id, FULL_JOB_OWNER_ALLOWED_TOOLS_SETTING_KEY) - .await?, - ); - let default_mode = parse_default_permission_mode_setting( - store - .get_setting(user_id, FULL_JOB_DEFAULT_PERMISSION_MODE_SETTING_KEY) - .await?, - ); - Ok(FullJobPermissionSettings { - owner_allowed_tools, - default_mode, - }) -} - -pub fn effective_full_job_tool_permissions( - permission_mode: FullJobPermissionMode, - routine_tool_permissions: &[String], - owner_allowed_tools: &[String], -) -> Vec { - match permission_mode { - FullJobPermissionMode::Explicit => { - normalize_tool_names(routine_tool_permissions.iter().cloned()) - } - FullJobPermissionMode::InheritOwner => normalize_tool_names( - owner_allowed_tools - .iter() - .cloned() - .chain(routine_tool_permissions.iter().cloned()), - ), - } -} - /// A routine is a named, persistent, user-owned task with a trigger and an action. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Routine { @@ -400,15 +235,6 @@ pub enum RoutineAction { /// Max reasoning iterations (default: 10). #[serde(default = "default_max_iterations")] max_iterations: u32, - /// Tool names pre-authorized for `Always`-approval tools (e.g. destructive - /// shell commands, cross-channel messaging). `UnlessAutoApproved` tools are - /// automatically permitted in routine jobs without listing them here. - #[serde(default)] - tool_permissions: Vec, - /// Whether this routine should inherit the owner's durable full-job - /// permission allowlist or use only its explicit `tool_permissions`. - #[serde(default)] - permission_mode: FullJobPermissionMode, }, } @@ -433,18 +259,6 @@ fn clamp_max_tool_rounds(value: u64) -> u32 { value.clamp(1, MAX_TOOL_ROUNDS_LIMIT as u64) as u32 } -/// Parse a `tool_permissions` JSON array into a `Vec`. -pub fn parse_tool_permissions(value: &serde_json::Value) -> Vec { - normalize_tool_names( - value - .get("tool_permissions") - .and_then(|v| v.as_array()) - .into_iter() - .flatten() - .filter_map(|v| v.as_str().map(String::from)), - ) -} - impl RoutineAction { /// The string tag stored in the DB action_type column. pub fn type_tag(&self) -> &'static str { @@ -519,14 +333,10 @@ impl RoutineAction { .and_then(|v| v.as_u64()) .unwrap_or(default_max_iterations() as u64) as u32; - let tool_permissions = parse_tool_permissions(&config); - let permission_mode = parse_full_job_permission_mode(&config); Ok(RoutineAction::FullJob { title, description, max_iterations, - tool_permissions, - permission_mode, }) } other => Err(RoutineError::UnknownActionType { @@ -555,14 +365,10 @@ impl RoutineAction { title, description, max_iterations, - tool_permissions, - permission_mode, } => serde_json::json!({ "title": title, "description": description, "max_iterations": max_iterations, - "tool_permissions": tool_permissions, - "permission_mode": permission_mode, }), } } @@ -896,9 +702,8 @@ pub fn describe_cron(schedule: &str, timezone: Option<&str>) -> String { #[cfg(test)] mod tests { use crate::agent::routine::{ - FullJobPermissionMode, MAX_TOOL_ROUNDS_LIMIT, RoutineAction, RoutineGuardrails, RunStatus, - Trigger, content_hash, describe_cron, effective_full_job_tool_permissions, next_cron_fire, - normalize_cron_expression, + MAX_TOOL_ROUNDS_LIMIT, RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash, + describe_cron, next_cron_fire, normalize_cron_expression, }; #[test] @@ -965,66 +770,48 @@ mod tests { title: "Deploy review".to_string(), description: "Review and deploy pending changes".to_string(), max_iterations: 5, - tool_permissions: vec!["shell".to_string()], - permission_mode: FullJobPermissionMode::InheritOwner, }; let json = action.to_config_json(); let parsed = RoutineAction::from_db("full_job", json).expect("parse full_job"); assert!( - matches!(parsed, RoutineAction::FullJob { title, max_iterations, tool_permissions, permission_mode, .. } + matches!(parsed, RoutineAction::FullJob { title, max_iterations, .. } if title == "Deploy review" - && max_iterations == 5 - && tool_permissions == vec!["shell".to_string()] - && permission_mode == FullJobPermissionMode::InheritOwner) + && max_iterations == 5) ); } #[test] - fn test_action_full_job_missing_permission_mode_defaults_to_explicit() { + fn test_action_full_job_ignores_legacy_permission_fields() { let parsed = RoutineAction::from_db( "full_job", serde_json::json!({ "title": "Deploy review", "description": "Review and deploy pending changes", "max_iterations": 5, - "tool_permissions": ["shell"] + "tool_permissions": ["shell"], + "permission_mode": "inherit_owner" }), ) .expect("parse full_job"); assert!(matches!( parsed, RoutineAction::FullJob { - permission_mode: FullJobPermissionMode::Explicit, + ref title, + ref description, + max_iterations, .. - } + } if title == "Deploy review" + && description == "Review and deploy pending changes" + && max_iterations == 5 )); - } - - #[test] - fn test_effective_full_job_tool_permissions_inherit_owner_unions_lists() { - let resolved = effective_full_job_tool_permissions( - FullJobPermissionMode::InheritOwner, - &["shell".to_string(), "message".to_string()], - &["message".to_string(), "http".to_string()], - ); assert_eq!( - resolved, - vec![ - "message".to_string(), - "http".to_string(), - "shell".to_string() - ] - ); - } - - #[test] - fn test_effective_full_job_tool_permissions_explicit_ignores_owner_defaults() { - let resolved = effective_full_job_tool_permissions( - FullJobPermissionMode::Explicit, - &["shell".to_string()], - &["message".to_string(), "http".to_string()], + parsed.to_config_json(), + serde_json::json!({ + "title": "Deploy review", + "description": "Review and deploy pending changes", + "max_iterations": 5, + }) ); - assert_eq!(resolved, vec!["shell".to_string()]); } #[test] diff --git a/src/agent/routine_engine.rs b/src/agent/routine_engine.rs index a4f35ccbe1..7cfdba2052 100644 --- a/src/agent/routine_engine.rs +++ b/src/agent/routine_engine.rs @@ -22,19 +22,20 @@ use uuid::Uuid; use crate::agent::Scheduler; use crate::agent::routine::{ - NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, - effective_full_job_tool_permissions, load_full_job_permission_settings, next_cron_fire, + NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire, }; use crate::channels::OutgoingResponse; use crate::config::RoutineConfig; use crate::context::{JobContext, JobState}; use crate::db::Database; use crate::error::RoutineError; +use crate::extensions::ExtensionManager; use crate::llm::{ ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest, }; use crate::tools::{ - ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, prepare_tool_params, + ToolError, ToolRegistry, autonomous_allowed_tool_names, autonomous_unavailable_message, + prepare_tool_params, }; use crate::workspace::Workspace; use ironclaw_safety::SafetyLayer; @@ -69,6 +70,8 @@ pub struct RoutineEngine { event_cache: Arc>>, /// Scheduler for dispatching jobs (FullJob mode). scheduler: Option>, + /// Owner-scoped extension activation state for autonomous tool resolution. + extension_manager: Option>, /// Tool registry for lightweight routine tool execution. tools: Arc, /// Safety layer for tool output sanitization. @@ -90,6 +93,7 @@ impl RoutineEngine { workspace: Arc, notify_tx: mpsc::Sender, scheduler: Option>, + extension_manager: Option>, tools: Arc, safety: Arc, sandbox_readiness: SandboxReadiness, @@ -103,6 +107,7 @@ impl RoutineEngine { running_count: Arc::new(AtomicUsize::new(0)), event_cache: Arc::new(RwLock::new(Vec::new())), scheduler, + extension_manager, tools, safety, sandbox_readiness, @@ -702,6 +707,7 @@ impl RoutineEngine { notify_tx: self.notify_tx.clone(), running_count: self.running_count.clone(), scheduler: self.scheduler.clone(), + extension_manager: self.extension_manager.clone(), tools: self.tools.clone(), safety: self.safety.clone(), sandbox_readiness: self.sandbox_readiness, @@ -738,6 +744,7 @@ impl RoutineEngine { notify_tx: self.notify_tx.clone(), running_count: self.running_count.clone(), scheduler: self.scheduler.clone(), + extension_manager: self.extension_manager.clone(), tools: self.tools.clone(), safety: self.safety.clone(), sandbox_readiness: self.sandbox_readiness, @@ -875,6 +882,7 @@ struct EngineContext { notify_tx: mpsc::Sender, running_count: Arc, scheduler: Option>, + extension_manager: Option>, tools: Arc, safety: Arc, sandbox_readiness: SandboxReadiness, @@ -908,15 +916,11 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun) title, description, max_iterations, - tool_permissions, - permission_mode, } => { let execution = FullJobExecutionConfig { title, description, max_iterations: *max_iterations, - tool_permissions, - permission_mode: *permission_mode, }; execute_full_job(&ctx, &routine, &run, &execution).await } @@ -1048,8 +1052,6 @@ struct FullJobExecutionConfig<'a> { title: &'a str, description: &'a str, max_iterations: u32, - tool_permissions: &'a [String], - permission_mode: crate::agent::routine::FullJobPermissionMode, } async fn execute_full_job( @@ -1094,40 +1096,12 @@ async fn execute_full_job( } metadata["notify_user"] = serde_json::json!(&routine.notify.user); - let effective_permissions = match execution.permission_mode { - crate::agent::routine::FullJobPermissionMode::Explicit => { - effective_full_job_tool_permissions( - execution.permission_mode, - execution.tool_permissions, - &[], - ) - } - crate::agent::routine::FullJobPermissionMode::InheritOwner => { - let owner_permissions = - load_full_job_permission_settings(ctx.store.as_ref(), &routine.user_id) - .await - .map_err(|e| RoutineError::Database { - reason: format!("failed to load routine permission settings: {e}"), - })?; - effective_full_job_tool_permissions( - execution.permission_mode, - execution.tool_permissions, - &owner_permissions.owner_allowed_tools, - ) - } - }; - - // Build approval context: UnlessAutoApproved tools are auto-approved for routines; - // Always tools require explicit listing in the resolved effective permissions. - let approval_context = ApprovalContext::autonomous_with_tools(effective_permissions); - let job_id = scheduler - .dispatch_job_with_context( + .dispatch_job( &routine.user_id, execution.title, execution.description, Some(metadata), - approval_context, ) .await .map_err(|e| RoutineError::JobDispatchFailed { @@ -1416,6 +1390,9 @@ async fn execute_lightweight_with_tools( description: routine.name.clone(), ..Default::default() }; + let allowed_tools = + autonomous_allowed_tool_names(&ctx.tools, ctx.extension_manager.as_ref(), &routine.user_id) + .await; loop { iteration += 1; @@ -1450,8 +1427,11 @@ async fn execute_lightweight_with_tools( // Tool-enabled iteration let tool_defs = ctx .tools - .tool_definitions_excluding(ROUTINE_TOOL_DENYLIST) - .await; + .tool_definitions() + .await + .into_iter() + .filter(|tool| allowed_tools.contains(&tool.name)) + .collect(); let request_messages = snapshot_messages_for_tool_iteration(&messages); let request = ToolCompletionRequest::new(request_messages, tool_defs) @@ -1486,7 +1466,7 @@ async fn execute_lightweight_with_tools( // Execute tools sequentially for tc in response.tool_calls { - let result = execute_routine_tool(ctx, &job_ctx, &tc).await; + let result = execute_routine_tool(ctx, &job_ctx, &allowed_tools, &tc).await; // Sanitize and wrap result (including errors) let result_content = match result { @@ -1555,31 +1535,16 @@ fn snapshot_messages_for_tool_iteration(messages: &[ChatMessage]) -> Vec, tc: &ToolCall, ) -> Result> { - // Block tools that pose autonomy-escalation risks - if ROUTINE_TOOL_DENYLIST.contains(&tc.name.as_str()) { - return Err(format!( - "Tool '{}' is not available in lightweight routines", - tc.name - ) - .into()); + if !allowed_tools.contains(&tc.name) { + let message = autonomous_unavailable_message(&tc.name, &job_ctx.user_id); + return Err(message.into()); } // Check if tool exists @@ -1590,22 +1555,6 @@ async fn execute_routine_tool( .ok_or_else(|| format!("Tool '{}' not found", tc.name))?; let normalized_params = prepare_tool_params(tool.as_ref(), &tc.arguments); - // Check approval requirement: only allow Never tools in lightweight routines. - // UnlessAutoApproved and Always tools are blocked to prevent prompt injection attacks. - // Lightweight routines can be triggered by external events and may process untrusted data, - // making them vulnerable to prompt injection that could trick the LLM into calling - // sensitive tools. Blocking these tools entirely is the safest approach. - match tool.requires_approval(&normalized_params) { - ApprovalRequirement::Never => {} - ApprovalRequirement::UnlessAutoApproved | ApprovalRequirement::Always => { - return Err(format!( - "Tool '{}' requires manual approval and cannot be used in lightweight routines", - tc.name - ) - .into()); - } - } - // Validate tool parameters let validation = ctx .safety @@ -2021,8 +1970,8 @@ mod tests { ]; for tool in &denylisted { assert!( - super::ROUTINE_TOOL_DENYLIST.contains(tool), - "Tool '{}' should be in ROUTINE_TOOL_DENYLIST", + crate::tools::AUTONOMOUS_TOOL_DENYLIST.contains(tool), + "Tool '{}' should be in AUTONOMOUS_TOOL_DENYLIST", tool ); } @@ -2033,8 +1982,8 @@ mod tests { let allowed = vec!["echo", "time", "json", "http", "memory_search", "shell"]; for tool in &allowed { assert!( - !super::ROUTINE_TOOL_DENYLIST.contains(tool), - "Tool '{}' should NOT be in ROUTINE_TOOL_DENYLIST", + !crate::tools::AUTONOMOUS_TOOL_DENYLIST.contains(tool), + "Tool '{}' should NOT be in AUTONOMOUS_TOOL_DENYLIST", tool ); } diff --git a/src/agent/scheduler.rs b/src/agent/scheduler.rs index fa7364a493..2e23b35f60 100644 --- a/src/agent/scheduler.rs +++ b/src/agent/scheduler.rs @@ -14,10 +14,14 @@ use crate::config::AgentConfig; use crate::context::{ContextManager, JobContext, JobState}; use crate::db::Database; use crate::error::{Error, JobError}; +use crate::extensions::ExtensionManager; use crate::hooks::HookRegistry; use crate::llm::LlmProvider; use crate::safety::SafetyLayer; -use crate::tools::{ApprovalContext, ToolRegistry, prepare_tool_params}; +use crate::tools::{ + ApprovalContext, ToolRegistry, autonomous_allowed_tool_names, autonomous_unavailable_error, + prepare_tool_params, +}; use crate::worker::job::{Worker, WorkerDeps}; /// Message to send to a worker. @@ -45,6 +49,14 @@ struct ScheduledSubtask { handle: JoinHandle>, } +/// Shared scheduler-owned dependencies that are forwarded into autonomous runs. +pub struct SchedulerDeps { + pub tools: Arc, + pub extension_manager: Option>, + pub store: Option>, + pub hooks: Arc, +} + /// Schedules and manages parallel job execution. pub struct Scheduler { config: AgentConfig, @@ -52,6 +64,7 @@ pub struct Scheduler { llm: Arc, safety: Arc, tools: Arc, + extension_manager: Option>, store: Option>, hooks: Arc, /// SSE broadcast sender for live job event streaming. @@ -71,18 +84,17 @@ impl Scheduler { context_manager: Arc, llm: Arc, safety: Arc, - tools: Arc, - store: Option>, - hooks: Arc, + deps: SchedulerDeps, ) -> Self { Self { config, context_manager, llm, safety, - tools, - store, - hooks, + tools: deps.tools, + extension_manager: deps.extension_manager, + store: deps.store, + hooks: deps.hooks, sse_tx: None, http_interceptor: None, jobs: Arc::new(RwLock::new(HashMap::new())), @@ -120,14 +132,21 @@ impl Scheduler { description: &str, metadata: Option, ) -> Result { - self.dispatch_job_inner(user_id, title, description, metadata, None) - .await + let approval_context = self.autonomous_approval_context(user_id).await; + self.dispatch_job_inner( + user_id, + title, + description, + metadata, + Some(approval_context), + ) + .await } /// Dispatch a job with an explicit approval context for autonomous execution. /// /// Same as `dispatch_job`, but the worker will use the given `ApprovalContext` - /// to determine which tools are pre-approved (instead of blocking all non-`Never` tools). + /// to determine the explicit autonomous allowlist for that job. pub async fn dispatch_job_with_context( &self, user_id: &str, @@ -216,6 +235,13 @@ impl Scheduler { Ok(job_id) } + async fn autonomous_approval_context(&self, user_id: &str) -> ApprovalContext { + ApprovalContext::autonomous_with_tools( + autonomous_allowed_tool_names(&self.tools, self.extension_manager.as_ref(), user_id) + .await, + ) + } + /// Schedule a job for execution. pub async fn schedule(&self, job_id: Uuid) -> Result<(), JobError> { self.schedule_with_context(job_id, None).await @@ -518,10 +544,7 @@ impl Scheduler { let blocked = ApprovalContext::is_blocked_or_default(&approval_context, tool_name, requirement); if blocked { - return Err(crate::error::ToolError::AuthRequired { - name: tool_name.to_string(), - } - .into()); + return Err(autonomous_unavailable_error(tool_name, &job_ctx.user_id).into()); } // Delegate to shared tool execution pipeline @@ -776,7 +799,18 @@ mod tests { let tools = Arc::new(ToolRegistry::new()); let hooks = Arc::new(HookRegistry::default()); - Scheduler::new(config, cm, llm, safety, tools, None, hooks) + Scheduler::new( + config, + cm, + llm, + safety, + SchedulerDeps { + tools, + extension_manager: None, + store: None, + hooks, + }, + ) } #[tokio::test] @@ -1003,12 +1037,14 @@ mod tests { async fn test_execute_tool_task_autonomous_unblocks_soft() { let (tools, cm, safety, job_id) = setup_tools_and_job().await; - // Autonomous context auto-approves UnlessAutoApproved + // Autonomous execution only allows tools explicitly in scope. let result = Scheduler::execute_tool_task( tools.clone(), cm.clone(), safety.clone(), - Some(ApprovalContext::autonomous()), + Some(ApprovalContext::autonomous_with_tools([ + "soft_gate".to_string() + ])), job_id, "soft_gate", serde_json::json!({}), @@ -1040,8 +1076,11 @@ mod tests { async fn test_execute_tool_task_autonomous_with_permissions() { let (tools, cm, safety, job_id) = setup_tools_and_job().await; - // Autonomous context with explicit permission for hard_gate - let ctx = ApprovalContext::autonomous_with_tools(["hard_gate".to_string()]); + // Autonomous context with explicit permission for both tools. + let ctx = ApprovalContext::autonomous_with_tools([ + "soft_gate".to_string(), + "hard_gate".to_string(), + ]); let result = Scheduler::execute_tool_task( tools.clone(), diff --git a/src/channels/web/handlers/routines.rs b/src/channels/web/handlers/routines.rs index 99d319917c..41bfee5a96 100644 --- a/src/channels/web/handlers/routines.rs +++ b/src/channels/web/handlers/routines.rs @@ -10,29 +10,11 @@ use axum::{ use serde::Deserialize; use uuid::Uuid; -use crate::agent::routine::{ - FullJobPermissionDefaultMode, FullJobPermissionMode, RoutineAction, Trigger, - effective_full_job_tool_permissions, load_full_job_permission_settings, next_cron_fire, -}; +use crate::agent::routine::{Trigger, next_cron_fire}; use crate::channels::web::server::GatewayState; use crate::channels::web::types::*; use crate::error::RoutineError; -fn permission_mode_label(mode: FullJobPermissionMode) -> String { - match mode { - FullJobPermissionMode::Explicit => "explicit".to_string(), - FullJobPermissionMode::InheritOwner => "inherit_owner".to_string(), - } -} - -fn default_permission_mode_label(mode: FullJobPermissionDefaultMode) -> String { - match mode { - FullJobPermissionDefaultMode::Explicit => "explicit".to_string(), - FullJobPermissionDefaultMode::InheritOwner => "inherit_owner".to_string(), - FullJobPermissionDefaultMode::CopyOwner => "copy_owner".to_string(), - } -} - pub async fn routines_list_handler( State(state): State>, ) -> Result, (StatusCode, String)> { @@ -131,30 +113,6 @@ pub async fn routines_detail_handler( }) .collect(); let routine_info = RoutineInfo::from_routine(&routine); - let full_job_permissions = match &routine.action { - RoutineAction::FullJob { - tool_permissions, - permission_mode, - .. - } => { - let owner_settings = - load_full_job_permission_settings(store.as_ref(), &routine.user_id) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Some(FullJobPermissionInfo { - permission_mode: permission_mode_label(*permission_mode), - default_permission_mode: default_permission_mode_label(owner_settings.default_mode), - stored_tool_permissions: tool_permissions.clone(), - effective_tool_permissions: effective_full_job_tool_permissions( - *permission_mode, - tool_permissions, - &owner_settings.owner_allowed_tools, - ), - owner_allowed_tools: owner_settings.owner_allowed_tools, - }) - } - RoutineAction::Lightweight { .. } => None, - }; Ok(Json(RoutineDetailResponse { id: routine.id, @@ -173,7 +131,6 @@ pub async fn routines_detail_handler( run_count: routine.run_count, consecutive_failures: routine.consecutive_failures, created_at: routine.created_at.to_rfc3339(), - full_job_permissions, recent_runs, })) } diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index e8e84132ed..0b247a6316 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -3942,18 +3942,6 @@ function renderRoutineDetail(routine) { + '
' + escapeHtml(JSON.stringify(routine.trigger, null, 2)) + '
'; } - // Action config - if (routine.full_job_permissions) { - html += '

Full Job Permissions

' - + '
' - + metaItem('Mode', routine.full_job_permissions.permission_mode) - + metaItem('Owner Default', routine.full_job_permissions.default_permission_mode) - + metaItem('Inherited Tools', (routine.full_job_permissions.owner_allowed_tools || []).join(', ') || '-') - + metaItem('Stored Tools', (routine.full_job_permissions.stored_tool_permissions || []).join(', ') || '-') - + metaItem('Effective Tools', (routine.full_job_permissions.effective_tool_permissions || []).join(', ') || '-') - + '
'; - } - html += '

Action

' + '
' + escapeHtml(JSON.stringify(routine.action, null, 2)) + '
'; @@ -4788,10 +4776,6 @@ var AGENT_SETTINGS = [ settings: [ { key: 'routines.max_concurrent', label: 'cfg.routines_max_concurrent.label', description: 'cfg.routines_max_concurrent.desc', type: 'number', min: 0 }, { key: 'routines.default_cooldown_secs', label: 'cfg.routines_cooldown.label', description: 'cfg.routines_cooldown.desc', type: 'number', min: 0 }, - { key: 'routines.full_job_default_permission_mode', label: 'cfg.routines_full_job_default_mode.label', description: 'cfg.routines_full_job_default_mode.desc', - type: 'select', options: ['inherit_owner', 'explicit', 'copy_owner'] }, - { key: 'routines.full_job_owner_allowed_tools', label: 'cfg.routines_full_job_owner_tools.label', description: 'cfg.routines_full_job_owner_tools.desc', - type: 'list', placeholder: 'shell, http' }, ] }, { diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js index de08c7dbf2..6029075d2f 100644 --- a/src/channels/web/static/i18n/en.js +++ b/src/channels/web/static/i18n/en.js @@ -481,10 +481,6 @@ I18n.register('en', { 'cfg.routines_max_concurrent.desc': 'Maximum routines running simultaneously', 'cfg.routines_cooldown.label': 'Default Cooldown', 'cfg.routines_cooldown.desc': 'Minimum seconds between routine fires', - 'cfg.routines_full_job_default_mode.label': 'Full Job Default Mode', - 'cfg.routines_full_job_default_mode.desc': 'Default permission behavior for new full_job routines. When unset, inherit_owner is used.', - 'cfg.routines_full_job_owner_tools.label': 'Full Job Owner Allowlist', - 'cfg.routines_full_job_owner_tools.desc': 'Comma-separated tool names that full_job routines may inherit at run time.', // Safety settings 'cfg.safety_max_output.label': 'Max Output Length', diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js index 8bc6edd444..480724c9b0 100644 --- a/src/channels/web/static/i18n/zh-CN.js +++ b/src/channels/web/static/i18n/zh-CN.js @@ -480,10 +480,6 @@ I18n.register('zh-CN', { 'cfg.routines_max_concurrent.desc': '同时运行的最大定时任务数', 'cfg.routines_cooldown.label': '默认冷却时间', 'cfg.routines_cooldown.desc': '定时任务触发间的最小秒数', - 'cfg.routines_full_job_default_mode.label': '完整任务默认权限模式', - 'cfg.routines_full_job_default_mode.desc': '新建 full_job 定时任务的默认权限行为。未设置时使用 inherit_owner。', - 'cfg.routines_full_job_owner_tools.label': '完整任务所有者允许工具', - 'cfg.routines_full_job_owner_tools.desc': '逗号分隔的工具名列表,full_job 定时任务可在运行时继承这些工具权限。', // 安全设置 'cfg.safety_max_output.label': '最大输出长度', diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index c8601fdd7e..861b5bd2d4 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -884,20 +884,9 @@ pub struct RoutineDetailResponse { pub run_count: u64, pub consecutive_failures: u32, pub created_at: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub full_job_permissions: Option, pub recent_runs: Vec, } -#[derive(Debug, Serialize)] -pub struct FullJobPermissionInfo { - pub permission_mode: String, - pub default_permission_mode: String, - pub stored_tool_permissions: Vec, - pub owner_allowed_tools: Vec, - pub effective_tool_permissions: Vec, -} - #[derive(Debug, Serialize)] pub struct RoutineRunInfo { pub id: Uuid, diff --git a/src/error.rs b/src/error.rs index 29131f4ccb..413bc8fd49 100644 --- a/src/error.rs +++ b/src/error.rs @@ -168,6 +168,9 @@ pub enum ToolError { #[error("Tool {name} requires authentication")] AuthRequired { name: String }, + #[error("Tool {name} is not available for autonomous execution: {reason}")] + AutonomousUnavailable { name: String, reason: String }, + #[error("Tool {name} is rate limited, retry after {retry_after:?}")] RateLimited { name: String, diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 0762f3ed3f..f06def204d 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -463,6 +463,37 @@ fn sanitize_url_for_logging(url: &str) -> String { } impl ExtensionManager { + pub fn owner_id(&self) -> &str { + &self.user_id + } + + pub async fn active_tool_names(&self) -> HashSet { + let mut names = HashSet::new(); + match self.list(None, false).await { + Ok(extensions) => { + for extension in extensions { + match extension.kind { + ExtensionKind::WasmTool if extension.active => { + names.insert(extension.name); + } + ExtensionKind::McpServer if extension.active => { + names.extend(extension.tools); + } + _ => {} + } + } + } + Err(err) => { + tracing::warn!( + owner_id = %self.user_id, + "Failed to list active extensions while resolving autonomous tool scope: {}", + err + ); + } + } + names + } + #[allow(clippy::too_many_arguments)] pub fn new( mcp_session_manager: Arc, diff --git a/src/tools/autonomy.rs b/src/tools/autonomy.rs new file mode 100644 index 0000000000..ab3e502942 --- /dev/null +++ b/src/tools/autonomy.rs @@ -0,0 +1,210 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use crate::extensions::ExtensionManager; + +use super::ToolRegistry; + +pub const AUTONOMOUS_TOOL_DENYLIST: &[&str] = &[ + "routine_create", + "routine_update", + "routine_delete", + "routine_fire", + "event_emit", + "create_job", + "job_prompt", + "restart", + "tool_install", + "tool_auth", + "tool_activate", + "tool_remove", + "tool_upgrade", + "skill_install", + "skill_remove", + "secret_list", + "secret_delete", +]; + +pub fn is_autonomous_tool_denylisted(tool_name: &str) -> bool { + AUTONOMOUS_TOOL_DENYLIST.contains(&tool_name) +} + +pub fn autonomous_unavailable_message(tool_name: &str, owner_id: &str) -> String { + if is_autonomous_tool_denylisted(tool_name) { + format!("Tool '{tool_name}' is not available in autonomous jobs or routines") + } else { + format!("Tool '{tool_name}' is not currently available for owner '{owner_id}'") + } +} + +pub fn autonomous_unavailable_error(tool_name: &str, owner_id: &str) -> crate::error::ToolError { + crate::error::ToolError::AutonomousUnavailable { + name: tool_name.to_string(), + reason: autonomous_unavailable_message(tool_name, owner_id), + } +} + +pub async fn autonomous_allowed_tool_names( + tools: &Arc, + extension_manager: Option<&Arc>, + owner_id: &str, +) -> HashSet { + let mut allowed = tools.builtin_tool_names().await; + allowed.retain(|name| !is_autonomous_tool_denylisted(name)); + + if let Some(extension_manager) = extension_manager + && extension_manager.owner_id() == owner_id + { + allowed.extend( + extension_manager + .active_tool_names() + .await + .into_iter() + .filter(|name| !is_autonomous_tool_denylisted(name)), + ); + } + + allowed +} + +#[cfg(test)] +mod tests { + use std::path::Path; + use std::time::Duration; + + use async_trait::async_trait; + use secrecy::SecretString; + + use super::*; + use crate::context::JobContext; + use crate::extensions::ExtensionManager; + use crate::hooks::HookRegistry; + use crate::secrets::{InMemorySecretsStore, SecretsCrypto, SecretsStore}; + use crate::tools::mcp::{McpProcessManager, McpSessionManager}; + use crate::tools::{Tool, ToolError, ToolOutput}; + + struct FakeTool { + name: &'static str, + } + + #[async_trait] + impl Tool for FakeTool { + fn name(&self) -> &str { + self.name + } + + fn description(&self) -> &str { + "test tool" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": {}, + }) + } + + async fn execute( + &self, + _params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + Ok(ToolOutput::text("ok", Duration::from_millis(1))) + } + } + + async fn write_test_extension_wasm(tools_dir: &Path, name: &str) { + tokio::fs::create_dir_all(tools_dir) + .await + .expect("create test tools dir"); + tokio::fs::write(tools_dir.join(format!("{name}.wasm")), b"\0asm") + .await + .expect("write wasm marker"); + } + + fn make_extension_manager( + tools: Arc, + tools_dir: &Path, + owner_id: &str, + ) -> Arc { + let crypto = Arc::new( + SecretsCrypto::new(SecretString::from( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + )) + .expect("test crypto"), + ); + let secrets: Arc = + Arc::new(InMemorySecretsStore::new(crypto)); + + Arc::new(ExtensionManager::new( + Arc::new(McpSessionManager::new()), + Arc::new(McpProcessManager::new()), + secrets, + tools, + Some(Arc::new(HookRegistry::default())), + None, + tools_dir.to_path_buf(), + tools_dir.join("channels"), + None, + owner_id.to_string(), + None, + Vec::new(), + )) + } + + #[tokio::test] + async fn autonomous_scope_keeps_allowed_builtins_and_blocks_denylisted_builtins() { + let tools = Arc::new(ToolRegistry::new()); + tools.register_sync(Arc::new(FakeTool { name: "echo" })); + tools.register_sync(Arc::new(FakeTool { name: "restart" })); + + let allowed = autonomous_allowed_tool_names(&tools, None, "default").await; + + assert!(allowed.contains("echo")); + assert!(!allowed.contains("restart")); + } + + #[tokio::test] + async fn autonomous_scope_includes_active_extension_tools_for_matching_owner() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let tools_dir = temp_dir.path().join("wasm-tools"); + let tools = Arc::new(ToolRegistry::new()); + tools + .register(Arc::new(FakeTool { name: "owner_gate" })) + .await; + write_test_extension_wasm(&tools_dir, "owner_gate").await; + let manager = make_extension_manager(tools.clone(), &tools_dir, "default"); + + let allowed = autonomous_allowed_tool_names(&tools, Some(&manager), "default").await; + + assert!(allowed.contains("owner_gate")); + } + + #[tokio::test] + async fn autonomous_scope_excludes_inactive_extension_tools() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let tools_dir = temp_dir.path().join("wasm-tools"); + let tools = Arc::new(ToolRegistry::new()); + let manager = make_extension_manager(tools.clone(), &tools_dir, "default"); + + let allowed = autonomous_allowed_tool_names(&tools, Some(&manager), "default").await; + + assert!(!allowed.contains("owner_gate")); + } + + #[tokio::test] + async fn autonomous_scope_excludes_active_extension_tools_for_other_owner() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let tools_dir = temp_dir.path().join("wasm-tools"); + let tools = Arc::new(ToolRegistry::new()); + tools + .register(Arc::new(FakeTool { name: "owner_gate" })) + .await; + write_test_extension_wasm(&tools_dir, "owner_gate").await; + let manager = make_extension_manager(tools.clone(), &tools_dir, "someone-else"); + + let allowed = autonomous_allowed_tool_names(&tools, Some(&manager), "default").await; + + assert!(!allowed.contains("owner_gate")); + } +} diff --git a/src/tools/builtin/routine.rs b/src/tools/builtin/routine.rs index 76a29a660b..b37932ffa9 100644 --- a/src/tools/builtin/routine.rs +++ b/src/tools/builtin/routine.rs @@ -19,9 +19,8 @@ use serde_json::{Map, Value}; use uuid::Uuid; use crate::agent::routine::{ - FullJobPermissionDefaultMode, FullJobPermissionMode, NotifyConfig, Routine, RoutineAction, - RoutineGuardrails, Trigger, load_full_job_permission_settings, next_cron_fire, - normalize_cron_expression, normalize_tool_names, + NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, next_cron_fire, + normalize_cron_expression, }; use crate::agent::routine_engine::RoutineEngine; use crate::context::JobContext; @@ -56,21 +55,12 @@ enum NormalizedExecutionMode { FullJob, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RequestedFullJobPermissionMode { - Explicit, - InheritOwner, - CopyOwner, -} - #[derive(Debug, Clone, PartialEq, Eq)] struct NormalizedExecutionRequest { mode: NormalizedExecutionMode, context_paths: Vec, use_tools: bool, max_tool_rounds: u32, - tool_permissions: Vec, - permission_mode: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -154,16 +144,6 @@ fn execution_properties() -> Value { "maximum": crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT, "default": 3, "description": "Only applies when execution.mode='lightweight' and use_tools=true. Runtime-capped to prevent loops." - }, - "tool_permissions": { - "type": "array", - "items": { "type": "string" }, - "description": "Only applies when execution.mode='full_job'. These tools are pre-authorized for Always-approval checks." - }, - "permission_mode": { - "type": "string", - "enum": ["inherit_owner", "explicit", "copy_owner"], - "description": "Only applies when execution.mode='full_job'. 'inherit_owner' uses the owner defaults at run time, 'explicit' uses only tool_permissions, and 'copy_owner' snapshots the current owner allowlist into tool_permissions." } }) } @@ -336,22 +316,12 @@ fn lightweight_execution_variant() -> Value { fn full_job_execution_variant() -> Value { serde_json::json!({ "type": "object", - "description": "Full-job execution. Uses owner-scoped permission defaults plus tool_permissions and ignores lightweight-only fields such as use_tools, max_tool_rounds, and context_paths.", + "description": "Full-job execution. Uses the owner's live autonomous tool scope and ignores lightweight-only fields such as use_tools, max_tool_rounds, and context_paths.", "properties": { "mode": { "type": "string", "enum": ["full_job"], "description": "Full-job execution mode." - }, - "tool_permissions": { - "type": "array", - "items": { "type": "string" }, - "description": "Tools pre-authorized for Always-approval checks." - }, - "permission_mode": { - "type": "string", - "enum": ["inherit_owner", "explicit", "copy_owner"], - "description": "When omitted, new routines use the owner default. 'copy_owner' snapshots the current owner allowlist into this routine." } }, "required": ["mode"] @@ -369,7 +339,7 @@ fn execution_discovery_schema() -> Value { ], "examples": [ { "mode": "lightweight", "use_tools": true, "max_tool_rounds": 3 }, - { "mode": "full_job", "permission_mode": "inherit_owner", "tool_permissions": ["message", "http"] } + { "mode": "full_job" } ] }) } @@ -418,9 +388,7 @@ fn routine_create_examples() -> Vec { "filters": { "repository": "nearai/ironclaw" } }, "execution": { - "mode": "full_job", - "permission_mode": "inherit_owner", - "tool_permissions": ["message"] + "mode": "full_job" } }), ] @@ -433,7 +401,7 @@ fn routine_create_tool_summary() -> ToolDiscoverySummary { "request.kind='cron' requires request.schedule.".into(), "request.kind='message_event' requires request.pattern.".into(), "request.kind='system_event' requires request.source and request.event_type.".into(), - "execution.mode='full_job' uses permission_mode and tool_permissions, and ignores use_tools, max_tool_rounds, and context_paths.".into(), + "execution.mode='full_job' uses the owner's live autonomous tool scope and ignores use_tools, max_tool_rounds, and context_paths.".into(), ], notes: vec![ "Omitting execution defaults to lightweight mode.".into(), @@ -590,22 +558,6 @@ fn routine_create_schema(include_compatibility_aliases: bool) -> Value { "description": "Compatibility alias for execution.max_tool_rounds." }), ); - properties.insert( - "tool_permissions".to_string(), - serde_json::json!({ - "type": "array", - "items": { "type": "string" }, - "description": "Compatibility alias for execution.tool_permissions." - }), - ); - properties.insert( - "permission_mode".to_string(), - serde_json::json!({ - "type": "string", - "enum": ["inherit_owner", "explicit", "copy_owner"], - "description": "Compatibility alias for execution.permission_mode." - }), - ); properties.insert( "notify_channel".to_string(), serde_json::json!({ @@ -684,16 +636,6 @@ pub(crate) fn routine_update_parameters_schema() -> Value { "description": { "type": "string", "description": "New description" - }, - "tool_permissions": { - "type": "array", - "items": { "type": "string" }, - "description": "Updated Always-approval tool allowlist for full_job routines only." - }, - "permission_mode": { - "type": "string", - "enum": ["inherit_owner", "explicit", "copy_owner"], - "description": "Updated permission mode for full_job routines only. 'copy_owner' snapshots the current owner allowlist into the routine and persists as explicit." } }, "required": ["name"] @@ -739,27 +681,6 @@ fn u64_field(params: &Value, group: &str, field: &str, aliases: &[&str]) -> Opti } fn string_array_field(params: &Value, group: &str, field: &str, aliases: &[&str]) -> Vec { - normalize_tool_names( - nested_object(params, group) - .and_then(|obj| obj.get(field)) - .and_then(Value::as_array) - .or_else(|| { - aliases - .iter() - .find_map(|alias| params.get(*alias).and_then(Value::as_array)) - }) - .into_iter() - .flatten() - .filter_map(|value| value.as_str().map(String::from)), - ) -} - -fn optional_string_array_field( - params: &Value, - group: &str, - field: &str, - aliases: &[&str], -) -> Option> { nested_object(params, group) .and_then(|obj| obj.get(field)) .and_then(Value::as_array) @@ -769,11 +690,21 @@ fn optional_string_array_field( .find_map(|alias| params.get(*alias).and_then(Value::as_array)) }) .map(|arr| { - normalize_tool_names( - arr.iter() - .filter_map(|value| value.as_str().map(String::from)), - ) + let mut seen = std::collections::HashSet::new(); + arr.iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .filter_map(|value| { + if seen.insert(value.to_string()) { + Some(value.to_string()) + } else { + None + } + }) + .collect() }) + .unwrap_or_default() } fn object_field( @@ -912,20 +843,6 @@ fn parse_execution_mode(value: Option) -> Result, -) -> Result, ToolError> { - match value.as_deref() { - None => Ok(None), - Some("explicit") => Ok(Some(RequestedFullJobPermissionMode::Explicit)), - Some("inherit_owner") => Ok(Some(RequestedFullJobPermissionMode::InheritOwner)), - Some("copy_owner") => Ok(Some(RequestedFullJobPermissionMode::CopyOwner)), - Some(other) => Err(ToolError::InvalidParameters(format!( - "unknown full_job permission_mode: {other}" - ))), - } -} - fn parse_routine_execution(params: &Value) -> Result { let mode = parse_execution_mode(string_field(params, "execution", "mode", &["action_type"]))?; let context_paths = @@ -935,26 +852,12 @@ fn parse_routine_execution(params: &Value) -> Result Trigger { } } -async fn build_routine_action( - store: &dyn Database, - user_id: &str, +fn build_routine_action( name: &str, prompt: &str, execution: &NormalizedExecutionRequest, -) -> Result { +) -> RoutineAction { match execution.mode { - NormalizedExecutionMode::Lightweight => Ok(RoutineAction::Lightweight { + NormalizedExecutionMode::Lightweight => RoutineAction::Lightweight { prompt: prompt.to_string(), context_paths: execution.context_paths.clone(), max_tokens: 4096, use_tools: execution.use_tools, max_tool_rounds: execution.max_tool_rounds, - }), - NormalizedExecutionMode::FullJob => { - let mut owner_settings = None; - let requested_mode = match execution.permission_mode { - Some(mode) => mode, - None => { - let settings = load_full_job_permission_settings(store, user_id) - .await - .map_err(|e| { - ToolError::ExecutionFailed(format!( - "failed to load routine permission settings: {e}" - )) - })?; - let mode = match settings.default_mode { - FullJobPermissionDefaultMode::Explicit => { - RequestedFullJobPermissionMode::Explicit - } - FullJobPermissionDefaultMode::InheritOwner => { - RequestedFullJobPermissionMode::InheritOwner - } - FullJobPermissionDefaultMode::CopyOwner => { - RequestedFullJobPermissionMode::CopyOwner - } - }; - owner_settings = Some(settings); - mode - } - }; - let (permission_mode, tool_permissions) = match requested_mode { - RequestedFullJobPermissionMode::Explicit => ( - FullJobPermissionMode::Explicit, - execution.tool_permissions.clone(), - ), - RequestedFullJobPermissionMode::InheritOwner => ( - FullJobPermissionMode::InheritOwner, - execution.tool_permissions.clone(), - ), - RequestedFullJobPermissionMode::CopyOwner => { - let owner_allowed_tools = match owner_settings { - Some(settings) => settings.owner_allowed_tools, - None => { - load_full_job_permission_settings(store, user_id) - .await - .map_err(|e| { - ToolError::ExecutionFailed(format!( - "failed to load routine permission settings: {e}" - )) - })? - .owner_allowed_tools - } - }; - ( - FullJobPermissionMode::Explicit, - normalize_tool_names( - owner_allowed_tools - .into_iter() - .chain(execution.tool_permissions.iter().cloned()), - ), - ) - } - }; - Ok(RoutineAction::FullJob { - title: name.to_string(), - description: prompt.to_string(), - max_iterations: 10, - tool_permissions, - permission_mode, - }) - } + }, + NormalizedExecutionMode::FullJob => RoutineAction::FullJob { + title: name.to_string(), + description: prompt.to_string(), + max_iterations: 10, + }, } } @@ -1108,13 +946,6 @@ fn routine_requests_full_job(params: &Value) -> bool { ) } -fn routine_permission_fields_present(params: &Value) -> bool { - nested_object(params, "execution").is_some_and(|execution| { - execution.contains_key("tool_permissions") || execution.contains_key("permission_mode") - }) || params.get("tool_permissions").is_some() - || params.get("permission_mode").is_some() -} - fn event_emit_schema(include_source_alias: bool) -> Value { let mut schema = serde_json::json!({ "type": "object", @@ -1241,14 +1072,8 @@ impl Tool for RoutineCreateTool { let start = std::time::Instant::now(); let normalized = parse_routine_create_request(¶ms)?; let trigger = build_routine_trigger(&normalized.trigger); - let action = build_routine_action( - self.store.as_ref(), - &ctx.user_id, - &normalized.name, - &normalized.prompt, - &normalized.execution, - ) - .await?; + let action = + build_routine_action(&normalized.name, &normalized.prompt, &normalized.execution); // Compute next fire time for cron let next_fire = if let Trigger::Cron { @@ -1412,22 +1237,13 @@ impl Tool for RoutineUpdateTool { fn description(&self) -> &str { "Update an existing routine. Can change prompt, description, enabled state, cron schedule/timezone, \ - or full_job permission settings. Pass the routine name and only the fields you want to change. \ - This does not convert trigger types." + Pass the routine name and only the fields you want to change. This does not convert trigger types." } fn parameters_schema(&self) -> serde_json::Value { routine_update_parameters_schema() } - fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement { - if routine_permission_fields_present(params) { - ApprovalRequirement::UnlessAutoApproved - } else { - ApprovalRequirement::Never - } - } - async fn execute( &self, params: serde_json::Value, @@ -1460,72 +1276,6 @@ impl Tool for RoutineUpdateTool { } } - let requested_permission_mode = parse_requested_full_job_permission_mode(string_field( - ¶ms, - "execution", - "permission_mode", - &["permission_mode"], - ))?; - let requested_tool_permissions = optional_string_array_field( - ¶ms, - "execution", - "tool_permissions", - &["tool_permissions"], - ); - let updates_permissions = - requested_permission_mode.is_some() || requested_tool_permissions.is_some(); - - if updates_permissions { - match &mut routine.action { - RoutineAction::FullJob { - tool_permissions, - permission_mode, - .. - } => { - let next_tool_permissions = - requested_tool_permissions.unwrap_or_else(|| tool_permissions.clone()); - match requested_permission_mode { - Some(RequestedFullJobPermissionMode::Explicit) => { - *permission_mode = FullJobPermissionMode::Explicit; - *tool_permissions = next_tool_permissions; - } - Some(RequestedFullJobPermissionMode::InheritOwner) => { - *permission_mode = FullJobPermissionMode::InheritOwner; - *tool_permissions = next_tool_permissions; - } - Some(RequestedFullJobPermissionMode::CopyOwner) => { - let owner_settings = load_full_job_permission_settings( - self.store.as_ref(), - &ctx.user_id, - ) - .await - .map_err(|e| { - ToolError::ExecutionFailed(format!( - "failed to load routine permission settings: {e}" - )) - })?; - *permission_mode = FullJobPermissionMode::Explicit; - *tool_permissions = normalize_tool_names( - owner_settings - .owner_allowed_tools - .into_iter() - .chain(next_tool_permissions), - ); - } - None => { - *tool_permissions = next_tool_permissions; - } - } - } - RoutineAction::Lightweight { .. } => { - return Err(ToolError::InvalidParameters( - "permission_mode and tool_permissions can only be updated for full_job routines" - .to_string(), - )); - } - } - } - // Validate timezone param if provided let new_timezone = params .get("timezone") @@ -1936,8 +1686,6 @@ mod tests { "context_paths", "use_tools", "max_tool_rounds", - "tool_permissions", - "permission_mode", "notify_channel", "notify_user", "cooldown_secs", @@ -2036,8 +1784,7 @@ mod tests { "timezone": "UTC" }, "execution": { - "mode": "full_job", - "tool_permissions": ["message", "http"] + "mode": "full_job" }, "delivery": { "channel": "telegram", @@ -2062,11 +1809,6 @@ mod tests { matches!(parsed.execution.mode, NormalizedExecutionMode::FullJob), "expected full_job execution mode", ); - assert_eq!( - parsed.execution.tool_permissions, - vec!["message".to_string(), "http".to_string()], - ); - assert_eq!(parsed.execution.permission_mode, None); assert_eq!(parsed.delivery.channel.as_deref(), Some("telegram")); assert_eq!(parsed.delivery.user.as_deref(), Some("ops-team")); assert_eq!(parsed.cooldown_secs, 30); @@ -2108,6 +1850,37 @@ mod tests { ); } + #[test] + fn parses_context_paths_with_trim_drop_empty_and_stable_dedupe() { + let params = serde_json::json!({ + "name": "deploy-watch", + "prompt": "Look for deploy requests.", + "request": { + "kind": "manual" + }, + "execution": { + "context_paths": [ + " context/deploy.md ", + "", + " ", + "context/deploy.md", + "context/notes.md" + ] + } + }); + + let parsed = + parse_routine_create_request(¶ms).expect("parse context_paths normalization"); + + assert_eq!( + parsed.execution.context_paths, + vec![ + "context/deploy.md".to_string(), + "context/notes.md".to_string() + ], + ); + } + #[test] fn parses_grouped_system_event_request() { let params = serde_json::json!({ @@ -2187,7 +1960,6 @@ mod tests { "event_pattern": "hello", "event_channel": "telegram", "action_type": "full_job", - "tool_permissions": ["message"], "notify_channel": "telegram", "notify_user": "123" }); @@ -2206,10 +1978,6 @@ mod tests { matches!(parsed.execution.mode, NormalizedExecutionMode::FullJob), "expected full_job execution mode", ); - assert_eq!( - parsed.execution.tool_permissions, - vec!["message".to_string()], - ); assert_eq!(parsed.delivery.channel.as_deref(), Some("telegram")); assert_eq!(parsed.delivery.user.as_deref(), Some("123")); } @@ -2396,9 +2164,8 @@ mod tests { .and_then(Value::as_object) .expect("full_job properties"); assert!( - full_job_props.contains_key("tool_permissions") - && full_job_props.contains_key("permission_mode"), - "full_job variant should expose permission fields", + full_job_props.len() == 1 && full_job_props.contains_key("mode"), + "full_job variant should only expose the execution mode", ); } @@ -2503,8 +2270,6 @@ mod tests { "schedule", "timezone", "description", - "tool_permissions", - "permission_mode", ] { let _ = schema_property(&schema, field); } @@ -2587,71 +2352,26 @@ mod tests { ); } - #[cfg(feature = "libsql")] - #[tokio::test] - async fn build_full_job_action_defaults_to_inherit_owner_for_new_routines() { - let (db, _tmp) = crate::testing::test_db().await; - let execution = NormalizedExecutionRequest { - mode: NormalizedExecutionMode::FullJob, - context_paths: Vec::new(), - use_tools: false, - max_tool_rounds: 3, - tool_permissions: vec!["shell".to_string()], - permission_mode: None, - }; - - let action = - build_routine_action(db.as_ref(), "default", "issue-1316", "Run it", &execution) - .await - .expect("build action"); - - assert!(matches!( - action, - RoutineAction::FullJob { - permission_mode: FullJobPermissionMode::InheritOwner, - tool_permissions, - .. - } if tool_permissions == vec!["shell".to_string()] - )); - } - - #[cfg(feature = "libsql")] - #[tokio::test] - async fn build_full_job_action_copy_owner_snapshots_allowlist() { - let (db, _tmp) = crate::testing::test_db().await; - db.set_setting( - "default", - crate::agent::routine::FULL_JOB_OWNER_ALLOWED_TOOLS_SETTING_KEY, - &serde_json::json!(["http", "shell"]), - ) - .await - .expect("set owner allowlist"); + #[test] + fn build_full_job_action_uses_live_owner_scope_defaults() { let execution = NormalizedExecutionRequest { mode: NormalizedExecutionMode::FullJob, context_paths: Vec::new(), use_tools: false, max_tool_rounds: 3, - tool_permissions: vec!["message".to_string(), "shell".to_string()], - permission_mode: Some(RequestedFullJobPermissionMode::CopyOwner), }; - let action = - build_routine_action(db.as_ref(), "default", "issue-1316", "Run it", &execution) - .await - .expect("build action"); + let action = build_routine_action("issue-1316", "Run it", &execution); assert!(matches!( action, RoutineAction::FullJob { - permission_mode: FullJobPermissionMode::Explicit, - tool_permissions, - .. - } if tool_permissions - == vec![ - "http".to_string(), - "shell".to_string(), - "message".to_string(), - ] + title, + description, + max_iterations, + } if title == "issue-1316" + && description == "Run it" + && max_iterations == 10 )); } } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index d1659ddb4b..653544fdef 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -7,6 +7,7 @@ //! - Delegate tasks to other services //! - Build new software and tools +mod autonomy; pub mod builder; pub mod builtin; mod coercion; @@ -20,6 +21,10 @@ pub mod wasm; mod registry; mod tool; +pub use autonomy::{ + AUTONOMOUS_TOOL_DENYLIST, autonomous_allowed_tool_names, autonomous_unavailable_error, + autonomous_unavailable_message, is_autonomous_tool_denylisted, +}; pub use builder::{ BuildPhase, BuildRequirement, BuildResult, BuildSoftwareTool, BuilderConfig, Language, LlmSoftwareBuilder, SoftwareBuilder, SoftwareType, Template, TemplateEngine, TemplateType, diff --git a/src/tools/registry.rs b/src/tools/registry.rs index c64b637f04..4564de7c65 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -83,7 +83,7 @@ const PROTECTED_TOOL_NAMES: &[&str] = &[ /// Registry of available tools. pub struct ToolRegistry { tools: RwLock>>, - /// Tracks which names were registered as built-in (protected from shadowing). + /// Tracks which names were registered via the built-in startup path. builtin_names: RwLock>, /// Shared credential registry populated by WASM tools, consumed by HTTP tool. credential_registry: Option>, @@ -138,10 +138,12 @@ impl ToolRegistry { &self.rate_limiter } - /// Register a tool. Rejects dynamic tools that try to shadow a built-in name. + /// Register a tool. Rejects dynamic tools that try to shadow a protected built-in name. pub async fn register(&self, tool: Arc) { let name = tool.name().to_string(); - if self.builtin_names.read().await.contains(&name) { + if PROTECTED_TOOL_NAMES.contains(&name.as_str()) + && self.builtin_names.read().await.contains(&name) + { tracing::warn!( tool = %name, "Rejected tool registration: would shadow a built-in tool" @@ -157,10 +159,7 @@ impl ToolRegistry { let name = tool.name().to_string(); if let Ok(mut tools) = self.tools.try_write() { tools.insert(name.clone(), tool); - // Mark as built-in so it can't be shadowed later - if PROTECTED_TOOL_NAMES.contains(&name.as_str()) - && let Ok(mut builtins) = self.builtin_names.try_write() - { + if let Ok(mut builtins) = self.builtin_names.try_write() { builtins.insert(name.clone()); } tracing::debug!("Registered tool: {}", name); @@ -210,6 +209,11 @@ impl ToolRegistry { self.tools.read().await.values().cloned().collect() } + /// Get the set of built-in tool names currently registered. + pub async fn builtin_tool_names(&self) -> std::collections::HashSet { + self.builtin_names.read().await.clone() + } + /// Get tool definitions for LLM function calling. pub async fn tool_definitions(&self) -> Vec { let mut defs: Vec = self @@ -888,7 +892,7 @@ mod tests { #[tokio::test] async fn test_builtin_tool_cannot_be_shadowed() { let registry = ToolRegistry::new(); - // Register echo as built-in (uses register_sync which marks protected names) + // Register echo as built-in (uses register_sync and echo is protected). registry.register_sync(Arc::new(EchoTool)); assert!(registry.has("echo").await); @@ -935,6 +939,37 @@ mod tests { assert_ne!(desc, "EVIL SHADOW"); } + #[tokio::test] + async fn test_builtin_tool_names_include_non_protected_sync_tools() { + struct NonProtectedBuiltin; + + #[async_trait::async_trait] + impl Tool for NonProtectedBuiltin { + fn name(&self) -> &str { + "owner_gate" + } + fn description(&self) -> &str { + "test builtin" + } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({}) + } + async fn execute( + &self, + _params: serde_json::Value, + _ctx: &crate::context::JobContext, + ) -> Result { + unreachable!() + } + } + + let registry = ToolRegistry::new(); + registry.register_sync(Arc::new(NonProtectedBuiltin)); + + let builtins = registry.builtin_tool_names().await; + assert!(builtins.contains("owner_gate")); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn concurrent_register_and_read_no_panic() { use std::sync::Arc as StdArc; diff --git a/src/tools/tool.rs b/src/tools/tool.rs index e80712a93d..c361e50c07 100644 --- a/src/tools/tool.rs +++ b/src/tools/tool.rs @@ -28,30 +28,29 @@ impl ApprovalRequirement { } } -/// Approval context for autonomous tool execution (routines, background jobs). +/// Precomputed autonomous tool scope for background jobs and routines. /// -/// Interactive sessions don't use this type — they rely on session-level -/// auto-approve lists managed by the UI. This enum models only the autonomous -/// case where no interactive user is present. +/// Interactive sessions don't use this type — they still rely on +/// `requires_approval()` and session-level approval state. #[derive(Debug, Clone)] pub enum ApprovalContext { - /// Autonomous job with no interactive user. `UnlessAutoApproved` tools are - /// pre-approved. `Always` tools are blocked unless listed in `allowed_tools`. + /// Autonomous job with no interactive user. Only tools in `allowed_tools` + /// may run; interactive approval requirements are ignored. Autonomous { - /// Tool names that are pre-authorized even for `Always` approval. + /// Tool names that may run autonomously for this job/run. allowed_tools: std::collections::HashSet, }, } impl ApprovalContext { - /// Create an autonomous context with no extra tool permissions. + /// Create an autonomous context with no allowed tools. pub fn autonomous() -> Self { Self::Autonomous { allowed_tools: std::collections::HashSet::new(), } } - /// Create an autonomous context with specific tools pre-authorized. + /// Create an autonomous context with specific allowed tools. pub fn autonomous_with_tools(tools: impl IntoIterator) -> Self { Self::Autonomous { allowed_tools: tools.into_iter().collect(), @@ -59,13 +58,9 @@ impl ApprovalContext { } /// Check whether a tool invocation is blocked in this context. - pub fn is_blocked(&self, tool_name: &str, requirement: ApprovalRequirement) -> bool { + pub fn is_blocked(&self, tool_name: &str, _requirement: ApprovalRequirement) -> bool { match self { - Self::Autonomous { allowed_tools } => match requirement { - ApprovalRequirement::Never => false, - ApprovalRequirement::UnlessAutoApproved => false, - ApprovalRequirement::Always => !allowed_tools.contains(tool_name), - }, + Self::Autonomous { allowed_tools } => !allowed_tools.contains(tool_name), } } @@ -889,26 +884,27 @@ mod tests { } #[test] - fn test_approval_context_autonomous_allows_unless_auto_approved() { + fn test_approval_context_autonomous_blocks_tools_not_in_scope() { let ctx = ApprovalContext::autonomous(); - assert!(!ctx.is_blocked("shell", ApprovalRequirement::Never)); - assert!(!ctx.is_blocked("shell", ApprovalRequirement::UnlessAutoApproved)); + assert!(ctx.is_blocked("shell", ApprovalRequirement::Never)); + assert!(ctx.is_blocked("shell", ApprovalRequirement::UnlessAutoApproved)); assert!(ctx.is_blocked("shell", ApprovalRequirement::Always)); } #[test] - fn test_approval_context_autonomous_with_tools_allows_always() { + fn test_approval_context_autonomous_with_tools_allows_registered_name() { let ctx = ApprovalContext::autonomous_with_tools(["shell".to_string(), "message".to_string()]); + assert!(!ctx.is_blocked("shell", ApprovalRequirement::Never)); assert!(!ctx.is_blocked("shell", ApprovalRequirement::Always)); assert!(!ctx.is_blocked("message", ApprovalRequirement::Always)); assert!(ctx.is_blocked("http", ApprovalRequirement::Always)); } #[test] - fn test_approval_context_never_is_not_blocked() { + fn test_approval_context_blocks_never_when_not_in_scope() { let ctx = ApprovalContext::autonomous(); - assert!(!ctx.is_blocked("any_tool", ApprovalRequirement::Never)); + assert!(ctx.is_blocked("any_tool", ApprovalRequirement::Never)); } #[test] @@ -946,7 +942,7 @@ mod tests { "other", ApprovalRequirement::Always )); - assert!(!ApprovalContext::is_blocked_or_default( + assert!(ApprovalContext::is_blocked_or_default( &ctx, "any", ApprovalRequirement::UnlessAutoApproved diff --git a/src/worker/job.rs b/src/worker/job.rs index 87b9cfeb9f..738c2354a4 100644 --- a/src/worker/job.rs +++ b/src/worker/job.rs @@ -30,7 +30,9 @@ use crate::llm::{ use crate::safety::SafetyLayer; use crate::tools::execute::process_tool_result; use crate::tools::rate_limiter::RateLimitResult; -use crate::tools::{ApprovalContext, ToolRegistry, prepare_tool_params, redact_params}; +use crate::tools::{ + ApprovalContext, ToolRegistry, autonomous_unavailable_error, prepare_tool_params, redact_params, +}; /// Shared dependencies for worker execution. /// @@ -486,22 +488,20 @@ Report when the job is complete or if you encounter issues you cannot resolve."# let normalized_params = prepare_tool_params(tool.as_ref(), params); + // Fetch job context early so we have the real user_id for approval, hooks, + // and rate limiting decisions. + let mut job_ctx = deps.context_manager.get_context(job_id).await?; + // Propagate http_interceptor for trace recording/replay + if job_ctx.http_interceptor.is_none() { + job_ctx.http_interceptor = deps.http_interceptor.clone(); + } + // Check approval: use context-aware check if available, else block all non-Never tools let requirement = tool.requires_approval(&normalized_params); let blocked = ApprovalContext::is_blocked_or_default(&deps.approval_context, tool_name, requirement); if blocked { - return Err(crate::error::ToolError::AuthRequired { - name: tool_name.to_string(), - } - .into()); - } - - // Fetch job context early so we have the real user_id for hooks and rate limiting - let mut job_ctx = deps.context_manager.get_context(job_id).await?; - // Propagate http_interceptor for trace recording/replay - if job_ctx.http_interceptor.is_none() { - job_ctx.http_interceptor = deps.http_interceptor.clone(); + return Err(autonomous_unavailable_error(tool_name, &job_ctx.user_id).into()); } // Check per-tool rate limit before running hooks or executing (cheaper check first) @@ -761,12 +761,12 @@ Report when the job is complete or if you encounter issues you cannot resolve."# ); reason_ctx.messages.push(message); - match &result { + match result { Ok(raw_output) => { let sanitized = self .deps .safety - .sanitize_tool_output(&selection.tool_name, raw_output); + .sanitize_tool_output(&selection.tool_name, &raw_output); self.log_event( "tool_result", serde_json::json!({ @@ -807,7 +807,14 @@ Report when the job is complete or if you encounter issues you cannot resolve."# }), ); - Ok(()) + if matches!( + &e, + Error::Tool(crate::error::ToolError::AutonomousUnavailable { .. }) + ) { + Err(e) + } else { + Ok(()) + } } } } @@ -1802,7 +1809,7 @@ mod tests { } #[tokio::test] - async fn test_approval_context_unblocks_unless_auto_approved() { + async fn test_approval_context_requires_explicit_allowed_tool_names() { let worker_blocked = make_worker_with_approval(vec![Arc::new(ApprovalTool)], None).await; let result = worker_blocked .execute_tool("needs_approval", &serde_json::json!({})) @@ -1815,13 +1822,18 @@ mod tests { let worker_allowed = make_worker_with_approval( vec![Arc::new(ApprovalTool)], - Some(crate::tools::ApprovalContext::autonomous()), + Some(crate::tools::ApprovalContext::autonomous_with_tools([ + "needs_approval".to_string(), + ])), ) .await; let result = worker_allowed .execute_tool("needs_approval", &serde_json::json!({})) .await; - assert!(result.is_ok(), "Should be allowed with autonomous context"); // safety: test + assert!( + result.is_ok(), + "Should be allowed when the tool is in the autonomous scope" + ); // safety: test } #[tokio::test] @@ -1857,6 +1869,25 @@ mod tests { ); } + #[tokio::test] + async fn test_approval_context_returns_structured_autonomous_unavailable_error() { + let worker = make_worker_with_approval( + vec![Arc::new(AlwaysApprovalTool)], + Some(crate::tools::ApprovalContext::autonomous()), + ) + .await; + + let result = worker + .execute_tool("always_approval", &serde_json::json!({})) + .await; + + assert!(matches!( + result, + Err(Error::Tool(crate::error::ToolError::AutonomousUnavailable { name, .. })) + if name == "always_approval" + )); + } + #[tokio::test] async fn test_token_budget_exceeded_fails_job() { let worker = make_worker(vec![]).await; diff --git a/tests/dispatched_routine_run_tests.rs b/tests/dispatched_routine_run_tests.rs index e5024570f4..d790274e53 100644 --- a/tests/dispatched_routine_run_tests.rs +++ b/tests/dispatched_routine_run_tests.rs @@ -15,8 +15,7 @@ mod tests { use uuid::Uuid; use ironclaw::agent::routine::{ - FullJobPermissionMode, Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, - Trigger, + Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger, }; use ironclaw::context::{JobContext, JobState}; use ironclaw::db::Database; @@ -46,8 +45,6 @@ mod tests { title: "Test job".to_string(), description: "Test description".to_string(), max_iterations: 5, - tool_permissions: vec![], - permission_mode: FullJobPermissionMode::Explicit, }, guardrails: RoutineGuardrails { cooldown: std::time::Duration::from_secs(0), diff --git a/tests/e2e_builtin_tool_coverage.rs b/tests/e2e_builtin_tool_coverage.rs index 03c1aefe01..c8d5eff1f4 100644 --- a/tests/e2e_builtin_tool_coverage.rs +++ b/tests/e2e_builtin_tool_coverage.rs @@ -10,7 +10,7 @@ mod support; mod tests { use std::time::Duration; - use ironclaw::agent::routine::{FullJobPermissionMode, RoutineAction, Trigger}; + use ironclaw::agent::routine::{RoutineAction, Trigger}; use crate::support::test_rig::TestRigBuilder; use crate::support::trace_llm::LlmTrace; @@ -356,15 +356,8 @@ mod tests { } match &routine.action { - RoutineAction::FullJob { - description, - tool_permissions, - permission_mode, - .. - } => { + RoutineAction::FullJob { description, .. } => { assert!(description.contains("Summarize the new issue")); - assert_eq!(tool_permissions, &vec!["shell".to_string()]); - assert_eq!(permission_mode, &FullJobPermissionMode::InheritOwner); } other => panic!("expected full_job action, got {other:?}"), } @@ -412,18 +405,8 @@ mod tests { } match &routine.action { - RoutineAction::FullJob { - description, - tool_permissions, - permission_mode, - .. - } => { + RoutineAction::FullJob { description, .. } => { assert!(description.contains("Prepare the morning digest")); - assert_eq!( - tool_permissions, - &vec!["message".to_string(), "http".to_string()] - ); - assert_eq!(permission_mode, &FullJobPermissionMode::InheritOwner); } other => panic!("expected full_job action, got {other:?}"), } diff --git a/tests/e2e_routine_heartbeat.rs b/tests/e2e_routine_heartbeat.rs index b467c9c89a..12125d43d0 100644 --- a/tests/e2e_routine_heartbeat.rs +++ b/tests/e2e_routine_heartbeat.rs @@ -8,27 +8,33 @@ mod support; #[cfg(feature = "libsql")] mod tests { + use std::path::Path; use std::sync::Arc; use std::time::Duration; use chrono::Utc; use libsql::params; + use secrecy::SecretString; use uuid::Uuid; use ironclaw::agent::routine::{ - FullJobPermissionMode, NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun, - RunStatus, Trigger, + NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger, }; use ironclaw::agent::routine_engine::RoutineEngine; - use ironclaw::agent::{HeartbeatConfig, HeartbeatRunner, SandboxReadiness, Scheduler}; + use ironclaw::agent::{ + HeartbeatConfig, HeartbeatRunner, SandboxReadiness, Scheduler, SchedulerDeps, + }; use ironclaw::channels::IncomingMessage; use ironclaw::config::{AgentConfig, RoutineConfig, SafetyConfig}; use ironclaw::context::{ContextManager, JobContext}; use ironclaw::db::{Database, libsql::LibSqlBackend}; + use ironclaw::extensions::ExtensionManager; use ironclaw::hooks::HookRegistry; use ironclaw::llm::LlmProvider; use ironclaw::safety::SafetyLayer; + use ironclaw::secrets::{InMemorySecretsStore, SecretsCrypto, SecretsStore}; use ironclaw::tools::builtin::routine::RoutineUpdateTool; + use ironclaw::tools::mcp::{McpProcessManager, McpSessionManager}; use ironclaw::tools::{ApprovalRequirement, Tool, ToolError, ToolOutput, ToolRegistry}; use ironclaw::workspace::Workspace; use ironclaw::workspace::hygiene::HygieneConfig; @@ -165,11 +171,7 @@ mod tests { } } - fn make_full_job_routine( - name: &str, - permission_mode: FullJobPermissionMode, - tool_permissions: Vec, - ) -> Routine { + fn make_full_job_routine(name: &str) -> Routine { Routine { id: Uuid::new_v4(), name: name.to_string(), @@ -181,8 +183,6 @@ mod tests { title: name.to_string(), description: "Use the owner-gated tool when permitted.".to_string(), max_iterations: 3, - tool_permissions, - permission_mode, }, guardrails: RoutineGuardrails { cooldown: Duration::from_secs(0), @@ -234,27 +234,112 @@ mod tests { LlmTrace::single_turn("test-owner-gate", "run owner gate", steps) } - async fn setup_owner_gate_engine(db: Arc, trace: LlmTrace) -> Arc { + fn owner_gate_lightweight_trace() -> LlmTrace { + LlmTrace::single_turn( + "test-owner-gate-lightweight", + "run owner gate", + vec![ + TraceStep { + request_hint: None, + response: TraceResponse::ToolCalls { + tool_calls: vec![TraceToolCall { + id: "call_owner_gate".to_string(), + name: "owner_gate".to_string(), + arguments: serde_json::json!({}), + }], + input_tokens: 40, + output_tokens: 10, + }, + expected_tool_results: vec![], + }, + TraceStep { + request_hint: None, + response: TraceResponse::Text { + content: "ROUTINE_OK".to_string(), + input_tokens: 20, + output_tokens: 5, + }, + expected_tool_results: vec![], + }, + ], + ) + } + + async fn write_test_extension_wasm(tools_dir: &Path, name: &str) { + tokio::fs::create_dir_all(tools_dir) + .await + .expect("create test wasm tools dir"); + tokio::fs::write(tools_dir.join(format!("{name}.wasm")), b"\0asm") + .await + .expect("write test wasm tool marker"); + } + + fn make_test_extension_manager( + tools: Arc, + tools_dir: &Path, + owner_id: &str, + ) -> Arc { + let crypto = Arc::new( + SecretsCrypto::new(SecretString::from( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + )) + .expect("test crypto"), + ); + let secrets: Arc = + Arc::new(InMemorySecretsStore::new(crypto)); + Arc::new(ExtensionManager::new( + Arc::new(McpSessionManager::new()), + Arc::new(McpProcessManager::new()), + secrets, + tools, + None, + None, + tools_dir.to_path_buf(), + tools_dir.join("channels"), + None, + owner_id.to_string(), + None, + Vec::new(), + )) + } + + async fn setup_owner_gate_engine( + db: Arc, + trace: LlmTrace, + tools_dir: &Path, + extension_owner_id: Option<&str>, + activate_owner_gate: bool, + ) -> Arc { let ws = create_workspace(&db); let (notify_tx, _rx) = tokio::sync::mpsc::channel(16); let registry = Arc::new(ToolRegistry::new()); - registry - .register(Arc::new(OwnerGateTool { store: db.clone() })) - .await; + if extension_owner_id.is_some() { + registry + .register(Arc::new(OwnerGateTool { store: db.clone() })) + .await; + } + if activate_owner_gate { + write_test_extension_wasm(tools_dir, "owner_gate").await; + } let safety = Arc::new(SafetyLayer::new(&SafetyConfig { max_output_length: 100_000, injection_check_enabled: false, })); let llm: Arc = Arc::new(TraceLlm::from_trace(trace)); + let extension_manager = extension_owner_id + .map(|owner_id| make_test_extension_manager(registry.clone(), tools_dir, owner_id)); let scheduler = Arc::new(Scheduler::new( AgentConfig::for_testing(), Arc::new(ContextManager::new(5)), llm.clone(), safety.clone(), - registry.clone(), - Some(db.clone()), - Arc::new(HookRegistry::new()), + SchedulerDeps { + tools: registry.clone(), + extension_manager: extension_manager.clone(), + store: Some(db.clone()), + hooks: Arc::new(HookRegistry::new()), + }, )); Arc::new(RoutineEngine::new( @@ -264,9 +349,10 @@ mod tests { ws, notify_tx, Some(scheduler), + extension_manager, registry, safety, - SandboxReadiness::DisabledByConfig, + SandboxReadiness::Available, )) } @@ -303,6 +389,28 @@ mod tests { } } + async fn wait_for_any_run_completion(db: &Arc, routine_id: Uuid) -> RoutineRun { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + let runs = db + .list_routine_runs(routine_id, 10) + .await + .expect("list_routine_runs"); + if let Some(run) = runs + .into_iter() + .find(|run| run.status != RunStatus::Running) + { + return run; + } + + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for any routine run for {routine_id} to complete" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + // ----------------------------------------------------------------------- // Test 1: cron_routine_fires // ----------------------------------------------------------------------- @@ -345,6 +453,7 @@ mod tests { ws, notify_tx, None, + None, tools, safety, SandboxReadiness::DisabledByConfig, @@ -423,6 +532,7 @@ mod tests { ws, notify_tx, None, + None, tools, safety, SandboxReadiness::DisabledByConfig, @@ -517,6 +627,7 @@ mod tests { ws, notify_tx, None, + None, tools, safety, SandboxReadiness::DisabledByConfig, @@ -625,6 +736,7 @@ mod tests { ws, notify_tx, None, + None, tools, safety, SandboxReadiness::DisabledByConfig, @@ -767,6 +879,7 @@ mod tests { ws, notify_tx, None, + None, tools, safety, SandboxReadiness::DisabledByConfig, @@ -953,6 +1066,7 @@ mod tests { ws, notify_tx, None, + None, tools, safety, SandboxReadiness::DisabledByConfig, @@ -1083,6 +1197,7 @@ mod tests { ws, notify_tx, None, // no scheduler — rejected before dispatch + None, tools, safety, SandboxReadiness::DisabledByConfig, @@ -1100,8 +1215,6 @@ mod tests { title: "t".to_string(), description: "d".to_string(), max_iterations: 3, - tool_permissions: vec![], - permission_mode: ironclaw::agent::routine::FullJobPermissionMode::Explicit, }, guardrails: RoutineGuardrails { cooldown: Duration::from_secs(0), @@ -1192,6 +1305,7 @@ mod tests { ws, notify_tx, None, + None, tools, safety, SandboxReadiness::DisabledByConfig, @@ -1250,28 +1364,27 @@ mod tests { } // ----------------------------------------------------------------------- - // Test: inherit_owner full_job routines can use owner-gated tools + // Test: lightweight manual routines use the owner's active extension tools // ----------------------------------------------------------------------- #[tokio::test] - async fn full_job_inherit_owner_uses_owner_allowlist() { - let (backend, _tmp) = create_test_backend().await; + async fn lightweight_manual_routine_uses_active_owner_extension_tool() { + let (backend, tmp) = create_test_backend().await; let db: Arc = backend; - let engine = setup_owner_gate_engine(db.clone(), owner_gate_trace(true)).await; - - db.set_setting( - "default", - ironclaw::agent::routine::FULL_JOB_OWNER_ALLOWED_TOOLS_SETTING_KEY, - &serde_json::json!(["owner_gate"]), + let tools_dir = tmp.path().join("wasm-tools"); + let engine = setup_owner_gate_engine( + db.clone(), + owner_gate_lightweight_trace(), + tools_dir.as_path(), + Some("default"), + true, ) - .await - .expect("set owner allowlist"); + .await; - let routine = make_full_job_routine( - "inherit-owner-allowed", - FullJobPermissionMode::InheritOwner, - vec![], - ); + let mut routine = make_routine("manual-owner-gate", Trigger::Manual, "Use owner_gate."); + if let RoutineAction::Lightweight { use_tools, .. } = &mut routine.action { + *use_tools = true; + } db.create_routine(&routine).await.expect("create_routine"); let run_id = engine @@ -1285,20 +1398,181 @@ mod tests { } // ----------------------------------------------------------------------- - // Test: inherit_owner full_job routines stay blocked without owner allowlist + // Test: full_job cron routines use the owner's active extension tools + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn full_job_cron_routine_uses_active_owner_extension_tool() { + let (backend, tmp) = create_test_backend().await; + let db: Arc = backend; + let tools_dir = tmp.path().join("wasm-tools"); + let engine = setup_owner_gate_engine( + db.clone(), + owner_gate_trace(true), + tools_dir.as_path(), + Some("default"), + true, + ) + .await; + + let mut routine = make_full_job_routine("cron-owner-gate"); + routine.trigger = Trigger::Cron { + schedule: "* * * * *".to_string(), + timezone: None, + }; + routine.next_fire_at = Some(Utc::now() - chrono::Duration::minutes(1)); + db.create_routine(&routine).await.expect("create_routine"); + + engine.check_cron_triggers().await; + let run = wait_for_any_run_completion(&db, routine.id).await; + + assert_eq!(run.status, RunStatus::Ok); + assert_eq!(owner_gate_count(&db).await, 1); + } + + // ----------------------------------------------------------------------- + // Test: lightweight event routines use the owner's active extension tools + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn lightweight_event_routine_uses_active_owner_extension_tool() { + let (backend, tmp) = create_test_backend().await; + let db: Arc = backend; + let tools_dir = tmp.path().join("wasm-tools"); + let engine = setup_owner_gate_engine( + db.clone(), + owner_gate_lightweight_trace(), + tools_dir.as_path(), + Some("default"), + true, + ) + .await; + + let mut routine = make_routine( + "event-owner-gate", + Trigger::Event { + channel: None, + pattern: "owner-gate".to_string(), + }, + "Use owner_gate.", + ); + if let RoutineAction::Lightweight { use_tools, .. } = &mut routine.action { + *use_tools = true; + } + db.create_routine(&routine).await.expect("create_routine"); + engine.refresh_event_cache().await; + + let fired = engine + .check_event_triggers("default", "test", "owner-gate") + .await; + assert_eq!(fired, 1, "expected one matching event routine"); + + let run = wait_for_any_run_completion(&db, routine.id).await; + assert_eq!(run.status, RunStatus::Ok); + assert_eq!(owner_gate_count(&db).await, 1); + } + + // ----------------------------------------------------------------------- + // Test: full_job system-event routines use the owner's active extension tools + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn full_job_system_event_routine_uses_active_owner_extension_tool() { + let (backend, tmp) = create_test_backend().await; + let db: Arc = backend; + let tools_dir = tmp.path().join("wasm-tools"); + let engine = setup_owner_gate_engine( + db.clone(), + owner_gate_trace(true), + tools_dir.as_path(), + Some("default"), + true, + ) + .await; + + let mut routine = make_full_job_routine("system-owner-gate"); + routine.trigger = Trigger::SystemEvent { + source: "github".to_string(), + event_type: "issue.opened".to_string(), + filters: std::collections::HashMap::new(), + }; + db.create_routine(&routine).await.expect("create_routine"); + engine.refresh_event_cache().await; + + let fired = engine + .emit_system_event( + "github", + "issue.opened", + &serde_json::json!({"issue_number": 7}), + Some("default"), + ) + .await; + assert_eq!(fired, 1, "expected one matching system_event routine"); + + let run = wait_for_any_run_completion(&db, routine.id).await; + assert_eq!(run.status, RunStatus::Ok); + assert_eq!(owner_gate_count(&db).await, 1); + } + + // ----------------------------------------------------------------------- + // Test: autonomous runs fail loudly when an extension tool is inactive // ----------------------------------------------------------------------- #[tokio::test] - async fn full_job_inherit_owner_blocks_without_owner_allowlist() { - let (backend, _tmp) = create_test_backend().await; + async fn full_job_blocks_without_active_owner_extension_tool() { + let (backend, tmp) = create_test_backend().await; let db: Arc = backend; - let engine = setup_owner_gate_engine(db.clone(), owner_gate_trace(false)).await; + let tools_dir = tmp.path().join("wasm-tools"); + let engine = setup_owner_gate_engine( + db.clone(), + owner_gate_trace(false), + tools_dir.as_path(), + Some("default"), + false, + ) + .await; + + let routine = make_full_job_routine("inactive-owner-gate"); + db.create_routine(&routine).await.expect("create_routine"); + + let run_id = engine + .fire_manual(routine.id, None) + .await + .expect("fire manual"); + let run = wait_for_run_completion(&db, routine.id, run_id).await; - let routine = make_full_job_routine( - "inherit-owner-blocked", - FullJobPermissionMode::InheritOwner, - vec![], + assert_eq!(run.status, RunStatus::Failed); + assert_eq!(owner_gate_count(&db).await, 0); + let failure_reason = db + .get_agent_job_failure_reason(run.job_id.expect("linked job id")) + .await + .expect("load job failure reason") + .expect("missing job failure reason"); + assert!( + failure_reason.contains("owner_gate"), + "expected missing-tool failure reason, got {failure_reason}" ); + } + + // ----------------------------------------------------------------------- + // Test: extension tools activated for another owner are not inherited + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn full_job_blocks_when_extension_belongs_to_another_owner() { + let (backend, tmp) = create_test_backend().await; + let db: Arc = backend; + let tools_dir = tmp.path().join("wasm-tools"); + let engine = setup_owner_gate_engine( + db.clone(), + owner_gate_trace(false), + tools_dir.as_path(), + Some("someone-else"), + true, + ) + .await; + + let routine = make_full_job_routine("other-owner-gate"); db.create_routine(&routine).await.expect("create_routine"); let run_id = engine @@ -1309,27 +1583,27 @@ mod tests { assert_eq!(run.status, RunStatus::Failed); assert_eq!(owner_gate_count(&db).await, 0); + let failure_reason = db + .get_agent_job_failure_reason(run.job_id.expect("linked job id")) + .await + .expect("load job failure reason") + .expect("missing job failure reason"); + assert!( + failure_reason.contains("owner_gate"), + "expected owner-mismatch failure reason, got {failure_reason}" + ); } // ----------------------------------------------------------------------- - // Test: legacy full_job routines remain explicit until updated + // Test: legacy permission fields are ignored on read and removed on rewrite // ----------------------------------------------------------------------- #[tokio::test] - async fn legacy_full_job_stays_explicit_until_updated() { - let (backend, _tmp) = create_test_backend().await; + async fn legacy_full_job_permission_fields_are_ignored_and_removed_on_update() { + let (backend, tmp) = create_test_backend().await; let db: Arc = backend.clone(); - db.set_setting( - "default", - ironclaw::agent::routine::FULL_JOB_OWNER_ALLOWED_TOOLS_SETTING_KEY, - &serde_json::json!(["owner_gate"]), - ) - .await - .expect("set owner allowlist"); - - let legacy_routine = - make_full_job_routine("legacy-full-job", FullJobPermissionMode::Explicit, vec![]); + let legacy_routine = make_full_job_routine("legacy-full-job"); db.create_routine(&legacy_routine) .await .expect("create_routine"); @@ -1342,59 +1616,77 @@ mod tests { "title": legacy_routine.name, "description": "Use the owner-gated tool when permitted.", "max_iterations": 3, - "tool_permissions": [], + "tool_permissions": ["owner_gate"], + "permission_mode": "inherit_owner", }) .to_string(), legacy_routine.id.to_string(), ], ) .await - .expect("strip permission_mode from action_config"); + .expect("inject legacy permission fields into action_config"); - let blocked_engine = setup_owner_gate_engine(db.clone(), owner_gate_trace(false)).await; - let first_run_id = blocked_engine - .fire_manual(legacy_routine.id, None) + let loaded = db + .get_routine(legacy_routine.id) .await - .expect("fire manual legacy routine"); - let first_run = wait_for_run_completion(&db, legacy_routine.id, first_run_id).await; - - assert_eq!(first_run.status, RunStatus::Failed); - assert_eq!(owner_gate_count(&db).await, 0); + .expect("get_routine") + .expect("routine should still exist"); + assert!(matches!( + loaded.action, + RoutineAction::FullJob { + ref title, + ref description, + max_iterations, + } if title == "legacy-full-job" + && description == "Use the owner-gated tool when permitted." + && max_iterations == 3 + )); - let update_tool = RoutineUpdateTool::new(db.clone(), blocked_engine.clone()); + let tools_dir = tmp.path().join("wasm-tools"); + let engine = setup_owner_gate_engine( + db.clone(), + owner_gate_trace(false), + tools_dir.as_path(), + None, + false, + ) + .await; + let update_tool = RoutineUpdateTool::new(db.clone(), engine); let update_ctx = JobContext::with_user("default", "update", "update legacy routine"); update_tool .execute( serde_json::json!({ "name": legacy_routine.name, - "permission_mode": "inherit_owner", + "prompt": "Updated legacy description", }), &update_ctx, ) .await .expect("routine_update should succeed"); - let updated = db - .get_routine(legacy_routine.id) + let mut rows = conn + .query( + "SELECT action_config FROM routines WHERE id = ?1", + params![legacy_routine.id.to_string()], + ) .await - .expect("get_routine") - .expect("routine should still exist"); - assert!(matches!( - updated.action, - RoutineAction::FullJob { - permission_mode: FullJobPermissionMode::InheritOwner, - .. - } - )); - - let allowed_engine = setup_owner_gate_engine(db.clone(), owner_gate_trace(true)).await; - let second_run_id = allowed_engine - .fire_manual(legacy_routine.id, None) + .expect("select updated action_config"); + let row = rows + .next() .await - .expect("fire manual updated routine"); - let second_run = wait_for_run_completion(&db, legacy_routine.id, second_run_id).await; + .expect("next row") + .expect("updated routine row"); + let action_config_raw: String = row.get(0).expect("action_config text"); + let action_config: serde_json::Value = + serde_json::from_str(&action_config_raw).expect("parse updated action_config"); - assert_eq!(second_run.status, RunStatus::Ok); - assert_eq!(owner_gate_count(&db).await, 1); + assert_eq!( + action_config, + serde_json::json!({ + "title": "legacy-full-job", + "description": "Updated legacy description", + "max_iterations": 3, + }) + ); } } diff --git a/tests/gateway_workflow_integration.rs b/tests/gateway_workflow_integration.rs index e6aeca9c7a..c955e5a1a5 100644 --- a/tests/gateway_workflow_integration.rs +++ b/tests/gateway_workflow_integration.rs @@ -15,7 +15,7 @@ mod tests { use chrono::Utc; use ironclaw::agent::routine::{ - FullJobPermissionMode, NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, + NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, }; use uuid::Uuid; @@ -266,7 +266,7 @@ mod tests { } #[tokio::test] - async fn routines_detail_exposes_full_job_permission_resolution() { + async fn routines_detail_omits_legacy_full_job_permission_surface() { let mock = MockOpenAiServerBuilder::new() .with_default_response(MockOpenAiResponse::Text("ack".to_string())) .start() @@ -276,25 +276,6 @@ mod tests { GatewayWorkflowHarness::start_openai_compatible(&mock.openai_base_url(), "mock-model") .await; - harness - .db - .set_setting( - &harness.user_id, - ironclaw::agent::routine::FULL_JOB_OWNER_ALLOWED_TOOLS_SETTING_KEY, - &serde_json::json!(["shell", "http"]), - ) - .await - .expect("set owner allowlist"); - harness - .db - .set_setting( - &harness.user_id, - ironclaw::agent::routine::FULL_JOB_DEFAULT_PERMISSION_MODE_SETTING_KEY, - &serde_json::json!("copy_owner"), - ) - .await - .expect("set owner default mode"); - let routine = Routine { id: Uuid::new_v4(), name: "wf-full-job-permissions".to_string(), @@ -306,8 +287,6 @@ mod tests { title: "permission-detail".to_string(), description: "Check effective permission detail".to_string(), max_iterations: 3, - tool_permissions: vec!["message".to_string()], - permission_mode: FullJobPermissionMode::InheritOwner, }, guardrails: RoutineGuardrails { cooldown: Duration::from_secs(0), @@ -346,21 +325,14 @@ mod tests { .await .expect("invalid detail response"); - assert_eq!( - detail["full_job_permissions"]["permission_mode"].as_str(), - Some("inherit_owner") - ); - assert_eq!( - detail["full_job_permissions"]["default_permission_mode"].as_str(), - Some("copy_owner") - ); - assert_eq!( - detail["full_job_permissions"]["owner_allowed_tools"], - serde_json::json!(["shell", "http"]) + assert!( + detail.get("full_job_permissions").is_none(), + "detail response should not expose legacy permission fields: {detail}" ); + assert_eq!(detail["action"]["type"].as_str(), Some("full_job")); assert_eq!( - detail["full_job_permissions"]["effective_tool_permissions"], - serde_json::json!(["shell", "http", "message"]) + detail["action"]["description"].as_str(), + Some("Check effective permission detail") ); harness.shutdown().await; diff --git a/tests/support/test_rig.rs b/tests/support/test_rig.rs index d23bb672d0..55cba5d067 100644 --- a/tests/support/test_rig.rs +++ b/tests/support/test_rig.rs @@ -591,6 +591,7 @@ impl TestRigBuilder { Arc::clone(ws), notify_tx, None, + None, components.tools.clone(), components.safety.clone(), ironclaw::agent::SandboxReadiness::Available, // tests don't use real Docker