Skip to main content

chio_guards/
code_execution.rs

1//! CodeExecutionGuard -- language allowlist, dangerous-module detection,
2//! network gating, and execution-time bounds for sandboxed interpreter
3//! actions.
4//!
5//! The guard applies to
6//! [`ToolAction::CodeExecution`] derived from tool calls like `python`,
7//! `eval`, `run_code`, `jupyter`, etc.  See [`crate::action::extract_action`]
8//! for the full list of tool names that map to code execution.
9//!
10//! # Enforcement surface
11//!
12//! | Policy                    | Behavior                                                   |
13//! |---------------------------|------------------------------------------------------------|
14//! | `language_allowlist`      | Languages outside the set are denied                       |
15//! | `dangerous_modules`       | Imports/uses of named modules (e.g. `subprocess`) are denied |
16//! | `network_access`          | When `false`, calls requesting network are denied          |
17//! | `max_execution_time_ms`   | When the arguments exceed this bound, the call is denied   |
18//!
19//! Network access is considered requested when either:
20//!
21//! - the arguments carry `network_access = true` / `allow_network = true`;
22//! - or the code contains an obvious network module import
23//!   (`socket`, `requests`, `urllib`, `http`, `httpx`, `aiohttp`, `fetch(`).
24//!
25//! The module-detection regexes target Python, JavaScript, and the common
26//! shell-style `import X` / `require('X')` / `from X import` forms.  The
27//! detection is intentionally conservative: regex matches are *denial
28//! signals*, never permit signals.
29//!
30//! # Fail-closed behavior
31//!
32//! - [`ToolAction::CodeExecution`] with no `language` value is denied when
33//!   a [`CodeExecutionConfig::language_allowlist`] is set;
34//! - malformed configuration (invalid regex patterns in
35//!   [`CodeExecutionConfig::module_denylist`]) causes
36//!   [`CodeExecutionGuard::with_config`] to return
37//!   [`CodeExecutionError::InvalidPattern`];
38//! - non-code-execution actions pass through with [`Verdict::Allow`].
39
40use std::collections::HashSet;
41use std::sync::OnceLock;
42
43use regex::Regex;
44use serde::{Deserialize, Serialize};
45
46use chio_kernel::{Guard, GuardContext, GuardDecision, KernelError};
47
48use crate::action::{extract_action_checked, ToolAction};
49
50/// Default dangerous module names (Python-focused; matches are case
51/// sensitive and use word boundaries).
52pub fn default_dangerous_modules() -> Vec<String> {
53    vec![
54        "os".to_string(),
55        "subprocess".to_string(),
56        "socket".to_string(),
57        "sys".to_string(),
58        "ctypes".to_string(),
59        "shutil".to_string(),
60        "pickle".to_string(),
61        "marshal".to_string(),
62        "importlib".to_string(),
63    ]
64}
65
66/// Default network-module names that signal a code body wants network
67/// access.  Used by the `network_access` gate when arguments do not carry
68/// an explicit flag.
69fn default_network_modules() -> &'static [&'static str] {
70    &[
71        "socket",
72        "requests",
73        "urllib",
74        "urllib2",
75        "urllib3",
76        "http",
77        "httpx",
78        "aiohttp",
79        "websockets",
80        "ftplib",
81        "smtplib",
82        "telnetlib",
83    ]
84}
85
86/// Errors produced when building a [`CodeExecutionGuard`] or parsing its
87/// configuration.
88#[derive(Debug, thiserror::Error)]
89pub enum CodeExecutionError {
90    /// A denylist entry was not a valid regex literal.
91    #[error("invalid module pattern `{pattern}`: {source}")]
92    InvalidPattern {
93        pattern: String,
94        #[source]
95        source: regex::Error,
96    },
97}
98
99/// Configuration for [`CodeExecutionGuard`].
100#[derive(Clone, Debug, Deserialize, Serialize)]
101#[serde(deny_unknown_fields)]
102pub struct CodeExecutionConfig {
103    /// Enable/disable the guard entirely.
104    #[serde(default = "default_true")]
105    pub enabled: bool,
106    /// Allowed interpreter languages.  Empty means "any language".
107    #[serde(default)]
108    pub language_allowlist: Vec<String>,
109    /// Dangerous module names (used as word-boundary literal matches
110    /// against the code body).  Defaults to
111    /// [`default_dangerous_modules`].
112    #[serde(default = "default_dangerous_modules")]
113    pub module_denylist: Vec<String>,
114    /// When `false`, deny code-execution calls that request network
115    /// access (either via argument flag or a detectable network import).
116    #[serde(default = "default_true")]
117    pub network_access: bool,
118    /// Maximum execution time in milliseconds.  When set, any call with
119    /// an `execution_time_ms` / `timeout_ms` argument above this value
120    /// is denied.  `None` disables the check.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub max_execution_time_ms: Option<u64>,
123    /// Maximum bytes of code to scan for module / network detection.
124    /// Longer code bodies are denied outright (fail-closed) so a
125    /// padding-based bypass cannot push a forbidden import past the
126    /// scan boundary.
127    #[serde(default = "default_max_scan_bytes")]
128    pub max_scan_bytes: usize,
129}
130
131fn default_true() -> bool {
132    true
133}
134
135fn default_max_scan_bytes() -> usize {
136    64 * 1024
137}
138
139impl Default for CodeExecutionConfig {
140    fn default() -> Self {
141        Self {
142            enabled: true,
143            language_allowlist: vec!["python".to_string()],
144            module_denylist: default_dangerous_modules(),
145            network_access: false,
146            max_execution_time_ms: None,
147            max_scan_bytes: default_max_scan_bytes(),
148        }
149    }
150}
151
152/// Guard that enforces [`CodeExecutionConfig`] policies against
153/// [`ToolAction::CodeExecution`] calls.
154pub struct CodeExecutionGuard {
155    enabled: bool,
156    language_allowlist: HashSet<String>,
157    module_patterns: Vec<(String, Regex)>,
158    network_access: bool,
159    max_execution_time_ms: Option<u64>,
160    max_scan_bytes: usize,
161}
162
163impl CodeExecutionGuard {
164    /// Build a guard with default configuration.  Never fails because the
165    /// default patterns are known-valid regex fragments.
166    pub fn new() -> Self {
167        match Self::with_config(CodeExecutionConfig::default()) {
168            Ok(g) => g,
169            Err(_) => Self::empty_failclosed(),
170        }
171    }
172
173    /// Build an empty guard that denies every code-execution call. Used
174    /// as a fallback when the default configuration somehow fails to
175    /// compile.
176    fn empty_failclosed() -> Self {
177        Self {
178            enabled: true,
179            language_allowlist: HashSet::new(),
180            module_patterns: Vec::new(),
181            network_access: false,
182            max_execution_time_ms: Some(0),
183            max_scan_bytes: default_max_scan_bytes(),
184        }
185    }
186
187    /// Build a guard with explicit configuration.  Returns an error when
188    /// any entry in `module_denylist` is not a valid literal identifier
189    /// (we build word-boundary regexes from the literal).
190    pub fn with_config(config: CodeExecutionConfig) -> Result<Self, CodeExecutionError> {
191        let mut module_patterns = Vec::with_capacity(config.module_denylist.len());
192        for module in &config.module_denylist {
193            let pattern = module_regex_source(module);
194            let re = Regex::new(&pattern).map_err(|e| CodeExecutionError::InvalidPattern {
195                pattern: module.clone(),
196                source: e,
197            })?;
198            module_patterns.push((module.clone(), re));
199        }
200        let language_allowlist: HashSet<String> = config
201            .language_allowlist
202            .into_iter()
203            .map(|s| s.to_ascii_lowercase())
204            .collect();
205        Ok(Self {
206            enabled: config.enabled,
207            language_allowlist,
208            module_patterns,
209            network_access: config.network_access,
210            max_execution_time_ms: config.max_execution_time_ms,
211            max_scan_bytes: config.max_scan_bytes.max(1),
212        })
213    }
214
215    /// Read the execution-time ceiling from the arguments.  Accepts
216    /// `execution_time_ms`, `timeout_ms`, `max_execution_time_ms`.
217    fn read_execution_time_ms(arguments: &serde_json::Value) -> Option<u64> {
218        for key in [
219            "execution_time_ms",
220            "executionTimeMs",
221            "timeout_ms",
222            "timeoutMs",
223            "max_execution_time_ms",
224            "maxExecutionTimeMs",
225        ] {
226            if let Some(v) = arguments.get(key).and_then(|v| v.as_u64()) {
227                return Some(v);
228            }
229        }
230        None
231    }
232
233    /// Read an explicit network-access flag from the arguments, if present.
234    fn requested_network_access(arguments: &serde_json::Value) -> Option<bool> {
235        for key in [
236            "network_access",
237            "networkAccess",
238            "allow_network",
239            "allowNetwork",
240        ] {
241            if let Some(v) = arguments.get(key).and_then(|v| v.as_bool()) {
242                return Some(v);
243            }
244        }
245        None
246    }
247
248    /// Return `true` if `code` appears to import or call into a
249    /// network-capable module.
250    fn code_uses_network(code: &str) -> bool {
251        let net_re = network_module_regex();
252        net_re.is_match(code)
253    }
254}
255
256impl Default for CodeExecutionGuard {
257    fn default() -> Self {
258        Self::new()
259    }
260}
261
262impl Guard for CodeExecutionGuard {
263    fn name(&self) -> &str {
264        "code-execution"
265    }
266
267    fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
268        if !self.enabled {
269            return Ok(GuardDecision::allow());
270        }
271
272        let action = match extract_action_checked(&ctx.request.tool_name, &ctx.request.arguments) {
273            Ok(action) => action,
274            Err(_) => return Ok(GuardDecision::deny(Vec::new())),
275        };
276        let (language, code) = match action {
277            ToolAction::CodeExecution { language, code } => (language, code),
278            _ => return Ok(GuardDecision::allow()),
279        };
280
281        // 1. Language allowlist.
282        if !self.language_allowlist.is_empty() {
283            let lang = language.to_ascii_lowercase();
284            if lang == "unknown" || !self.language_allowlist.contains(&lang) {
285                return Ok(GuardDecision::deny(Vec::new()));
286            }
287        }
288
289        // Bound scan size for module / network detection. Fail-closed: a
290        // body larger than the configured scan window cannot be safely
291        // analyzed without leaving room for a padding-based bypass that
292        // pushes a forbidden import past the truncation boundary, so we
293        // deny outright rather than silently scan only the prefix.
294        if code.len() > self.max_scan_bytes {
295            tracing::warn!(
296                guard = "code-execution",
297                code_len = code.len(),
298                max_scan_bytes = self.max_scan_bytes,
299                "denying code execution: payload exceeds max_scan_bytes"
300            );
301            return Ok(GuardDecision::deny(Vec::new()));
302        }
303        let scanned = code.as_str();
304
305        // 2. Dangerous-module detection.
306        for (name, re) in &self.module_patterns {
307            if re.is_match(scanned) {
308                tracing::warn!(
309                    guard = "code-execution",
310                    module = %name,
311                    "denying code execution: dangerous module detected"
312                );
313                return Ok(GuardDecision::deny(Vec::new()));
314            }
315        }
316
317        // 3. Network access gate.
318        if !self.network_access {
319            let requested = Self::requested_network_access(&ctx.request.arguments).unwrap_or(false);
320            if requested || Self::code_uses_network(scanned) {
321                return Ok(GuardDecision::deny(Vec::new()));
322            }
323        }
324
325        // 4. Execution-time bound.
326        if let Some(max_ms) = self.max_execution_time_ms {
327            if let Some(requested) = Self::read_execution_time_ms(&ctx.request.arguments) {
328                if requested > max_ms {
329                    return Ok(GuardDecision::deny(Vec::new()));
330                }
331            }
332        }
333
334        Ok(GuardDecision::allow())
335    }
336}
337
338/// Build a regex that matches `import <module>`, `from <module> import`,
339/// `require('<module>')`, or a bare `<module>.something` reference in
340/// code.  The source is escaped so dotted module names are treated as
341/// literals.
342fn module_regex_source(module: &str) -> String {
343    let escaped = regex::escape(module);
344    // Word-boundary anchors handle the `import subprocess`,
345    // `from subprocess`, and `subprocess.call` forms; a trailing alternation
346    // picks up `require("subprocess")` and `require('subprocess')`.
347    format!(
348        r#"(?m)(?:^|[^A-Za-z0-9_])(?:import\s+{m}(?:\s|$|\.|,)|from\s+{m}(?:\s|\.)|require\s*\(\s*['"]{m}['"]\s*\)|{m}\s*\.)"#,
349        m = escaped
350    )
351}
352
353/// Compiled once per process: detects calls/imports of the well-known
354/// network modules listed in [`default_network_modules`].
355fn network_module_regex() -> &'static Regex {
356    static RE: OnceLock<Regex> = OnceLock::new();
357    RE.get_or_init(|| {
358        let alternation = default_network_modules()
359            .iter()
360            .map(|m| regex::escape(m))
361            .collect::<Vec<_>>()
362            .join("|");
363        // Fall back to a never-matching regex rather than panicking.
364        match Regex::new(&format!(
365            r#"(?m)(?:^|[^A-Za-z0-9_])(?:import\s+(?:{a})(?:\s|$|\.|,)|from\s+(?:{a})(?:\s|\.)|require\s*\(\s*['"](?:{a})['"]\s*\)|\bfetch\s*\()"#,
366            a = alternation
367        )) {
368            Ok(re) => re,
369            Err(err) => {
370                tracing::error!(error = %err, "code-execution: failed to compile network regex");
371                // Safe fallback: regex that never matches anything.
372                // expect: `\A\z` is a compile-time constant literal, so this
373                // construction cannot fail at runtime.
374                #[allow(clippy::expect_used)]
375                {
376                    Regex::new(r"\A\z").expect("empty-string regex compiles")
377                }
378            }
379        }
380    })
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn module_regex_matches_import_forms() {
389        let re = Regex::new(&module_regex_source("subprocess")).unwrap();
390        assert!(re.is_match("import subprocess\n"));
391        assert!(re.is_match("from subprocess import call"));
392        assert!(re.is_match("require('subprocess')"));
393        assert!(re.is_match("subprocess.run(['ls'])"));
394        assert!(!re.is_match("import subprocesses\n"));
395        assert!(!re.is_match("# subprocess comment with no code"));
396    }
397
398    #[test]
399    fn network_module_regex_detects_requests() {
400        let re = network_module_regex();
401        assert!(re.is_match("import requests\n"));
402        assert!(re.is_match("from urllib import parse"));
403        assert!(re.is_match("fetch('https://x')"));
404        assert!(!re.is_match("import math"));
405    }
406}