1use 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;
38const MEMORY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
40const MAX_INSTRUCTIONS: u64 = 50_000_000;
42const 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
77pub 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 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
150pub 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 .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 let allowed_tools = gated_code_mode_tools(context);
259
260 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 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 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 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
338pub 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
365fn 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#[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#[derive(Debug, Default)]
403pub struct LuaOutcome {
404 pub stdout: String,
406 pub return_value: Option<Value>,
408 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#[derive(Debug, Clone)]
427pub struct VfsEntry {
428 pub name: String,
429 pub is_dir: bool,
430 pub size: i64,
431}
432
433#[derive(Debug, Clone)]
435pub struct VfsGrepHit {
436 pub path: String,
437 pub line_number: usize,
438 pub line: String,
439}
440
441pub 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 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 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 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#[cfg(not(feature = "lua"))]
578mod engine {
579 use super::{LuaLimits, LuaOutcome, LuaVfs, ToolContext};
580 use std::sync::Arc;
581
582 #[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#[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 const HOOK_EVERY: u32 = 100_000;
631 const HTTP_BODY_CAP: usize = 1024 * 1024;
633
634 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 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 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 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 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 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#[cfg(test)]
1217mod tests {
1218 use super::*;
1219
1220 #[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 #[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 #[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 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 #[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 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 #[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 #[tokio::test]
1486 async fn http_and_tools_disabled_by_default() {
1487 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 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 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 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 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 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 #[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}