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/// JSON array of `ApprovedOp` records for an approval-mode re-run (set by
53/// the resume machinery). Binds each approved index to the operation that
54/// was approved, so a replay whose control flow shifted cannot spend a
55/// grant on a different operation.
56pub(crate) const APPROVED_OPS_ENV: &str = "ASSAY_APPROVED_OPS";
57
58/// Prefix that marks a runtime error as an approval request. The tool-mode
59/// runner extracts the JSON payload that follows it to suspend the run.
60pub(crate) const APPROVAL_REQUEST_PREFIX: &str = "__assay_approval_request__:";
61
62pub fn approval_from_env() -> bool {
63    matches!(
64        std::env::var(APPROVAL_ENV).ok().as_deref().map(str::trim),
65        Some("1") | Some("true")
66    )
67}
68
69/// Execution mode selecting which post-registration gate (if any) is
70/// applied to a VM.
71#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
72pub enum ExecMode {
73    #[default]
74    Unrestricted,
75    ReadOnly,
76    Approval,
77}
78
79impl ExecMode {
80    pub fn is_readonly(self) -> bool {
81        matches!(self, ExecMode::ReadOnly)
82    }
83
84    pub fn is_approval(self) -> bool {
85        matches!(self, ExecMode::Approval)
86    }
87}
88
89/// One granted approval: the operation index it was issued for, the
90/// operation descriptor that was actually approved (e.g. `http.post`),
91/// and — for audit — who authorized it, when the caller supplied an
92/// identity. Serialized into resume state and the re-run environment.
93/// Crate-internal: travels via `ASSAY_APPROVED_OPS`, never the public
94/// `ApprovalConfig` API.
95#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
96pub(crate) struct ApprovedOp {
97    pub index: u64,
98    pub op: String,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub approver: Option<String>,
101}
102
103/// Resolve the op bindings for this run's grants from the environment
104/// (set by the resume machinery). Empty when absent or malformed —
105/// the approval gate then refuses every index-only grant, fail-closed.
106pub(crate) fn approved_ops_from_env() -> Vec<ApprovedOp> {
107    std::env::var(APPROVED_OPS_ENV)
108        .ok()
109        .and_then(|raw| serde_json::from_str::<Vec<ApprovedOp>>(&raw).ok())
110        .unwrap_or_default()
111}
112
113/// Per-run approval state consumed by the approval gate: which operation
114/// indices are pre-approved for this run and which single index (if any)
115/// must fail terminally.
116#[derive(Clone, Debug, Default)]
117pub struct ApprovalConfig {
118    pub approved_indices: Vec<u64>,
119    pub denied_index: Option<u64>,
120}
121
122/// Resolve the approval set + denied index from the environment. Empty
123/// when the vars are absent, which is the first-run (nothing approved
124/// yet) case.
125pub fn approval_config_from_env() -> ApprovalConfig {
126    let approved_indices = std::env::var(APPROVED_INDICES_ENV)
127        .ok()
128        .map(|raw| parse_indices(&raw))
129        .unwrap_or_default();
130    let denied_index = std::env::var(DENIED_INDEX_ENV)
131        .ok()
132        .and_then(|raw| raw.trim().parse::<u64>().ok());
133    ApprovalConfig {
134        approved_indices,
135        denied_index,
136    }
137}
138
139fn parse_indices(raw: &str) -> Vec<u64> {
140    raw.split(',')
141        .filter_map(|part| {
142            let trimmed = part.trim();
143            if trimmed.is_empty() {
144                None
145            } else {
146                trimmed.parse::<u64>().ok()
147            }
148        })
149        .collect()
150}
151
152/// Full VM configuration. New knobs are added here rather than by widening
153/// `create_vm_configured`, keeping the older factory signatures stable.
154#[derive(Clone, Debug, Default)]
155pub struct VmOptions {
156    pub global_modules_path: Option<String>,
157    pub mode: ExecMode,
158    pub approval: ApprovalConfig,
159}
160
161fn lua_err(e: mlua::Error) -> anyhow::Error {
162    anyhow::anyhow!("{e}")
163}
164
165#[allow(dead_code)]
166pub fn create_vm(client: reqwest::Client) -> Result<Lua> {
167    create_vm_configured(client, None, readonly_from_env())
168}
169
170#[allow(dead_code)]
171pub fn create_vm_with_lib_path(client: reqwest::Client, lib_path: String) -> Result<Lua> {
172    create_vm_configured(client, Some(lib_path), readonly_from_env())
173}
174
175#[allow(dead_code)]
176pub fn create_vm_with_paths(
177    client: reqwest::Client,
178    global_modules_path: Option<String>,
179) -> Result<Lua> {
180    create_vm_configured(client, global_modules_path, readonly_from_env())
181}
182
183pub fn create_vm_configured(
184    client: reqwest::Client,
185    global_modules_path: Option<String>,
186    readonly: bool,
187) -> Result<Lua> {
188    let mode = if readonly {
189        ExecMode::ReadOnly
190    } else {
191        ExecMode::Unrestricted
192    };
193    create_vm_with_options(
194        client,
195        VmOptions {
196            global_modules_path,
197            mode,
198            approval: ApprovalConfig::default(),
199        },
200    )
201}
202
203pub fn create_vm_with_options(client: reqwest::Client, options: VmOptions) -> Result<Lua> {
204    let VmOptions {
205        global_modules_path,
206        mode,
207        approval,
208    } = options;
209    let libs = StdLib::ALL_SAFE;
210    let lua = Lua::new_with(libs, LuaOptions::default()).map_err(lua_err)?;
211    lua.set_memory_limit(64 * 1024 * 1024).map_err(lua_err)?;
212    sandbox(&lua).map_err(lua_err)?;
213    register_fs_loader(&lua, global_modules_path).map_err(lua_err)?;
214    register_stdlib_loader(&lua).map_err(lua_err)?;
215    builtins::register_all(&lua, client).map_err(lua_err)?;
216    match mode {
217        ExecMode::ReadOnly => builtins::readonly::apply(&lua).map_err(lua_err)?,
218        ExecMode::Approval => builtins::approval::apply(&lua, &approval).map_err(lua_err)?,
219        ExecMode::Unrestricted => {}
220    }
221    Ok(lua)
222}
223
224fn sandbox(lua: &Lua) -> mlua::Result<()> {
225    // Block bytecode-level escape hatches only. Source-level loaders
226    // (`load` / `loadfile` / `dofile`) stay available — operator scripts
227    // are trusted to compose themselves out of multiple files (seed +
228    // init bootstraps, shared helpers, etc.). `string.dump` stays
229    // blocked because it produces native bytecode that defeats the
230    // memory/CPU caps the runtime relies on.
231    let globals = lua.globals();
232    let string_lib: mlua::Table = globals.get("string")?;
233    string_lib.set("dump", mlua::Value::Nil)?;
234
235    if let Ok(extra) = std::env::var(BLOCK_GLOBALS_ENV) {
236        for raw in extra.split(',') {
237            let name = raw.trim();
238            if name.is_empty() {
239                continue;
240            }
241            nil_dotted_path(lua, name)?;
242        }
243    }
244
245    Ok(())
246}
247
248/// Resolve a dotted Lua path (e.g. `"os.execute"` or `"debug.getinfo"`)
249/// against globals and set the leaf to nil. A bare name (e.g.
250/// `"dofile"`) clears it from `_G`. Missing intermediate tables are
251/// silently skipped so a typo in `ASSAY_BLOCK_GLOBALS` doesn't fail
252/// VM creation.
253fn nil_dotted_path(lua: &Lua, path: &str) -> mlua::Result<()> {
254    let parts: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
255    if parts.is_empty() {
256        return Ok(());
257    }
258    let mut current: mlua::Table = lua.globals();
259    for segment in &parts[..parts.len() - 1] {
260        let next: mlua::Value = current.get(*segment)?;
261        match next {
262            mlua::Value::Table(t) => current = t,
263            _ => return Ok(()),
264        }
265    }
266    current.set(parts[parts.len() - 1], mlua::Value::Nil)
267}
268
269fn register_stdlib_loader(lua: &Lua) -> mlua::Result<()> {
270    let package: mlua::Table = lua.globals().get("package")?;
271    let searchers: mlua::Table = package.get("searchers")?;
272
273    // Resolves `require("assay.ory.kratos")` -> "ory/kratos.lua" by replacing
274    // dots with slashes, matching standard Lua package loading convention.
275    // Tries "<path>.lua" first, then falls back to "<path>/init.lua" so
276    // both `stdlib/ory.lua` (flat convenience wrapper) and
277    // `stdlib/ory/kratos.lua` (nested submodule) resolve correctly.
278    let stdlib_searcher = lua.create_function(|lua, module_name: String| {
279        let rest = match module_name.strip_prefix("assay.") {
280            Some(r) => r,
281            None => {
282                return Ok(mlua::Value::String(
283                    lua.create_string(format!("not an assay.* module: {module_name}"))?,
284                ));
285            }
286        };
287
288        let base = rest.replace('.', "/");
289        let candidates = [format!("{base}.lua"), format!("{base}/init.lua")];
290
291        for path in &candidates {
292            if let Some(file) = STDLIB_DIR.get_file(path) {
293                let source = file
294                    .contents_utf8()
295                    .ok_or_else(|| mlua::Error::runtime(format!("stdlib {path}: invalid UTF-8")))?;
296                let loader = lua
297                    .load(source)
298                    .set_name(format!("@assay/{path}"))
299                    .into_function()?;
300                return Ok(mlua::Value::Function(loader));
301            }
302        }
303
304        Ok(mlua::Value::String(lua.create_string(format!(
305            "no embedded stdlib file: {}",
306            candidates[0]
307        ))?))
308    })?;
309
310    let len = searchers.len()?;
311    searchers.set(len + 1, stdlib_searcher)?;
312
313    Ok(())
314}
315
316fn register_fs_loader(lua: &Lua, global_modules_path: Option<String>) -> mlua::Result<()> {
317    let package: mlua::Table = lua.globals().get("package")?;
318    let searchers: mlua::Table = package.get("searchers")?;
319
320    // Same dotted-path resolution as the stdlib loader: `assay.ory.kratos`
321    // -> "ory/kratos.lua", falling back to "ory/kratos/init.lua".
322    let fs_searcher = lua.create_function(move |lua, module_name: String| {
323        let rest = match module_name.strip_prefix("assay.") {
324            Some(r) => r,
325            None => {
326                return Ok(mlua::Value::String(
327                    lua.create_string(format!("not an assay.* module: {module_name}"))?,
328                ));
329            }
330        };
331        let base = rest.replace('.', "/");
332        let candidates = [format!("{base}.lua"), format!("{base}/init.lua")];
333
334        let try_load = |dir: &std::path::Path| -> Option<(std::path::PathBuf, String)> {
335            for rel in &candidates {
336                let full = dir.join(rel);
337                if let Ok(source) = std::fs::read_to_string(&full) {
338                    return Some((full, source));
339                }
340            }
341            None
342        };
343
344        // Priority 1: ./modules/<path>.lua (per-project)
345        if let Some((full, source)) = try_load(std::path::Path::new("./modules")) {
346            let loader = lua
347                .load(source)
348                .set_name(format!("@{}", full.display()))
349                .into_function()?;
350            return Ok(mlua::Value::Function(loader));
351        }
352
353        // Priority 2: $ASSAY_MODULES_PATH or ~/.assay/modules/<path>.lua
354        let global_path = if let Some(ref custom_path) = global_modules_path {
355            std::path::PathBuf::from(custom_path)
356        } else if let Ok(modules_env) = std::env::var(MODULES_PATH_ENV) {
357            std::path::PathBuf::from(modules_env)
358        } else if let Ok(home) = std::env::var("HOME") {
359            std::path::Path::new(&home).join(".assay/modules")
360        } else {
361            std::path::PathBuf::new()
362        };
363
364        if !global_path.as_os_str().is_empty()
365            && let Some((full, source)) = try_load(&global_path)
366        {
367            let loader = lua
368                .load(source)
369                .set_name(format!("@{}", full.display()))
370                .into_function()?;
371            return Ok(mlua::Value::Function(loader));
372        }
373
374        // Priority 3: Built-in modules are handled by register_stdlib_loader
375        // Return nil to fall through to the next searcher
376        Ok(mlua::Value::Nil)
377    })?;
378
379    let len = searchers.len()?;
380    searchers.set(len + 1, fs_searcher)?;
381
382    Ok(())
383}
384
385pub fn inject_env(lua: &Lua, env: &std::collections::HashMap<String, String>) -> Result<()> {
386    if env.is_empty() {
387        return Ok(());
388    }
389    let globals = lua.globals();
390    let env_table: mlua::Table = globals.get("env").map_err(lua_err)?;
391    let check_env: mlua::Table = env_table.get("_check_env").map_err(lua_err)?;
392    for (k, v) in env {
393        check_env.set(k.as_str(), v.as_str()).map_err(lua_err)?;
394    }
395    Ok(())
396}