Skip to main content

everruns_core/capabilities/
lua.rs

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