Skip to main content

claudectl_core/
rules.rs

1use crate::session::ClaudeSession;
2use crate::terminals;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum RuleAction {
6    Approve,
7    Deny,
8    Send,
9    Terminate,
10    /// Route summarized output from the current session to another session.
11    Route {
12        target_pid: u32,
13    },
14    /// Spawn a new Claude Code session with a derived prompt.
15    Spawn {
16        prompt: String,
17        cwd: String,
18    },
19    /// Delegate work to an external agent by name.
20    Delegate {
21        agent: String,
22        prompt: String,
23    },
24}
25
26impl RuleAction {
27    pub fn parse(s: &str) -> Option<Self> {
28        match s.to_lowercase().as_str() {
29            "approve" => Some(Self::Approve),
30            "deny" => Some(Self::Deny),
31            "send" => Some(Self::Send),
32            "terminate" | "kill" => Some(Self::Terminate),
33            _ => None,
34        }
35    }
36
37    #[allow(dead_code)]
38    pub fn label(&self) -> &'static str {
39        match self {
40            Self::Approve => "approve",
41            Self::Deny => "deny",
42            Self::Send => "send",
43            Self::Terminate => "terminate",
44            Self::Route { .. } => "route",
45            Self::Spawn { .. } => "spawn",
46            Self::Delegate { .. } => "delegate",
47        }
48    }
49}
50
51#[derive(Debug, Clone)]
52pub struct AutoRule {
53    pub name: String,
54    pub match_status: Vec<String>,
55    pub match_tool: Vec<String>,
56    pub match_command: Vec<String>,
57    pub match_project: Vec<String>,
58    pub match_cost_above: Option<f64>,
59    pub match_last_error: Option<bool>,
60    pub match_file_conflict: Option<bool>,
61    pub action: RuleAction,
62    pub message: Option<String>,
63}
64
65impl AutoRule {
66    pub fn new(name: String, action: RuleAction) -> Self {
67        Self {
68            name,
69            match_status: Vec::new(),
70            match_tool: Vec::new(),
71            match_command: Vec::new(),
72            match_project: Vec::new(),
73            match_cost_above: None,
74            match_last_error: None,
75            match_file_conflict: None,
76            action,
77            message: None,
78        }
79    }
80}
81
82/// Result of evaluating rules against a session.
83#[derive(Debug, Clone)]
84pub struct RuleMatch {
85    pub rule_name: String,
86    pub action: RuleAction,
87    pub message: Option<String>,
88}
89
90/// Evaluate all rules against a session. Deny rules take precedence.
91/// Among non-deny rules, first match in config order wins.
92pub fn evaluate(rules: &[AutoRule], session: &ClaudeSession) -> Option<RuleMatch> {
93    let mut first_non_deny: Option<RuleMatch> = None;
94
95    for rule in rules {
96        if !matches_rule(rule, session) {
97            continue;
98        }
99
100        if rule.action == RuleAction::Deny {
101            return Some(RuleMatch {
102                rule_name: rule.name.clone(),
103                action: RuleAction::Deny,
104                message: rule.message.clone(),
105            });
106        }
107
108        if first_non_deny.is_none() {
109            first_non_deny = Some(RuleMatch {
110                rule_name: rule.name.clone(),
111                action: rule.action.clone(),
112                message: rule.message.clone(),
113            });
114        }
115    }
116
117    first_non_deny
118}
119
120/// Check if all of a rule's conditions match the session.
121/// Omitted conditions (empty vec / None) are treated as wildcards.
122fn matches_rule(rule: &AutoRule, session: &ClaudeSession) -> bool {
123    if !rule.match_status.is_empty() {
124        let status_str = session.status.to_string().to_lowercase();
125        let any_match = rule
126            .match_status
127            .iter()
128            .any(|s| status_str == s.to_lowercase());
129        if !any_match {
130            return false;
131        }
132    }
133
134    if !rule.match_tool.is_empty() {
135        let tool = match &session.pending_tool_name {
136            Some(t) => t.to_lowercase(),
137            None => return false,
138        };
139        let any_match = rule.match_tool.iter().any(|t| tool == t.to_lowercase());
140        if !any_match {
141            return false;
142        }
143    }
144
145    if !rule.match_command.is_empty() {
146        let cmd = match &session.pending_tool_input {
147            Some(c) => c.to_lowercase(),
148            None => return false,
149        };
150        let any_match = rule
151            .match_command
152            .iter()
153            .any(|pattern| cmd.contains(&pattern.to_lowercase()));
154        if !any_match {
155            return false;
156        }
157    }
158
159    if !rule.match_project.is_empty() {
160        let project = session.display_name().to_lowercase();
161        let any_match = rule
162            .match_project
163            .iter()
164            .any(|p| project.contains(&p.to_lowercase()));
165        if !any_match {
166            return false;
167        }
168    }
169
170    if let Some(threshold) = rule.match_cost_above {
171        if session.cost_usd <= threshold {
172            return false;
173        }
174    }
175
176    if let Some(expected) = rule.match_last_error {
177        if session.last_tool_error != expected {
178            return false;
179        }
180    }
181
182    if let Some(expected) = rule.match_file_conflict {
183        if session.has_file_conflict != expected {
184            return false;
185        }
186    }
187
188    true
189}
190
191/// Execute a rule action on a session. Returns a human-readable status message.
192pub fn execute(result: &RuleMatch, session: &ClaudeSession) -> Result<String, String> {
193    let name = session.display_name();
194    match result.action {
195        RuleAction::Approve => {
196            terminals::approve_session(session)?;
197            Ok(format!(
198                "Rule '{}': approved {} ({})",
199                result.rule_name,
200                name,
201                session.pending_tool_name.as_deref().unwrap_or("?")
202            ))
203        }
204        RuleAction::Deny => Ok(format!(
205            "Rule '{}': denied {} ({})",
206            result.rule_name,
207            name,
208            session.pending_tool_name.as_deref().unwrap_or("?")
209        )),
210        RuleAction::Send => {
211            let msg = result.message.as_deref().unwrap_or("continue");
212            terminals::send_input(session, msg)?;
213            Ok(format!(
214                "Rule '{}': sent \"{}\" to {}",
215                result.rule_name, msg, name
216            ))
217        }
218        RuleAction::Terminate => {
219            let pid = session.pid;
220            let output = std::process::Command::new("kill")
221                .arg(pid.to_string())
222                .output()
223                .map_err(|e| format!("kill failed: {e}"))?;
224            if output.status.success() {
225                Ok(format!("Rule '{}': terminated {}", result.rule_name, name))
226            } else {
227                Err(format!("Rule '{}': kill {} failed", result.rule_name, pid))
228            }
229        }
230        RuleAction::Route { .. } => {
231            // Route execution happens in the brain engine.
232            Ok(format!(
233                "Rule '{}': route queued for {}",
234                result.rule_name, name
235            ))
236        }
237        RuleAction::Spawn {
238            ref prompt,
239            ref cwd,
240        } => match terminals::launch_session(cwd, Some(prompt), None) {
241            Ok(msg) => Ok(format!(
242                "Rule '{}': spawned new session for {} — {msg}",
243                result.rule_name, name
244            )),
245            Err(e) => Err(format!(
246                "Rule '{}': spawn failed for {}: {e}",
247                result.rule_name, name
248            )),
249        },
250        RuleAction::Delegate { ref agent, .. } => {
251            // Delegate execution happens in the brain engine (needs agent registry
252            // + output capture). This arm logs the delegation.
253            Ok(format!(
254                "Rule '{}': delegated to agent '{}' for {}",
255                result.rule_name, agent, name
256            ))
257        }
258    }
259}
260
261/// Execute a Route action: summarize source output and send to target session.
262pub fn execute_route(
263    source: &ClaudeSession,
264    target: &ClaudeSession,
265    summary: &str,
266    rule_name: &str,
267) -> Result<String, String> {
268    let msg = format!("[From {}] {}", source.display_name(), summary);
269    terminals::send_input(target, &msg)?;
270    Ok(format!(
271        "Rule '{}': routed summary from {} → {}",
272        rule_name,
273        source.display_name(),
274        target.display_name(),
275    ))
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::session::{ClaudeSession, RawSession, SessionStatus, TelemetryStatus};
282
283    fn make_session() -> ClaudeSession {
284        let raw = RawSession {
285            pid: 100,
286            session_id: "test".into(),
287            cwd: "/tmp/my-project".into(),
288            started_at: 0,
289        };
290        let mut s = ClaudeSession::from_raw(raw);
291        s.status = SessionStatus::NeedsInput;
292        s.telemetry_status = TelemetryStatus::Available;
293        s.pending_tool_name = Some("Bash".into());
294        s.pending_tool_input = Some("cargo test".into());
295        s.cost_usd = 5.0;
296        s
297    }
298
299    fn approve_rule(name: &str) -> AutoRule {
300        AutoRule::new(name.into(), RuleAction::Approve)
301    }
302
303    fn deny_rule(name: &str) -> AutoRule {
304        AutoRule::new(name.into(), RuleAction::Deny)
305    }
306
307    #[test]
308    fn no_rules_returns_none() {
309        let s = make_session();
310        assert!(evaluate(&[], &s).is_none());
311    }
312
313    #[test]
314    fn wildcard_rule_matches_any_session() {
315        let s = make_session();
316        let rules = vec![approve_rule("catch_all")];
317        let m = evaluate(&rules, &s).unwrap();
318        assert_eq!(m.action, RuleAction::Approve);
319    }
320
321    #[test]
322    fn match_status_filters() {
323        let mut s = make_session();
324        s.status = SessionStatus::WaitingInput;
325
326        let mut rule = approve_rule("only_needs_input");
327        rule.match_status = vec!["Needs Input".into()];
328
329        assert!(evaluate(&[rule.clone()], &s).is_none());
330
331        s.status = SessionStatus::NeedsInput;
332        assert!(evaluate(&[rule], &s).is_some());
333    }
334
335    #[test]
336    fn match_tool_filters() {
337        let s = make_session(); // pending_tool_name = "Bash"
338
339        let mut rule = approve_rule("only_read");
340        rule.match_tool = vec!["Read".into()];
341        assert!(evaluate(&[rule], &s).is_none());
342
343        let mut rule2 = approve_rule("bash_ok");
344        rule2.match_tool = vec!["Bash".into()];
345        assert!(evaluate(&[rule2], &s).is_some());
346    }
347
348    #[test]
349    fn match_tool_case_insensitive() {
350        let s = make_session();
351
352        let mut rule = approve_rule("bash_lower");
353        rule.match_tool = vec!["bash".into()];
354        assert!(evaluate(&[rule], &s).is_some());
355    }
356
357    #[test]
358    fn match_command_substring() {
359        let s = make_session(); // pending_tool_input = "cargo test"
360
361        let mut rule = deny_rule("deny_rm");
362        rule.match_command = vec!["rm -rf".into()];
363        assert!(evaluate(&[rule], &s).is_none());
364
365        let mut rule2 = approve_rule("approve_cargo");
366        rule2.match_command = vec!["cargo".into()];
367        assert!(evaluate(&[rule2], &s).is_some());
368    }
369
370    #[test]
371    fn match_project_substring() {
372        let s = make_session(); // project_name = "my-project"
373
374        let mut rule = approve_rule("my_proj");
375        rule.match_project = vec!["my-project".into()];
376        assert!(evaluate(&[rule], &s).is_some());
377
378        let mut rule2 = approve_rule("other");
379        rule2.match_project = vec!["other-project".into()];
380        assert!(evaluate(&[rule2], &s).is_none());
381    }
382
383    #[test]
384    fn match_cost_above() {
385        let s = make_session(); // cost = 5.0
386
387        let mut rule = approve_rule("cheap");
388        rule.match_cost_above = Some(10.0);
389        assert!(evaluate(&[rule], &s).is_none());
390
391        let mut rule2 = approve_rule("expensive");
392        rule2.match_cost_above = Some(3.0);
393        assert!(evaluate(&[rule2], &s).is_some());
394    }
395
396    #[test]
397    fn match_last_error() {
398        let mut s = make_session();
399        s.last_tool_error = true;
400
401        let mut rule = approve_rule("on_error");
402        rule.match_last_error = Some(true);
403        assert!(evaluate(&[rule], &s).is_some());
404
405        let mut rule2 = approve_rule("no_error");
406        rule2.match_last_error = Some(false);
407        assert!(evaluate(&[rule2], &s).is_none());
408    }
409
410    #[test]
411    fn match_file_conflict() {
412        let mut s = make_session();
413        s.has_file_conflict = true;
414
415        let mut rule = deny_rule("deny_conflict");
416        rule.match_file_conflict = Some(true);
417        assert!(evaluate(&[rule], &s).is_some());
418
419        let mut rule2 = approve_rule("no_conflict");
420        rule2.match_file_conflict = Some(false);
421        assert!(evaluate(&[rule2], &s).is_none());
422    }
423
424    #[test]
425    fn match_file_conflict_false_matches_clean() {
426        let s = make_session(); // has_file_conflict defaults to false
427
428        let mut rule = approve_rule("clean");
429        rule.match_file_conflict = Some(false);
430        assert!(evaluate(&[rule], &s).is_some());
431    }
432
433    #[test]
434    fn deny_takes_precedence() {
435        let s = make_session();
436
437        let approve = approve_rule("approve_all");
438        let deny = deny_rule("deny_all");
439
440        // Approve first in config order, deny second — deny still wins
441        let rules = vec![approve, deny];
442        let m = evaluate(&rules, &s).unwrap();
443        assert_eq!(m.action, RuleAction::Deny);
444    }
445
446    #[test]
447    fn first_non_deny_wins() {
448        let s = make_session();
449
450        let mut r1 = AutoRule::new("send_continue".into(), RuleAction::Send);
451        r1.message = Some("keep going".into());
452
453        let r2 = approve_rule("approve_all");
454
455        let rules = vec![r1, r2];
456        let m = evaluate(&rules, &s).unwrap();
457        assert_eq!(m.action, RuleAction::Send);
458        assert_eq!(m.message.as_deref(), Some("keep going"));
459    }
460
461    #[test]
462    fn multiple_conditions_are_and() {
463        let s = make_session(); // Bash + "cargo test" + cost 5.0
464
465        let mut rule = approve_rule("bash_cargo_cheap");
466        rule.match_tool = vec!["Bash".into()];
467        rule.match_command = vec!["cargo".into()];
468        rule.match_cost_above = Some(10.0); // cost 5.0 does NOT exceed 10.0
469        assert!(evaluate(&[rule], &s).is_none());
470    }
471
472    #[test]
473    fn no_pending_tool_fails_tool_match() {
474        let mut s = make_session();
475        s.pending_tool_name = None;
476
477        let mut rule = approve_rule("bash");
478        rule.match_tool = vec!["Bash".into()];
479        assert!(evaluate(&[rule], &s).is_none());
480    }
481}