vtcode_core/tools/registry/
shell_policy.rs1use anyhow::{Result, anyhow};
2use regex::Regex;
3use std::collections::hash_map::DefaultHasher;
4use std::hash::{Hash, Hasher};
5use tracing::warn;
6
7#[derive(Clone, Debug)]
8pub struct ShellPolicyCacheEntry {
9 pub signature: u64,
10 pub deny_regexes: Vec<(String, Regex)>,
11 pub deny_globs: Vec<(String, Regex)>,
12}
13
14pub struct ShellPolicyChecker {
15 cache: Option<ShellPolicyCacheEntry>,
16 commands_config: Option<crate::config::CommandsConfig>,
17}
18
19impl ShellPolicyChecker {
20 pub fn new() -> Self {
21 Self { cache: None, commands_config: None }
22 }
23}
24
25impl Default for ShellPolicyChecker {
26 fn default() -> Self {
27 Self::new()
28 }
29}
30
31impl ShellPolicyChecker {
32 pub fn set_commands_config(&mut self, commands_config: &crate::config::CommandsConfig) {
33 self.commands_config = Some(commands_config.clone());
34 self.reset_cache();
35 }
36
37 pub fn commands_config(&self) -> Option<&crate::config::CommandsConfig> {
38 self.commands_config.as_ref()
39 }
40
41 pub fn check_command(
42 &mut self,
43 command: &str,
44 agent_type: &str,
45 deny_regex_patterns: &[String],
46 deny_glob_patterns: &[String],
47 ) -> Result<()> {
48 let mut hasher = DefaultHasher::new();
49 deny_regex_patterns.hash(&mut hasher);
50 deny_glob_patterns.hash(&mut hasher);
51 let signature = hasher.finish();
52
53 let entry = if let Some(ref entry) = self.cache
54 && entry.signature == signature
55 {
56 entry
57 } else {
58 let compiled_regexes = deny_regex_patterns
59 .iter()
60 .filter_map(|pattern| {
61 if pattern.is_empty() {
62 return None;
63 }
64 match Regex::new(pattern) {
65 Ok(re) => Some((pattern.clone(), re)),
66 Err(err) => {
67 warn!(agent = agent_type, pattern, error = %err, "Invalid deny regex pattern skipped");
68 None
69 }
70 }
71 })
72 .collect::<Vec<_>>();
73
74 let compiled_globs = deny_glob_patterns
75 .iter()
76 .filter_map(|pattern| {
77 if pattern.is_empty() {
78 return None;
79 }
80 let re_pattern = format!("^{}$", regex::escape(pattern).replace(r"\*", ".*").replace(r"\?", "."));
81 match Regex::new(&re_pattern) {
82 Ok(re) => Some((pattern.clone(), re)),
83 Err(err) => {
84 warn!(agent = agent_type, pattern, error = %err, "Invalid deny glob pattern skipped");
85 None
86 }
87 }
88 })
89 .collect::<Vec<_>>();
90
91 let new_entry = ShellPolicyCacheEntry {
92 signature,
93 deny_regexes: compiled_regexes,
94 deny_globs: compiled_globs,
95 };
96 self.cache = Some(new_entry);
97 self.cache
98 .as_ref()
99 .ok_or_else(|| anyhow!("Failed to initialize shell policy cache entry"))?
100 };
101
102 for sub_command in split_compound_command(command) {
107 let sub = sub_command.trim();
108 if sub.is_empty() {
109 continue;
110 }
111
112 for (pattern, compiled) in &entry.deny_regexes {
113 if compiled.is_match(sub) {
114 return Err(anyhow!("Shell command denied by agent regex policy: {pattern}"));
115 }
116 }
117
118 for (pattern, compiled) in &entry.deny_globs {
119 if compiled.is_match(sub) {
120 return Err(anyhow!("Shell command denied by agent glob policy: {pattern}"));
121 }
122 }
123 }
124
125 Ok(())
126 }
127
128 pub fn reset_cache(&mut self) {
129 self.cache = None;
130 }
131}
132
133fn split_compound_command(command: &str) -> Vec<&str> {
146 let mut parts = Vec::new();
147 let mut current_start: usize = 0;
148 let bytes = command.as_bytes();
151 let len = bytes.len();
152
153 let mut i: usize = 0;
154 while i < len {
155 if bytes[i] == b'&' && i + 1 < len && bytes[i + 1] == b'&' {
156 parts.push(&command[current_start..i]);
158 i += 2;
159 while i < len && bytes[i] == b' ' {
161 i += 1;
162 }
163 current_start = i;
164 } else if bytes[i] == b'|' && i + 1 < len && bytes[i + 1] == b'|' {
165 parts.push(&command[current_start..i]);
167 i += 2;
168 while i < len && bytes[i] == b' ' {
169 i += 1;
170 }
171 current_start = i;
172 } else if bytes[i] == b';' {
173 parts.push(&command[current_start..i]);
175 i += 1;
176 while i < len && bytes[i] == b' ' {
177 i += 1;
178 }
179 current_start = i;
180 } else {
181 i += 1;
182 }
183 }
184
185 if current_start < len {
187 parts.push(&command[current_start..]);
188 }
189
190 parts
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
198 fn split_compound_and_operator() {
199 let parts = split_compound_command("echo a && echo b");
200 assert_eq!(parts, vec!["echo a ", "echo b"]);
202 }
203
204 #[test]
205 fn split_compound_or_operator() {
206 let parts = split_compound_command("echo a || echo b");
207 assert_eq!(parts, vec!["echo a ", "echo b"]);
208 }
209
210 #[test]
211 fn split_compound_semicolon() {
212 let parts = split_compound_command("echo a; echo b");
213 assert_eq!(parts, vec!["echo a", "echo b"]);
214 }
215
216 #[test]
217 fn split_compound_mixed_operators() {
218 let parts = split_compound_command("echo a && echo b || echo c; echo d");
219 assert_eq!(parts, vec!["echo a ", "echo b ", "echo c", "echo d"]);
220 }
221
222 #[test]
223 fn split_compound_leading_and() {
224 let parts = split_compound_command("&& echo a");
225 assert_eq!(parts, vec!["", "echo a"]);
227 }
228
229 #[test]
230 fn split_compound_trailing_and() {
231 let parts = split_compound_command("echo a &&");
232 assert_eq!(parts, vec!["echo a "]);
233 }
234
235 #[test]
236 fn split_compound_no_operators() {
237 let parts = split_compound_command("echo hello world");
238 assert_eq!(parts, vec!["echo hello world"]);
239 }
240
241 #[test]
242 fn split_compound_pipe_not_split() {
243 let parts = split_compound_command("echo a | grep b");
245 assert_eq!(parts, vec!["echo a | grep b"]);
246 }
247
248 #[test]
249 fn split_compound_empty_string() {
250 let parts = split_compound_command("");
251 assert!(parts.is_empty());
253 }
254
255 #[test]
256 fn split_compound_non_ascii() {
257 let parts = split_compound_command("echo 日本語 && echo test");
260 assert_eq!(parts, vec!["echo 日本語 ", "echo test"]);
261 }
262
263 #[test]
264 fn glob_star_matches_command() {
265 let mut checker = ShellPolicyChecker::new();
266 let globs = vec!["curl*".to_string()];
267 assert!(checker.check_command("curl https://example.com", "test", &[], &globs).is_err());
269 }
270
271 #[test]
272 fn glob_question_mark_matches_single_char() {
273 let mut checker = ShellPolicyChecker::new();
274 let globs = vec!["rm ?".to_string()];
275 assert!(checker.check_command("rm f", "test", &[], &globs).is_err());
277 assert!(checker.check_command("rm foo", "test", &[], &globs).is_ok());
279 }
280
281 #[test]
282 fn glob_does_not_match_unrelated_command() {
283 let mut checker = ShellPolicyChecker::new();
284 let globs = vec!["curl*".to_string()];
285 assert!(checker.check_command("echo hello", "test", &[], &globs).is_ok());
286 }
287
288 #[test]
289 fn deny_regex_blocks_sub_command_after_split() {
290 let mut checker = ShellPolicyChecker::new();
291 let regexes = vec![r"\brm\b".to_string()];
292 assert!(
294 checker
295 .check_command("echo hello && rm -rf /tmp", "test", ®exes, &[])
296 .is_err()
297 );
298 assert!(checker.check_command("echo hello && echo world", "test", ®exes, &[]).is_ok());
300 }
301}