Skip to main content

assay/lua/
mod.rs

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