Skip to main content

vtcode_core/tools/registry/
shell_policy.rs

1use 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        // Split compound commands on shell operators (&&, ||, ;) and validate
103        // each sub-command independently. This prevents a denied sub-command
104        // (e.g. `rm -f /tmp/file`) from blocking the entire compound command
105        // when paired with a safe sub-command (e.g. `sg run ...`).
106        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
133/// Split a compound shell command on `&&`, `||`, and `;` operators.
134///
135/// Returns individual sub-commands so each can be validated independently
136/// against the deny policy. This prevents a denied sub-command in a compound
137/// expression (e.g. `rm -f /tmp/file && sg run ...`) from blocking the entire
138/// command when the other sub-commands are safe.
139///
140/// Note: This is a simple split that does not handle nested subshells,
141/// quoted strings containing operators, or pipeline operators (`|`).
142/// Pipeline operators are intentionally left unsplit because the individual
143/// segments of a pipe are typically all executed together and should be
144/// validated as a unit.
145fn split_compound_command(command: &str) -> Vec<&str> {
146    let mut parts = Vec::new();
147    let mut current_start: usize = 0;
148    // Use byte-level iteration since all operators (`&&`, `||`, `;`) are ASCII,
149    // and Rust string slicing requires byte offsets, not char indices.
150    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            // Found && — emit the sub-command before it
157            parts.push(&command[current_start..i]);
158            i += 2;
159            // Skip whitespace after &&
160            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            // Found || — emit the sub-command before it
166            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            // Found ; — emit the sub-command before it
174            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    // Emit the final sub-command
186    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        // Whitespace after && is skipped by the splitter.
201        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        // Leading empty fragment before && is emitted but skipped by check_command.
226        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        // Pipes are intentionally NOT split -- pipe segments execute as a unit.
244        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        // Empty input produces no parts (the final emit skips because current_start == len).
252        assert!(parts.is_empty());
253    }
254
255    #[test]
256    fn split_compound_non_ascii() {
257        // Multi-byte UTF-8 characters before an operator must not cause a panic.
258        // Before the byte-offset fix, this panicked because char indices != byte offsets.
259        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        // `curl https://example.com` should be denied by `curl*` glob.
268        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        // `rm f` matches `rm ?` (single char).
276        assert!(checker.check_command("rm f", "test", &[], &globs).is_err());
277        // `rm foo` does NOT match `rm ?` (multiple chars).
278        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        // `echo hello && rm -rf /tmp` -- the `rm` sub-command should be denied.
293        assert!(
294            checker
295                .check_command("echo hello && rm -rf /tmp", "test", &regexes, &[])
296                .is_err()
297        );
298        // `echo hello && echo world` -- both sub-commands are safe.
299        assert!(checker.check_command("echo hello && echo world", "test", &regexes, &[]).is_ok());
300    }
301}