Skip to main content

everruns_integrations_lua/
lib.rs

1//! Sandboxed Lua execution and code-mode integration for Everruns agents.
2//!
3//! Scripts run in a fresh vendored Lua 5.4 VM with bounded memory,
4//! instructions, time, output, and standard-library access. Filesystem and tool
5//! calls cross host-provided contracts rather than reaching the machine.
6//!
7//! It is part of the [Everruns](https://everruns.com) ecosystem and is an
8//! opt-in high-risk integration for `everruns-host`.
9//!
10//! # Example
11//!
12//! ```
13//! use everruns_core::Capability;
14//! use everruns_integrations_lua::LuaCapability;
15//!
16//! assert_eq!(LuaCapability.id(), "lua");
17//! ```
18
19use everruns_core::capabilities::{
20    Capability, CapabilityLocalization, CapabilityStatus, RiskLevel,
21};
22use everruns_core::*;
23use std::result::Result;
24
25mod code_mode;
26use crate::exec_tool_result::ExecToolResultPayload;
27use crate::session_file::SessionFile;
28use crate::tool_types::ToolHints;
29use crate::tools::{Tool, ToolExecutionResult};
30use crate::typed_id::SessionId;
31use async_trait::async_trait;
32pub use code_mode::{LUA_CODE_MODE_CAPABILITY_ID, LuaCodeModeCapability};
33use everruns_core::session_files::SessionFileSystem;
34use everruns_core::tool_context::ToolContext;
35#[cfg(test)]
36use everruns_provider::error;
37use everruns_provider::{tool_types, typed_id};
38use serde_json::{Value, json};
39use std::sync::Arc;
40use std::time::Duration;
41
42pub const LUA_CAPABILITY_ID: &str = "lua";
43
44const DEFAULT_TIMEOUT_MS: u64 = 30_000;
45const MAX_TIMEOUT_MS: u64 = 60_000;
46/// Hard memory cap for the VM (TM-LUA-003).
47const MEMORY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
48/// Instruction budget before the VM is interrupted (TM-LUA-002).
49const MAX_INSTRUCTIONS: u64 = 50_000_000;
50/// Cap on captured `print` output before further writes are dropped (TM-LUA-008).
51const MAX_OUTPUT_BYTES: usize = 64 * 1024;
52
53const TOOL_DESCRIPTION: &str = r#"Execute a Lua 5.4 script in an isolated, sandboxed environment.
54
55The session filesystem is available through the `fs` table (rooted at /workspace),
56and `json.encode` / `json.decode` are available for structured data. Use `print(...)`
57for output and `return <value>` to send a JSON-serializable result back.
58
59No network, no host process access, no `io`/`os.execute`. CPU, memory, and runtime
60are bounded."#;
61
62const SYSTEM_PROMPT: &str = r#"You can run Lua 5.4 scripts via the `lua` tool for logic, math, and structured
63data processing over the session workspace.
64
65Host API:
66- `fs.read(path)`, `fs.write(path, s)`, `fs.append(path, s)`, `fs.exists(path)`
67- `fs.list(path)`, `fs.stat(path)`, `fs.remove(path[, recursive])`, `fs.mkdir(path)`
68- `fs.grep(pattern[, path])` (indexed search)
69- `json.encode(value)`, `json.decode(string)`
70- `base64.encode(string)`, `base64.decode(string)`
71- `print(...)` for output; `return value` to return a JSON-serializable result.
72
73The full Lua 5.4 standard library is available (`string.*` incl. `format`/
74`find`/`match`/`gsub`, `table.*` incl. `sort`, `math.*`, `os.time`/`os.date`).
75
76When enabled by the environment (otherwise these globals are nil):
77- `http.get(url)` / `http.post(url, body)` -> `{ status, body }`, allow-listed
78  hosts only.
79- `tools.<name>(args_table)` -> result, to call other available tools.
80
81Disabled for sandboxing (do not use — nil): `io`, `os.execute`/`os.getenv`,
82`require`/`package`, `load`/`dofile`. Use `fs` for files; raw sockets are not
83available (use `http` when present)."#;
84
85// ============================================================================
86// Capability
87// ============================================================================
88
89/// Lua execution capability — sandboxed scripting over the session VFS.
90pub struct LuaCapability;
91
92impl Capability for LuaCapability {
93    fn id(&self) -> &str {
94        LUA_CAPABILITY_ID
95    }
96
97    fn name(&self) -> &str {
98        "Lua"
99    }
100
101    fn description(&self) -> &str {
102        r#"Execute Lua scripts in an isolated, sandboxed environment.
103
104> [!NOTE]
105> Scripts run in a virtual environment with no host or network access. The
106> session filesystem is available via the `fs` table and `json` is available
107> for structured data."#
108    }
109
110    fn localizations(&self) -> Vec<CapabilityLocalization> {
111        vec![CapabilityLocalization::text(
112            "uk",
113            "Lua",
114            r#"Виконуйте Lua-скрипти в ізольованому середовищі-пісочниці.
115
116> [!NOTE]
117> Скрипти виконуються у віртуальному середовищі без доступу до хоста чи мережі.
118> Файлова система сесії доступна через таблицю `fs`, а для структурованих даних
119> доступний `json`."#,
120        )]
121    }
122
123    fn status(&self) -> CapabilityStatus {
124        CapabilityStatus::Available
125    }
126
127    fn risk_level(&self) -> RiskLevel {
128        // Scripted code execution + LLM-driven invocation: same trust elevation
129        // as bashkit_shell. Admin-gated assignment (TM-LUA-001).
130        RiskLevel::High
131    }
132
133    fn icon(&self) -> Option<&str> {
134        Some("code")
135    }
136
137    fn category(&self) -> Option<&str> {
138        Some("Execution")
139    }
140
141    fn system_prompt_addition(&self) -> Option<&str> {
142        Some(SYSTEM_PROMPT)
143    }
144
145    fn tools(&self) -> Vec<Box<dyn Tool>> {
146        vec![Box::new(LuaTool)]
147    }
148
149    fn dependencies(&self) -> Vec<&'static str> {
150        vec!["session_file_system"]
151    }
152
153    fn features(&self) -> Vec<&'static str> {
154        vec!["file_system"]
155    }
156}
157
158// ============================================================================
159// Tool
160// ============================================================================
161
162/// Tool to execute a Lua script in the sandbox.
163pub struct LuaTool;
164
165#[async_trait]
166impl Tool for LuaTool {
167    fn name(&self) -> &str {
168        "lua"
169    }
170
171    fn display_name(&self) -> Option<&str> {
172        Some("Lua")
173    }
174
175    fn description(&self) -> &str {
176        TOOL_DESCRIPTION
177    }
178
179    fn parameters_schema(&self) -> Value {
180        json!({
181            "type": "object",
182            "properties": {
183                "script": {
184                    "type": "string",
185                    "description": "Lua 5.4 source to execute."
186                },
187                "working_dir": {
188                    "type": "string",
189                    "default": crate::session_path::WORKSPACE_PREFIX,
190                    "description": "Working directory (informational; `fs.*` paths default to /workspace and resolve through the session filesystem)."
191                },
192                "timeout_ms": {
193                    "type": "integer",
194                    "default": DEFAULT_TIMEOUT_MS,
195                    "description": "Wall-clock timeout in milliseconds (capped at 60000)."
196                },
197                "output": crate::tool_output_sanitizer::output_verbosity_schema(),
198            },
199            "required": ["script"],
200        })
201    }
202
203    fn hints(&self) -> ToolHints {
204        ToolHints::default()
205            .with_long_running(true)
206            .with_persist_output(true)
207            // Mutates the shared session workspace: serialize concurrent lua/bash
208            // calls in a batch so they don't race. Runs an in-process interpreter,
209            // so offload to its own task.
210            .with_concurrency_class("session_workspace")
211            .with_cpu_bound(true)
212    }
213
214    fn requires_context(&self) -> bool {
215        true
216    }
217
218    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
219        ToolExecutionResult::tool_error(
220            "lua requires context. This tool must be executed with session context.",
221        )
222    }
223
224    async fn execute_with_context(
225        &self,
226        arguments: Value,
227        context: &ToolContext,
228    ) -> ToolExecutionResult {
229        let script = match arguments.get("script").and_then(|v| v.as_str()) {
230            Some(s) => s.to_string(),
231            None => return ToolExecutionResult::tool_error("Missing required parameter: script"),
232        };
233
234        let timeout_ms = arguments
235            .get("timeout_ms")
236            .and_then(|v| v.as_u64())
237            .unwrap_or(DEFAULT_TIMEOUT_MS)
238            .min(MAX_TIMEOUT_MS);
239
240        let output_mode = arguments
241            .get("output")
242            .and_then(|v| v.as_str())
243            .unwrap_or("auto")
244            .to_string();
245
246        let file_store = match &context.file_store {
247            Some(store) => store.clone(),
248            None => {
249                return ToolExecutionResult::tool_error(
250                    "File system not available in this context",
251                );
252            }
253        };
254
255        let vfs = Arc::new(LuaVfs::new(context.session_id, file_store));
256        let limits = LuaLimits {
257            memory_bytes: MEMORY_LIMIT_BYTES,
258            max_instructions: MAX_INSTRUCTIONS,
259            timeout: Duration::from_millis(timeout_ms),
260            max_output_bytes: MAX_OUTPUT_BYTES,
261        };
262
263        // Code mode (`tools.<name>`): expose only Auto, non-destructive,
264        // non-execution sibling tools. Excludes approval/client-side tools and
265        // the execution tools themselves (no `lua`/`bash` re-entry).
266        let allowed_tools = gated_code_mode_tools(context);
267
268        // HTTP (`http.*`) is fail-closed: needs a host egress service AND a
269        // non-empty network allow-list (the per-URL check happens at call time).
270        let http_enabled = context.egress_service.is_some()
271            && context
272                .network_access
273                .as_ref()
274                .is_some_and(|a| !a.allowed.is_empty());
275
276        // Stream captured `print` output as tool.output.delta events.
277        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
278        let emit_context = context.clone();
279        let emit_task = tokio::spawn(async move {
280            while let Some(chunk) = rx.recv().await {
281                if !chunk.is_empty() {
282                    emit_context.emit_tool_output("lua", &chunk, "stdout").await;
283                }
284            }
285        });
286
287        // engine::run enforces its own deadline; the outer timeout is a backstop.
288        let outcome = tokio::time::timeout(
289            limits.timeout + Duration::from_secs(2),
290            engine::run(
291                &script,
292                vfs,
293                context.clone(),
294                allowed_tools,
295                http_enabled,
296                &limits,
297                tx,
298            ),
299        )
300        .await;
301
302        let _ = emit_task.await;
303
304        let outcome = match outcome {
305            Ok(o) => o,
306            Err(_) => {
307                return ToolExecutionResult::tool_error(format!(
308                    "Lua execution timed out after {}ms",
309                    timeout_ms
310                ));
311            }
312        };
313
314        if let Some(err) = outcome.error {
315            let clean = crate::tool_output_sanitizer::clean_exec_output(&outcome.stdout);
316            return ToolExecutionResult::tool_error(if clean.is_empty() {
317                format!("Lua error: {err}")
318            } else {
319                format!("Lua error: {err}\n--- output ---\n{clean}")
320            });
321        }
322
323        // Shape stdout exactly like the exec tools so UI/persistence stay uniform.
324        let payload = ExecToolResultPayload::new(&outcome.stdout, "", 0, &output_mode);
325        let ExecToolResultPayload {
326            stdout,
327            truncated,
328            total_lines,
329            raw_output,
330            ..
331        } = payload;
332
333        ToolExecutionResult::success_with_raw_output(
334            json!({
335                "stdout": stdout,
336                "result": outcome.return_value,
337                "success": true,
338                "truncated": truncated,
339                "total_lines": total_lines,
340            }),
341            raw_output,
342        )
343    }
344}
345
346/// Whether a tool is eligible to be driven through Lua "code mode" (the
347/// `tools.<name>` table) rather than called directly.
348///
349/// This is the single source of truth shared by two call sites so they can
350/// never drift apart: the engine's code-mode exposure
351/// ([`gated_code_mode_tools`]) and the `lua_code_mode` capability's
352/// tool-definition filter (which *hides* exactly this set from the model). If
353/// the two used different predicates a tool could be hidden from the model yet
354/// not re-exposed in Lua — an unreachable tool.
355///
356/// Conservative gate (TM-LUA-009): `Auto` policy only (never approval- or
357/// client-side tools), non-destructive, non-`cpu_bound`, and never the
358/// execution tools themselves (`lua`/`bash` are excluded so code mode cannot
359/// re-enter a shell or itself).
360pub fn is_code_mode_eligible(
361    name: &str,
362    policy: &crate::tool_types::ToolPolicy,
363    hints: &ToolHints,
364) -> bool {
365    use crate::tool_types::ToolPolicy;
366    name != "lua"
367        && name != "bash"
368        && *policy == ToolPolicy::Auto
369        && hints.destructive != Some(true)
370        && hints.cpu_bound != Some(true)
371}
372
373/// Sibling tools exposed to Lua "code mode" via the `tools` table.
374///
375/// Filters the live tool registry through [`is_code_mode_eligible`]. Returns an
376/// empty list when no tool registry is present.
377fn gated_code_mode_tools(context: &ToolContext) -> Vec<String> {
378    let Some(reg) = context.tool_registry.as_ref() else {
379        return Vec::new();
380    };
381    let mut out = Vec::new();
382    for name in reg.tool_names() {
383        let Some(tool) = reg.get(name) else { continue };
384        if is_code_mode_eligible(name, &tool.policy(), &tool.hints()) {
385            out.push(name.to_string());
386        }
387    }
388    out.sort();
389    out
390}
391
392// ============================================================================
393// Limits & outcome (engine-agnostic)
394// ============================================================================
395
396/// Resource limits enforced by the engine (TM-LUA-002/003/008).
397///
398#[derive(Debug, Clone)]
399pub struct LuaLimits {
400    pub memory_bytes: usize,
401    pub max_instructions: u64,
402    pub timeout: Duration,
403    pub max_output_bytes: usize,
404}
405
406/// Result of one script execution.
407#[derive(Debug, Default)]
408pub struct LuaOutcome {
409    /// Captured `print` output.
410    pub stdout: String,
411    /// The script's `return` value, JSON-serialized (if any).
412    pub return_value: Option<Value>,
413    /// Set when the script raised an error or hit a limit.
414    pub error: Option<String>,
415}
416
417impl LuaOutcome {
418    fn engine_error(msg: impl Into<String>) -> Self {
419        Self {
420            error: Some(msg.into()),
421            ..Default::default()
422        }
423    }
424}
425
426// ============================================================================
427// LuaVfs — the only seam to the (session-scoped) session filesystem (TM-LUA-004)
428// ============================================================================
429
430/// Entry returned by `fs.list` / `fs.stat`.
431#[derive(Debug, Clone)]
432pub struct VfsEntry {
433    pub name: String,
434    pub is_dir: bool,
435    pub size: i64,
436}
437
438/// A grep hit returned by `fs.grep`.
439#[derive(Debug, Clone)]
440pub struct VfsGrepHit {
441    pub path: String,
442    pub line_number: usize,
443    pub line: String,
444}
445
446/// Bridges the Lua `fs` table to `SessionFileSystem`, scoped to one session.
447pub struct LuaVfs {
448    session_id: SessionId,
449    store: Arc<dyn SessionFileSystem>,
450}
451
452impl LuaVfs {
453    pub fn new(session_id: SessionId, store: Arc<dyn SessionFileSystem>) -> Self {
454        Self { session_id, store }
455    }
456
457    pub async fn read(&self, path: &str) -> Result<String, String> {
458        let sp = crate::session_path::to_session_path(path);
459        match self.store.read_file(self.session_id, &sp).await {
460            Ok(Some(file)) => {
461                let content = file.content.unwrap_or_default();
462                let bytes = SessionFile::decode_content(&content, &file.encoding)
463                    .map_err(|e| e.to_string())?;
464                Ok(String::from_utf8_lossy(&bytes).into_owned())
465            }
466            Ok(None) => Err(format!("file not found: {path}")),
467            Err(e) => Err(e.to_string()),
468        }
469    }
470
471    pub async fn write(&self, path: &str, content: &str) -> Result<(), String> {
472        let sp = crate::session_path::to_session_path(path);
473        let (encoded, encoding) = SessionFile::encode_content(content.as_bytes());
474        self.store
475            .write_file(self.session_id, &sp, &encoded, &encoding)
476            .await
477            .map(|_| ())
478            .map_err(|e| e.to_string())
479    }
480
481    pub async fn append(&self, path: &str, content: &str) -> Result<(), String> {
482        // Append semantics: treat a missing file as empty.
483        let mut existing = self.read(path).await.unwrap_or_default();
484        existing.push_str(content);
485        self.write(path, &existing).await
486    }
487
488    pub async fn exists(&self, path: &str) -> Result<bool, String> {
489        let sp = crate::session_path::to_session_path(path);
490        if matches!(
491            self.store.read_file(self.session_id, &sp).await,
492            Ok(Some(_))
493        ) {
494            return Ok(true);
495        }
496        Ok(self
497            .store
498            .list_directory(self.session_id, &sp)
499            .await
500            .is_ok())
501    }
502
503    pub async fn stat(&self, path: &str) -> Result<Option<VfsEntry>, String> {
504        let sp = crate::session_path::to_session_path(path);
505        match self.store.stat_file(self.session_id, &sp).await {
506            Ok(Some(stat)) => Ok(Some(VfsEntry {
507                name: stat.name,
508                is_dir: stat.is_directory,
509                size: stat.size_bytes,
510            })),
511            Ok(None) => Ok(None),
512            Err(e) => Err(e.to_string()),
513        }
514    }
515
516    pub async fn list(&self, path: &str) -> Result<Vec<VfsEntry>, String> {
517        let sp = crate::session_path::to_session_path(path);
518        self.store
519            .list_directory(self.session_id, &sp)
520            .await
521            .map(|entries| {
522                entries
523                    .into_iter()
524                    .map(|e| VfsEntry {
525                        name: e.name,
526                        is_dir: e.is_directory,
527                        size: e.size_bytes,
528                    })
529                    .collect()
530            })
531            .map_err(|e| e.to_string())
532    }
533
534    pub async fn remove(&self, path: &str, recursive: bool) -> Result<bool, String> {
535        let sp = crate::session_path::to_session_path(path);
536        self.store
537            .delete_file(self.session_id, &sp, recursive)
538            .await
539            .map_err(|e| e.to_string())
540    }
541
542    pub async fn mkdir(&self, path: &str) -> Result<(), String> {
543        let sp = crate::session_path::to_session_path(path);
544        self.store
545            .create_directory(self.session_id, &sp)
546            .await
547            .map(|_| ())
548            .map_err(|e| e.to_string())
549    }
550
551    pub async fn grep(&self, pattern: &str, path: Option<&str>) -> Result<Vec<VfsGrepHit>, String> {
552        // None path => whole workspace; otherwise translate the scope.
553        let scope = match path {
554            Some(p) => {
555                let sp = crate::session_path::to_session_path(p);
556                if sp == "/" { None } else { Some(sp) }
557            }
558            None => None,
559        };
560        self.store
561            .grep_files(self.session_id, pattern, scope.as_deref())
562            .await
563            .map(|matches| {
564                matches
565                    .into_iter()
566                    .map(|m| VfsGrepHit {
567                        // Re-root to the Lua VFS namespace.
568                        path: crate::session_path::to_display_path(&m.path),
569                        line_number: m.line_number,
570                        line: m.line,
571                    })
572                    .collect()
573            })
574            .map_err(|e| e.to_string())
575    }
576}
577
578// ============================================================================
579// Engine (mlua)
580// ============================================================================
581
582// The engine: mlua (vendored Lua 5.4, never LuaJIT). mlua loads the full stdlib,
583// so the sandbox works by *scrubbing* the dangerous surface
584// (io/os.execute/package/require/load/dofile/string.dump) rather than by
585// omission. Hardening (TM-LUA-001..009), all on by default, no configuration:
586// - memory: `set_memory_limit` hard cap;
587// - CPU/time: instruction-count hook + wall-clock deadline;
588// - isolation: the VM runs on a dedicated blocking thread; `fs.*`, `http.*`, and
589//   `tools.*` calls are marshaled to the async runtime over a channel. A
590//   pathological *synchronous* op (e.g. catastrophic Lua pattern in C, which the
591//   instruction hook cannot interrupt) therefore occupies one blocking-pool
592//   thread instead of stalling a shared runtime worker — multitenant
593//   containment. Residual: such an op is not force-killable in-process; the
594//   robust fix is out-of-process execution.
595// - egress: `http.*` is fail-closed — it requires both a host `EgressService`
596//   and a non-empty network allow-list that permits the URL (TM-LUA-005).
597// - code mode: `tools.<name>(args)` re-enters only Auto, non-destructive,
598//   non-execution tools; the child context has no tool_registry, so code mode
599//   cannot recurse (TM-LUA-009).
600mod engine {
601    use super::{LuaLimits, LuaOutcome, LuaVfs, ToolContext, VfsEntry, VfsGrepHit};
602    use mlua::{
603        HookTriggers, Lua, LuaOptions, LuaSerdeExt, StdLib, Value as LuaValue, Variadic, VmState,
604    };
605    use std::sync::atomic::{AtomicU64, Ordering};
606    use std::sync::{Arc, Mutex};
607    use std::time::Instant;
608    use tokio::sync::{mpsc, oneshot};
609
610    /// Granularity of the interrupt hook (Lua instructions between checks).
611    const HOOK_EVERY: u32 = 100_000;
612    /// Cap on an HTTP response body handed back to Lua.
613    const HTTP_BODY_CAP: usize = 1024 * 1024;
614
615    // ---- Host-call bridge: blocking VM thread -> async runtime ----
616
617    enum Op {
618        Read(String),
619        Write(String, String),
620        Append(String, String),
621        Exists(String),
622        Stat(String),
623        List(String),
624        Remove(String, bool),
625        Mkdir(String),
626        Grep(String, Option<String>),
627        Http {
628            method: &'static str,
629            url: String,
630            body: Option<String>,
631        },
632        Tool {
633            name: String,
634            args: serde_json::Value,
635        },
636    }
637
638    enum Reply {
639        Str(String),
640        Bool(bool),
641        Unit,
642        Stat(Option<VfsEntry>),
643        List(Vec<VfsEntry>),
644        Grep(Vec<VfsGrepHit>),
645        Http { status: u16, body: String },
646        Json(serde_json::Value),
647    }
648
649    struct Request {
650        op: Op,
651        reply: oneshot::Sender<Result<Reply, String>>,
652    }
653
654    async fn dispatch(vfs: &LuaVfs, ctx: &ToolContext, op: Op) -> Result<Reply, String> {
655        match op {
656            Op::Read(p) => vfs.read(&p).await.map(Reply::Str),
657            Op::Write(p, c) => vfs.write(&p, &c).await.map(|_| Reply::Unit),
658            Op::Append(p, c) => vfs.append(&p, &c).await.map(|_| Reply::Unit),
659            Op::Exists(p) => vfs.exists(&p).await.map(Reply::Bool),
660            Op::Stat(p) => vfs.stat(&p).await.map(Reply::Stat),
661            Op::List(p) => vfs.list(&p).await.map(Reply::List),
662            Op::Remove(p, r) => vfs.remove(&p, r).await.map(Reply::Bool),
663            Op::Mkdir(p) => vfs.mkdir(&p).await.map(|_| Reply::Unit),
664            Op::Grep(pat, scope) => vfs.grep(&pat, scope.as_deref()).await.map(Reply::Grep),
665            Op::Http { method, url, body } => do_http(ctx, method, url, body).await,
666            Op::Tool { name, args } => do_tool(ctx, &name, args).await.map(Reply::Json),
667        }
668    }
669
670    /// HTTP via the host egress boundary. Fail-closed: requires both an
671    /// `EgressService` and an allow-list that explicitly permits the URL.
672    async fn do_http(
673        ctx: &ToolContext,
674        method: &'static str,
675        url: String,
676        body: Option<String>,
677    ) -> Result<Reply, String> {
678        use crate::egress::{EgressRequest, EgressRequestKind};
679        let acl = ctx.network_access.as_ref();
680        let permitted = acl
681            .map(|a| !a.allowed.is_empty() && a.is_url_allowed(&url))
682            .unwrap_or(false);
683        if !permitted {
684            return Err(format!(
685                "network egress denied: {url} is not in the allow-list"
686            ));
687        }
688        let egress = ctx
689            .egress_service
690            .as_ref()
691            .ok_or_else(|| "network egress unavailable in this environment".to_string())?;
692        let mut req = EgressRequest::new(method, &url, EgressRequestKind::Capability)
693            .require_dns_pinning()
694            .timeout_ms(15_000);
695        if let Some(a) = acl {
696            req = req.network_access(Some(a.clone()));
697        }
698        if let Some(b) = body {
699            req = req
700                .header("content-type", "application/json")
701                .body(b.into_bytes());
702        }
703        let resp = egress.send(req).await.map_err(|e| e.to_string())?;
704        let slice = if resp.body.len() > HTTP_BODY_CAP {
705            &resp.body[..HTTP_BODY_CAP]
706        } else {
707            &resp.body[..]
708        };
709        Ok(Reply::Http {
710            status: resp.status,
711            body: String::from_utf8_lossy(slice).into_owned(),
712        })
713    }
714
715    /// Code mode: re-enter another tool. The child context drops `tool_registry`
716    /// so a code-mode tool cannot itself open code mode (no recursion).
717    async fn do_tool(
718        ctx: &ToolContext,
719        name: &str,
720        args: serde_json::Value,
721    ) -> Result<serde_json::Value, String> {
722        use crate::tools::ToolExecutionResult as R;
723        let reg = ctx
724            .tool_registry
725            .as_ref()
726            .ok_or_else(|| "tools unavailable in this environment".to_string())?;
727        let tool = reg
728            .get(name)
729            .ok_or_else(|| format!("unknown tool: {name}"))?;
730        let mut child = ctx.clone();
731        child.tool_registry = None;
732        child.tool_call_id = Some(format!("lua:{name}"));
733        match tool.execute_with_context(args, &child).await {
734            R::Success(v) => Ok(v),
735            R::SuccessWithImages { result, .. } => Ok(result),
736            R::ToolError(e) => Err(e),
737            R::InternalError(_) => Err("tool internal error".to_string()),
738            R::ConnectionRequired { provider } => {
739                Err(format!("tool requires a connection: {provider}"))
740            }
741        }
742    }
743
744    /// Synchronous host call from inside a Lua function (blocking thread).
745    fn call(tx: &mpsc::UnboundedSender<Request>, op: Op) -> Result<Reply, String> {
746        let (reply_tx, reply_rx) = oneshot::channel();
747        tx.send(Request {
748            op,
749            reply: reply_tx,
750        })
751        .map_err(|_| "host channel closed".to_string())?;
752        reply_rx
753            .blocking_recv()
754            .map_err(|_| "host reply dropped".to_string())?
755    }
756
757    #[allow(clippy::too_many_arguments)]
758    pub(super) async fn run(
759        script: &str,
760        vfs: Arc<LuaVfs>,
761        ctx: ToolContext,
762        allowed_tools: Vec<String>,
763        http_enabled: bool,
764        limits: &LuaLimits,
765        output: mpsc::UnboundedSender<String>,
766    ) -> LuaOutcome {
767        let (req_tx, mut req_rx) = mpsc::unbounded_channel::<Request>();
768        let script = script.to_string();
769        let limits = limits.clone();
770
771        let join = tokio::task::spawn_blocking(move || {
772            run_blocking(script, limits, req_tx, output, allowed_tools, http_enabled)
773        });
774
775        while let Some(request) = req_rx.recv().await {
776            let result = dispatch(&vfs, &ctx, request.op).await;
777            let _ = request.reply.send(result);
778        }
779
780        match join.await {
781            Ok(outcome) => outcome,
782            Err(e) => LuaOutcome::engine_error(format!("lua task panicked: {e}")),
783        }
784    }
785
786    fn run_blocking(
787        script: String,
788        limits: LuaLimits,
789        req_tx: mpsc::UnboundedSender<Request>,
790        output: mpsc::UnboundedSender<String>,
791        allowed_tools: Vec<String>,
792        http_enabled: bool,
793    ) -> LuaOutcome {
794        let libs = StdLib::STRING | StdLib::TABLE | StdLib::MATH | StdLib::OS | StdLib::UTF8;
795        let lua = match Lua::new_with(libs, LuaOptions::default()) {
796            Ok(l) => l,
797            Err(e) => return LuaOutcome::engine_error(format!("lua init failed: {e}")),
798        };
799
800        let _ = lua.set_memory_limit(limits.memory_bytes);
801
802        if let Err(e) = scrub_globals(&lua) {
803            return LuaOutcome::engine_error(e);
804        }
805
806        let deadline = Instant::now() + limits.timeout;
807        let budget = limits.max_instructions;
808        let counted = Arc::new(AtomicU64::new(0));
809        {
810            let counted = counted.clone();
811            // mlua 0.11 makes `set_hook` fallible; surface a setup failure as an
812            // engine error rather than silently ignoring the result.
813            if let Err(e) = lua.set_hook(
814                HookTriggers::new().every_nth_instruction(HOOK_EVERY),
815                move |_lua, _debug| {
816                    if Instant::now() >= deadline {
817                        return Err(mlua::Error::runtime("lua: exceeded time limit"));
818                    }
819                    if counted.fetch_add(HOOK_EVERY as u64, Ordering::Relaxed) >= budget {
820                        return Err(mlua::Error::runtime("lua: exceeded instruction budget"));
821                    }
822                    Ok(VmState::Continue)
823                },
824            ) {
825                return LuaOutcome::engine_error(format!("install hook: {e}"));
826            }
827        }
828
829        let stdout = Arc::new(Mutex::new(String::new()));
830        if let Err(e) = install_print(&lua, stdout.clone(), output, limits.max_output_bytes) {
831            return LuaOutcome::engine_error(format!("install print: {e}"));
832        }
833        if let Err(e) = install_json(&lua) {
834            return LuaOutcome::engine_error(format!("install json: {e}"));
835        }
836        if let Err(e) = install_base64(&lua) {
837            return LuaOutcome::engine_error(format!("install base64: {e}"));
838        }
839        if let Err(e) = install_fs(&lua, req_tx.clone()) {
840            return LuaOutcome::engine_error(format!("install fs: {e}"));
841        }
842        if http_enabled && let Err(e) = install_http(&lua, req_tx.clone()) {
843            return LuaOutcome::engine_error(format!("install http: {e}"));
844        }
845        if !allowed_tools.is_empty()
846            && let Err(e) = install_tools(&lua, req_tx, allowed_tools)
847        {
848            return LuaOutcome::engine_error(format!("install tools: {e}"));
849        }
850
851        let result: mlua::Result<LuaValue> = lua.load(&script).set_name("agent_script").eval();
852        let captured = stdout.lock().map(|s| s.clone()).unwrap_or_default();
853
854        match result {
855            Ok(value) => {
856                let return_value = match value {
857                    LuaValue::Nil => None,
858                    other => lua.from_value::<serde_json::Value>(other).ok(),
859                };
860                LuaOutcome {
861                    stdout: captured,
862                    return_value,
863                    error: None,
864                }
865            }
866            Err(e) => LuaOutcome {
867                stdout: captured,
868                return_value: None,
869                error: Some(e.to_string()),
870            },
871        }
872    }
873
874    /// Remove dangerous globals exposed by the loaded libraries
875    /// (TM-LUA-001/006/007).
876    fn scrub_globals(lua: &Lua) -> Result<(), String> {
877        let g = lua.globals();
878        for name in [
879            "io",
880            "package",
881            "require",
882            "dofile",
883            "loadfile",
884            "load",
885            "loadstring",
886            "collectgarbage",
887        ] {
888            g.set(name, LuaValue::Nil).map_err(|e| e.to_string())?;
889        }
890        if let Ok(os) = g.get::<mlua::Table>("os") {
891            for name in [
892                "execute",
893                "getenv",
894                "exit",
895                "remove",
896                "rename",
897                "tmpname",
898                "setlocale",
899            ] {
900                os.set(name, LuaValue::Nil).map_err(|e| e.to_string())?;
901            }
902        }
903        if let Ok(string) = g.get::<mlua::Table>("string") {
904            string
905                .set("dump", LuaValue::Nil)
906                .map_err(|e| e.to_string())?;
907        }
908        Ok(())
909    }
910
911    fn install_print(
912        lua: &Lua,
913        buf: Arc<Mutex<String>>,
914        sink: mpsc::UnboundedSender<String>,
915        cap: usize,
916    ) -> mlua::Result<()> {
917        let print = lua.create_function(move |lua, args: Variadic<LuaValue>| {
918            let mut parts = Vec::with_capacity(args.len());
919            for a in args.iter() {
920                let s = lua
921                    .coerce_string(a.clone())?
922                    .map(|ls| ls.to_string_lossy())
923                    .unwrap_or_else(|| "nil".to_string());
924                parts.push(s);
925            }
926            let mut line = parts.join("\t");
927            line.push('\n');
928            if let Ok(mut g) = buf.lock()
929                && g.len() < cap
930            {
931                g.push_str(&line);
932            }
933            let _ = sink.send(line);
934            Ok(())
935        })?;
936        lua.globals().set("print", print)?;
937        Ok(())
938    }
939
940    fn install_json(lua: &Lua) -> mlua::Result<()> {
941        let json = lua.create_table()?;
942        json.set(
943            "encode",
944            lua.create_function(|lua, value: LuaValue| {
945                let v: serde_json::Value = lua.from_value(value)?;
946                serde_json::to_string(&v).map_err(mlua::Error::external)
947            })?,
948        )?;
949        json.set(
950            "decode",
951            lua.create_function(|lua, s: String| {
952                let v: serde_json::Value =
953                    serde_json::from_str(&s).map_err(mlua::Error::external)?;
954                lua.to_value(&v)
955            })?,
956        )?;
957        lua.globals().set("json", json)?;
958        Ok(())
959    }
960
961    fn install_base64(lua: &Lua) -> mlua::Result<()> {
962        use base64::Engine as _;
963        let table = lua.create_table()?;
964        table.set(
965            "encode",
966            lua.create_function(|lua, s: mlua::LuaString| {
967                let out = base64::engine::general_purpose::STANDARD.encode(s.as_bytes());
968                lua.create_string(out)
969            })?,
970        )?;
971        table.set(
972            "decode",
973            lua.create_function(|lua, s: mlua::LuaString| {
974                let bytes = base64::engine::general_purpose::STANDARD
975                    .decode(s.as_bytes())
976                    .map_err(mlua::Error::external)?;
977                lua.create_string(bytes)
978            })?,
979        )?;
980        lua.globals().set("base64", table)?;
981        Ok(())
982    }
983
984    fn install_http(lua: &Lua, tx: mpsc::UnboundedSender<Request>) -> mlua::Result<()> {
985        let http = lua.create_table()?;
986
987        let t = tx.clone();
988        http.set(
989            "get",
990            lua.create_function(move |lua, url: String| {
991                let reply = call(
992                    &t,
993                    Op::Http {
994                        method: "GET",
995                        url,
996                        body: None,
997                    },
998                )
999                .map_err(mlua::Error::runtime)?;
1000                http_reply_to_table(lua, reply)
1001            })?,
1002        )?;
1003
1004        let t = tx.clone();
1005        http.set(
1006            "post",
1007            lua.create_function(move |lua, (url, body): (String, Option<String>)| {
1008                let reply = call(
1009                    &t,
1010                    Op::Http {
1011                        method: "POST",
1012                        url,
1013                        body,
1014                    },
1015                )
1016                .map_err(mlua::Error::runtime)?;
1017                http_reply_to_table(lua, reply)
1018            })?,
1019        )?;
1020
1021        lua.globals().set("http", http)?;
1022        Ok(())
1023    }
1024
1025    fn http_reply_to_table(lua: &Lua, reply: Reply) -> mlua::Result<mlua::Table> {
1026        let Reply::Http { status, body } = reply else {
1027            return Err(mlua::Error::runtime("http: unexpected reply"));
1028        };
1029        let t = lua.create_table()?;
1030        t.set("status", status)?;
1031        t.set("body", body)?;
1032        Ok(t)
1033    }
1034
1035    fn install_tools(
1036        lua: &Lua,
1037        tx: mpsc::UnboundedSender<Request>,
1038        names: Vec<String>,
1039    ) -> mlua::Result<()> {
1040        let tools = lua.create_table()?;
1041        for name in names {
1042            let t = tx.clone();
1043            let n = name.clone();
1044            tools.set(
1045                name,
1046                lua.create_function(move |lua, args: LuaValue| {
1047                    let json: serde_json::Value = match args {
1048                        LuaValue::Nil => serde_json::json!({}),
1049                        other => lua.from_value(other)?,
1050                    };
1051                    let reply = call(
1052                        &t,
1053                        Op::Tool {
1054                            name: n.clone(),
1055                            args: json,
1056                        },
1057                    )
1058                    .map_err(mlua::Error::runtime)?;
1059                    let Reply::Json(v) = reply else {
1060                        return Err(mlua::Error::runtime("tool: unexpected reply"));
1061                    };
1062                    lua.to_value(&v)
1063                })?,
1064            )?;
1065        }
1066        lua.globals().set("tools", tools)?;
1067        Ok(())
1068    }
1069
1070    fn install_fs(lua: &Lua, tx: mpsc::UnboundedSender<Request>) -> mlua::Result<()> {
1071        let fs = lua.create_table()?;
1072
1073        let t = tx.clone();
1074        fs.set(
1075            "read",
1076            lua.create_function(move |lua, path: String| {
1077                match call(&t, Op::Read(path)).map_err(mlua::Error::runtime)? {
1078                    Reply::Str(s) => lua.create_string(s),
1079                    _ => Err(mlua::Error::runtime("vfs: unexpected reply")),
1080                }
1081            })?,
1082        )?;
1083
1084        let t = tx.clone();
1085        fs.set(
1086            "write",
1087            lua.create_function(move |_lua, (path, content): (String, String)| {
1088                call(&t, Op::Write(path, content)).map_err(mlua::Error::runtime)?;
1089                Ok(())
1090            })?,
1091        )?;
1092
1093        let t = tx.clone();
1094        fs.set(
1095            "append",
1096            lua.create_function(move |_lua, (path, content): (String, String)| {
1097                call(&t, Op::Append(path, content)).map_err(mlua::Error::runtime)?;
1098                Ok(())
1099            })?,
1100        )?;
1101
1102        let t = tx.clone();
1103        fs.set(
1104            "exists",
1105            lua.create_function(move |_lua, path: String| {
1106                let reply = call(&t, Op::Exists(path)).map_err(mlua::Error::runtime)?;
1107                Ok(matches!(reply, Reply::Bool(true)))
1108            })?,
1109        )?;
1110
1111        let t = tx.clone();
1112        fs.set(
1113            "stat",
1114            lua.create_function(move |lua, path: String| {
1115                match call(&t, Op::Stat(path)).map_err(mlua::Error::runtime)? {
1116                    Reply::Stat(Some(entry)) => Ok(LuaValue::Table(entry_to_table(lua, &entry)?)),
1117                    Reply::Stat(None) => Ok(LuaValue::Nil),
1118                    _ => Err(mlua::Error::runtime("vfs: unexpected reply")),
1119                }
1120            })?,
1121        )?;
1122
1123        let t = tx.clone();
1124        fs.set(
1125            "list",
1126            lua.create_function(move |lua, path: String| {
1127                let Reply::List(entries) =
1128                    call(&t, Op::List(path)).map_err(mlua::Error::runtime)?
1129                else {
1130                    return Err(mlua::Error::runtime("vfs: unexpected reply"));
1131                };
1132                let arr = lua.create_table()?;
1133                for (i, entry) in entries.iter().enumerate() {
1134                    arr.set(i + 1, entry_to_table(lua, entry)?)?;
1135                }
1136                Ok(arr)
1137            })?,
1138        )?;
1139
1140        let t = tx.clone();
1141        fs.set(
1142            "remove",
1143            lua.create_function(move |_lua, (path, recursive): (String, Option<bool>)| {
1144                let reply = call(&t, Op::Remove(path, recursive.unwrap_or(false)))
1145                    .map_err(mlua::Error::runtime)?;
1146                Ok(matches!(reply, Reply::Bool(true)))
1147            })?,
1148        )?;
1149
1150        let t = tx.clone();
1151        fs.set(
1152            "mkdir",
1153            lua.create_function(move |_lua, path: String| {
1154                call(&t, Op::Mkdir(path)).map_err(mlua::Error::runtime)?;
1155                Ok(())
1156            })?,
1157        )?;
1158
1159        let t = tx.clone();
1160        fs.set(
1161            "grep",
1162            lua.create_function(move |lua, (pattern, path): (String, Option<String>)| {
1163                let Reply::Grep(hits) =
1164                    call(&t, Op::Grep(pattern, path)).map_err(mlua::Error::runtime)?
1165                else {
1166                    return Err(mlua::Error::runtime("vfs: unexpected reply"));
1167                };
1168                let arr = lua.create_table()?;
1169                for (i, h) in hits.iter().enumerate() {
1170                    let row = lua.create_table()?;
1171                    row.set("path", h.path.clone())?;
1172                    row.set("line_number", h.line_number)?;
1173                    row.set("line", h.line.clone())?;
1174                    arr.set(i + 1, row)?;
1175                }
1176                Ok(arr)
1177            })?,
1178        )?;
1179
1180        lua.globals().set("fs", fs)?;
1181        Ok(())
1182    }
1183
1184    fn entry_to_table(lua: &Lua, entry: &VfsEntry) -> mlua::Result<mlua::Table> {
1185        let t = lua.create_table()?;
1186        t.set("name", entry.name.clone())?;
1187        t.set("is_dir", entry.is_dir)?;
1188        t.set("size", entry.size)?;
1189        Ok(t)
1190    }
1191}
1192
1193// ============================================================================
1194// Tests (engine-agnostic)
1195// ============================================================================
1196
1197#[cfg(test)]
1198mod tests {
1199    use super::*;
1200
1201    // Metadata/tool-list constants covered by builtin_capabilities_satisfy_registry_invariants.
1202
1203    #[test]
1204    fn capability_features() {
1205        let cap = LuaCapability;
1206        assert_eq!(cap.features(), vec!["file_system"]);
1207    }
1208
1209    #[test]
1210    fn schema_requires_script() {
1211        let schema = LuaTool.parameters_schema();
1212        let required = schema["required"].as_array().unwrap();
1213        assert!(required.iter().any(|v| v == "script"));
1214        assert!(schema["properties"].get("script").is_some());
1215    }
1216
1217    // Path normalization is the shared `session_path::to_session_path` (tested
1218    // there); LuaVfs no longer carries its own copy and delegates resolution to
1219    // the store (MountFs in production).
1220
1221    #[tokio::test]
1222    async fn execute_without_context_errors() {
1223        let result = LuaTool.execute(json!({"script": "return 1"})).await;
1224        assert!(
1225            matches!(result, ToolExecutionResult::ToolError(msg) if msg.contains("requires context"))
1226        );
1227    }
1228
1229    #[tokio::test]
1230    async fn execute_missing_script_errors() {
1231        let ctx = ToolContext::new(SessionId::new());
1232        let result = LuaTool.execute_with_context(json!({}), &ctx).await;
1233        assert!(
1234            matches!(result, ToolExecutionResult::ToolError(msg) if msg.contains("Missing required parameter"))
1235        );
1236    }
1237
1238    #[tokio::test]
1239    async fn execute_without_file_store_errors() {
1240        let ctx = ToolContext::new(SessionId::new());
1241        let result = LuaTool
1242            .execute_with_context(json!({"script": "return 1"}), &ctx)
1243            .await;
1244        assert!(
1245            matches!(result, ToolExecutionResult::ToolError(msg) if msg.contains("not available"))
1246        );
1247    }
1248
1249    /// Minimal file store used by engine tests.
1250    struct EmptyFileStore;
1251
1252    #[async_trait]
1253    impl SessionFileSystem for EmptyFileStore {
1254        fn is_mount_resolver(&self) -> bool {
1255            false
1256        }
1257
1258        async fn read_file(
1259            &self,
1260            _session_id: SessionId,
1261            _path: &str,
1262        ) -> everruns_provider::error::Result<Option<SessionFile>> {
1263            Ok(None)
1264        }
1265
1266        async fn write_file(
1267            &self,
1268            session_id: SessionId,
1269            path: &str,
1270            content: &str,
1271            encoding: &str,
1272        ) -> everruns_provider::error::Result<SessionFile> {
1273            Ok(SessionFile {
1274                id: uuid::Uuid::new_v4(),
1275                session_id: session_id.into(),
1276                path: path.to_string(),
1277                name: path.rsplit('/').next().unwrap_or("").to_string(),
1278                is_directory: false,
1279                is_readonly: false,
1280                content: Some(content.to_string()),
1281                encoding: encoding.to_string(),
1282                size_bytes: content.len() as i64,
1283                created_at: chrono::Utc::now(),
1284                updated_at: chrono::Utc::now(),
1285            })
1286        }
1287
1288        async fn delete_file(
1289            &self,
1290            _session_id: SessionId,
1291            _path: &str,
1292            _recursive: bool,
1293        ) -> everruns_provider::error::Result<bool> {
1294            Ok(false)
1295        }
1296
1297        async fn list_directory(
1298            &self,
1299            _session_id: SessionId,
1300            _path: &str,
1301        ) -> everruns_provider::error::Result<Vec<crate::session_file::FileInfo>> {
1302            Ok(vec![])
1303        }
1304
1305        async fn stat_file(
1306            &self,
1307            _session_id: SessionId,
1308            _path: &str,
1309        ) -> everruns_provider::error::Result<Option<crate::FileStat>> {
1310            Ok(None)
1311        }
1312
1313        async fn grep_files(
1314            &self,
1315            _session_id: SessionId,
1316            _pattern: &str,
1317            _path_pattern: Option<&str>,
1318        ) -> everruns_provider::error::Result<Vec<crate::GrepMatch>> {
1319            Ok(vec![])
1320        }
1321
1322        async fn create_directory(
1323            &self,
1324            session_id: SessionId,
1325            path: &str,
1326        ) -> everruns_provider::error::Result<crate::session_file::FileInfo> {
1327            Ok(crate::session_file::FileInfo {
1328                id: uuid::Uuid::new_v4(),
1329                session_id: session_id.into(),
1330                path: path.to_string(),
1331                name: path.rsplit('/').next().unwrap_or("").to_string(),
1332                is_directory: true,
1333                is_readonly: false,
1334                size_bytes: 0,
1335                created_at: chrono::Utc::now(),
1336                updated_at: chrono::Utc::now(),
1337            })
1338        }
1339    }
1340
1341    // ========================================================================
1342    // End-to-end engine tests (run for whichever engine feature is enabled).
1343    // ========================================================================
1344
1345    mod engine_tests {
1346        use super::*;
1347        use std::collections::HashMap;
1348        use std::sync::Mutex;
1349
1350        async fn run(script: &str, store: Arc<dyn SessionFileSystem>) -> Value {
1351            let mut ctx = ToolContext::new(SessionId::new());
1352            ctx.file_store = Some(store);
1353            match LuaTool
1354                .execute_with_context(json!({ "script": script }), &ctx)
1355                .await
1356            {
1357                ToolExecutionResult::Success(v) => v,
1358                other => panic!("expected success, got {other:?}"),
1359            }
1360        }
1361
1362        #[tokio::test]
1363        async fn runs_logic_and_math() {
1364            let v = run("return 2 + 3 * 4", Arc::new(EmptyFileStore)).await;
1365            assert_eq!(v["result"], json!(14));
1366            assert_eq!(v["success"], json!(true));
1367        }
1368
1369        #[tokio::test]
1370        async fn captures_print_output() {
1371            let v = run("print('hello'); print('world')", Arc::new(EmptyFileStore)).await;
1372            assert_eq!(v["stdout"], json!("hello\nworld\n"));
1373        }
1374
1375        #[tokio::test]
1376        async fn sandbox_blocks_dangerous_globals() {
1377            // No filesystem/process/dynamic-code escape hatches (TM-LUA-001/006).
1378            // Both engines guarantee these globals are nil.
1379            let script = r#"
1380                return {
1381                    io = io == nil,
1382                    package = package == nil,
1383                    require = require == nil,
1384                    load = load == nil,
1385                    dofile = dofile == nil,
1386                }
1387            "#;
1388            let v = run(script, Arc::new(EmptyFileStore)).await;
1389            for key in ["io", "package", "require", "load", "dofile"] {
1390                assert_eq!(v["result"][key], json!(true), "{key} should be nil");
1391            }
1392        }
1393
1394        // The sandbox retains the safe os.* subset.
1395        #[tokio::test]
1396        async fn safe_os_subset_available() {
1397            let v = run("return type(os.time())", Arc::new(EmptyFileStore)).await;
1398            assert_eq!(v["result"], json!("number"));
1399        }
1400
1401        #[tokio::test]
1402        async fn fs_write_read_roundtrip() {
1403            let store = Arc::new(MapFileStore::default());
1404            let v = run(
1405                r#"
1406                fs.write("/workspace/a.txt", "hello")
1407                return fs.read("/workspace/a.txt")
1408                "#,
1409                store,
1410            )
1411            .await;
1412            assert_eq!(v["result"], json!("hello"));
1413        }
1414
1415        #[tokio::test]
1416        async fn json_roundtrip_through_vfs() {
1417            let store = Arc::new(MapFileStore::default());
1418            let v = run(
1419                r#"
1420                fs.write("/workspace/d.json", json.encode({ n = 42, name = "x" }))
1421                local t = json.decode(fs.read("/workspace/d.json"))
1422                return t.n
1423                "#,
1424                store,
1425            )
1426            .await;
1427            assert_eq!(v["result"], json!(42));
1428        }
1429
1430        #[tokio::test]
1431        async fn tonumber_shim_works() {
1432            let v = run(r#"return tonumber("41") + 1"#, Arc::new(EmptyFileStore)).await;
1433            assert_eq!(v["result"], json!(42));
1434        }
1435
1436        #[tokio::test]
1437        async fn base64_roundtrip() {
1438            let v = run(
1439                r#"return base64.decode(base64.encode("hello"))"#,
1440                Arc::new(EmptyFileStore),
1441            )
1442            .await;
1443            assert_eq!(v["result"], json!("hello"));
1444        }
1445
1446        // ---- Phase 4: http (egress) + code mode (tools) ----
1447
1448        #[tokio::test]
1449        async fn http_and_tools_disabled_by_default() {
1450            // No egress service, no allow-list, no tool registry → both nil.
1451            let v = run(
1452                "return { http = http == nil, tools = tools == nil }",
1453                Arc::new(EmptyFileStore),
1454            )
1455            .await;
1456            assert_eq!(v["result"]["http"], json!(true));
1457            assert_eq!(v["result"]["tools"], json!(true));
1458        }
1459
1460        #[tokio::test]
1461        async fn http_get_through_egress_allowlist() {
1462            let mut ctx = ToolContext::new(SessionId::new());
1463            ctx.file_store = Some(Arc::new(EmptyFileStore));
1464            ctx.egress_service = Some(Arc::new(MockEgress));
1465            ctx.network_access = Some(crate::network_access::NetworkAccessList::allow_only([
1466                "93.184.216.34",
1467            ]));
1468            let v = match LuaTool
1469                .execute_with_context(
1470                    json!({ "script": r#"local r = http.get("http://93.184.216.34/x"); return { s = r.status, b = r.body }"# }),
1471                    &ctx,
1472                )
1473                .await
1474            {
1475                ToolExecutionResult::Success(v) => v,
1476                other => panic!("expected success, got {other:?}"),
1477            };
1478            assert_eq!(v["result"]["s"], json!(200));
1479            assert_eq!(v["result"]["b"], json!("pong"));
1480        }
1481
1482        #[tokio::test]
1483        async fn http_denies_allowlisted_loopback_ip_before_egress() {
1484            let mut ctx = ToolContext::new(SessionId::new());
1485            ctx.file_store = Some(Arc::new(EmptyFileStore));
1486            ctx.egress_service = Some(Arc::new(everruns_http::DirectEgressService::new()));
1487            ctx.network_access = Some(crate::network_access::NetworkAccessList::allow_only([
1488                "127.0.0.1",
1489            ]));
1490            let result = LuaTool
1491                .execute_with_context(
1492                    json!({ "script": r#"return http.get("http://127.0.0.1/latest/meta-data").status"# }),
1493                    &ctx,
1494                )
1495                .await;
1496            assert!(
1497                matches!(result, ToolExecutionResult::ToolError(ref msg) if msg.contains("private/internal address")),
1498                "expected egress boundary denial, got {result:?}"
1499            );
1500        }
1501
1502        #[tokio::test]
1503        async fn http_denied_when_not_in_allowlist() {
1504            let mut ctx = ToolContext::new(SessionId::new());
1505            ctx.file_store = Some(Arc::new(EmptyFileStore));
1506            ctx.egress_service = Some(Arc::new(MockEgress));
1507            ctx.network_access = Some(crate::network_access::NetworkAccessList::allow_only([
1508                "allowed.com",
1509            ]));
1510            let result = LuaTool
1511                .execute_with_context(
1512                    json!({ "script": r#"return http.get("https://evil.com/x").status"# }),
1513                    &ctx,
1514                )
1515                .await;
1516            assert!(
1517                matches!(result, ToolExecutionResult::ToolError(msg) if msg.contains("egress denied"))
1518            );
1519        }
1520
1521        #[tokio::test]
1522        async fn code_mode_calls_a_sibling_tool() {
1523            let mut registry = crate::tools::ToolRegistry::new();
1524            registry.register(EchoTool);
1525            let mut ctx = ToolContext::new(SessionId::new());
1526            ctx.file_store = Some(Arc::new(EmptyFileStore));
1527            ctx.tool_registry = Some(Arc::new(registry));
1528            let v = match LuaTool
1529                .execute_with_context(
1530                    json!({ "script": r#"return tools.echo({ n = 5 }).n"# }),
1531                    &ctx,
1532                )
1533                .await
1534            {
1535                ToolExecutionResult::Success(v) => v,
1536                other => panic!("expected success, got {other:?}"),
1537            };
1538            assert_eq!(v["result"], json!(5));
1539        }
1540
1541        #[tokio::test]
1542        async fn code_mode_excludes_execution_tools() {
1543            // The `lua` tool itself must never be exposed via code mode.
1544            let mut registry = crate::tools::ToolRegistry::new();
1545            registry.register(EchoTool);
1546            registry.register(LuaTool);
1547            let mut ctx = ToolContext::new(SessionId::new());
1548            ctx.file_store = Some(Arc::new(EmptyFileStore));
1549            ctx.tool_registry = Some(Arc::new(registry));
1550            let v = run_with_ctx(
1551                "return { lua = tools.lua == nil, echo = tools.echo ~= nil }",
1552                &ctx,
1553            )
1554            .await;
1555            assert_eq!(
1556                v["result"]["lua"],
1557                json!(true),
1558                "lua tool must not be exposed"
1559            );
1560            assert_eq!(v["result"]["echo"], json!(true));
1561        }
1562
1563        async fn run_with_ctx(script: &str, ctx: &ToolContext) -> Value {
1564            match LuaTool
1565                .execute_with_context(json!({ "script": script }), ctx)
1566                .await
1567            {
1568                ToolExecutionResult::Success(v) => v,
1569                other => panic!("expected success, got {other:?}"),
1570            }
1571        }
1572
1573        /// Echoes its JSON argument back as the result.
1574        struct EchoTool;
1575
1576        #[async_trait]
1577        impl Tool for EchoTool {
1578            fn name(&self) -> &str {
1579                "echo"
1580            }
1581            fn description(&self) -> &str {
1582                "Echo the argument."
1583            }
1584            fn parameters_schema(&self) -> Value {
1585                json!({ "type": "object" })
1586            }
1587            async fn execute(&self, arguments: Value) -> ToolExecutionResult {
1588                ToolExecutionResult::success(arguments)
1589            }
1590        }
1591
1592        /// Minimal egress service that returns a canned 200 response.
1593        struct MockEgress;
1594
1595        #[async_trait]
1596        impl crate::egress::EgressService for MockEgress {
1597            async fn send(
1598                &self,
1599                request: crate::egress::EgressRequest,
1600            ) -> crate::egress::EgressResult<crate::egress::EgressResponse> {
1601                if !request.dns_pinning_required {
1602                    return Err(crate::egress::EgressError::InvalidRequest(
1603                        "missing DNS-pinned validation request".to_string(),
1604                    ));
1605                }
1606                Ok(crate::egress::EgressResponse {
1607                    status: 200,
1608                    headers: std::collections::BTreeMap::new(),
1609                    body: b"pong".to_vec(),
1610                })
1611            }
1612
1613            async fn send_stream(
1614                &self,
1615                _request: crate::egress::EgressRequest,
1616            ) -> crate::egress::EgressResult<crate::egress::EgressStreamResponse> {
1617                Err(crate::egress::EgressError::Transport(
1618                    "streaming not supported in mock".to_string(),
1619                ))
1620            }
1621        }
1622
1623        #[tokio::test]
1624        async fn fs_addresses_paths_outside_workspace() {
1625            // The store (MountFs) resolves any path into the session-scoped
1626            // backend — `/workspace` is just the default cwd, the root mount
1627            // makes everything addressable (still contained by the backend; the
1628            // session scope is the tenant boundary). A path outside /workspace
1629            // round-trips rather than being rejected.
1630            let store = Arc::new(MapFileStore::default());
1631            let v = run(
1632                r#"
1633                fs.write("/tmp/note.txt", "hi")
1634                return fs.read("/tmp/note.txt")
1635                "#,
1636                store,
1637            )
1638            .await;
1639            assert_eq!(v["result"], json!("hi"));
1640        }
1641
1642        #[tokio::test]
1643        async fn instruction_budget_terminates_infinite_loop() {
1644            // A tight infinite loop must be interrupted by the hook, not hang.
1645            let result = LuaTool
1646                .execute_with_context(
1647                    json!({ "script": "while true do end", "timeout_ms": 1000 }),
1648                    &{
1649                        let mut ctx = ToolContext::new(SessionId::new());
1650                        ctx.file_store = Some(Arc::new(EmptyFileStore));
1651                        ctx
1652                    },
1653                )
1654                .await;
1655            assert!(matches!(result, ToolExecutionResult::ToolError(_)));
1656        }
1657
1658        /// HashMap-backed store (normalized session paths) for fs round-trips.
1659        #[derive(Default)]
1660        struct MapFileStore {
1661            files: Mutex<HashMap<String, String>>,
1662        }
1663
1664        #[async_trait]
1665        impl SessionFileSystem for MapFileStore {
1666            fn is_mount_resolver(&self) -> bool {
1667                false
1668            }
1669
1670            async fn read_file(
1671                &self,
1672                session_id: SessionId,
1673                path: &str,
1674            ) -> everruns_provider::error::Result<Option<SessionFile>> {
1675                let files = self.files.lock().unwrap();
1676                Ok(files.get(path).map(|content| SessionFile {
1677                    id: uuid::Uuid::new_v4(),
1678                    session_id: session_id.into(),
1679                    path: path.to_string(),
1680                    name: path.rsplit('/').next().unwrap_or("").to_string(),
1681                    is_directory: false,
1682                    is_readonly: false,
1683                    content: Some(content.clone()),
1684                    encoding: "text".to_string(),
1685                    size_bytes: content.len() as i64,
1686                    created_at: chrono::Utc::now(),
1687                    updated_at: chrono::Utc::now(),
1688                }))
1689            }
1690
1691            async fn write_file(
1692                &self,
1693                session_id: SessionId,
1694                path: &str,
1695                content: &str,
1696                encoding: &str,
1697            ) -> everruns_provider::error::Result<SessionFile> {
1698                self.files
1699                    .lock()
1700                    .unwrap()
1701                    .insert(path.to_string(), content.to_string());
1702                Ok(SessionFile {
1703                    id: uuid::Uuid::new_v4(),
1704                    session_id: session_id.into(),
1705                    path: path.to_string(),
1706                    name: path.rsplit('/').next().unwrap_or("").to_string(),
1707                    is_directory: false,
1708                    is_readonly: false,
1709                    content: Some(content.to_string()),
1710                    encoding: encoding.to_string(),
1711                    size_bytes: content.len() as i64,
1712                    created_at: chrono::Utc::now(),
1713                    updated_at: chrono::Utc::now(),
1714                })
1715            }
1716
1717            async fn delete_file(
1718                &self,
1719                _session_id: SessionId,
1720                path: &str,
1721                _recursive: bool,
1722            ) -> everruns_provider::error::Result<bool> {
1723                Ok(self.files.lock().unwrap().remove(path).is_some())
1724            }
1725
1726            async fn list_directory(
1727                &self,
1728                session_id: SessionId,
1729                path: &str,
1730            ) -> everruns_provider::error::Result<Vec<crate::session_file::FileInfo>> {
1731                let prefix = if path == "/" {
1732                    "/".to_string()
1733                } else {
1734                    format!("{path}/")
1735                };
1736                let files = self.files.lock().unwrap();
1737                Ok(files
1738                    .iter()
1739                    .filter(|(p, _)| p.starts_with(&prefix))
1740                    .map(|(p, c)| crate::session_file::FileInfo {
1741                        id: uuid::Uuid::new_v4(),
1742                        session_id: session_id.into(),
1743                        path: p.clone(),
1744                        name: p.rsplit('/').next().unwrap_or("").to_string(),
1745                        is_directory: false,
1746                        is_readonly: false,
1747                        size_bytes: c.len() as i64,
1748                        created_at: chrono::Utc::now(),
1749                        updated_at: chrono::Utc::now(),
1750                    })
1751                    .collect())
1752            }
1753
1754            async fn stat_file(
1755                &self,
1756                _session_id: SessionId,
1757                path: &str,
1758            ) -> everruns_provider::error::Result<Option<crate::FileStat>> {
1759                let files = self.files.lock().unwrap();
1760                Ok(files.get(path).map(|c| crate::FileStat {
1761                    path: path.to_string(),
1762                    name: path.rsplit('/').next().unwrap_or("").to_string(),
1763                    is_directory: false,
1764                    is_readonly: false,
1765                    size_bytes: c.len() as i64,
1766                    created_at: chrono::Utc::now(),
1767                    updated_at: chrono::Utc::now(),
1768                }))
1769            }
1770
1771            async fn grep_files(
1772                &self,
1773                _session_id: SessionId,
1774                pattern: &str,
1775                path_pattern: Option<&str>,
1776            ) -> everruns_provider::error::Result<Vec<crate::GrepMatch>> {
1777                let re = regex::Regex::new(pattern)
1778                    .map_err(|e| crate::error::AgentLoopError::store(e.to_string()))?;
1779                let files = self.files.lock().unwrap();
1780                let mut out = Vec::new();
1781                for (p, c) in files.iter() {
1782                    if let Some(pp) = path_pattern
1783                        && !p.starts_with(pp)
1784                    {
1785                        continue;
1786                    }
1787                    for (i, line) in c.lines().enumerate() {
1788                        if re.is_match(line) {
1789                            out.push(crate::GrepMatch {
1790                                path: p.clone(),
1791                                line_number: i + 1,
1792                                line: line.to_string(),
1793                            });
1794                        }
1795                    }
1796                }
1797                Ok(out)
1798            }
1799
1800            async fn create_directory(
1801                &self,
1802                session_id: SessionId,
1803                path: &str,
1804            ) -> everruns_provider::error::Result<crate::session_file::FileInfo> {
1805                Ok(crate::session_file::FileInfo {
1806                    id: uuid::Uuid::new_v4(),
1807                    session_id: session_id.into(),
1808                    path: path.to_string(),
1809                    name: path.rsplit('/').next().unwrap_or("").to_string(),
1810                    is_directory: true,
1811                    is_readonly: false,
1812                    size_bytes: 0,
1813                    created_at: chrono::Utc::now(),
1814                    updated_at: chrono::Utc::now(),
1815                })
1816            }
1817        }
1818    }
1819}