From 6a2491f9108406cd785e7efcf56bcb78de5b4100 Mon Sep 17 00:00:00 2001 From: adam Date: Fri, 20 Mar 2026 22:57:12 +1100 Subject: [PATCH 1/7] fix(up): count profile capacity by compatible runtime --- src/cli/up.rs | 123 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 118 insertions(+), 5 deletions(-) diff --git a/src/cli/up.rs b/src/cli/up.rs index 0403467..1271f8e 100644 --- a/src/cli/up.rs +++ b/src/cli/up.rs @@ -1321,7 +1321,7 @@ fn count_active_for_profile( { let project_root = config_path.parent().unwrap(); seen_roots.insert(project_root.to_path_buf()); - count += count_running_agents(&config); + count += count_running_agents_for_profile(&config, global, profile_name); } } @@ -1330,20 +1330,45 @@ fn count_active_for_profile( if !seen_roots.contains(&root_buf) && workspace_default_profile(config) == Some(profile_name) { - count += count_running_agents(config); + count += count_running_agents_for_profile(config, global, profile_name); } } count } -fn count_running_agents(config: &TuttiConfig) -> u32 { +fn agent_uses_profile( + config: &TuttiConfig, + global: &GlobalConfig, + agent: &crate::config::AgentConfig, + profile_name: &str, +) -> bool { + let Some(profile) = global.get_profile(profile_name) else { + return false; + }; + let Some(runtime_name) = agent.resolved_runtime(&config.defaults) else { + return false; + }; + runtime::compatible_command_override( + &runtime_name, + Some(profile.provider.as_str()), + Some(profile.command.as_str()), + ) + .is_some() +} + +fn count_running_agents_for_profile( + config: &TuttiConfig, + global: &GlobalConfig, + profile_name: &str, +) -> u32 { config .agents .iter() .filter(|agent| { let session = TmuxSession::session_name(&config.workspace.name, &agent.name); TmuxSession::session_exists(&session) + && agent_uses_profile(config, global, agent, profile_name) }) .count() as u32 } @@ -1454,7 +1479,7 @@ fn run_all( if let Ok((config, _)) = TuttiConfig::load(&ws.path) && let Some(profile_name) = workspace_default_profile(&config) { - let running = count_running_agents(&config); + let running = count_running_agents_for_profile(&config, &global, profile_name); *active_by_profile .entry(profile_name.to_string()) .or_insert(0) += running; @@ -1693,7 +1718,9 @@ fn run_all( ); println!(" launched {}", agent.name); - if let Some(limit) = &profile_limit { + if let Some(limit) = &profile_limit + && agent_uses_profile(&config, &global, agent, &limit.profile_name) + { *active_by_profile .entry(limit.profile_name.clone()) .or_insert(0) += 1; @@ -1733,6 +1760,92 @@ mod tests { } } + #[test] + fn agent_uses_profile_only_for_compatible_runtime() { + let config = TuttiConfig { + workspace: WorkspaceConfig { + name: "test".to_string(), + description: None, + env: None, + auth: Some(WorkspaceAuth { + default_profile: Some("claude-max".to_string()), + }), + }, + defaults: DefaultsConfig { + worktree: true, + runtime: Some("claude-code".to_string()), + }, + launch: None, + agents: vec![ + AgentConfig { + name: "claude-agent".to_string(), + runtime: Some("claude-code".to_string()), + scope: None, + prompt: None, + depends_on: vec![], + worktree: None, + fresh_worktree: None, + branch: None, + persistent: false, + memory: None, + env: HashMap::new(), + }, + AgentConfig { + name: "codex-agent".to_string(), + runtime: Some("codex".to_string()), + scope: None, + prompt: None, + depends_on: vec![], + worktree: None, + fresh_worktree: None, + branch: None, + persistent: false, + memory: None, + env: HashMap::new(), + }, + ], + tool_packs: vec![], + workflows: vec![], + hooks: vec![], + handoff: None, + observe: None, + budget: None, + }; + let global = GlobalConfig { + user: None, + profiles: vec![ProfileConfig { + name: "claude-max".to_string(), + provider: "anthropic".to_string(), + command: "claude".to_string(), + max_concurrent: Some(5), + monthly_budget: None, + priority: None, + plan: None, + reset_day: None, + weekly_hours: None, + }], + registered_workspaces: vec![], + dashboard: None, + resilience: None, + permissions: None, + serve: None, + remotes: vec![], + }; + + assert!(agent_uses_profile( + &config, + &global, + &config.agents[0], + "claude-max" + )); + assert!(!agent_uses_profile( + &config, + &global, + &config.agents[1], + "claude-max" + )); + } + #[test] fn topo_sort_linear_chain() { let agents = vec![ From 236cd2797fd75360db9b5f42d65d73a7ba7ac7df Mon Sep 17 00:00:00 2001 From: adam Date: Fri, 20 Mar 2026 23:08:24 +1100 Subject: [PATCH 2/7] test: serialize HOME-sensitive serve token check --- src/cli/serve.rs | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/cli/serve.rs b/src/cli/serve.rs index 20bf937..059b173 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -1376,6 +1376,24 @@ fn _assert_path(_: &Path) {} #[cfg(test)] mod tests { use super::*; + use serial_test::serial; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct HomeGuard(Option); + + impl Drop for HomeGuard { + fn drop(&mut self) { + if let Some(value) = self.0.take() { + unsafe { + std::env::set_var("HOME", value); + } + } else { + unsafe { + std::env::remove_var("HOME"); + } + } + } + } #[test] fn strategy_requests_rotation_matches_supported_values() { @@ -1487,13 +1505,19 @@ mod tests { } #[test] + #[serial] fn token_generation_produces_valid_hex_and_reloads() { - let temp = std::env::temp_dir().join(format!("tutti-test-token-{}", std::process::id())); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default(); + let temp = std::env::temp_dir() + .join(format!("tutti-test-token-{}-{nanos}", std::process::id())); let _ = std::fs::remove_dir_all(&temp); std::fs::create_dir_all(temp.join(".config").join("tutti")).unwrap(); - // Temporarily override HOME so token writes to temp - let original_home = std::env::var("HOME").ok(); + // Temporarily override HOME so token writes to temp. + let _home_guard = HomeGuard(std::env::var("HOME").ok()); unsafe { std::env::set_var("HOME", &temp) }; let token1 = load_or_generate_serve_token().expect("first generation should succeed"); @@ -1507,10 +1531,6 @@ mod tests { let token2 = load_or_generate_serve_token().expect("reload should succeed"); assert_eq!(token1, token2, "reloaded token should match original"); - // Restore HOME - if let Some(h) = original_home { - unsafe { std::env::set_var("HOME", h) }; - } let _ = std::fs::remove_dir_all(&temp); } From ae93ab121f018ee8f8a2e486ea65bb30d1278f56 Mon Sep 17 00:00:00 2001 From: adam Date: Fri, 20 Mar 2026 23:17:08 +1100 Subject: [PATCH 3/7] [auto] #86 feat: generic webhook endpoint with trigger matching Add WebhookConfig struct to TuttiConfig with [[webhook]] TOML array parsing (source, events, workflow, agent, prompt fields). Add POST /v1/webhooks/generic endpoint in serve.rs that accepts JSON payloads, enforces a 1MB size limit, matches against configured triggers by source and event type, and dispatches via the existing workflow run or agent send infrastructure. Includes unit tests for config deserialization. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/automation/mod.rs | 2 + src/budget/mod.rs | 1 + src/cli/doctor.rs | 1 + src/cli/handoff.rs | 1 + src/cli/serve.rs | 127 +++++++++++++++++++++++++++++++++++++++++- src/cli/up.rs | 10 ++++ src/cli/watch.rs | 1 + src/config/mod.rs | 64 +++++++++++++++++++++ 8 files changed, 205 insertions(+), 2 deletions(-) diff --git a/src/automation/mod.rs b/src/automation/mod.rs index 7cf287e..35e045b 100644 --- a/src/automation/mod.rs +++ b/src/automation/mod.rs @@ -3907,6 +3907,7 @@ mod tests { handoff: None, observe: None, budget: None, + webhooks: vec![], } } @@ -5186,6 +5187,7 @@ mod tests { handoff: None, observe: None, budget: None, + webhooks: vec![], }; let dir = std::env::temp_dir().join("tutti-test-resolver-control-steps"); diff --git a/src/budget/mod.rs b/src/budget/mod.rs index 2ce35ed..4899303 100644 --- a/src/budget/mod.rs +++ b/src/budget/mod.rs @@ -270,6 +270,7 @@ mod tests { workspace_weekly_tokens: Some(200), agent_weekly_tokens: caps, }), + webhooks: vec![], } } diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index 9ba6881..233212d 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -599,6 +599,7 @@ mod tests { handoff: None, observe: None, budget: None, + webhooks: vec![], } } diff --git a/src/cli/handoff.rs b/src/cli/handoff.rs index 97a7d92..19a110b 100644 --- a/src/cli/handoff.rs +++ b/src/cli/handoff.rs @@ -585,6 +585,7 @@ mod tests { }), observe: None, budget: None, + webhooks: vec![], } } diff --git a/src/cli/serve.rs b/src/cli/serve.rs index 059b173..5e84f19 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -447,6 +447,14 @@ fn route_request(request: &mut Request, targets: &[WorkspaceTarget]) -> (StatusC api_err("action", "action_failed", e.to_string()).to_string(), ), } + } else if method == Method::Post && path == "/v1/webhooks/generic" { + match route_webhook(request, targets) { + Ok(value) => (StatusCode(200), value.to_string()), + Err(e) => ( + StatusCode(400), + api_err("webhook", "webhook_failed", e.to_string()).to_string(), + ), + } } else { ( StatusCode(404), @@ -694,6 +702,121 @@ fn execute_action(action: &str, body: &Value, target: &WorkspaceTarget) -> Resul } } +/// Maximum webhook payload size (1 MB) +const WEBHOOK_MAX_BODY_BYTES: usize = 1_048_576; + +/// Handle POST /v1/webhooks/generic +fn route_webhook(request: &mut Request, targets: &[WorkspaceTarget]) -> Result { + // Payload size guard + let content_length = request + .headers() + .iter() + .find(|h| h.field.equiv("Content-Length")) + .and_then(|h| h.value.as_str().parse::().ok()) + .unwrap_or(0); + if content_length > WEBHOOK_MAX_BODY_BYTES { + return Err(TuttiError::ConfigValidation(format!( + "webhook payload too large ({content_length} bytes, max {WEBHOOK_MAX_BODY_BYTES})" + ))); + } + + let body = read_json_body(request)?; + + let source = body + .get("source") + .and_then(Value::as_str) + .unwrap_or("generic"); + let event_type = body + .get("event") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let workspace_hint = body.get("workspace").and_then(Value::as_str); + + let target = resolve_action_workspace(targets, workspace_hint)?; + let webhooks = &target.config.webhooks; + + // Find matching webhook triggers + let matched: Vec<_> = webhooks + .iter() + .filter(|wh| { + // Match source + if wh.source != source && wh.source != "*" { + return false; + } + // Match event type + if wh.events.is_empty() { + return true; // no events filter means match all + } + wh.events.iter().any(|e| e == "*" || e == event_type) + }) + .collect(); + + if matched.is_empty() { + return Ok(api_ok( + "webhook.received", + json!({ + "source": source, + "event": event_type, + "matched": false, + "triggers_fired": 0 + }), + )); + } + + let mut dispatched = Vec::new(); + for wh in &matched { + if let Some(workflow) = &wh.workflow { + with_project_root(&target.project_root, || { + super::run::run( + Some(workflow), + None, + false, + wh.agent.as_deref(), + false, + false, + false, + ) + })?; + dispatched.push(json!({ + "type": "workflow", + "workflow": workflow, + "agent": wh.agent, + })); + } else if let Some(agent) = &wh.agent { + let prompt = wh + .prompt + .as_deref() + .unwrap_or("Webhook event received — check recent events for details."); + let options = super::send::SendOptions { + auto_up: false, + wait: false, + timeout_secs: 900, + idle_stable_secs: 5, + output: false, + output_lines: 200, + }; + with_project_root(&target.project_root, || { + super::send::run(agent, &[prompt.to_string()], options).map(|_| ()) + })?; + dispatched.push(json!({ + "type": "send", + "agent": agent, + })); + } + } + + Ok(api_ok( + "webhook.received", + json!({ + "source": source, + "event": event_type, + "matched": true, + "triggers_fired": dispatched.len(), + "dispatched": dispatched + }), + )) +} + /// Resolve the target workspace for an action, defaulting to the only one if singular fn resolve_action_workspace<'a>( targets: &'a [WorkspaceTarget], @@ -1511,8 +1634,8 @@ mod tests { .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or_default(); - let temp = std::env::temp_dir() - .join(format!("tutti-test-token-{}-{nanos}", std::process::id())); + let temp = + std::env::temp_dir().join(format!("tutti-test-token-{}-{nanos}", std::process::id())); let _ = std::fs::remove_dir_all(&temp); std::fs::create_dir_all(temp.join(".config").join("tutti")).unwrap(); diff --git a/src/cli/up.rs b/src/cli/up.rs index 1271f8e..41caef0 100644 --- a/src/cli/up.rs +++ b/src/cli/up.rs @@ -1810,6 +1810,7 @@ mod tests { handoff: None, observe: None, budget: None, + webhooks: vec![], }; let global = GlobalConfig { user: None, @@ -1927,6 +1928,7 @@ mod tests { handoff: None, observe: None, budget: None, + webhooks: vec![], }; let env = build_workspace_env(&config); assert_eq!(env.get("GIT_AUTHOR_NAME").unwrap(), "Test User"); @@ -1961,6 +1963,7 @@ mod tests { handoff: None, observe: None, budget: None, + webhooks: vec![], }; let mut env = build_workspace_env(&config); // Simulate agent-level override @@ -1996,6 +1999,7 @@ mod tests { handoff: None, observe: None, budget: None, + webhooks: vec![], }; let global = GlobalConfig { user: None, @@ -2046,6 +2050,7 @@ mod tests { handoff: None, observe: None, budget: None, + webhooks: vec![], }; let global = GlobalConfig { user: None, @@ -2108,6 +2113,7 @@ mod tests { handoff: None, observe: None, budget: None, + webhooks: vec![], }; let global = GlobalConfig { user: None, @@ -2159,6 +2165,7 @@ mod tests { handoff: None, observe: None, budget: None, + webhooks: vec![], }; let global = GlobalConfig { user: None, @@ -2219,6 +2226,7 @@ mod tests { handoff: None, observe: None, budget: None, + webhooks: vec![], }; let global = GlobalConfig { user: None, @@ -2304,6 +2312,7 @@ mod tests { handoff: None, observe: None, budget: None, + webhooks: vec![], }; let resolved = resolve_launch_settings( @@ -2336,6 +2345,7 @@ mod tests { handoff: None, observe: None, budget: None, + webhooks: vec![], }; let launch_settings = LaunchSettings { mode: LaunchMode::Auto, diff --git a/src/cli/watch.rs b/src/cli/watch.rs index 38acd2e..9e57663 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -835,6 +835,7 @@ mod tests { handoff: None, observe: None, budget: None, + webhooks: vec![], } } diff --git a/src/config/mod.rs b/src/config/mod.rs index d9366b6..9230359 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -28,6 +28,8 @@ pub struct TuttiConfig { pub observe: Option, #[serde(default)] pub budget: Option, + #[serde(default, rename = "webhook")] + pub webhooks: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -310,6 +312,24 @@ pub struct BudgetConfig { pub agent_weekly_tokens: HashMap, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookConfig { + /// Source identifier (e.g. "github", "slack", "generic") + pub source: String, + /// Event types to match (e.g. ["issues.labeled", "push"]). Use ["*"] for all. + #[serde(default)] + pub events: Vec, + /// Workflow name to trigger on match + #[serde(default)] + pub workflow: Option, + /// Agent to send a prompt to on match (alternative to workflow) + #[serde(default)] + pub agent: Option, + /// Prompt to send when using agent dispatch + #[serde(default)] + pub prompt: Option, +} + // ── Global config (~/.config/tutti/config.toml) ── #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -2180,4 +2200,48 @@ workflow_source = "run" PathBuf::from("/tmp/test2") ); } + + #[test] + fn webhook_config_serde_round_trip() { + let toml_str = r#" +[workspace] +name = "test" + +[[webhook]] +source = "github" +events = ["issues.labeled", "push"] +workflow = "sdlc-auto" + +[[webhook]] +source = "generic" +events = ["*"] +agent = "implementer" +prompt = "Handle incoming event" +"#; + let config: TuttiConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.webhooks.len(), 2); + + let wh0 = &config.webhooks[0]; + assert_eq!(wh0.source, "github"); + assert_eq!(wh0.events, vec!["issues.labeled", "push"]); + assert_eq!(wh0.workflow.as_deref(), Some("sdlc-auto")); + assert!(wh0.agent.is_none()); + + let wh1 = &config.webhooks[1]; + assert_eq!(wh1.source, "generic"); + assert_eq!(wh1.events, vec!["*"]); + assert!(wh1.workflow.is_none()); + assert_eq!(wh1.agent.as_deref(), Some("implementer")); + assert_eq!(wh1.prompt.as_deref(), Some("Handle incoming event")); + } + + #[test] + fn webhook_config_empty_is_default() { + let toml_str = r#" +[workspace] +name = "test" +"#; + let config: TuttiConfig = toml::from_str(toml_str).unwrap(); + assert!(config.webhooks.is_empty()); + } } From e0f40cef3047794f14119522f94e551948d7a3ea Mon Sep 17 00:00:00 2001 From: adam Date: Fri, 20 Mar 2026 23:40:54 +1100 Subject: [PATCH 4/7] fix: address CodeRabbit review comments on PR #97 - Enforce webhook body size cap while reading via take() instead of only checking Content-Length header (serve.rs) - Add webhook deduplication using X-GitHub-Delivery header, Idempotency-Key, or payload hash to prevent duplicate dispatches from retries (serve.rs) - Serialize webhook dispatches with a mutex to prevent concurrent cwd races from with_project_root (serve.rs) - Only enforce profile max_concurrent limit against agents that actually use the profile's runtime (up.rs) - Add webhook config validation for workflow and agent references (config/mod.rs) - Extract webhook matching, template expansion, and event logging into src/webhook.rs module Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/serve.rs | 125 ++++++++++++++++++++--------- src/cli/up.rs | 18 ++++- src/config/mod.rs | 37 +++++++++ src/main.rs | 1 + src/webhook.rs | 200 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 341 insertions(+), 40 deletions(-) create mode 100644 src/webhook.rs diff --git a/src/cli/serve.rs b/src/cli/serve.rs index 5e84f19..a8b47ce 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -6,14 +6,15 @@ use crate::scheduler; use crate::session::TmuxSession; use crate::state; use crate::state::ControlEvent; +use crate::webhook; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::collections::{HashMap, HashSet}; use std::io::{self, Read}; use std::path::{Path, PathBuf}; -use std::sync::Arc; use std::sync::mpsc; +use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; @@ -705,21 +706,14 @@ fn execute_action(action: &str, body: &Value, target: &WorkspaceTarget) -> Resul /// Maximum webhook payload size (1 MB) const WEBHOOK_MAX_BODY_BYTES: usize = 1_048_576; +/// Mutex to serialize webhook dispatches so that `with_project_root` cwd changes +/// do not race across concurrent request threads. +static WEBHOOK_DISPATCH_LOCK: std::sync::LazyLock> = + std::sync::LazyLock::new(|| Mutex::new(())); + /// Handle POST /v1/webhooks/generic fn route_webhook(request: &mut Request, targets: &[WorkspaceTarget]) -> Result { - // Payload size guard - let content_length = request - .headers() - .iter() - .find(|h| h.field.equiv("Content-Length")) - .and_then(|h| h.value.as_str().parse::().ok()) - .unwrap_or(0); - if content_length > WEBHOOK_MAX_BODY_BYTES { - return Err(TuttiError::ConfigValidation(format!( - "webhook payload too large ({content_length} bytes, max {WEBHOOK_MAX_BODY_BYTES})" - ))); - } - + // Body size is enforced inside read_json_body via take() let body = read_json_body(request)?; let source = body @@ -730,29 +724,24 @@ fn route_webhook(request: &mut Request, targets: &[WorkspaceTarget]) -> Result = webhooks - .iter() - .filter(|wh| { - // Match source - if wh.source != source && wh.source != "*" { - return false; - } - // Match event type - if wh.events.is_empty() { - return true; // no events filter means match all - } - wh.events.iter().any(|e| e == "*" || e == event_type) - }) - .collect(); + // Deduplication: derive an idempotency key from the delivery ID header, + // Idempotency-Key header, or a hash of the payload to prevent duplicate + // workflow runs from GitHub retries or concurrent identical deliveries. + let dedup_key = webhook_dedup_key(request, &body); + if let Some(cached) = idempotency_lookup(target, &dedup_key)? { + return Ok(cached.response); + } + + let matched = webhook::match_triggers(&target.config.webhooks, source, event_type); if matched.is_empty() { - return Ok(api_ok( + webhook::log_event(&target.project_root, source, event_type, None, "no_match"); + let response = api_ok( "webhook.received", json!({ "source": source, @@ -760,9 +749,17 @@ fn route_webhook(request: &mut Request, targets: &[WorkspaceTarget]) -> Result Result Result Result Result String { + // Check X-GitHub-Delivery header (unique per GitHub webhook delivery) + for header in request.headers() { + if header.field.equiv("X-GitHub-Delivery") { + let val = header.value.as_str().trim(); + if !val.is_empty() { + return format!("webhook:{val}"); + } + } + } + // Check Idempotency-Key header or body field + if let Some(key) = read_idempotency_key(request, body) { + return format!("webhook:{key}"); + } + // Fall back to payload content hash + use std::hash::{DefaultHasher, Hash, Hasher}; + let serialized = body.to_string(); + let mut hasher = DefaultHasher::new(); + serialized.hash(&mut hasher); + format!("webhook:hash:{:x}", hasher.finish()) } /// Resolve the target workspace for an action, defaulting to the only one if singular @@ -837,10 +876,20 @@ fn resolve_action_workspace<'a>( )) } -/// Read and parse the request body as JSON, defaulting to an empty object +/// Read and parse the request body as JSON, defaulting to an empty object. +/// Enforces the body size cap while reading via `take()` to prevent memory exhaustion +/// from oversized payloads (a malicious client could send more bytes than Content-Length). fn read_json_body(request: &mut Request) -> Result { let mut body = String::new(); - request.as_reader().read_to_string(&mut body)?; + request + .as_reader() + .take((WEBHOOK_MAX_BODY_BYTES + 1) as u64) + .read_to_string(&mut body)?; + if body.len() > WEBHOOK_MAX_BODY_BYTES { + return Err(TuttiError::ConfigValidation(format!( + "webhook payload too large (>{WEBHOOK_MAX_BODY_BYTES} bytes)" + ))); + } if body.trim().is_empty() { return Ok(json!({})); } diff --git a/src/cli/up.rs b/src/cli/up.rs index 41caef0..8906a83 100644 --- a/src/cli/up.rs +++ b/src/cli/up.rs @@ -153,6 +153,9 @@ pub fn run( if let Some(limit) = &profile_limit && active_for_profile >= limit.max_concurrent + && global + .as_ref() + .is_some_and(|g| agent_uses_profile(&config, g, agent, &limit.profile_name)) { refused_by_limit = true; eprintln!( @@ -330,7 +333,16 @@ pub fn run( ); launched.push((agent.name.clone(), session, runtime_name)); - if profile_limit.is_some() { + if profile_limit.is_some() + && global.as_ref().is_some_and(|g| { + agent_uses_profile( + &config, + g, + agent, + &profile_limit.as_ref().unwrap().profile_name, + ) + }) + { active_for_profile += 1; } } @@ -1540,7 +1552,9 @@ fn run_all( if let Some(limit) = &profile_limit { let active = *active_by_profile.get(&limit.profile_name).unwrap_or(&0); - if active >= limit.max_concurrent { + if active >= limit.max_concurrent + && agent_uses_profile(&config, &global, agent, &limit.profile_name) + { eprintln!( " Skipping {} (profile '{}' at max_concurrent {}/{})", agent.name, limit.profile_name, active, limit.max_concurrent diff --git a/src/config/mod.rs b/src/config/mod.rs index 9230359..8776873 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -663,10 +663,47 @@ impl TuttiConfig { self.validate_automation()?; self.validate_tool_packs()?; self.validate_budget()?; + self.validate_webhooks()?; Ok(()) } + fn validate_webhooks(&self) -> Result<()> { + let agent_names: std::collections::HashSet<&str> = + self.agents.iter().map(|a| a.name.as_str()).collect(); + let workflow_names: std::collections::HashSet<&str> = + self.workflows.iter().map(|w| w.name.as_str()).collect(); + + for (i, wh) in self.webhooks.iter().enumerate() { + if wh.source.trim().is_empty() { + return Err(TuttiError::ConfigValidation(format!( + "webhook[{i}] source cannot be empty" + ))); + } + if wh.workflow.is_none() && wh.agent.is_none() { + return Err(TuttiError::ConfigValidation(format!( + "webhook[{i}] (source '{}') must specify either 'workflow' or 'agent'", + wh.source + ))); + } + if let Some(ref workflow) = wh.workflow + && !workflow_names.contains(workflow.as_str()) + { + return Err(TuttiError::ConfigValidation(format!( + "webhook[{i}] references unknown workflow '{workflow}'" + ))); + } + if let Some(ref agent) = wh.agent + && !agent_names.contains(agent.as_str()) + { + return Err(TuttiError::ConfigValidation(format!( + "webhook[{i}] references unknown agent '{agent}'" + ))); + } + } + Ok(()) + } + fn validate_automation(&self) -> Result<()> { let agent_names: std::collections::HashSet<&str> = self.agents.iter().map(|a| a.name.as_str()).collect(); diff --git a/src/main.rs b/src/main.rs index d0fc661..5c66be1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod scheduler; mod session; mod state; mod usage; +mod webhook; mod worktree; use clap::Parser; diff --git a/src/webhook.rs b/src/webhook.rs new file mode 100644 index 0000000..79f5388 --- /dev/null +++ b/src/webhook.rs @@ -0,0 +1,200 @@ +use crate::config::WebhookConfig; +use serde_json::Value; +use std::path::Path; + +/// Find all webhook configs that match a given source and event type. +pub fn match_triggers<'a>( + webhooks: &'a [WebhookConfig], + source: &str, + event_type: &str, +) -> Vec<&'a WebhookConfig> { + webhooks + .iter() + .filter(|wh| { + if wh.source != source && wh.source != "*" { + return false; + } + if wh.events.is_empty() { + return true; + } + wh.events.iter().any(|e| e == "*" || e == event_type) + }) + .collect() +} + +/// Expand `{{event.field}}` and `{{event.nested.field}}` placeholders in a +/// template string using values from the JSON payload. +pub fn expand_template(template: &str, payload: &Value) -> String { + let mut result = String::with_capacity(template.len()); + let mut rest = template; + while let Some(start) = rest.find("{{event.") { + result.push_str(&rest[..start]); + let after_open = &rest[start + 2..]; // skip "{{" + if let Some(end) = after_open.find("}}") { + let key = &after_open[..end]; // e.g. "event.issue.number" + let path = key.strip_prefix("event.").unwrap_or(key); + let value = resolve_json_path(payload, path); + result.push_str(&value); + rest = &after_open[end + 2..]; + } else { + result.push_str(&rest[start..]); + rest = ""; + } + } + result.push_str(rest); + result +} + +/// Walk a dotted path into a JSON value, returning the leaf as a string. +fn resolve_json_path(value: &Value, path: &str) -> String { + let mut current = value; + for segment in path.split('.') { + match current.get(segment) { + Some(v) => current = v, + None => return String::new(), + } + } + match current { + Value::String(s) => s.clone(), + Value::Null => String::new(), + other => other.to_string(), + } +} + +/// Append a webhook event record to the JSONL log file. +pub fn log_event( + project_root: &Path, + source: &str, + event_type: &str, + matched_rule: Option<&str>, + outcome: &str, +) { + use std::io::Write; + let log_path = project_root + .join(".tutti") + .join("state") + .join("webhook-events.jsonl"); + let record = serde_json::json!({ + "timestamp": chrono::Utc::now().to_rfc3339(), + "source": source, + "event": event_type, + "matched_rule": matched_rule, + "outcome": outcome, + }); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + { + let _ = writeln!(f, "{}", record); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::WebhookConfig; + use serde_json::json; + + fn wh(source: &str, events: &[&str], workflow: Option<&str>) -> WebhookConfig { + WebhookConfig { + source: source.to_string(), + events: events.iter().map(|s| s.to_string()).collect(), + workflow: workflow.map(|s| s.to_string()), + agent: None, + prompt: None, + } + } + + #[test] + fn match_by_source_and_event() { + let hooks = vec![ + wh("github", &["issues.labeled", "push"], Some("sdlc")), + wh("slack", &["message"], Some("deploy")), + wh("generic", &["*"], Some("catch-all")), + ]; + let matched = match_triggers(&hooks, "github", "push"); + assert_eq!(matched.len(), 1); + assert_eq!(matched[0].workflow.as_deref(), Some("sdlc")); + } + + #[test] + fn wildcard_source_matches_all() { + let hooks = vec![wh("*", &["push"], Some("any-push"))]; + let matched = match_triggers(&hooks, "github", "push"); + assert_eq!(matched.len(), 1); + } + + #[test] + fn wildcard_event_matches_all() { + let hooks = vec![wh("generic", &["*"], Some("catch-all"))]; + let matched = match_triggers(&hooks, "generic", "anything"); + assert_eq!(matched.len(), 1); + } + + #[test] + fn empty_events_matches_all() { + let hooks = vec![wh("generic", &[], Some("no-filter"))]; + let matched = match_triggers(&hooks, "generic", "whatever"); + assert_eq!(matched.len(), 1); + } + + #[test] + fn no_match_returns_empty() { + let hooks = vec![wh("github", &["push"], Some("ci"))]; + let matched = match_triggers(&hooks, "slack", "message"); + assert!(matched.is_empty()); + } + + #[test] + fn expand_template_simple() { + let payload = json!({"action": "labeled", "number": 42}); + assert_eq!( + expand_template("Issue #{{event.number}} was {{event.action}}", &payload), + "Issue #42 was labeled" + ); + } + + #[test] + fn expand_template_nested() { + let payload = json!({"issue": {"number": 7, "title": "Fix bug"}}); + assert_eq!( + expand_template( + "Fix {{event.issue.title}} (#{{event.issue.number}})", + &payload + ), + "Fix Fix bug (#7)" + ); + } + + #[test] + fn expand_template_missing_key() { + let payload = json!({"action": "opened"}); + assert_eq!( + expand_template("{{event.missing}} happened", &payload), + " happened" + ); + } + + #[test] + fn expand_template_no_placeholders() { + let payload = json!({}); + assert_eq!(expand_template("plain text", &payload), "plain text"); + } + + #[test] + fn log_event_creates_file() { + let dir = std::env::temp_dir().join(format!("tutti-webhook-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join(".tutti").join("state")).unwrap(); + log_event(&dir, "generic", "test.event", Some("rule-1"), "dispatched"); + let log_path = dir + .join(".tutti") + .join("state") + .join("webhook-events.jsonl"); + let content = std::fs::read_to_string(&log_path).unwrap(); + assert!(content.contains("\"source\":\"generic\"")); + assert!(content.contains("\"outcome\":\"dispatched\"")); + let _ = std::fs::remove_dir_all(&dir); + } +} From e104293599d7bb287a4228e7fe086db08c2921c2 Mon Sep 17 00:00:00 2001 From: adam Date: Sat, 21 Mar 2026 00:02:49 +1100 Subject: [PATCH 5/7] fix: reject prompt on workflow webhooks in config validation Prompt is only consumed for direct agent dispatch; a workflow webhook with prompt silently drops it. Fail at config validation instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/config/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/config/mod.rs b/src/config/mod.rs index 8776873..2114ce6 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -680,6 +680,11 @@ impl TuttiConfig { "webhook[{i}] source cannot be empty" ))); } + if wh.workflow.is_some() && wh.prompt.as_deref().is_some_and(|p| !p.trim().is_empty()) { + return Err(TuttiError::ConfigValidation(format!( + "webhook[{i}] prompt is only supported for direct agent dispatch; remove 'prompt' or switch to agent-only dispatch" + ))); + } if wh.workflow.is_none() && wh.agent.is_none() { return Err(TuttiError::ConfigValidation(format!( "webhook[{i}] (source '{}') must specify either 'workflow' or 'agent'", From 40ec0f6910daa506f7ed4b5a87ac1eb9d799d673 Mon Sep 17 00:00:00 2001 From: adam Date: Sat, 21 Mar 2026 05:28:29 +1100 Subject: [PATCH 6/7] fix: enforce webhook workflow/agent mutual exclusivity, use stable hash - Reject webhooks that specify both workflow and agent in validation - Replace DefaultHasher with FNV-1a for deterministic dedup keys across server restarts Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/serve.rs | 12 +++++++----- src/config/mod.rs | 6 ++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/cli/serve.rs b/src/cli/serve.rs index 4a9c083..ae9779b 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -907,12 +907,14 @@ fn webhook_dedup_key(request: &Request, body: &Value) -> String { if let Some(key) = read_idempotency_key(request, body) { return format!("webhook:{key}"); } - // Fall back to payload content hash - use std::hash::{DefaultHasher, Hash, Hasher}; + // Fall back to a stable content hash (FNV-1a, deterministic across restarts) let serialized = body.to_string(); - let mut hasher = DefaultHasher::new(); - serialized.hash(&mut hasher); - format!("webhook:hash:{:x}", hasher.finish()) + let mut hash: u64 = 0xcbf29ce484222325; + for byte in serialized.bytes() { + hash ^= byte as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + format!("webhook:hash:{hash:016x}") } /// Resolve the target workspace for an action, defaulting to the only one if singular diff --git a/src/config/mod.rs b/src/config/mod.rs index 2114ce6..a79f6a3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -691,6 +691,12 @@ impl TuttiConfig { wh.source ))); } + if wh.workflow.is_some() && wh.agent.is_some() { + return Err(TuttiError::ConfigValidation(format!( + "webhook[{i}] (source '{}') must specify 'workflow' or 'agent', not both", + wh.source + ))); + } if let Some(ref workflow) = wh.workflow && !workflow_names.contains(workflow.as_str()) { From 53f21be9d21ce16f519d8053382ee927ef6dca24 Mon Sep 17 00:00:00 2001 From: adam Date: Sat, 21 Mar 2026 05:40:44 +1100 Subject: [PATCH 7/7] docs: document prompt injection risk in webhook template expansion Webhook payloads are untrusted; interpolated values are inserted verbatim. Document this so operators configure appropriate agent permissions for webhook-triggered workflows. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/webhook.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/webhook.rs b/src/webhook.rs index 79f5388..8d4ac70 100644 --- a/src/webhook.rs +++ b/src/webhook.rs @@ -24,6 +24,13 @@ pub fn match_triggers<'a>( /// Expand `{{event.field}}` and `{{event.nested.field}}` placeholders in a /// template string using values from the JSON payload. +/// +/// # Security +/// +/// Interpolated values come from untrusted webhook payloads and are inserted +/// verbatim — no escaping or sanitization is applied. Operators configuring +/// webhook triggers should treat expanded prompts as untrusted input and +/// avoid granting agents destructive permissions on webhook-triggered workflows. pub fn expand_template(template: &str, payload: &Value) -> String { let mut result = String::with_capacity(template.len()); let mut rest = template;