mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 16:49:34 +00:00
Use live owner tool scope for autonomous routines and jobs (#1453)
* Use live owner tool scope for autonomous runs * Address autonomous tool scope review feedback * Normalize routine context paths again
This commit is contained in:
@@ -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<ToolRegistry>,
|
||||
extension_manager: Option<&Arc<ExtensionManager>>,
|
||||
owner_id: &str,
|
||||
) -> HashSet<String> {
|
||||
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<ToolOutput, ToolError> {
|
||||
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<ToolRegistry>,
|
||||
tools_dir: &Path,
|
||||
owner_id: &str,
|
||||
) -> Arc<ExtensionManager> {
|
||||
let crypto = Arc::new(
|
||||
SecretsCrypto::new(SecretString::from(
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
))
|
||||
.expect("test crypto"),
|
||||
);
|
||||
let secrets: Arc<dyn SecretsStore + Send + Sync> =
|
||||
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"));
|
||||
}
|
||||
}
|
||||
+75
-355
@@ -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<String>,
|
||||
use_tools: bool,
|
||||
max_tool_rounds: u32,
|
||||
tool_permissions: Vec<String>,
|
||||
permission_mode: Option<RequestedFullJobPermissionMode>,
|
||||
}
|
||||
|
||||
#[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<Value> {
|
||||
"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<String> {
|
||||
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<Vec<String>> {
|
||||
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<String>) -> Result<NormalizedExecutionMode
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_requested_full_job_permission_mode(
|
||||
value: Option<String>,
|
||||
) -> Result<Option<RequestedFullJobPermissionMode>, 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<NormalizedExecutionRequest, ToolError> {
|
||||
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<NormalizedExecutionRequest,
|
||||
.unwrap_or(3)
|
||||
.clamp(1, crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT as u64)
|
||||
as u32;
|
||||
let tool_permissions = string_array_field(
|
||||
params,
|
||||
"execution",
|
||||
"tool_permissions",
|
||||
&["tool_permissions"],
|
||||
);
|
||||
let permission_mode = parse_requested_full_job_permission_mode(string_field(
|
||||
params,
|
||||
"execution",
|
||||
"permission_mode",
|
||||
&["permission_mode"],
|
||||
))?;
|
||||
|
||||
Ok(NormalizedExecutionRequest {
|
||||
mode,
|
||||
context_paths,
|
||||
use_tools,
|
||||
max_tool_rounds,
|
||||
tool_permissions,
|
||||
permission_mode,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1015,89 +918,24 @@ fn build_routine_trigger(trigger: &NormalizedTriggerRequest) -> Trigger {
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_routine_action(
|
||||
store: &dyn Database,
|
||||
user_id: &str,
|
||||
fn build_routine_action(
|
||||
name: &str,
|
||||
prompt: &str,
|
||||
execution: &NormalizedExecutionRequest,
|
||||
) -> Result<RoutineAction, ToolError> {
|
||||
) -> 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;
|
||||
#[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!["shell".to_string()],
|
||||
permission_mode: None,
|
||||
};
|
||||
|
||||
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::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");
|
||||
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");
|
||||
|
||||
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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+43
-8
@@ -83,7 +83,7 @@ const PROTECTED_TOOL_NAMES: &[&str] = &[
|
||||
/// Registry of available tools.
|
||||
pub struct ToolRegistry {
|
||||
tools: RwLock<HashMap<String, Arc<dyn Tool>>>,
|
||||
/// 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<std::collections::HashSet<String>>,
|
||||
/// Shared credential registry populated by WASM tools, consumed by HTTP tool.
|
||||
credential_registry: Option<Arc<SharedCredentialRegistry>>,
|
||||
@@ -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<dyn Tool>) {
|
||||
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<String> {
|
||||
self.builtin_names.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get tool definitions for LLM function calling.
|
||||
pub async fn tool_definitions(&self) -> Vec<ToolDefinition> {
|
||||
let mut defs: Vec<ToolDefinition> = 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<crate::tools::tool::ToolOutput, crate::tools::tool::ToolError> {
|
||||
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;
|
||||
|
||||
+18
-22
@@ -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<String>,
|
||||
},
|
||||
}
|
||||
|
||||
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<Item = String>) -> 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
|
||||
|
||||
Reference in New Issue
Block a user