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/// The action is produced by inspecting `ToolCallRequest` fields rather than
13/// being supplied directly.
14#[derive(Clone, Debug)]
15pub enum ToolAction {
16    /// File system read (path).
17    FileAccess(String),
18    /// File system write (path, content bytes).
19    FileWrite(String, Vec<u8>),
20    /// Network egress (host, port).
21    NetworkEgress(String, u16),
22    /// Shell command execution (command line).
23    ShellCommand(String),
24    /// MCP tool invocation (tool_name, args).
25    McpTool(String, Value),
26    /// Patch application (file, diff).
27    Patch(String, String),
28    /// Code execution via an interpreter (language, code snippet).
29    CodeExecution { language: String, code: String },
30    /// Browser automation action (verb, optional target URL).
31    BrowserAction {
32        verb: String,
33        target: Option<String>,
34    },
35    /// Database query (database/engine identifier, raw query text).
36    DatabaseQuery { database: String, query: String },
37    /// External API call (service name, endpoint/path).
38    ExternalApiCall { service: String, endpoint: String },
39    /// Agent memory write (store/collection id, key).
40    MemoryWrite { store: String, key: String },
41    /// Agent memory read (store/collection id, optional key).
42    MemoryRead { store: String, key: Option<String> },
43    /// Unknown / not categorized -- guards that don't match should allow.
44    Unknown,
45}
46
47/// A recognized tool-action shape with missing or mistyped arguments.
48///
49/// Guard boundaries must treat this as deny. The plain [`extract_action`]
50/// helper returns a best-effort `ToolAction` for non-guard callers; guards and
51/// guard-like host enrichers should use [`extract_action_checked`] instead.
52#[derive(Clone, Debug, Eq, PartialEq)]
53pub struct MalformedAction {
54    pub tool_name: String,
55    pub field: String,
56    pub expected: &'static str,
57}
58
59impl ToolAction {
60    /// Return the path targeted by clearly filesystem-shaped actions.
61    pub fn filesystem_path(&self) -> Option<&str> {
62        match self {
63            Self::FileAccess(path) | Self::FileWrite(path, _) | Self::Patch(path, _) => {
64                Some(path.as_str())
65            }
66            _ => None,
67        }
68    }
69}
70
71mod extractor;
72
73#[cfg(test)]
74use extractor::{string_arg, StringArgViolation};
75
76/// Extract a `ToolAction` from a tool name and its arguments.
77///
78/// This uses a best-effort heuristic based on common tool naming conventions.
79/// Recognized but malformed action shapes map to `ToolAction::Unknown`.
80/// Guard-boundary code should call [`extract_action_checked`] and deny
81/// malformed-action errors.
82pub fn extract_action(tool_name: &str, arguments: &Value) -> ToolAction {
83    extractor::extract_action(tool_name, arguments)
84}
85
86/// Extract a `ToolAction`, failing closed on recognized malformed action shapes.
87pub fn extract_action_checked(
88    tool_name: &str,
89    arguments: &Value,
90) -> Result<ToolAction, MalformedAction> {
91    extractor::extract_action_checked(tool_name, arguments)
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    fn malformed_field(tool_name: &str, args: &Value) -> String {
99        match extract_action_checked(tool_name, args) {
100            Ok(action) => panic!("expected malformed action, got: {action:?}"),
101            Err(err) => err.field,
102        }
103    }
104
105    // Compile-time tripwire: this match has no `_` arm, so adding a
106    // `ToolAction` variant breaks the build here and forces an audit of every
107    // guard's action dispatch (each guard matches its domain variants and
108    // passes the rest; a new variant must be classified, not silently allowed).
109    #[test]
110    fn tool_action_dispatch_is_exhaustive() {
111        fn classify(action: &ToolAction) {
112            match action {
113                ToolAction::FileAccess(_)
114                | ToolAction::FileWrite(_, _)
115                | ToolAction::NetworkEgress(_, _)
116                | ToolAction::ShellCommand(_)
117                | ToolAction::McpTool(_, _)
118                | ToolAction::Patch(_, _)
119                | ToolAction::CodeExecution { .. }
120                | ToolAction::BrowserAction { .. }
121                | ToolAction::DatabaseQuery { .. }
122                | ToolAction::ExternalApiCall { .. }
123                | ToolAction::MemoryWrite { .. }
124                | ToolAction::MemoryRead { .. }
125                | ToolAction::Unknown => {}
126            }
127        }
128        classify(&ToolAction::Unknown);
129    }
130
131    #[test]
132    fn extract_file_access() {
133        let args = serde_json::json!({"path": "/etc/shadow"});
134        let action = extract_action("read_file", &args);
135        assert!(matches!(action, ToolAction::FileAccess(ref p) if p == "/etc/shadow"));
136    }
137
138    #[test]
139    fn string_arg_preserves_key_priority_and_rejects_non_strings() {
140        let args = serde_json::json!({
141            "path": "/tmp/from-path",
142            "file": "/tmp/from-file",
143            "filename": "/tmp/from-filename"
144        });
145
146        assert_eq!(
147            string_arg(&args, &["path", "file", "filename"]),
148            Ok(Some("/tmp/from-path"))
149        );
150
151        let args = serde_json::json!({
152            "path": 42,
153            "file": "/tmp/from-file",
154            "filename": "/tmp/from-filename"
155        });
156
157        assert_eq!(
158            string_arg(&args, &["path", "file", "filename"]),
159            Err(StringArgViolation::Malformed { field: "path" })
160        );
161        assert_eq!(
162            string_arg(&args, &["missing", "path"]),
163            Err(StringArgViolation::Malformed { field: "path" })
164        );
165        assert_eq!(string_arg(&args, &["missing"]), Ok(None));
166    }
167
168    #[test]
169    fn extract_file_write() {
170        let args = serde_json::json!({"path": "/tmp/out.txt", "content": "hello"});
171        let action = extract_action("write_file", &args);
172        assert!(matches!(action, ToolAction::FileWrite(ref p, _) if p == "/tmp/out.txt"));
173    }
174
175    #[test]
176    fn extract_shell_command() {
177        let args = serde_json::json!({"command": "ls -la"});
178        let action = extract_action("bash", &args);
179        assert!(matches!(action, ToolAction::ShellCommand(ref c) if c == "ls -la"));
180    }
181
182    #[test]
183    fn shell_command_rejects_malformed_primary_alias() {
184        let args = serde_json::json!({"command": ["rm", "-rf", "/"], "cmd": "echo safe"});
185        assert_eq!(malformed_field("bash", &args), "command");
186    }
187
188    #[test]
189    fn extract_network_egress() {
190        let args = serde_json::json!({"url": "https://evil.com/api"});
191        let action = extract_action("http_request", &args);
192        assert!(matches!(action, ToolAction::NetworkEgress(ref h, 443) if h == "evil.com"));
193    }
194
195    #[test]
196    fn network_tool_with_filesystem_shaped_arguments_stays_network_egress() {
197        let args = serde_json::json!({
198            "url": "http://169.254.169.254/latest",
199            "path": "/tmp/cache-entry",
200            "action": "delete"
201        });
202        let action = extract_action("http_request", &args);
203        assert!(
204            matches!(action, ToolAction::NetworkEgress(ref h, 80) if h == "169.254.169.254"),
205            "expected NetworkEgress for http_request, got: {action:?}"
206        );
207    }
208
209    #[test]
210    fn network_rejects_malformed_primary_alias() {
211        let args =
212            serde_json::json!({"url": {"host": "169.254.169.254"}, "uri": "https://example.com"});
213        assert_eq!(malformed_field("http_request", &args), "url");
214    }
215
216    #[test]
217    fn network_rejects_unparseable_url() {
218        let args = serde_json::json!({"url": "not-a-host"});
219        assert_eq!(malformed_field("http_request", &args), "url");
220    }
221
222    #[test]
223    fn network_rejects_invalid_explicit_port() {
224        let args = serde_json::json!({"url": "http://127.0.0.1:notaport/latest"});
225        assert_eq!(malformed_field("http_request", &args), "url");
226    }
227
228    #[test]
229    fn network_rejects_ambiguous_unbracketed_ipv6() {
230        let args = serde_json::json!({"url": "http://fd00:ec2::254/latest"});
231        assert_eq!(malformed_field("http_request", &args), "url");
232    }
233
234    #[test]
235    fn extract_network_with_port() {
236        let args = serde_json::json!({"url": "http://localhost:8080/health"});
237        let action = extract_action("fetch", &args);
238        assert!(matches!(action, ToolAction::NetworkEgress(ref h, 8080) if h == "localhost"));
239    }
240
241    #[test]
242    fn extract_network_with_scheme_relative_url() {
243        let args = serde_json::json!({"url": "//169.254.169.254/latest"});
244        let action = extract_action("http_request", &args);
245        assert!(matches!(action, ToolAction::NetworkEgress(ref h, 443) if h == "169.254.169.254"));
246    }
247
248    #[test]
249    fn extract_network_with_mixed_case_scheme() {
250        let args = serde_json::json!({"url": "HTTPS://Example.COM/api"});
251        let action = extract_action("fetch", &args);
252        assert!(matches!(action, ToolAction::NetworkEgress(ref h, 443) if h == "example.com"));
253    }
254
255    #[test]
256    fn extract_network_strips_userinfo_and_ipv6_brackets() {
257        let userinfo_args = serde_json::json!({"url": "https://user:pass@evil.com/path"});
258        let userinfo_action = extract_action("http_request", &userinfo_args);
259        assert!(
260            matches!(userinfo_action, ToolAction::NetworkEgress(ref h, 443) if h == "evil.com")
261        );
262
263        let ipv6_args = serde_json::json!({"url": "https://[fd00:ec2::254]/latest"});
264        let ipv6_action = extract_action("http_request", &ipv6_args);
265        assert!(
266            matches!(ipv6_action, ToolAction::NetworkEgress(ref h, 443) if h == "fd00:ec2::254")
267        );
268    }
269
270    #[test]
271    fn extract_network_strips_query_and_fragment_from_authority() {
272        let query_args = serde_json::json!({"url": "https://metadata.google.internal?x=1"});
273        let query_action = extract_action("http_request", &query_args);
274        assert!(matches!(
275            query_action,
276            ToolAction::NetworkEgress(ref h, 443) if h == "metadata.google.internal"
277        ));
278
279        let fragment_args = serde_json::json!({"url": "https://metadata.google.internal#anchor"});
280        let fragment_action = extract_action("fetch", &fragment_args);
281        assert!(matches!(
282            fragment_action,
283            ToolAction::NetworkEgress(ref h, 443) if h == "metadata.google.internal"
284        ));
285    }
286
287    #[test]
288    fn unknown_tool_becomes_mcp_tool() {
289        let args = serde_json::json!({"foo": "bar"});
290        let action = extract_action("custom_tool", &args);
291        assert!(matches!(action, ToolAction::McpTool(_, _)));
292    }
293
294    #[test]
295    fn unknown_tool_with_path_still_becomes_mcp_tool() {
296        let args = serde_json::json!({"path": "/etc/shadow"});
297        let action = extract_action("custom_tool", &args);
298        assert!(matches!(action, ToolAction::McpTool(_, _)));
299    }
300
301    #[test]
302    fn filesystem_tool_read_by_default() {
303        let args = serde_json::json!({"path": "/etc/shadow"});
304        let action = extract_action("filesystem", &args);
305        assert!(
306            matches!(action, ToolAction::FileAccess(ref p) if p == "/etc/shadow"),
307            "expected FileAccess for filesystem tool with path-only params, got: {action:?}"
308        );
309    }
310
311    #[test]
312    fn filesystem_tool_rejects_malformed_primary_path_alias() {
313        let args = serde_json::json!({
314            "path": 42,
315            "file": "/home/user/project/src/main.rs"
316        });
317        assert_eq!(malformed_field("filesystem", &args), "path");
318    }
319
320    #[test]
321    fn filesystem_tool_rejects_missing_path() {
322        let args = serde_json::json!({"action": "read"});
323        assert_eq!(malformed_field("filesystem", &args), "path");
324    }
325
326    #[test]
327    fn filesystem_tool_rejects_malformed_action() {
328        let args = serde_json::json!({"path": "/tmp/out.txt", "action": ["write"]});
329        assert_eq!(malformed_field("filesystem", &args), "action");
330    }
331
332    #[test]
333    fn filesystem_write_rejects_malformed_content() {
334        let args = serde_json::json!({"path": "/tmp/out.txt", "content": {"bytes": "hi"}});
335        assert_eq!(malformed_field("write_file", &args), "content");
336    }
337
338    #[test]
339    fn recognized_extractors_reject_malformed_higher_priority_aliases() {
340        let cases = [
341            (
342                "python",
343                serde_json::json!({"code": ["print('unsafe')"], "source": "print('safe')"}),
344                "code",
345            ),
346            (
347                "eval",
348                serde_json::json!({"code": "console.log(1)", "language": ["javascript"], "lang": "python"}),
349                "language",
350            ),
351            (
352                "browser",
353                serde_json::json!({"action": ["click"], "verb": "navigate"}),
354                "action",
355            ),
356            (
357                "browser",
358                serde_json::json!({"action": "click", "url": ["https://evil.example"], "selector": "#safe"}),
359                "url",
360            ),
361            (
362                "sql",
363                serde_json::json!({"query": {"raw": "DROP TABLE users"}, "sql": "SELECT 1"}),
364                "query",
365            ),
366            (
367                "postgres",
368                serde_json::json!({"query": "SELECT 1", "database": ["prod"], "db": "readonly"}),
369                "database",
370            ),
371            (
372                "vector_upsert",
373                serde_json::json!({"collection": ["sensitive"], "store": "safe"}),
374                "collection",
375            ),
376            (
377                "vector_query",
378                serde_json::json!({"collection": "facts", "id": ["secret"], "key": "safe"}),
379                "id",
380            ),
381            (
382                "slack_send_message",
383                serde_json::json!({"endpoint": ["chat.postMessage"], "path": "chat.safe"}),
384                "endpoint",
385            ),
386        ];
387
388        for (tool_name, args, expected_field) in cases {
389            assert_eq!(
390                malformed_field(tool_name, &args),
391                expected_field,
392                "tool {tool_name} should reject malformed {expected_field}"
393            );
394        }
395    }
396
397    #[test]
398    fn filesystem_tool_explicit_read_action() {
399        let args = serde_json::json!({"path": "/etc/shadow", "action": "read"});
400        let action = extract_action("filesystem", &args);
401        assert!(
402            matches!(action, ToolAction::FileAccess(ref p) if p == "/etc/shadow"),
403            "expected FileAccess for filesystem tool with action=read, got: {action:?}"
404        );
405    }
406
407    #[test]
408    fn filesystem_tool_write_action() {
409        let args = serde_json::json!({"path": "/tmp/out.txt", "action": "write", "content": "hi"});
410        let action = extract_action("filesystem", &args);
411        assert!(
412            matches!(action, ToolAction::FileWrite(ref p, _) if p == "/tmp/out.txt"),
413            "expected FileWrite for filesystem tool with action=write, got: {action:?}"
414        );
415    }
416
417    #[test]
418    fn filesystem_tool_write_inferred_from_content() {
419        let args = serde_json::json!({"path": "/tmp/out.txt", "content": "data"});
420        let action = extract_action("filesystem", &args);
421        assert!(
422            matches!(action, ToolAction::FileWrite(ref p, _) if p == "/tmp/out.txt"),
423            "expected FileWrite for filesystem tool with content field, got: {action:?}"
424        );
425    }
426
427    #[test]
428    fn fs_tool_alias() {
429        let args = serde_json::json!({"path": "/etc/passwd"});
430        let action = extract_action("fs", &args);
431        assert!(
432            matches!(action, ToolAction::FileAccess(ref p) if p == "/etc/passwd"),
433            "expected FileAccess for fs tool alias, got: {action:?}"
434        );
435    }
436
437    #[test]
438    fn acp_fs_read_text_file_classifies_as_file_access() {
439        let args = serde_json::json!({"path": "/etc/shadow", "sessionId": "sess-1"});
440        let action = extract_action("fs/read_text_file", &args);
441        assert!(
442            matches!(action, ToolAction::FileAccess(ref p) if p == "/etc/shadow"),
443            "expected FileAccess for ACP fs/read_text_file, got: {action:?}"
444        );
445    }
446
447    #[test]
448    fn acp_fs_write_text_file_classifies_as_file_write() {
449        let args = serde_json::json!({
450            "path": "/workspace/out.txt",
451            "content": "hello",
452            "sessionId": "sess-1"
453        });
454        let action = extract_action("fs/write_text_file", &args);
455        assert!(
456            matches!(action, ToolAction::FileWrite(ref p, ref content)
457                if p == "/workspace/out.txt" && content == b"hello"),
458            "expected FileWrite for ACP fs/write_text_file, got: {action:?}"
459        );
460    }
461
462    #[test]
463    fn fs_prefix_stat_classifies_as_file_access() {
464        let args = serde_json::json!({"path": "/workspace/file.txt"});
465        let action = extract_action("fs_stat", &args);
466        assert!(
467            matches!(action, ToolAction::FileAccess(ref p) if p == "/workspace/file.txt"),
468            "expected FileAccess for fs_stat, got: {action:?}"
469        );
470    }
471
472    #[test]
473    fn file_tool_alias() {
474        let args = serde_json::json!({"path": "/etc/passwd"});
475        let action = extract_action("file", &args);
476        assert!(
477            matches!(action, ToolAction::FileAccess(ref p) if p == "/etc/passwd"),
478            "expected FileAccess for file tool alias, got: {action:?}"
479        );
480    }
481
482    #[test]
483    fn patch_tool_rejects_malformed_diff_alias() {
484        let action = extract_action(
485            "apply_patch",
486            &serde_json::json!({"path": "/repo/src/lib.rs", "diff": ["@@ -1 +1 @@"], "patch": "@@ -1 +1 @@"}),
487        );
488        assert!(matches!(action, ToolAction::Unknown));
489
490        let err_field = malformed_field(
491            "apply_patch",
492            &serde_json::json!({"path": "/repo/src/lib.rs", "diff": ["@@ -1 +1 @@"], "patch": "@@ -1 +1 @@"}),
493        );
494        assert_eq!(err_field, "diff");
495    }
496
497    #[test]
498    fn extract_code_execution_python() {
499        let args = serde_json::json!({"code": "import os; os.listdir('.')"});
500        let action = extract_action("python", &args);
501        match action {
502            ToolAction::CodeExecution { language, code } => {
503                assert_eq!(language, "python");
504                assert!(code.contains("os.listdir"));
505            }
506            other => panic!("expected CodeExecution, got: {other:?}"),
507        }
508    }
509
510    #[test]
511    fn extract_code_execution_explicit_language() {
512        let args = serde_json::json!({"source": "console.log(1)", "language": "javascript"});
513        let action = extract_action("eval", &args);
514        match action {
515            ToolAction::CodeExecution { language, code } => {
516                assert_eq!(language, "javascript");
517                assert_eq!(code, "console.log(1)");
518            }
519            other => panic!("expected CodeExecution, got: {other:?}"),
520        }
521    }
522
523    #[test]
524    fn extract_browser_navigate() {
525        let args = serde_json::json!({"url": "https://example.com"});
526        let action = extract_action("navigate", &args);
527        match action {
528            ToolAction::BrowserAction { verb, target } => {
529                assert_eq!(verb, "navigate");
530                assert_eq!(target.as_deref(), Some("https://example.com"));
531            }
532            other => panic!("expected BrowserAction, got: {other:?}"),
533        }
534    }
535
536    #[test]
537    fn extract_browser_click_with_selector() {
538        let args = serde_json::json!({"action": "click", "selector": "#submit"});
539        let action = extract_action("browser", &args);
540        match action {
541            ToolAction::BrowserAction { verb, target } => {
542                assert_eq!(verb, "click");
543                assert_eq!(target.as_deref(), Some("#submit"));
544            }
545            other => panic!("expected BrowserAction, got: {other:?}"),
546        }
547    }
548
549    #[test]
550    fn extract_database_query() {
551        let args = serde_json::json!({"query": "SELECT * FROM users", "database": "prod"});
552        let action = extract_action("sql", &args);
553        match action {
554            ToolAction::DatabaseQuery { database, query } => {
555                assert_eq!(database, "prod");
556                assert!(query.contains("SELECT"));
557            }
558            other => panic!("expected DatabaseQuery, got: {other:?}"),
559        }
560    }
561
562    #[test]
563    fn extract_database_query_default_db() {
564        let args = serde_json::json!({"query": "SELECT 1"});
565        let action = extract_action("postgres", &args);
566        match action {
567            ToolAction::DatabaseQuery { database, .. } => {
568                assert_eq!(database, "postgres");
569            }
570            other => panic!("expected DatabaseQuery, got: {other:?}"),
571        }
572    }
573
574    #[test]
575    fn extract_memory_write() {
576        let args = serde_json::json!({"collection": "agent-notes", "id": "mem-42"});
577        let action = extract_action("vector_upsert", &args);
578        match action {
579            ToolAction::MemoryWrite { store, key } => {
580                assert_eq!(store, "agent-notes");
581                assert_eq!(key, "mem-42");
582            }
583            other => panic!("expected MemoryWrite, got: {other:?}"),
584        }
585    }
586
587    #[test]
588    fn extract_memory_read_with_key() {
589        let args = serde_json::json!({"namespace": "session-1", "id": "fact-7"});
590        let action = extract_action("recall", &args);
591        match action {
592            ToolAction::MemoryRead { store, key } => {
593                assert_eq!(store, "session-1");
594                assert_eq!(key.as_deref(), Some("fact-7"));
595            }
596            other => panic!("expected MemoryRead, got: {other:?}"),
597        }
598    }
599
600    #[test]
601    fn extract_memory_read_without_key() {
602        let args = serde_json::json!({"collection": "facts"});
603        let action = extract_action("vector_query", &args);
604        match action {
605            ToolAction::MemoryRead { store, key } => {
606                assert_eq!(store, "facts");
607                assert!(key.is_none());
608            }
609            other => panic!("expected MemoryRead, got: {other:?}"),
610        }
611    }
612
613    #[test]
614    fn extract_external_api_call_slack() {
615        let args = serde_json::json!({"endpoint": "chat.postMessage"});
616        let action = extract_action("slack_send_message", &args);
617        match action {
618            ToolAction::ExternalApiCall { service, endpoint } => {
619                assert_eq!(service, "slack");
620                assert_eq!(endpoint, "chat.postMessage");
621            }
622            other => panic!("expected ExternalApiCall, got: {other:?}"),
623        }
624    }
625
626    #[test]
627    fn extract_external_api_call_stripe_default_endpoint() {
628        let args = serde_json::json!({});
629        let action = extract_action("stripe_create_charge", &args);
630        match action {
631            ToolAction::ExternalApiCall { service, endpoint } => {
632                assert_eq!(service, "stripe");
633                assert_eq!(endpoint, "stripe_create_charge");
634            }
635            other => panic!("expected ExternalApiCall, got: {other:?}"),
636        }
637    }
638
639    #[test]
640    fn filesystem_tool_actions_expose_target_path() {
641        let read = extract_action(
642            "filesystem",
643            &serde_json::json!({"path": "/repo/src/lib.rs"}),
644        );
645        let write = extract_action(
646            "filesystem",
647            &serde_json::json!({"path": "/repo/src/lib.rs", "action": "write", "content": "hi"}),
648        );
649        let patch = extract_action(
650            "apply_patch",
651            &serde_json::json!({"path": "/repo/src/lib.rs", "patch": "@@ -1 +1 @@"}),
652        );
653
654        assert_eq!(read.filesystem_path(), Some("/repo/src/lib.rs"));
655        assert_eq!(write.filesystem_path(), Some("/repo/src/lib.rs"));
656        assert_eq!(patch.filesystem_path(), Some("/repo/src/lib.rs"));
657    }
658}