Skip to main content

chio_guards/
action.rs

1//! Tool action extraction from Chio tool call requests.
2//!
3//! Guards need to know *what kind of action* a tool call performs (file access,
4//! shell command, network egress, etc.).  This module provides a `ToolAction`
5//! enum that guards match on, plus extraction logic that derives the action
6//! from `ToolCallRequest.tool_name` and `ToolCallRequest.arguments`.
7
8use serde_json::Value;
9
10/// A categorized action derived from a tool call request.
11///
12/// This plays the same role as ClawdStrike's `GuardAction` enum, but is
13/// produced by inspecting `ToolCallRequest` fields rather than being
14/// supplied directly.
15#[derive(Clone, Debug)]
16pub enum ToolAction {
17    /// File system read (path).
18    FileAccess(String),
19    /// File system write (path, content bytes).
20    FileWrite(String, Vec<u8>),
21    /// Network egress (host, port).
22    NetworkEgress(String, u16),
23    /// Shell command execution (command line).
24    ShellCommand(String),
25    /// MCP tool invocation (tool_name, args).
26    McpTool(String, Value),
27    /// Patch application (file, diff).
28    Patch(String, String),
29    /// Code execution via an interpreter (language, code snippet).
30    CodeExecution { language: String, code: String },
31    /// Browser automation action (verb, optional target URL).
32    BrowserAction {
33        verb: String,
34        target: Option<String>,
35    },
36    /// Database query (database/engine identifier, raw query text).
37    DatabaseQuery { database: String, query: String },
38    /// External API call (service name, endpoint/path).
39    ExternalApiCall { service: String, endpoint: String },
40    /// Agent memory write (store/collection id, key).
41    MemoryWrite { store: String, key: String },
42    /// Agent memory read (store/collection id, optional key).
43    MemoryRead { store: String, key: Option<String> },
44    /// Unknown / not categorized -- guards that don't match should allow.
45    Unknown,
46}
47
48impl ToolAction {
49    /// Return the path targeted by clearly filesystem-shaped actions.
50    pub fn filesystem_path(&self) -> Option<&str> {
51        match self {
52            Self::FileAccess(path) | Self::FileWrite(path, _) | Self::Patch(path, _) => {
53                Some(path.as_str())
54            }
55            _ => None,
56        }
57    }
58}
59
60/// Extract a `ToolAction` from a tool name and its arguments.
61///
62/// This uses a best-effort heuristic based on common tool naming conventions.
63/// Guards that receive `ToolAction::Unknown` should return `Verdict::Allow`
64/// (the guard simply does not apply to that action type).
65pub fn extract_action(tool_name: &str, arguments: &Value) -> ToolAction {
66    let tool = tool_name.to_lowercase();
67
68    // File read tools
69    if matches!(
70        tool.as_str(),
71        "read_file" | "read" | "file_read" | "get_file" | "cat"
72    ) {
73        if let Some(path) = extract_path(arguments) {
74            return ToolAction::FileAccess(path);
75        }
76    }
77
78    // File write tools
79    if matches!(
80        tool.as_str(),
81        "write_file" | "write" | "file_write" | "create_file" | "put_file" | "edit_file" | "edit"
82    ) {
83        if let Some(path) = extract_path(arguments) {
84            let content = arguments
85                .get("content")
86                .and_then(|v| v.as_str())
87                .unwrap_or("")
88                .as_bytes()
89                .to_vec();
90            return ToolAction::FileWrite(path, content);
91        }
92    }
93
94    // Generic filesystem tools -- disambiguate read vs write by inspecting
95    // the `action` parameter or the presence of `content`.
96    if matches!(tool.as_str(), "filesystem" | "fs" | "file") {
97        if let Some(path) = extract_path(arguments) {
98            let is_write = arguments
99                .get("action")
100                .and_then(|v| v.as_str())
101                .map(|a| {
102                    let a = a.to_lowercase();
103                    a == "write" || a == "create" || a == "append"
104                })
105                .unwrap_or(false)
106                || arguments.get("content").is_some();
107
108            if is_write {
109                let content = arguments
110                    .get("content")
111                    .and_then(|v| v.as_str())
112                    .unwrap_or("")
113                    .as_bytes()
114                    .to_vec();
115                return ToolAction::FileWrite(path, content);
116            } else {
117                return ToolAction::FileAccess(path);
118            }
119        }
120    }
121
122    // Patch / apply diff tools
123    if matches!(tool.as_str(), "apply_patch" | "patch" | "apply_diff") {
124        if let Some(path) = extract_path(arguments) {
125            let diff = arguments
126                .get("diff")
127                .or_else(|| arguments.get("patch"))
128                .and_then(|v| v.as_str())
129                .unwrap_or("")
130                .to_string();
131            return ToolAction::Patch(path, diff);
132        }
133    }
134
135    // Shell / command execution tools
136    if matches!(
137        tool.as_str(),
138        "bash" | "shell" | "run_command" | "exec" | "execute" | "run" | "shell_exec" | "terminal"
139    ) {
140        if let Some(cmd) = arguments
141            .get("command")
142            .or_else(|| arguments.get("cmd"))
143            .or_else(|| arguments.get("input"))
144            .and_then(|v| v.as_str())
145        {
146            return ToolAction::ShellCommand(cmd.to_string());
147        }
148    }
149
150    // Network / HTTP tools
151    if matches!(
152        tool.as_str(),
153        "http_request" | "fetch" | "curl" | "http" | "request" | "web_request"
154    ) {
155        if let Some(url) = arguments
156            .get("url")
157            .or_else(|| arguments.get("uri"))
158            .and_then(|v| v.as_str())
159        {
160            if let Some((host, port)) = parse_host_port(url) {
161                return ToolAction::NetworkEgress(host, port);
162            }
163        }
164    }
165
166    // Code execution via interpreter (Python/JS eval, notebook cell, REPL).
167    if matches!(
168        tool.as_str(),
169        "python"
170            | "python_exec"
171            | "run_python"
172            | "eval"
173            | "evaluate"
174            | "code_exec"
175            | "exec_code"
176            | "run_code"
177            | "notebook"
178            | "notebook_cell"
179            | "repl"
180            | "jupyter"
181            | "ipython"
182    ) {
183        let code = arguments
184            .get("code")
185            .or_else(|| arguments.get("source"))
186            .or_else(|| arguments.get("snippet"))
187            .or_else(|| arguments.get("script"))
188            .or_else(|| arguments.get("input"))
189            .and_then(|v| v.as_str())
190            .unwrap_or("")
191            .to_string();
192        let language = arguments
193            .get("language")
194            .or_else(|| arguments.get("lang"))
195            .and_then(|v| v.as_str())
196            .map(String::from)
197            .unwrap_or_else(|| infer_language_from_tool(&tool));
198        return ToolAction::CodeExecution { language, code };
199    }
200
201    // Browser automation.
202    if matches!(
203        tool.as_str(),
204        "browser"
205            | "browser_action"
206            | "browser_navigate"
207            | "navigate"
208            | "goto"
209            | "click"
210            | "type"
211            | "screenshot"
212            | "browser_click"
213            | "browser_type"
214            | "browser_screenshot"
215            | "playwright"
216            | "puppeteer"
217            | "selenium"
218    ) {
219        let verb = arguments
220            .get("action")
221            .or_else(|| arguments.get("verb"))
222            .and_then(|v| v.as_str())
223            .map(String::from)
224            .unwrap_or_else(|| tool.clone());
225        let target = arguments
226            .get("url")
227            .or_else(|| arguments.get("target"))
228            .or_else(|| arguments.get("href"))
229            .or_else(|| arguments.get("selector"))
230            .and_then(|v| v.as_str())
231            .map(String::from);
232        return ToolAction::BrowserAction { verb, target };
233    }
234
235    // Database queries (SQL and NoSQL). Detect by tool name and presence of
236    // a query/statement argument.
237    if matches!(
238        tool.as_str(),
239        "sql"
240            | "query"
241            | "db_query"
242            | "database"
243            | "execute_sql"
244            | "run_sql"
245            | "postgres"
246            | "mysql"
247            | "sqlite"
248            | "snowflake"
249            | "bigquery"
250            | "redshift"
251            | "mongo"
252            | "mongodb"
253            | "redis"
254    ) {
255        if let Some(q) = arguments
256            .get("query")
257            .or_else(|| arguments.get("sql"))
258            .or_else(|| arguments.get("statement"))
259            .or_else(|| arguments.get("command"))
260            .and_then(|v| v.as_str())
261        {
262            let database = arguments
263                .get("database")
264                .or_else(|| arguments.get("db"))
265                .or_else(|| arguments.get("connection"))
266                .and_then(|v| v.as_str())
267                .map(String::from)
268                .unwrap_or_else(|| tool.clone());
269            return ToolAction::DatabaseQuery {
270                database,
271                query: q.to_string(),
272            };
273        }
274    }
275
276    // Vector database / memory writes.
277    if matches!(
278        tool.as_str(),
279        "memory_write"
280            | "remember"
281            | "store_memory"
282            | "vector_upsert"
283            | "vector_write"
284            | "upsert"
285            | "pinecone_upsert"
286            | "weaviate_write"
287            | "qdrant_upsert"
288    ) {
289        let store = arguments
290            .get("collection")
291            .or_else(|| arguments.get("index"))
292            .or_else(|| arguments.get("namespace"))
293            .or_else(|| arguments.get("store"))
294            .and_then(|v| v.as_str())
295            .map(String::from)
296            .unwrap_or_else(|| tool.clone());
297        let key = arguments
298            .get("id")
299            .or_else(|| arguments.get("key"))
300            .or_else(|| arguments.get("memory_id"))
301            .and_then(|v| v.as_str())
302            .map(String::from)
303            .unwrap_or_default();
304        return ToolAction::MemoryWrite { store, key };
305    }
306
307    // Vector database / memory reads.
308    if matches!(
309        tool.as_str(),
310        "memory_read"
311            | "recall"
312            | "retrieve_memory"
313            | "vector_query"
314            | "vector_search"
315            | "similarity_search"
316            | "pinecone_query"
317            | "weaviate_search"
318            | "qdrant_search"
319    ) {
320        let store = arguments
321            .get("collection")
322            .or_else(|| arguments.get("index"))
323            .or_else(|| arguments.get("namespace"))
324            .or_else(|| arguments.get("store"))
325            .and_then(|v| v.as_str())
326            .map(String::from)
327            .unwrap_or_else(|| tool.clone());
328        let key = arguments
329            .get("id")
330            .or_else(|| arguments.get("key"))
331            .or_else(|| arguments.get("memory_id"))
332            .and_then(|v| v.as_str())
333            .map(String::from);
334        return ToolAction::MemoryRead { store, key };
335    }
336
337    // External API calls with recognizable service prefixes.
338    if let Some(service) = detect_api_service(&tool) {
339        let endpoint = arguments
340            .get("endpoint")
341            .or_else(|| arguments.get("path"))
342            .or_else(|| arguments.get("action"))
343            .or_else(|| arguments.get("method"))
344            .and_then(|v| v.as_str())
345            .map(String::from)
346            .unwrap_or_else(|| tool.clone());
347        return ToolAction::ExternalApiCall { service, endpoint };
348    }
349
350    // MCP tool invocations (generic passthrough)
351    if tool.starts_with("mcp_") || tool.contains("mcp") {
352        return ToolAction::McpTool(tool_name.to_string(), arguments.clone());
353    }
354
355    // Fallback: treat as a generic MCP tool invocation so the MCP guard can
356    // still apply its block/allow lists.
357    ToolAction::McpTool(tool_name.to_string(), arguments.clone())
358}
359
360fn infer_language_from_tool(tool: &str) -> String {
361    match tool {
362        "python" | "python_exec" | "run_python" | "jupyter" | "ipython" | "notebook"
363        | "notebook_cell" => "python".to_string(),
364        "repl" => "javascript".to_string(),
365        _ => "unknown".to_string(),
366    }
367}
368
369fn detect_api_service(tool: &str) -> Option<String> {
370    for prefix in [
371        "slack_",
372        "stripe_",
373        "github_",
374        "gitlab_",
375        "jira_",
376        "twilio_",
377        "sendgrid_",
378        "pagerduty_",
379        "opsgenie_",
380        "zendesk_",
381        "salesforce_",
382        "hubspot_",
383        "notion_",
384        "linear_",
385        "intercom_",
386    ] {
387        if let Some(rest) = tool.strip_prefix(prefix) {
388            if !rest.is_empty() {
389                let service = prefix.trim_end_matches('_').to_string();
390                return Some(service);
391            }
392        }
393    }
394    None
395}
396
397fn extract_path(arguments: &Value) -> Option<String> {
398    arguments
399        .get("path")
400        .or_else(|| arguments.get("file"))
401        .or_else(|| arguments.get("file_path"))
402        .or_else(|| arguments.get("filename"))
403        .and_then(|v| v.as_str())
404        .map(String::from)
405}
406
407fn parse_host_port(url: &str) -> Option<(String, u16)> {
408    let url = url.trim();
409    if url.is_empty() {
410        return None;
411    }
412
413    let lowered = url.to_ascii_lowercase();
414    if lowered.starts_with("data:")
415        || lowered.starts_with("javascript:")
416        || lowered.starts_with("about:")
417        || lowered.starts_with("file:")
418    {
419        return None;
420    }
421
422    let (rest, default_port, parsed_as_url) = if lowered.starts_with("https://") {
423        (&url["https://".len()..], 443, true)
424    } else if lowered.starts_with("http://") {
425        (&url["http://".len()..], 80, true)
426    } else if let Some(rest) = url.strip_prefix("//") {
427        (rest, 443, true)
428    } else {
429        (url, 443, false)
430    };
431
432    let host_with_port = rest.split(['/', '?', '#']).next().unwrap_or(rest);
433    let host_without_userinfo = host_with_port
434        .rsplit_once('@')
435        .map(|(_, host)| host)
436        .unwrap_or(host_with_port);
437
438    let (host, port) = if let Some(bracketed) = host_without_userinfo.strip_prefix('[') {
439        let (host, remainder) = bracketed.split_once(']')?;
440        let port = if remainder.is_empty() {
441            default_port
442        } else if let Some(port_str) = remainder.strip_prefix(':') {
443            port_str.parse::<u16>().ok()?
444        } else {
445            return None;
446        };
447        (host.to_string(), port)
448    } else {
449        split_host_port(host_without_userinfo, default_port)
450    };
451
452    let host = host.trim_matches(|c: char| c == '/' || c == '.');
453    let looks_like_host = host.contains('.') || host == "localhost" || host.contains(':');
454    if host.is_empty() || (!parsed_as_url && !looks_like_host) {
455        return None;
456    }
457
458    Some((host.to_ascii_lowercase(), port))
459}
460
461fn split_host_port(host_with_port: &str, default_port: u16) -> (String, u16) {
462    if let Some((host, port_str)) = host_with_port.rsplit_once(':') {
463        if let Ok(port) = port_str.parse::<u16>() {
464            return (host.to_string(), port);
465        }
466    }
467    (host_with_port.to_string(), default_port)
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    #[test]
475    fn extract_file_access() {
476        let args = serde_json::json!({"path": "/etc/shadow"});
477        let action = extract_action("read_file", &args);
478        assert!(matches!(action, ToolAction::FileAccess(ref p) if p == "/etc/shadow"));
479    }
480
481    #[test]
482    fn extract_file_write() {
483        let args = serde_json::json!({"path": "/tmp/out.txt", "content": "hello"});
484        let action = extract_action("write_file", &args);
485        assert!(matches!(action, ToolAction::FileWrite(ref p, _) if p == "/tmp/out.txt"));
486    }
487
488    #[test]
489    fn extract_shell_command() {
490        let args = serde_json::json!({"command": "ls -la"});
491        let action = extract_action("bash", &args);
492        assert!(matches!(action, ToolAction::ShellCommand(ref c) if c == "ls -la"));
493    }
494
495    #[test]
496    fn extract_network_egress() {
497        let args = serde_json::json!({"url": "https://evil.com/api"});
498        let action = extract_action("http_request", &args);
499        assert!(matches!(action, ToolAction::NetworkEgress(ref h, 443) if h == "evil.com"));
500    }
501
502    #[test]
503    fn extract_network_with_port() {
504        let args = serde_json::json!({"url": "http://localhost:8080/health"});
505        let action = extract_action("fetch", &args);
506        assert!(matches!(action, ToolAction::NetworkEgress(ref h, 8080) if h == "localhost"));
507    }
508
509    #[test]
510    fn extract_network_with_scheme_relative_url() {
511        let args = serde_json::json!({"url": "//169.254.169.254/latest"});
512        let action = extract_action("http_request", &args);
513        assert!(matches!(action, ToolAction::NetworkEgress(ref h, 443) if h == "169.254.169.254"));
514    }
515
516    #[test]
517    fn extract_network_with_mixed_case_scheme() {
518        let args = serde_json::json!({"url": "HTTPS://Example.COM/api"});
519        let action = extract_action("fetch", &args);
520        assert!(matches!(action, ToolAction::NetworkEgress(ref h, 443) if h == "example.com"));
521    }
522
523    #[test]
524    fn extract_network_strips_userinfo_and_ipv6_brackets() {
525        let userinfo_args = serde_json::json!({"url": "https://user:pass@evil.com/path"});
526        let userinfo_action = extract_action("http_request", &userinfo_args);
527        assert!(
528            matches!(userinfo_action, ToolAction::NetworkEgress(ref h, 443) if h == "evil.com")
529        );
530
531        let ipv6_args = serde_json::json!({"url": "https://[fd00:ec2::254]/latest"});
532        let ipv6_action = extract_action("http_request", &ipv6_args);
533        assert!(
534            matches!(ipv6_action, ToolAction::NetworkEgress(ref h, 443) if h == "fd00:ec2::254")
535        );
536    }
537
538    #[test]
539    fn extract_network_strips_query_and_fragment_from_authority() {
540        let query_args = serde_json::json!({"url": "https://metadata.google.internal?x=1"});
541        let query_action = extract_action("http_request", &query_args);
542        assert!(matches!(
543            query_action,
544            ToolAction::NetworkEgress(ref h, 443) if h == "metadata.google.internal"
545        ));
546
547        let fragment_args = serde_json::json!({"url": "https://metadata.google.internal#anchor"});
548        let fragment_action = extract_action("fetch", &fragment_args);
549        assert!(matches!(
550            fragment_action,
551            ToolAction::NetworkEgress(ref h, 443) if h == "metadata.google.internal"
552        ));
553    }
554
555    #[test]
556    fn unknown_tool_becomes_mcp_tool() {
557        let args = serde_json::json!({"foo": "bar"});
558        let action = extract_action("custom_tool", &args);
559        assert!(matches!(action, ToolAction::McpTool(_, _)));
560    }
561
562    #[test]
563    fn filesystem_tool_read_by_default() {
564        let args = serde_json::json!({"path": "/etc/shadow"});
565        let action = extract_action("filesystem", &args);
566        assert!(
567            matches!(action, ToolAction::FileAccess(ref p) if p == "/etc/shadow"),
568            "expected FileAccess for filesystem tool with path-only params, got: {action:?}"
569        );
570    }
571
572    #[test]
573    fn filesystem_tool_explicit_read_action() {
574        let args = serde_json::json!({"path": "/etc/shadow", "action": "read"});
575        let action = extract_action("filesystem", &args);
576        assert!(
577            matches!(action, ToolAction::FileAccess(ref p) if p == "/etc/shadow"),
578            "expected FileAccess for filesystem tool with action=read, got: {action:?}"
579        );
580    }
581
582    #[test]
583    fn filesystem_tool_write_action() {
584        let args = serde_json::json!({"path": "/tmp/out.txt", "action": "write", "content": "hi"});
585        let action = extract_action("filesystem", &args);
586        assert!(
587            matches!(action, ToolAction::FileWrite(ref p, _) if p == "/tmp/out.txt"),
588            "expected FileWrite for filesystem tool with action=write, got: {action:?}"
589        );
590    }
591
592    #[test]
593    fn filesystem_tool_write_inferred_from_content() {
594        let args = serde_json::json!({"path": "/tmp/out.txt", "content": "data"});
595        let action = extract_action("filesystem", &args);
596        assert!(
597            matches!(action, ToolAction::FileWrite(ref p, _) if p == "/tmp/out.txt"),
598            "expected FileWrite for filesystem tool with content field, got: {action:?}"
599        );
600    }
601
602    #[test]
603    fn fs_tool_alias() {
604        let args = serde_json::json!({"path": "/etc/passwd"});
605        let action = extract_action("fs", &args);
606        assert!(
607            matches!(action, ToolAction::FileAccess(ref p) if p == "/etc/passwd"),
608            "expected FileAccess for fs tool alias, got: {action:?}"
609        );
610    }
611
612    #[test]
613    fn file_tool_alias() {
614        let args = serde_json::json!({"path": "/etc/passwd"});
615        let action = extract_action("file", &args);
616        assert!(
617            matches!(action, ToolAction::FileAccess(ref p) if p == "/etc/passwd"),
618            "expected FileAccess for file tool alias, got: {action:?}"
619        );
620    }
621
622    #[test]
623    fn extract_code_execution_python() {
624        let args = serde_json::json!({"code": "import os; os.listdir('.')"});
625        let action = extract_action("python", &args);
626        match action {
627            ToolAction::CodeExecution { language, code } => {
628                assert_eq!(language, "python");
629                assert!(code.contains("os.listdir"));
630            }
631            other => panic!("expected CodeExecution, got: {other:?}"),
632        }
633    }
634
635    #[test]
636    fn extract_code_execution_explicit_language() {
637        let args = serde_json::json!({"source": "console.log(1)", "language": "javascript"});
638        let action = extract_action("eval", &args);
639        match action {
640            ToolAction::CodeExecution { language, code } => {
641                assert_eq!(language, "javascript");
642                assert_eq!(code, "console.log(1)");
643            }
644            other => panic!("expected CodeExecution, got: {other:?}"),
645        }
646    }
647
648    #[test]
649    fn extract_browser_navigate() {
650        let args = serde_json::json!({"url": "https://example.com"});
651        let action = extract_action("navigate", &args);
652        match action {
653            ToolAction::BrowserAction { verb, target } => {
654                assert_eq!(verb, "navigate");
655                assert_eq!(target.as_deref(), Some("https://example.com"));
656            }
657            other => panic!("expected BrowserAction, got: {other:?}"),
658        }
659    }
660
661    #[test]
662    fn extract_browser_click_with_selector() {
663        let args = serde_json::json!({"action": "click", "selector": "#submit"});
664        let action = extract_action("browser", &args);
665        match action {
666            ToolAction::BrowserAction { verb, target } => {
667                assert_eq!(verb, "click");
668                assert_eq!(target.as_deref(), Some("#submit"));
669            }
670            other => panic!("expected BrowserAction, got: {other:?}"),
671        }
672    }
673
674    #[test]
675    fn extract_database_query() {
676        let args = serde_json::json!({"query": "SELECT * FROM users", "database": "prod"});
677        let action = extract_action("sql", &args);
678        match action {
679            ToolAction::DatabaseQuery { database, query } => {
680                assert_eq!(database, "prod");
681                assert!(query.contains("SELECT"));
682            }
683            other => panic!("expected DatabaseQuery, got: {other:?}"),
684        }
685    }
686
687    #[test]
688    fn extract_database_query_default_db() {
689        let args = serde_json::json!({"query": "SELECT 1"});
690        let action = extract_action("postgres", &args);
691        match action {
692            ToolAction::DatabaseQuery { database, .. } => {
693                assert_eq!(database, "postgres");
694            }
695            other => panic!("expected DatabaseQuery, got: {other:?}"),
696        }
697    }
698
699    #[test]
700    fn extract_memory_write() {
701        let args = serde_json::json!({"collection": "agent-notes", "id": "mem-42"});
702        let action = extract_action("vector_upsert", &args);
703        match action {
704            ToolAction::MemoryWrite { store, key } => {
705                assert_eq!(store, "agent-notes");
706                assert_eq!(key, "mem-42");
707            }
708            other => panic!("expected MemoryWrite, got: {other:?}"),
709        }
710    }
711
712    #[test]
713    fn extract_memory_read_with_key() {
714        let args = serde_json::json!({"namespace": "session-1", "id": "fact-7"});
715        let action = extract_action("recall", &args);
716        match action {
717            ToolAction::MemoryRead { store, key } => {
718                assert_eq!(store, "session-1");
719                assert_eq!(key.as_deref(), Some("fact-7"));
720            }
721            other => panic!("expected MemoryRead, got: {other:?}"),
722        }
723    }
724
725    #[test]
726    fn extract_memory_read_without_key() {
727        let args = serde_json::json!({"collection": "facts"});
728        let action = extract_action("vector_query", &args);
729        match action {
730            ToolAction::MemoryRead { store, key } => {
731                assert_eq!(store, "facts");
732                assert!(key.is_none());
733            }
734            other => panic!("expected MemoryRead, got: {other:?}"),
735        }
736    }
737
738    #[test]
739    fn extract_external_api_call_slack() {
740        let args = serde_json::json!({"endpoint": "chat.postMessage"});
741        let action = extract_action("slack_send_message", &args);
742        match action {
743            ToolAction::ExternalApiCall { service, endpoint } => {
744                assert_eq!(service, "slack");
745                assert_eq!(endpoint, "chat.postMessage");
746            }
747            other => panic!("expected ExternalApiCall, got: {other:?}"),
748        }
749    }
750
751    #[test]
752    fn extract_external_api_call_stripe_default_endpoint() {
753        let args = serde_json::json!({});
754        let action = extract_action("stripe_create_charge", &args);
755        match action {
756            ToolAction::ExternalApiCall { service, endpoint } => {
757                assert_eq!(service, "stripe");
758                assert_eq!(endpoint, "stripe_create_charge");
759            }
760            other => panic!("expected ExternalApiCall, got: {other:?}"),
761        }
762    }
763
764    #[test]
765    fn filesystem_tool_actions_expose_target_path() {
766        let read = extract_action(
767            "filesystem",
768            &serde_json::json!({"path": "/repo/src/lib.rs"}),
769        );
770        let write = extract_action(
771            "filesystem",
772            &serde_json::json!({"path": "/repo/src/lib.rs", "action": "write", "content": "hi"}),
773        );
774        let patch = extract_action(
775            "apply_patch",
776            &serde_json::json!({"path": "/repo/src/lib.rs", "patch": "@@ -1 +1 @@"}),
777        );
778
779        assert_eq!(read.filesystem_path(), Some("/repo/src/lib.rs"));
780        assert_eq!(write.filesystem_path(), Some("/repo/src/lib.rs"));
781        assert_eq!(patch.filesystem_path(), Some("/repo/src/lib.rs"));
782    }
783}