1mod mutation;
5pub use mutation::{is_docs_only_write, is_plan_artifact_write, tmp_plans_dir};
6
7pub mod actions;
8pub mod activity;
9pub mod classify;
10pub mod readonly;
11pub mod types;
12
13pub use actions::{
14 action_qualified_policy_name, command_session_action, command_session_action_in, command_session_action_is,
15 file_operation_action, file_operation_action_in, file_operation_action_is, mcp_action, mcp_action_is,
16};
17pub use activity::{
18 GENERIC_VERIFIER_DESCRIPTION, ShellActivity, VERIFIER_SHELL_FORM_NOTE, classify_shell_activity,
19 default_verifier_for_workspace, exec_mutation_should_incur_debt, rewrite_truncation_only_verifier,
20 shell_args_as_executed, shell_command_contains_verifier, shell_command_is_admitted_verification_attempt,
21 shell_command_is_output_search, shell_command_is_standalone_grep_search, verification_recovery_directive,
22 verifier_reference,
23};
24pub use classify::{
25 builtin_tool_behavior, canonical_command_session_tool_name, classify_tool_intent, is_command_run_tool,
26 is_command_run_tool_call, is_command_session_tool, is_command_tool, is_edited_file_conflict_guarded_call,
27 is_exec_session_cleanup_call, is_parallel_safe_call, is_parallel_safe_call_with_intent, is_turn_budget_exempt_call,
28 planning_allowed_actions, remap_file_operation_command_args_to_command_session, should_use_spool_reference_only,
29};
30pub use readonly::{is_readonly_command_session_command, is_spool_file_read_command};
31pub use types::{ToolBehavior, ToolIntent, ToolIntentClassifier, ToolMutationModel, ToolSurfaceKind};
32
33#[cfg(test)]
34mod tests {
35 use super::{
36 canonical_command_session_tool_name, classify_tool_intent, file_operation_action, is_command_run_tool_call,
37 is_command_session_tool, is_edited_file_conflict_guarded_call, is_parallel_safe_call,
38 is_parallel_safe_call_with_intent, remap_file_operation_command_args_to_command_session,
39 should_use_spool_reference_only,
40 };
41 use crate::config::constants::tools;
42 use serde_json::json;
43
44 #[test]
45 fn file_operation_read_is_retry_safe() {
46 let intent = classify_tool_intent(tools::UNIFIED_FILE, &json!({"action": "read", "path": "README.md"}));
47 assert!(!intent.mutating);
48 assert!(intent.readonly_unified_action);
49 assert!(intent.retry_safe);
50 }
51
52 #[test]
53 fn command_session_poll_is_retry_safe() {
54 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "poll", "session_id": 1}));
55 assert!(!intent.mutating);
56 assert!(intent.readonly_unified_action);
57 assert!(intent.retry_safe);
58 }
59
60 #[test]
61 fn turn_budget_exempt_covers_wait_and_inspect_only() {
62 use super::is_turn_budget_exempt_call;
63
64 assert!(is_turn_budget_exempt_call(tools::UNIFIED_EXEC, &json!({"action": "wait", "session_id": "run-1"})));
66 assert!(is_turn_budget_exempt_call(
67 tools::UNIFIED_EXEC,
68 &json!({"action": "inspect", "spool_path": ".vtcode/context/tool_outputs/run-1.txt"})
69 ));
70 assert!(is_turn_budget_exempt_call(tools::WRITE_STDIN, &json!({"action": "wait", "session_id": "run-1"})));
71 assert!(is_turn_budget_exempt_call(tools::EXEC_COMMAND, &json!({"action": "wait", "session_id": "run-1"})));
72
73 assert!(!is_turn_budget_exempt_call(
75 tools::UNIFIED_EXEC,
76 &json!({"action": "run", "command": "./scripts/check-dev.sh"})
77 ));
78 assert!(!is_turn_budget_exempt_call(tools::UNIFIED_EXEC, &json!({"action": "poll", "session_id": "run-1"})));
79 assert!(!is_turn_budget_exempt_call(
80 tools::UNIFIED_EXEC,
81 &json!({"action": "continue", "session_id": "run-1", "input": "y"})
82 ));
83 assert!(!is_turn_budget_exempt_call(tools::UNIFIED_EXEC, &json!({"action": "list"})));
84
85 assert!(!is_turn_budget_exempt_call(tools::READ_FILE, &json!({"action": "wait", "path": "x.txt"})));
87 assert!(!is_turn_budget_exempt_call(tools::APPLY_PATCH, &json!({"action": "inspect", "input": "..."})));
88 assert!(!is_turn_budget_exempt_call(tools::UNIFIED_FILE, &json!({"action": "read", "path": "x.txt"})));
89
90 assert!(is_turn_budget_exempt_call(tools::RUN_PTY_CMD, &json!({"action": "wait", "session_id": "run-1"})));
92 }
93
94 #[test]
95 fn skill_loading_is_mutating_and_requires_confirmation() {
96 let intent = classify_tool_intent(tools::LOAD_SKILL, &json!({"name": "example"}));
97 assert!(intent.mutating);
98 assert!(intent.destructive);
99 assert!(!intent.retry_safe);
100 }
101
102 #[test]
103 fn command_session_inspect_is_retry_safe() {
104 let intent = classify_tool_intent(
105 tools::UNIFIED_EXEC,
106 &json!({"action": "inspect", "spool_path": ".vtcode/context/tool_outputs/run-1.txt"}),
107 );
108 assert!(!intent.mutating);
109 assert!(intent.readonly_unified_action);
110 assert!(intent.retry_safe);
111 }
112
113 #[test]
114 fn command_session_continue_without_input_is_retry_safe() {
115 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "continue", "session_id": "run-1"}));
116 assert!(!intent.mutating);
117 assert!(intent.readonly_unified_action);
118 assert!(intent.retry_safe);
119 }
120
121 #[test]
122 fn command_session_continue_with_input_is_mutating_and_destructive() {
123 let intent = classify_tool_intent(
124 tools::UNIFIED_EXEC,
125 &json!({"action": "continue", "session_id": "run-1", "input": "q"}),
126 );
127 assert!(intent.mutating);
128 assert!(intent.destructive);
129 assert!(!intent.readonly_unified_action);
130 assert!(!intent.retry_safe);
131 }
132
133 #[test]
134 fn command_session_continue_with_empty_input_stays_retry_safe() {
135 let intent = classify_tool_intent(
136 tools::UNIFIED_EXEC,
137 &json!({"action": "continue", "session_id": "run-1", "input": ""}),
138 );
139 assert!(!intent.mutating);
140 assert!(intent.readonly_unified_action);
141 assert!(intent.retry_safe);
142 }
143
144 #[test]
145 fn command_session_run_is_mutating_and_destructive() {
146 let intent =
147 classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": "rm -rf /tmp/test"}));
148 assert!(intent.mutating);
149 assert!(intent.destructive);
150 assert!(!intent.retry_safe);
151 }
152
153 #[test]
154 fn command_session_run_echo_is_read_only() {
155 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": "echo hi"}));
156 assert!(!intent.mutating);
157 assert!(intent.readonly_unified_action);
158 assert!(intent.retry_safe);
159 }
160
161 #[test]
162 fn command_session_run_diff_is_read_only() {
163 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": "diff a.rs b.rs"}));
164 assert!(!intent.mutating);
165 assert!(intent.readonly_unified_action);
166 assert!(intent.retry_safe);
167 }
168
169 #[test]
170 fn command_session_run_find_is_read_only() {
171 let intent = classify_tool_intent(
172 tools::UNIFIED_EXEC,
173 &json!({"action": "run", "command": "find . -type f -name '*.rs' -not -path '*/target/*'"}),
174 );
175 assert!(!intent.mutating);
176 assert!(intent.readonly_unified_action);
177 assert!(intent.retry_safe);
178 }
179
180 #[test]
181 fn command_session_run_grep_wc_head_are_read_only() {
182 for cmd in [
183 "grep -rn 'todo' src",
184 "wc -l src/main.rs",
185 "head -50 src/lib.rs",
186 "tail -20 src/lib.rs",
187 "sort src/words.txt | uniq",
188 "ast-grep -p 'foo($A)' -l rs",
189 ] {
190 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": cmd}));
191 assert!(!intent.mutating, "expected '{cmd}' to be read-only");
192 assert!(intent.readonly_unified_action, "expected '{cmd}' to be readonly_unified_action");
193 }
194 }
195
196 #[test]
197 fn command_session_run_with_redirection_is_mutating() {
198 for cmd in [
199 "cat a.txt > b.txt",
200 "grep x src > out.txt",
201 "diff a b | wc -l > count.txt",
202 "echo $(date) > log.txt",
203 ] {
204 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": cmd}));
205 assert!(intent.mutating, "expected '{cmd}' to be mutating because it contains redirection/substitution");
206 }
207 }
208
209 #[test]
210 fn command_session_run_find_with_destructive_flags_is_mutating() {
211 for cmd in [
212 "find . -type f -delete",
213 "find . -name '*.tmp' -exec rm {} \\;",
214 "find . -name '*.tmp' -exec chmod 600 {} \\;",
215 ] {
216 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": cmd}));
217 assert!(intent.mutating, "expected '{cmd}' to be mutating because it has destructive find flags");
218 }
219 }
220
221 #[test]
222 fn command_session_run_pipelines_with_unsafe_segments_are_mutating() {
223 for cmd in ["cat a.txt | tee b.txt", "python script.py | cat", "grep x src | rm -rf"] {
224 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": cmd}));
225 assert!(intent.mutating, "expected '{cmd}' to be mutating because a pipeline segment is unsafe");
226 }
227 }
228
229 #[test]
230 fn command_session_run_allowlisted_is_read_only() {
231 let intent =
232 classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": "rg planning_active src"}));
233 assert!(!intent.mutating);
234 assert!(intent.readonly_unified_action);
235 assert!(intent.retry_safe);
236 }
237
238 #[test]
239 fn command_session_run_dry_run_is_read_only() {
240 let intent =
241 classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": "npm install --dry-run"}));
242 assert!(!intent.mutating);
243 assert!(intent.readonly_unified_action);
244 assert!(intent.retry_safe);
245 }
246
247 #[test]
248 fn parallel_safe_calls_reject_control_and_exec_paths() {
249 assert!(is_parallel_safe_call(tools::READ_FILE, &json!({"path": "README.md"})));
250 assert!(!is_parallel_safe_call(tools::LIST_PTY_SESSIONS, &json!({})));
251 assert!(!is_parallel_safe_call(tools::REQUEST_USER_INPUT, &json!({"questions": []})));
252 assert!(!is_parallel_safe_call(tools::UNIFIED_EXEC, &json!({"action": "inspect", "session_id": "run-1"})));
253 assert!(is_parallel_safe_call(tools::EXEC_COMMAND, &json!({"cmd": "sed -n '1,40p' README.md"})));
254 assert!(!is_parallel_safe_call(tools::EXEC_COMMAND, &json!({"cmd": "cat README.md && cat Cargo.toml"})));
255 assert!(!is_parallel_safe_call(
256 tools::EXEC_COMMAND,
257 &json!({"cmd": "sed -n '1,40p' README.md", "tty": true})
258 ));
259 }
260
261 #[test]
262 fn parallel_safe_with_intent_matches_classifying_variant() {
263 for (tool_name, args) in [
267 (tools::UNIFIED_EXEC, json!({"action": "run", "command": "rg -n 'todo' src"})),
268 (tools::UNIFIED_EXEC, json!({"action": "run", "command": "rm -rf /tmp/test"})),
269 (tools::UNIFIED_EXEC, json!({"action": "poll", "session_id": "run-1"})),
270 (tools::EXEC_COMMAND, json!({"cmd": "sed -n '1,40p' README.md"})),
271 (tools::READ_FILE, json!({"path": "README.md"})),
272 (tools::WRITE_FILE, json!({"path": "README.md", "content": "x"})),
273 (tools::APPLY_PATCH, json!({"input": "*** Begin Patch\n*** End Patch\n"})),
274 ("totally_unknown_tool", json!({"path": "README.md"})),
275 ] {
276 let with_intent =
277 is_parallel_safe_call_with_intent(tool_name, &args, &classify_tool_intent(tool_name, &args));
278 assert_eq!(
279 with_intent,
280 is_parallel_safe_call(tool_name, &args),
281 "variants disagree for {tool_name} with {args}"
282 );
283 }
284 }
285
286 #[test]
287 fn command_session_cmd_alias_infers_run() {
288 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"cmd": "rm -rf /tmp/test"}));
289 assert!(intent.mutating);
290 assert!(intent.destructive);
291 assert!(!intent.readonly_unified_action);
292 }
293
294 #[test]
295 fn command_session_chars_alias_infers_write() {
296 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"session_id": "abc123", "chars": "status\n"}));
297 assert!(intent.mutating);
298 assert!(intent.destructive);
299 assert!(!intent.readonly_unified_action);
300 }
301
302 #[test]
303 fn write_stdin_empty_chars_is_read_only_and_retry_safe() {
304 let intent = classify_tool_intent(tools::WRITE_STDIN, &json!({"session_id": "abc123", "chars": ""}));
305
306 assert!(!intent.mutating);
307 assert!(!intent.destructive);
308 assert!(!intent.readonly_unified_action);
309 assert!(intent.retry_safe);
310 }
311
312 #[test]
313 fn write_stdin_non_empty_chars_is_mutating() {
314 let intent = classify_tool_intent(tools::WRITE_STDIN, &json!({"session_id": "abc123", "chars": " status\n"}));
315
316 assert!(intent.mutating);
317 assert!(intent.destructive);
318 assert!(!intent.readonly_unified_action);
319 assert!(!intent.retry_safe);
320 }
321
322 #[test]
323 fn command_session_text_alias_infers_write() {
324 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"session_id": "abc123", "text": "status\n"}));
325 assert!(intent.mutating);
326 assert!(intent.destructive);
327 assert!(!intent.readonly_unified_action);
328 }
329
330 #[test]
331 fn command_session_spool_path_alias_infers_inspect() {
332 let intent =
333 classify_tool_intent(tools::UNIFIED_EXEC, &json!({"spool_path": ".vtcode/context/tool_outputs/run-1.txt"}));
334 assert!(!intent.mutating);
335 assert!(!intent.destructive);
336 assert!(intent.readonly_unified_action);
337 }
338
339 #[test]
340 fn command_session_compact_session_alias_infers_poll() {
341 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"s": "run-1"}));
342 assert!(!intent.mutating);
343 assert!(!intent.destructive);
344 assert!(intent.readonly_unified_action);
345 }
346
347 #[test]
348 fn file_operation_unknown_args_require_action() {
349 let args = json!({
350 "unexpected": true
351 });
352 let action = file_operation_action(&args);
353 assert_eq!(action, None);
354 }
355
356 #[test]
357 fn file_operation_compact_path_alias_infers_read() {
358 let args = json!({
359 "p": "README.md"
360 });
361 let action = file_operation_action(&args);
362 assert_eq!(action, Some("read"));
363 }
364
365 #[test]
366 fn remap_file_operation_command_args_maps_command_payload_to_command_session() {
367 let remapped = remap_file_operation_command_args_to_command_session(&json!({
368 "command": "cargo check",
369 "cwd": ".",
370 "timeout_ms": 1000
371 }))
372 .expect("command payload should remap");
373
374 assert_eq!(remapped["action"], "run");
375 assert_eq!(remapped["command"], "cargo check");
376 assert_eq!(remapped["cwd"], ".");
377 assert_eq!(remapped["timeout_ms"], 1000);
378 }
379
380 #[test]
381 fn remap_file_operation_command_args_accepts_exec_action_aliases() {
382 let remapped = remap_file_operation_command_args_to_command_session(&json!({
383 "action": "shell",
384 "cmd": "echo ok"
385 }))
386 .expect("shell action alias should remap");
387
388 assert_eq!(remapped["action"], "run");
389 assert_eq!(remapped["command"], "echo ok");
390 }
391
392 #[test]
393 fn remap_file_operation_command_args_rejects_non_command_actions() {
394 let remapped = remap_file_operation_command_args_to_command_session(&json!({
395 "action": "read",
396 "command": "echo ok"
397 }));
398
399 assert_eq!(remapped, None);
400 }
401
402 #[test]
403 fn edited_file_conflict_guard_accepts_supported_mutations() {
404 assert!(is_edited_file_conflict_guarded_call(
405 tools::WRITE_FILE,
406 &json!({"path": "README.md", "content": "agent"})
407 ));
408 assert!(is_edited_file_conflict_guarded_call(
409 tools::CREATE_FILE,
410 &json!({"path": "README.md", "content": "agent"})
411 ));
412 assert!(is_edited_file_conflict_guarded_call(
413 tools::EDIT_FILE,
414 &json!({"path": "README.md", "old_str": "a", "new_str": "b"})
415 ));
416 assert!(is_edited_file_conflict_guarded_call(
417 tools::APPLY_PATCH,
418 &json!({"patch": "*** Begin Patch\n*** End Patch\n"})
419 ));
420 assert!(is_edited_file_conflict_guarded_call(
421 tools::UNIFIED_FILE,
422 &json!({"action": "write", "path": "README.md", "content": "agent"})
423 ));
424 assert!(is_edited_file_conflict_guarded_call(
425 tools::UNIFIED_FILE,
426 &json!({"action": "create", "path": "README.md", "content": "agent"})
427 ));
428 }
429
430 #[test]
431 fn edited_file_conflict_guard_rejects_non_guarded_calls() {
432 assert!(!is_edited_file_conflict_guarded_call(tools::READ_FILE, &json!({"path": "README.md"})));
433 assert!(!is_edited_file_conflict_guarded_call(tools::GREP_FILE, &json!({"pattern": "needle", "path": "."})));
434 assert!(!is_edited_file_conflict_guarded_call(tools::LIST_FILES, &json!({"path": "."})));
435 assert!(!is_edited_file_conflict_guarded_call(
436 tools::UNIFIED_FILE,
437 &json!({"action": "read", "path": "README.md"})
438 ));
439 assert!(!is_edited_file_conflict_guarded_call(
440 tools::UNIFIED_FILE,
441 &json!({"action": "delete", "path": "README.md"})
442 ));
443 assert!(!is_edited_file_conflict_guarded_call(
444 tools::UNIFIED_EXEC,
445 &json!({"action": "run", "command": "git status"})
446 ));
447 }
448
449 #[test]
450 fn canonical_command_session_tool_name_normalizes_exec_aliases() {
451 for alias in [
452 tools::UNIFIED_EXEC,
453 tools::RUN_PTY_CMD,
454 tools::SEND_PTY_INPUT,
455 tools::READ_PTY_SESSION,
456 tools::LIST_PTY_SESSIONS,
457 tools::CLOSE_PTY_SESSION,
458 tools::EXECUTE_CODE,
459 tools::EXEC_PTY_CMD,
460 tools::EXEC_COMMAND,
461 tools::WRITE_STDIN,
462 tools::SHELL,
463 "bash",
464 "exec",
465 "container.exec",
466 ] {
467 assert_eq!(canonical_command_session_tool_name(alias), Some(tools::UNIFIED_EXEC));
468 assert!(is_command_session_tool(alias));
469 }
470 assert!(!is_command_session_tool(tools::READ_FILE));
471 assert!(!is_command_session_tool(""));
472 }
473
474 #[test]
475 fn spool_reference_only_detects_exec_aliases() {
476 assert!(should_use_spool_reference_only(
477 Some(tools::RUN_PTY_CMD),
478 &json!({"spool_path": ".vtcode/context/tool_outputs/run-1.txt"})
479 ));
480 }
481
482 #[test]
483 fn spool_reference_only_detects_exec_payload_without_tool_name() {
484 assert!(should_use_spool_reference_only(
485 None,
486 &json!({
487 "command": "cargo check",
488 "spool_path": ".vtcode/context/tool_outputs/run-1.txt",
489 "exit_code": 0
490 })
491 ));
492 }
493
494 #[test]
495 fn spool_reference_only_skips_loop_recovery_payloads() {
496 assert!(!should_use_spool_reference_only(
497 Some(tools::UNIFIED_EXEC),
498 &json!({
499 "spool_path": ".vtcode/context/tool_outputs/run-1.txt",
500 "exit_code": 0,
501 "loop_detected": true
502 })
503 ));
504 }
505
506 #[test]
507 fn is_command_run_tool_call_only_accepts_run_actions() {
508 assert!(is_command_run_tool_call(tools::RUN_PTY_CMD, &json!({"command": "cargo check"})));
509 assert!(is_command_run_tool_call(tools::UNIFIED_EXEC, &json!({"action": "run", "command": "cargo check"})));
510 assert!(is_command_run_tool_call(tools::EXEC_COMMAND, &json!({"cmd": "cargo check"})));
511 assert!(!is_command_run_tool_call(tools::UNIFIED_EXEC, &json!({"action": "poll", "session_id": "run-1"})));
512 assert!(!is_command_run_tool_call(tools::WRITE_STDIN, &json!({"session_id": "run-1", "chars": "q"})));
513 }
514}