1use serde_json::Value;
9
10#[derive(Clone, Debug)]
15pub enum ToolAction {
16 FileAccess(String),
18 FileWrite(String, Vec<u8>),
20 NetworkEgress(String, u16),
22 ShellCommand(String),
24 McpTool(String, Value),
26 Patch(String, String),
28 CodeExecution { language: String, code: String },
30 BrowserAction {
32 verb: String,
33 target: Option<String>,
34 },
35 DatabaseQuery { database: String, query: String },
37 ExternalApiCall { service: String, endpoint: String },
39 MemoryWrite { store: String, key: String },
41 MemoryRead { store: String, key: Option<String> },
43 Unknown,
45}
46
47#[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 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
76pub fn extract_action(tool_name: &str, arguments: &Value) -> ToolAction {
83 extractor::extract_action(tool_name, arguments)
84}
85
86pub 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 #[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}