Add owner-scoped permissions for full-job routines (#1440)

* docs: add comments explaining CLI_ENABLED=false in service templates (#990)

Clarify that CLI_ENABLED=false is needed in daemon mode (launchd/systemd)
to prevent blocking on stdin when running as a background service.

Closes #990

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* Add owner-scoped full-job routine permissions

* Address PR review feedback

* Fix owner gate test timing

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Henry Park
2026-03-19 18:32:47 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent c4ab382522
commit cac6f4013c
14 changed files with 1247 additions and 232 deletions
+3 -1
View File
@@ -15,7 +15,8 @@ mod tests {
use uuid::Uuid;
use ironclaw::agent::routine::{
Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
FullJobPermissionMode, Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus,
Trigger,
};
use ironclaw::context::{JobContext, JobState};
use ironclaw::db::Database;
@@ -46,6 +47,7 @@ mod tests {
description: "Test description".to_string(),
max_iterations: 5,
tool_permissions: vec![],
permission_mode: FullJobPermissionMode::Explicit,
},
guardrails: RoutineGuardrails {
cooldown: std::time::Duration::from_secs(0),
+5 -1
View File
@@ -10,7 +10,7 @@ mod support;
mod tests {
use std::time::Duration;
use ironclaw::agent::routine::{RoutineAction, Trigger};
use ironclaw::agent::routine::{FullJobPermissionMode, RoutineAction, Trigger};
use crate::support::test_rig::TestRigBuilder;
use crate::support::trace_llm::LlmTrace;
@@ -359,10 +359,12 @@ mod tests {
RoutineAction::FullJob {
description,
tool_permissions,
permission_mode,
..
} => {
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:?}"),
}
@@ -413,6 +415,7 @@ mod tests {
RoutineAction::FullJob {
description,
tool_permissions,
permission_mode,
..
} => {
assert!(description.contains("Prepare the morning digest"));
@@ -420,6 +423,7 @@ mod tests {
tool_permissions,
&vec!["message".to_string(), "http".to_string()]
);
assert_eq!(permission_mode, &FullJobPermissionMode::InheritOwner);
}
other => panic!("expected full_job action, got {other:?}"),
}
+371 -12
View File
@@ -12,35 +12,107 @@ mod tests {
use std::time::Duration;
use chrono::Utc;
use libsql::params;
use uuid::Uuid;
use ironclaw::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger,
FullJobPermissionMode, NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun,
RunStatus, Trigger,
};
use ironclaw::agent::routine_engine::RoutineEngine;
use ironclaw::agent::{HeartbeatConfig, HeartbeatRunner};
use ironclaw::agent::{HeartbeatConfig, HeartbeatRunner, Scheduler};
use ironclaw::channels::IncomingMessage;
use ironclaw::config::{RoutineConfig, SafetyConfig};
use ironclaw::db::Database;
use ironclaw::config::{AgentConfig, RoutineConfig, SafetyConfig};
use ironclaw::context::{ContextManager, JobContext};
use ironclaw::db::{Database, libsql::LibSqlBackend};
use ironclaw::hooks::HookRegistry;
use ironclaw::llm::LlmProvider;
use ironclaw::safety::SafetyLayer;
use ironclaw::tools::ToolRegistry;
use ironclaw::tools::builtin::routine::RoutineUpdateTool;
use ironclaw::tools::{ApprovalRequirement, Tool, ToolError, ToolOutput, ToolRegistry};
use ironclaw::workspace::Workspace;
use ironclaw::workspace::hygiene::HygieneConfig;
use crate::support::trace_llm::{LlmTrace, TraceLlm, TraceResponse, TraceStep};
use crate::support::trace_llm::{LlmTrace, TraceLlm, TraceResponse, TraceStep, TraceToolCall};
const OWNER_GATE_COUNT_SETTING_KEY: &str = "tests.owner_gate_count";
struct OwnerGateTool {
store: Arc<dyn Database>,
}
#[async_trait::async_trait]
impl Tool for OwnerGateTool {
fn name(&self) -> &str {
"owner_gate"
}
fn description(&self) -> &str {
"Test-only tool gated by owner full_job permissions"
}
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> {
let start = std::time::Instant::now();
let current = self
.store
.get_setting(&ctx.user_id, OWNER_GATE_COUNT_SETTING_KEY)
.await
.map_err(|e| {
ToolError::ExecutionFailed(format!("failed to read owner gate count: {e}"))
})?
.and_then(|value| value.as_i64())
.unwrap_or(0);
self.store
.set_setting(
&ctx.user_id,
OWNER_GATE_COUNT_SETTING_KEY,
&serde_json::json!(current + 1),
)
.await
.map_err(|e| {
ToolError::ExecutionFailed(format!("failed to persist owner gate count: {e}"))
})?;
Ok(ToolOutput::text("owner gate executed", start.elapsed()))
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::Always
}
fn requires_sanitization(&self) -> bool {
false
}
}
/// Create a temp libSQL database with migrations applied.
async fn create_test_db() -> (Arc<dyn Database>, tempfile::TempDir) {
use ironclaw::db::libsql::LibSqlBackend;
let (backend, temp_dir) = create_test_backend().await;
let db: Arc<dyn Database> = backend;
(db, temp_dir)
}
async fn create_test_backend() -> (Arc<LibSqlBackend>, tempfile::TempDir) {
let temp_dir = tempfile::tempdir().expect("tempdir");
let db_path = temp_dir.path().join("test.db");
let backend = LibSqlBackend::new_local(&db_path)
.await
.expect("LibSqlBackend");
let backend = Arc::new(
LibSqlBackend::new_local(&db_path)
.await
.expect("LibSqlBackend"),
);
backend.run_migrations().await.expect("migrations");
let db: Arc<dyn Database> = Arc::new(backend);
(db, temp_dir)
(backend, temp_dir)
}
/// Create a workspace backed by the test database.
@@ -93,6 +165,143 @@ mod tests {
}
}
fn make_full_job_routine(
name: &str,
permission_mode: FullJobPermissionMode,
tool_permissions: Vec<String>,
) -> Routine {
Routine {
id: Uuid::new_v4(),
name: name.to_string(),
description: format!("Full-job test routine: {name}"),
user_id: "default".to_string(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::FullJob {
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),
max_concurrent: 1,
dedup_window: None,
},
notify: NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
fn owner_gate_trace(include_completion: bool) -> LlmTrace {
let mut steps = 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![],
}];
if include_completion {
// The worker first calls `select_tools()`, then falls back to
// `respond_with_tools()` when no tool calls are returned. Both
// methods consume a trace step, so the successful completion path
// needs two text responses after the tool call.
for _ in 0..2 {
steps.push(TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "I have completed the task.".to_string(),
input_tokens: 20,
output_tokens: 5,
},
expected_tool_results: vec![],
});
}
}
LlmTrace::single_turn("test-owner-gate", "run owner gate", steps)
}
async fn setup_owner_gate_engine(db: Arc<dyn Database>, trace: LlmTrace) -> Arc<RoutineEngine> {
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;
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
}));
let llm: Arc<dyn LlmProvider> = Arc::new(TraceLlm::from_trace(trace));
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()),
));
Arc::new(RoutineEngine::new(
RoutineConfig::default(),
db,
llm,
ws,
notify_tx,
Some(scheduler),
registry,
safety,
))
}
async fn owner_gate_count(db: &Arc<dyn Database>) -> i64 {
db.get_setting("default", OWNER_GATE_COUNT_SETTING_KEY)
.await
.expect("get owner gate count")
.and_then(|value| value.as_i64())
.unwrap_or(0)
}
async fn wait_for_run_completion(
db: &Arc<dyn Database>,
routine_id: Uuid,
run_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.id == run_id)
&& run.status != RunStatus::Running
{
return run;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for routine run {run_id} to complete"
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
// -----------------------------------------------------------------------
// Test 1: cron_routine_fires
// -----------------------------------------------------------------------
@@ -884,6 +1093,7 @@ mod tests {
description: "d".to_string(),
max_iterations: 3,
tool_permissions: vec![],
permission_mode: ironclaw::agent::routine::FullJobPermissionMode::Explicit,
},
guardrails: RoutineGuardrails {
cooldown: Duration::from_secs(0),
@@ -1029,4 +1239,153 @@ mod tests {
"cron routine should fire after global slot is released"
);
}
// -----------------------------------------------------------------------
// Test: inherit_owner full_job routines can use owner-gated tools
// -----------------------------------------------------------------------
#[tokio::test]
async fn full_job_inherit_owner_uses_owner_allowlist() {
let (backend, _tmp) = create_test_backend().await;
let db: Arc<dyn Database> = 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"]),
)
.await
.expect("set owner allowlist");
let routine = make_full_job_routine(
"inherit-owner-allowed",
FullJobPermissionMode::InheritOwner,
vec![],
);
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;
assert_eq!(run.status, RunStatus::Ok);
assert_eq!(owner_gate_count(&db).await, 1);
}
// -----------------------------------------------------------------------
// Test: inherit_owner full_job routines stay blocked without owner allowlist
// -----------------------------------------------------------------------
#[tokio::test]
async fn full_job_inherit_owner_blocks_without_owner_allowlist() {
let (backend, _tmp) = create_test_backend().await;
let db: Arc<dyn Database> = backend;
let engine = setup_owner_gate_engine(db.clone(), owner_gate_trace(false)).await;
let routine = make_full_job_routine(
"inherit-owner-blocked",
FullJobPermissionMode::InheritOwner,
vec![],
);
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;
assert_eq!(run.status, RunStatus::Failed);
assert_eq!(owner_gate_count(&db).await, 0);
}
// -----------------------------------------------------------------------
// Test: legacy full_job routines remain explicit until updated
// -----------------------------------------------------------------------
#[tokio::test]
async fn legacy_full_job_stays_explicit_until_updated() {
let (backend, _tmp) = create_test_backend().await;
let db: Arc<dyn Database> = 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![]);
db.create_routine(&legacy_routine)
.await
.expect("create_routine");
let conn = backend.connect().await.expect("connect");
conn.execute(
"UPDATE routines SET action_config = ?1 WHERE id = ?2",
params![
serde_json::json!({
"title": legacy_routine.name,
"description": "Use the owner-gated tool when permitted.",
"max_iterations": 3,
"tool_permissions": [],
})
.to_string(),
legacy_routine.id.to_string(),
],
)
.await
.expect("strip permission_mode from 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)
.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);
let update_tool = RoutineUpdateTool::new(db.clone(), blocked_engine.clone());
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",
}),
&update_ctx,
)
.await
.expect("routine_update should succeed");
let updated = db
.get_routine(legacy_routine.id)
.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)
.await
.expect("fire manual updated routine");
let second_run = wait_for_run_completion(&db, legacy_routine.id, second_run_id).await;
assert_eq!(second_run.status, RunStatus::Ok);
assert_eq!(owner_gate_count(&db).await, 1);
}
}
+106
View File
@@ -13,6 +13,10 @@ mod support;
mod tests {
use std::time::Duration;
use chrono::Utc;
use ironclaw::agent::routine::{
FullJobPermissionMode, NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger,
};
use uuid::Uuid;
use crate::support::gateway_workflow_harness::GatewayWorkflowHarness;
@@ -260,4 +264,106 @@ mod tests {
harness.shutdown().await;
mock.shutdown().await;
}
#[tokio::test]
async fn routines_detail_exposes_full_job_permission_resolution() {
let mock = MockOpenAiServerBuilder::new()
.with_default_response(MockOpenAiResponse::Text("ack".to_string()))
.start()
.await;
let harness =
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(),
description: "Permission detail regression test".to_string(),
user_id: harness.user_id.clone(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::FullJob {
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),
max_concurrent: 1,
dedup_window: None,
},
notify: NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
};
harness
.db
.create_routine(&routine)
.await
.expect("create routine");
let detail = harness
.client
.get(format!(
"{}/api/routines/{}",
harness.base_url(),
routine.id
))
.bearer_auth(&harness.auth_token)
.send()
.await
.expect("detail request failed")
.error_for_status()
.expect("detail non-2xx")
.json::<serde_json::Value>()
.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_eq!(
detail["full_job_permissions"]["effective_tool_permissions"],
serde_json::json!(["shell", "http", "message"])
);
harness.shutdown().await;
mock.shutdown().await;
}
}