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    /// Digest of the exact call the grant was issued for. A grant without
101    /// one is refused: an older resume state cannot silently widen into an
102    /// op-name-only approval.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub digest: Option<String>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub approver: Option<String>,
107}
108
109/// Resolve the op bindings for this run's grants from the environment
110/// (set by the resume machinery). Empty when absent or malformed —
111/// the approval gate then refuses every index-only grant, fail-closed.
112pub(crate) fn approved_ops_from_env() -> Vec<ApprovedOp> {
113    std::env::var(APPROVED_OPS_ENV)
114        .ok()
115        .and_then(|raw| serde_json::from_str::<Vec<ApprovedOp>>(&raw).ok())
116        .unwrap_or_default()
117}
118
119/// Per-run approval state consumed by the approval gate: which operation
120/// indices are pre-approved for this run and which single index (if any)
121/// must fail terminally.
122#[derive(Clone, Debug, Default)]
123pub struct ApprovalConfig {
124    pub approved_indices: Vec<u64>,
125    pub denied_index: Option<u64>,
126}
127
128/// Resolve the approval set + denied index from the environment. Empty
129/// when the vars are absent, which is the first-run (nothing approved
130/// yet) case.
131pub fn approval_config_from_env() -> ApprovalConfig {
132    let approved_indices = std::env::var(APPROVED_INDICES_ENV)
133        .ok()
134        .map(|raw| parse_indices(&raw))
135        .unwrap_or_default();
136    let denied_index = std::env::var(DENIED_INDEX_ENV)
137        .ok()
138        .and_then(|raw| raw.trim().parse::<u64>().ok());
139    ApprovalConfig {
140        approved_indices,
141        denied_index,
142    }
143}
144
145fn parse_indices(raw: &str) -> Vec<u64> {
146    raw.split(',')
147        .filter_map(|part| {
148            let trimmed = part.trim();
149            if trimmed.is_empty() {
150                None
151            } else {
152                trimmed.parse::<u64>().ok()
153            }
154        })
155        .collect()
156}
157
158/// Full VM configuration. New knobs are added here rather than by widening
159/// `create_vm_configured`, keeping the older factory signatures stable.
160#[derive(Clone, Debug, Default)]
161pub struct VmOptions {
162    pub global_modules_path: Option<String>,
163    pub mode: ExecMode,
164    pub approval: ApprovalConfig,
165}
166
167fn lua_err(e: mlua::Error) -> anyhow::Error {
168    anyhow::anyhow!("{e}")
169}
170
171/// An explicit policy wins; otherwise fall back to `ASSAY_POLICY_FILE`, so a
172/// deployment can police every VM without touching call sites.
173fn resolve_policy(
174    explicit: Option<std::sync::Arc<policy::Policy>>,
175) -> Result<Option<std::sync::Arc<policy::Policy>>> {
176    match explicit {
177        Some(policy) => Ok(Some(policy)),
178        None => policy::from_env().map_err(|e| anyhow::anyhow!("{e}")),
179    }
180}
181
182#[allow(dead_code)]
183pub fn create_vm(client: reqwest::Client) -> Result<Lua> {
184    create_vm_configured(client, None, readonly_from_env())
185}
186
187#[allow(dead_code)]
188pub fn create_vm_with_lib_path(client: reqwest::Client, lib_path: String) -> Result<Lua> {
189    create_vm_configured(client, Some(lib_path), readonly_from_env())
190}
191
192#[allow(dead_code)]
193pub fn create_vm_with_paths(
194    client: reqwest::Client,
195    global_modules_path: Option<String>,
196) -> Result<Lua> {
197    create_vm_configured(client, global_modules_path, readonly_from_env())
198}
199
200pub fn create_vm_configured(
201    client: reqwest::Client,
202    global_modules_path: Option<String>,
203    readonly: bool,
204) -> Result<Lua> {
205    let mode = if readonly {
206        ExecMode::ReadOnly
207    } else {
208        ExecMode::Unrestricted
209    };
210    create_vm_with_options(
211        client,
212        VmOptions {
213            global_modules_path,
214            mode,
215            approval: ApprovalConfig::default(),
216        },
217    )
218}
219
220pub fn create_vm_with_options(client: reqwest::Client, options: VmOptions) -> Result<Lua> {
221    create_vm_with_policy(client, options, None)
222}
223
224/// Same as `create_vm_with_options`, with an explicit policy for embedders
225/// that resolve one themselves instead of through `ASSAY_POLICY_FILE`.
226pub fn create_vm_with_policy(
227    client: reqwest::Client,
228    options: VmOptions,
229    policy: Option<std::sync::Arc<policy::Policy>>,
230) -> Result<Lua> {
231    let VmOptions {
232        global_modules_path,
233        mode,
234        approval,
235    } = options;
236    let libs = StdLib::ALL_SAFE;
237    let lua = Lua::new_with(libs, LuaOptions::default()).map_err(lua_err)?;
238    lua.set_memory_limit(64 * 1024 * 1024).map_err(lua_err)?;
239    // Installed before the builtins register so `env` and the module
240    // searchers can consult it on their very first call.
241    let policed = resolve_policy(policy)?;
242    if let Some(policy) = policed.clone() {
243        policy::install(&lua, policy);
244    }
245    sandbox(&lua).map_err(lua_err)?;
246    register_fs_loader(&lua, global_modules_path).map_err(lua_err)?;
247    register_stdlib_loader(&lua).map_err(lua_err)?;
248    builtins::register_all(&lua, client).map_err(lua_err)?;
249    // Before the mode gates, so a gate wrapping an http builtin wraps the
250    // policy-guarded version and both checks run.
251    if let Some(policy) = policed.as_ref() {
252        policy::credential::register(&lua, policy).map_err(lua_err)?;
253        policy::apply::apply(&lua).map_err(lua_err)?;
254    }
255    match mode {
256        ExecMode::ReadOnly => builtins::readonly::apply(&lua).map_err(lua_err)?,
257        ExecMode::Approval => builtins::approval::apply(&lua, &approval).map_err(lua_err)?,
258        ExecMode::Unrestricted => {}
259    }
260    apply_global_blocks(&lua).map_err(lua_err)?;
261    Ok(lua)
262}
263
264/// Clear every name `ASSAY_BLOCK_GLOBALS` and the policy's `globals.block`
265/// name. One application point, and it is deliberately the last thing the
266/// constructor does.
267///
268/// Running any of it earlier is what made blocking a name *weaken* the VM.
269/// The mode gates skip a table that is not on `_G` — a feature-gated build
270/// legitimately has none — so an earlier pass that deleted `io` meant the
271/// `io.popen` stub and the `io.open` write guard were never installed, while
272/// the real, ungated table sat in `package.loaded` for `require` to hand
273/// back.
274///
275/// Two things keep a block list monotonic, and the ordering is the lesser
276/// of them. `clear_package_entry` is what removes the survivor: a bare name
277/// goes from `package.loaded` and `package.preload` as well as `_G`, so no
278/// ungated handle is left for the skipped gate to have mattered. Running
279/// last then makes the skip unreachable rather than merely harmless. Should
280/// the cache clearing ever regress, this ordering would not save you — so
281/// treat them as one mechanism, and do not move either half on the
282/// assumption that the other covers it. Two tests pin it:
283/// `blocking_io_under_readonly_leaves_no_handle_at_all` and
284/// `blocking_a_table_never_leaves_an_ungated_one_behind`.
285///
286/// Call it again after installing a global of your own (the CLI's `arg`),
287/// or that global outlives the list that named it.
288pub fn apply_global_blocks(lua: &Lua) -> mlua::Result<()> {
289    block_globals_from_env(lua)?;
290    let Some(policy) = policy::active(lua) else {
291        return Ok(());
292    };
293    for name in policy.blocked_globals() {
294        nil_dotted_path(lua, name)?;
295    }
296    Ok(())
297}
298
299fn sandbox(lua: &Lua) -> mlua::Result<()> {
300    // Block bytecode-level escape hatches only. Source-level loaders
301    // (`load` / `loadfile` / `dofile`) stay available — operator scripts
302    // are trusted to compose themselves out of multiple files (seed +
303    // init bootstraps, shared helpers, etc.). `string.dump` stays
304    // blocked because it produces native bytecode that defeats the
305    // memory/CPU caps the runtime relies on.
306    let globals = lua.globals();
307    let string_lib: mlua::Table = globals.get("string")?;
308    string_lib.set("dump", mlua::Value::Nil)?;
309    Ok(())
310}
311
312/// Clear every name in `ASSAY_BLOCK_GLOBALS`.
313fn block_globals_from_env(lua: &Lua) -> mlua::Result<()> {
314    let Ok(extra) = std::env::var(BLOCK_GLOBALS_ENV) else {
315        return Ok(());
316    };
317    for raw in extra.split(',') {
318        let name = raw.trim();
319        if name.is_empty() {
320            continue;
321        }
322        nil_dotted_path(lua, name)?;
323    }
324    Ok(())
325}
326
327/// Resolve a dotted Lua path (e.g. `"os.execute"` or `"debug.getinfo"`)
328/// and set the leaf to nil, everywhere the name is reachable. A bare name
329/// (e.g. `"io"`) clears it from `_G` and from `package.loaded` /
330/// `package.preload`, because `require` reads that cache before any
331/// searcher and would otherwise hand back the library `_G` no longer
332/// names. A dotted path clears the field on every table the head resolves
333/// to, which for `os` is both assay's replacement on `_G` and Lua's real
334/// one behind `require`. Missing tables are silently skipped so a typo in
335/// `ASSAY_BLOCK_GLOBALS` or a policy doesn't fail VM creation.
336fn nil_dotted_path(lua: &Lua, path: &str) -> mlua::Result<()> {
337    let parts: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
338    let Some((leaf, prefix)) = parts.split_last() else {
339        return Ok(());
340    };
341    if prefix.is_empty() {
342        lua.globals().set(*leaf, mlua::Value::Nil)?;
343        return clear_package_entry(lua, leaf);
344    }
345    for root in builtins::gated::tables_for(lua, prefix[0])? {
346        let mut current = root;
347        let mut reached = true;
348        for segment in &prefix[1..] {
349            match current.get::<mlua::Value>(*segment)? {
350                mlua::Value::Table(t) => current = t,
351                _ => {
352                    reached = false;
353                    break;
354                }
355            }
356        }
357        if reached {
358            current.set(*leaf, mlua::Value::Nil)?;
359        }
360    }
361    Ok(())
362}
363
364/// Drop a name from `require`'s caches, so a cleared global cannot be
365/// fetched back through `require("<name>")`.
366fn clear_package_entry(lua: &Lua, name: &str) -> mlua::Result<()> {
367    let Some(package) = lua.globals().get::<Option<mlua::Table>>("package")? else {
368        return Ok(());
369    };
370    for registry in ["loaded", "preload"] {
371        if let Some(sub) = package.get::<Option<mlua::Table>>(registry)? {
372            sub.set(name, mlua::Value::Nil)?;
373        }
374    }
375    Ok(())
376}
377
378/// Both searchers resolve `assay.ory.kratos` to `ory/kratos.lua`, falling
379/// back to `ory/kratos/init.lua`, and both refuse a module the policy has
380/// not allowed. `None` means the name is not an `assay.*` module at all.
381fn module_candidates(lua: &Lua, module_name: &str) -> mlua::Result<Option<[String; 2]>> {
382    let Some(rest) = module_name.strip_prefix("assay.") else {
383        return Ok(None);
384    };
385    policy::guard_require(lua, module_name)?;
386    let base = rest.replace('.', "/");
387    Ok(Some([format!("{base}.lua"), format!("{base}/init.lua")]))
388}
389
390fn not_an_assay_module(lua: &Lua, module_name: &str) -> mlua::Result<mlua::Value> {
391    Ok(mlua::Value::String(lua.create_string(format!(
392        "not an assay.* module: {module_name}"
393    ))?))
394}
395
396fn register_stdlib_loader(lua: &Lua) -> mlua::Result<()> {
397    let package: mlua::Table = lua.globals().get("package")?;
398    let searchers: mlua::Table = package.get("searchers")?;
399
400    // Resolves `require("assay.ory.kratos")` -> "ory/kratos.lua" by replacing
401    // dots with slashes, matching standard Lua package loading convention.
402    // Tries "<path>.lua" first, then falls back to "<path>/init.lua" so
403    // both `stdlib/ory.lua` (flat convenience wrapper) and
404    // `stdlib/ory/kratos.lua` (nested submodule) resolve correctly.
405    let stdlib_searcher = lua.create_function(|lua, module_name: String| {
406        let candidates = match module_candidates(lua, &module_name)? {
407            Some(c) => c,
408            None => return not_an_assay_module(lua, &module_name),
409        };
410
411        for path in &candidates {
412            if let Some(file) = STDLIB_DIR.get_file(path) {
413                let source = file
414                    .contents_utf8()
415                    .ok_or_else(|| mlua::Error::runtime(format!("stdlib {path}: invalid UTF-8")))?;
416                let loader = lua
417                    .load(source)
418                    .set_name(format!("@assay/{path}"))
419                    .into_function()?;
420                return Ok(mlua::Value::Function(loader));
421            }
422        }
423
424        Ok(mlua::Value::String(lua.create_string(format!(
425            "no embedded stdlib file: {}",
426            candidates[0]
427        ))?))
428    })?;
429
430    let len = searchers.len()?;
431    searchers.set(len + 1, stdlib_searcher)?;
432
433    Ok(())
434}
435
436fn register_fs_loader(lua: &Lua, global_modules_path: Option<String>) -> mlua::Result<()> {
437    let package: mlua::Table = lua.globals().get("package")?;
438    let searchers: mlua::Table = package.get("searchers")?;
439
440    // Same dotted-path resolution as the stdlib loader: `assay.ory.kratos`
441    // -> "ory/kratos.lua", falling back to "ory/kratos/init.lua".
442    let fs_searcher = lua.create_function(move |lua, module_name: String| {
443        let candidates = match module_candidates(lua, &module_name)? {
444            Some(c) => c,
445            None => return not_an_assay_module(lua, &module_name),
446        };
447
448        let try_load = |dir: &std::path::Path| -> Option<(std::path::PathBuf, String)> {
449            for rel in &candidates {
450                let full = dir.join(rel);
451                if let Ok(source) = std::fs::read_to_string(&full) {
452                    return Some((full, source));
453                }
454            }
455            None
456        };
457
458        // Priority 1: ./modules/<path>.lua (per-project)
459        if let Some((full, source)) = try_load(std::path::Path::new("./modules")) {
460            let loader = lua
461                .load(source)
462                .set_name(format!("@{}", full.display()))
463                .into_function()?;
464            return Ok(mlua::Value::Function(loader));
465        }
466
467        // Priority 2: $ASSAY_MODULES_PATH or ~/.assay/modules/<path>.lua
468        let global_path = if let Some(ref custom_path) = global_modules_path {
469            std::path::PathBuf::from(custom_path)
470        } else if let Ok(modules_env) = std::env::var(MODULES_PATH_ENV) {
471            std::path::PathBuf::from(modules_env)
472        } else if let Ok(home) = std::env::var("HOME") {
473            std::path::Path::new(&home).join(".assay/modules")
474        } else {
475            std::path::PathBuf::new()
476        };
477
478        if !global_path.as_os_str().is_empty()
479            && let Some((full, source)) = try_load(&global_path)
480        {
481            let loader = lua
482                .load(source)
483                .set_name(format!("@{}", full.display()))
484                .into_function()?;
485            return Ok(mlua::Value::Function(loader));
486        }
487
488        // Priority 3: Built-in modules are handled by register_stdlib_loader
489        // Return nil to fall through to the next searcher
490        Ok(mlua::Value::Nil)
491    })?;
492
493    let len = searchers.len()?;
494    searchers.set(len + 1, fs_searcher)?;
495
496    Ok(())
497}
498
499pub fn inject_env(lua: &Lua, env: &std::collections::HashMap<String, String>) -> Result<()> {
500    if env.is_empty() {
501        return Ok(());
502    }
503    let globals = lua.globals();
504    let env_table: mlua::Table = globals.get("env").map_err(lua_err)?;
505    let check_env: mlua::Table = env_table.get("_check_env").map_err(lua_err)?;
506    for (k, v) in env {
507        check_env.set(k.as_str(), v.as_str()).map_err(lua_err)?;
508    }
509    Ok(())
510}