Skip to main content

assay/lua/
mod.rs

1pub mod async_bridge;
2pub mod builtins;
3pub mod file_source;
4
5#[cfg(feature = "server")]
6#[allow(unused_imports)]
7pub use builtins::LuaAxumRouter;
8
9use anyhow::Result;
10use include_dir::{Dir, include_dir};
11use mlua::{Lua, LuaOptions, StdLib};
12
13static STDLIB_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/stdlib");
14
15/// Environment variable to override the global module search path.
16pub const MODULES_PATH_ENV: &str = "ASSAY_MODULES_PATH";
17
18/// Comma-separated list of additional globals to nil out at VM
19/// creation time (defense-in-depth knob for hardened deployments).
20/// Names support dotted paths into stdlib tables — e.g.
21/// `ASSAY_BLOCK_GLOBALS=dofile,os.execute,debug.getinfo`.
22pub const BLOCK_GLOBALS_ENV: &str = "ASSAY_BLOCK_GLOBALS";
23
24/// Set to `1` or `true` to activate read-only mode for every VM the
25/// process creates. Mutating builtins stay registered but raise
26/// `readonly: <name> blocked` errors instead of executing. The
27/// `--readonly` CLI flag activates the same mode per invocation.
28pub const READONLY_ENV: &str = "ASSAY_READONLY";
29
30pub fn readonly_from_env() -> bool {
31    matches!(
32        std::env::var(READONLY_ENV).ok().as_deref().map(str::trim),
33        Some("1") | Some("true")
34    )
35}
36
37/// Set to `1` or `true` to activate approval mode for every VM the process
38/// creates. Mutating builtins stay registered but suspend for
39/// per-operation approval via the resume flow instead of executing. The
40/// `--approval-mode` CLI flag activates the same mode per invocation and
41/// takes precedence over read-only mode.
42pub const APPROVAL_ENV: &str = "ASSAY_APPROVAL";
43
44/// Comma-separated set of already-approved operation indices for an
45/// approval-mode re-run (set by the resume machinery).
46pub(crate) const APPROVED_INDICES_ENV: &str = "ASSAY_APPROVED_INDICES";
47
48/// The single operation index to fail terminally on an approval-mode
49/// re-run (set by the resume machinery when a decision is `no`).
50pub(crate) const DENIED_INDEX_ENV: &str = "ASSAY_DENIED_INDEX";
51
52/// Prefix that marks a runtime error as an approval request. The tool-mode
53/// runner extracts the JSON payload that follows it to suspend the run.
54pub(crate) const APPROVAL_REQUEST_PREFIX: &str = "__assay_approval_request__:";
55
56pub fn approval_from_env() -> bool {
57    matches!(
58        std::env::var(APPROVAL_ENV).ok().as_deref().map(str::trim),
59        Some("1") | Some("true")
60    )
61}
62
63/// Execution mode selecting which post-registration gate (if any) is
64/// applied to a VM.
65#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
66pub enum ExecMode {
67    #[default]
68    Unrestricted,
69    ReadOnly,
70    Approval,
71}
72
73impl ExecMode {
74    pub fn is_readonly(self) -> bool {
75        matches!(self, ExecMode::ReadOnly)
76    }
77
78    pub fn is_approval(self) -> bool {
79        matches!(self, ExecMode::Approval)
80    }
81}
82
83/// Per-run approval state consumed by the approval gate: which operation
84/// indices are pre-approved for this run and which single index (if any)
85/// must fail terminally.
86#[derive(Clone, Debug, Default)]
87pub struct ApprovalConfig {
88    pub approved_indices: Vec<u64>,
89    pub denied_index: Option<u64>,
90}
91
92/// Resolve the approval set + denied index from the environment. Empty
93/// when the vars are absent, which is the first-run (nothing approved
94/// yet) case.
95pub fn approval_config_from_env() -> ApprovalConfig {
96    let approved_indices = std::env::var(APPROVED_INDICES_ENV)
97        .ok()
98        .map(|raw| parse_indices(&raw))
99        .unwrap_or_default();
100    let denied_index = std::env::var(DENIED_INDEX_ENV)
101        .ok()
102        .and_then(|raw| raw.trim().parse::<u64>().ok());
103    ApprovalConfig {
104        approved_indices,
105        denied_index,
106    }
107}
108
109fn parse_indices(raw: &str) -> Vec<u64> {
110    raw.split(',')
111        .filter_map(|part| {
112            let trimmed = part.trim();
113            if trimmed.is_empty() {
114                None
115            } else {
116                trimmed.parse::<u64>().ok()
117            }
118        })
119        .collect()
120}
121
122/// Full VM configuration. New knobs are added here rather than by widening
123/// `create_vm_configured`, keeping the older factory signatures stable.
124#[derive(Clone, Debug, Default)]
125pub struct VmOptions {
126    pub global_modules_path: Option<String>,
127    pub mode: ExecMode,
128    pub approval: ApprovalConfig,
129}
130
131fn lua_err(e: mlua::Error) -> anyhow::Error {
132    anyhow::anyhow!("{e}")
133}
134
135#[allow(dead_code)]
136pub fn create_vm(client: reqwest::Client) -> Result<Lua> {
137    create_vm_configured(client, None, readonly_from_env())
138}
139
140#[allow(dead_code)]
141pub fn create_vm_with_lib_path(client: reqwest::Client, lib_path: String) -> Result<Lua> {
142    create_vm_configured(client, Some(lib_path), readonly_from_env())
143}
144
145#[allow(dead_code)]
146pub fn create_vm_with_paths(
147    client: reqwest::Client,
148    global_modules_path: Option<String>,
149) -> Result<Lua> {
150    create_vm_configured(client, global_modules_path, readonly_from_env())
151}
152
153pub fn create_vm_configured(
154    client: reqwest::Client,
155    global_modules_path: Option<String>,
156    readonly: bool,
157) -> Result<Lua> {
158    let mode = if readonly {
159        ExecMode::ReadOnly
160    } else {
161        ExecMode::Unrestricted
162    };
163    create_vm_with_options(
164        client,
165        VmOptions {
166            global_modules_path,
167            mode,
168            approval: ApprovalConfig::default(),
169        },
170    )
171}
172
173pub fn create_vm_with_options(client: reqwest::Client, options: VmOptions) -> Result<Lua> {
174    let VmOptions {
175        global_modules_path,
176        mode,
177        approval,
178    } = options;
179    let libs = StdLib::ALL_SAFE;
180    let lua = Lua::new_with(libs, LuaOptions::default()).map_err(lua_err)?;
181    lua.set_memory_limit(64 * 1024 * 1024).map_err(lua_err)?;
182    sandbox(&lua).map_err(lua_err)?;
183    register_fs_loader(&lua, global_modules_path).map_err(lua_err)?;
184    register_stdlib_loader(&lua).map_err(lua_err)?;
185    builtins::register_all(&lua, client).map_err(lua_err)?;
186    match mode {
187        ExecMode::ReadOnly => builtins::readonly::apply(&lua).map_err(lua_err)?,
188        ExecMode::Approval => builtins::approval::apply(&lua, &approval).map_err(lua_err)?,
189        ExecMode::Unrestricted => {}
190    }
191    Ok(lua)
192}
193
194fn sandbox(lua: &Lua) -> mlua::Result<()> {
195    // Block bytecode-level escape hatches only. Source-level loaders
196    // (`load` / `loadfile` / `dofile`) stay available — operator scripts
197    // are trusted to compose themselves out of multiple files (seed +
198    // init bootstraps, shared helpers, etc.). `string.dump` stays
199    // blocked because it produces native bytecode that defeats the
200    // memory/CPU caps the runtime relies on.
201    let globals = lua.globals();
202    let string_lib: mlua::Table = globals.get("string")?;
203    string_lib.set("dump", mlua::Value::Nil)?;
204
205    if let Ok(extra) = std::env::var(BLOCK_GLOBALS_ENV) {
206        for raw in extra.split(',') {
207            let name = raw.trim();
208            if name.is_empty() {
209                continue;
210            }
211            nil_dotted_path(lua, name)?;
212        }
213    }
214
215    Ok(())
216}
217
218/// Resolve a dotted Lua path (e.g. `"os.execute"` or `"debug.getinfo"`)
219/// against globals and set the leaf to nil. A bare name (e.g.
220/// `"dofile"`) clears it from `_G`. Missing intermediate tables are
221/// silently skipped so a typo in `ASSAY_BLOCK_GLOBALS` doesn't fail
222/// VM creation.
223fn nil_dotted_path(lua: &Lua, path: &str) -> mlua::Result<()> {
224    let parts: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
225    if parts.is_empty() {
226        return Ok(());
227    }
228    let mut current: mlua::Table = lua.globals();
229    for segment in &parts[..parts.len() - 1] {
230        let next: mlua::Value = current.get(*segment)?;
231        match next {
232            mlua::Value::Table(t) => current = t,
233            _ => return Ok(()),
234        }
235    }
236    current.set(parts[parts.len() - 1], mlua::Value::Nil)
237}
238
239fn register_stdlib_loader(lua: &Lua) -> mlua::Result<()> {
240    let package: mlua::Table = lua.globals().get("package")?;
241    let searchers: mlua::Table = package.get("searchers")?;
242
243    // Resolves `require("assay.ory.kratos")` -> "ory/kratos.lua" by replacing
244    // dots with slashes, matching standard Lua package loading convention.
245    // Tries "<path>.lua" first, then falls back to "<path>/init.lua" so
246    // both `stdlib/ory.lua` (flat convenience wrapper) and
247    // `stdlib/ory/kratos.lua` (nested submodule) resolve correctly.
248    let stdlib_searcher = lua.create_function(|lua, module_name: String| {
249        let rest = match module_name.strip_prefix("assay.") {
250            Some(r) => r,
251            None => {
252                return Ok(mlua::Value::String(
253                    lua.create_string(format!("not an assay.* module: {module_name}"))?,
254                ));
255            }
256        };
257
258        let base = rest.replace('.', "/");
259        let candidates = [format!("{base}.lua"), format!("{base}/init.lua")];
260
261        for path in &candidates {
262            if let Some(file) = STDLIB_DIR.get_file(path) {
263                let source = file
264                    .contents_utf8()
265                    .ok_or_else(|| mlua::Error::runtime(format!("stdlib {path}: invalid UTF-8")))?;
266                let loader = lua
267                    .load(source)
268                    .set_name(format!("@assay/{path}"))
269                    .into_function()?;
270                return Ok(mlua::Value::Function(loader));
271            }
272        }
273
274        Ok(mlua::Value::String(lua.create_string(format!(
275            "no embedded stdlib file: {}",
276            candidates[0]
277        ))?))
278    })?;
279
280    let len = searchers.len()?;
281    searchers.set(len + 1, stdlib_searcher)?;
282
283    Ok(())
284}
285
286fn register_fs_loader(lua: &Lua, global_modules_path: Option<String>) -> mlua::Result<()> {
287    let package: mlua::Table = lua.globals().get("package")?;
288    let searchers: mlua::Table = package.get("searchers")?;
289
290    // Same dotted-path resolution as the stdlib loader: `assay.ory.kratos`
291    // -> "ory/kratos.lua", falling back to "ory/kratos/init.lua".
292    let fs_searcher = lua.create_function(move |lua, module_name: String| {
293        let rest = match module_name.strip_prefix("assay.") {
294            Some(r) => r,
295            None => {
296                return Ok(mlua::Value::String(
297                    lua.create_string(format!("not an assay.* module: {module_name}"))?,
298                ));
299            }
300        };
301        let base = rest.replace('.', "/");
302        let candidates = [format!("{base}.lua"), format!("{base}/init.lua")];
303
304        let try_load = |dir: &std::path::Path| -> Option<(std::path::PathBuf, String)> {
305            for rel in &candidates {
306                let full = dir.join(rel);
307                if let Ok(source) = std::fs::read_to_string(&full) {
308                    return Some((full, source));
309                }
310            }
311            None
312        };
313
314        // Priority 1: ./modules/<path>.lua (per-project)
315        if let Some((full, source)) = try_load(std::path::Path::new("./modules")) {
316            let loader = lua
317                .load(source)
318                .set_name(format!("@{}", full.display()))
319                .into_function()?;
320            return Ok(mlua::Value::Function(loader));
321        }
322
323        // Priority 2: $ASSAY_MODULES_PATH or ~/.assay/modules/<path>.lua
324        let global_path = if let Some(ref custom_path) = global_modules_path {
325            std::path::PathBuf::from(custom_path)
326        } else if let Ok(modules_env) = std::env::var(MODULES_PATH_ENV) {
327            std::path::PathBuf::from(modules_env)
328        } else if let Ok(home) = std::env::var("HOME") {
329            std::path::Path::new(&home).join(".assay/modules")
330        } else {
331            std::path::PathBuf::new()
332        };
333
334        if !global_path.as_os_str().is_empty()
335            && let Some((full, source)) = try_load(&global_path)
336        {
337            let loader = lua
338                .load(source)
339                .set_name(format!("@{}", full.display()))
340                .into_function()?;
341            return Ok(mlua::Value::Function(loader));
342        }
343
344        // Priority 3: Built-in modules are handled by register_stdlib_loader
345        // Return nil to fall through to the next searcher
346        Ok(mlua::Value::Nil)
347    })?;
348
349    let len = searchers.len()?;
350    searchers.set(len + 1, fs_searcher)?;
351
352    Ok(())
353}
354
355pub fn inject_env(lua: &Lua, env: &std::collections::HashMap<String, String>) -> Result<()> {
356    if env.is_empty() {
357        return Ok(());
358    }
359    let globals = lua.globals();
360    let env_table: mlua::Table = globals.get("env").map_err(lua_err)?;
361    let check_env: mlua::Table = env_table.get("_check_env").map_err(lua_err)?;
362    for (k, v) in env {
363        check_env.set(k.as_str(), v.as_str()).map_err(lua_err)?;
364    }
365    Ok(())
366}