1use 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
50pub 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
66fn 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#[derive(Debug, thiserror::Error)]
89pub enum CodeExecutionError {
90 #[error("invalid module pattern `{pattern}`: {source}")]
92 InvalidPattern {
93 pattern: String,
94 #[source]
95 source: regex::Error,
96 },
97}
98
99#[derive(Clone, Debug, Deserialize, Serialize)]
101#[serde(deny_unknown_fields)]
102pub struct CodeExecutionConfig {
103 #[serde(default = "default_true")]
105 pub enabled: bool,
106 #[serde(default)]
108 pub language_allowlist: Vec<String>,
109 #[serde(default = "default_dangerous_modules")]
113 pub module_denylist: Vec<String>,
114 #[serde(default = "default_true")]
117 pub network_access: bool,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub max_execution_time_ms: Option<u64>,
123 #[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
152pub 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 pub fn new() -> Self {
167 match Self::with_config(CodeExecutionConfig::default()) {
168 Ok(g) => g,
169 Err(_) => Self::empty_failclosed(),
170 }
171 }
172
173 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 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 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 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 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 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 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 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 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 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
338fn module_regex_source(module: &str) -> String {
343 let escaped = regex::escape(module);
344 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
353fn 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 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 #[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}