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 let Some(policy) = policed.as_ref() {
247        policy::credential::register(&lua, policy).map_err(lua_err)?;
248        policy::apply::apply(&lua).map_err(lua_err)?;
249    }
250    match mode {
251        ExecMode::ReadOnly => builtins::readonly::apply(&lua).map_err(lua_err)?,
252        ExecMode::Approval => builtins::approval::apply(&lua, &approval).map_err(lua_err)?,
253        ExecMode::Unrestricted => {}
254    }
255    Ok(lua)
256}
257
258fn sandbox(lua: &Lua) -> mlua::Result<()> {
259    // Block bytecode-level escape hatches only. Source-level loaders
260    // (`load` / `loadfile` / `dofile`) stay available — operator scripts
261    // are trusted to compose themselves out of multiple files (seed +
262    // init bootstraps, shared helpers, etc.). `string.dump` stays
263    // blocked because it produces native bytecode that defeats the
264    // memory/CPU caps the runtime relies on.
265    let globals = lua.globals();
266    let string_lib: mlua::Table = globals.get("string")?;
267    string_lib.set("dump", mlua::Value::Nil)?;
268
269    if let Ok(extra) = std::env::var(BLOCK_GLOBALS_ENV) {
270        for raw in extra.split(',') {
271            let name = raw.trim();
272            if name.is_empty() {
273                continue;
274            }
275            nil_dotted_path(lua, name)?;
276        }
277    }
278
279    Ok(())
280}
281
282/// Resolve a dotted Lua path (e.g. `"os.execute"` or `"debug.getinfo"`)
283/// against globals and set the leaf to nil. A bare name (e.g.
284/// `"dofile"`) clears it from `_G`. Missing intermediate tables are
285/// silently skipped so a typo in `ASSAY_BLOCK_GLOBALS` doesn't fail
286/// VM creation.
287fn nil_dotted_path(lua: &Lua, path: &str) -> mlua::Result<()> {
288    let parts: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
289    if parts.is_empty() {
290        return Ok(());
291    }
292    let mut current: mlua::Table = lua.globals();
293    for segment in &parts[..parts.len() - 1] {
294        let next: mlua::Value = current.get(*segment)?;
295        match next {
296            mlua::Value::Table(t) => current = t,
297            _ => return Ok(()),
298        }
299    }
300    current.set(parts[parts.len() - 1], mlua::Value::Nil)
301}
302
303/// Both searchers resolve `assay.ory.kratos` to `ory/kratos.lua`, falling
304/// back to `ory/kratos/init.lua`, and both refuse a module the policy has
305/// not allowed. `None` means the name is not an `assay.*` module at all.
306fn module_candidates(lua: &Lua, module_name: &str) -> mlua::Result<Option<[String; 2]>> {
307    let Some(rest) = module_name.strip_prefix("assay.") else {
308        return Ok(None);
309    };
310    policy::guard_require(lua, module_name)?;
311    let base = rest.replace('.', "/");
312    Ok(Some([format!("{base}.lua"), format!("{base}/init.lua")]))
313}
314
315fn not_an_assay_module(lua: &Lua, module_name: &str) -> mlua::Result<mlua::Value> {
316    Ok(mlua::Value::String(lua.create_string(format!(
317        "not an assay.* module: {module_name}"
318    ))?))
319}
320
321fn register_stdlib_loader(lua: &Lua) -> mlua::Result<()> {
322    let package: mlua::Table = lua.globals().get("package")?;
323    let searchers: mlua::Table = package.get("searchers")?;
324
325    // Resolves `require("assay.ory.kratos")` -> "ory/kratos.lua" by replacing
326    // dots with slashes, matching standard Lua package loading convention.
327    // Tries "<path>.lua" first, then falls back to "<path>/init.lua" so
328    // both `stdlib/ory.lua` (flat convenience wrapper) and
329    // `stdlib/ory/kratos.lua` (nested submodule) resolve correctly.
330    let stdlib_searcher = lua.create_function(|lua, module_name: String| {
331        let candidates = match module_candidates(lua, &module_name)? {
332            Some(c) => c,
333            None => return not_an_assay_module(lua, &module_name),
334        };
335
336        for path in &candidates {
337            if let Some(file) = STDLIB_DIR.get_file(path) {
338                let source = file
339                    .contents_utf8()
340                    .ok_or_else(|| mlua::Error::runtime(format!("stdlib {path}: invalid UTF-8")))?;
341                let loader = lua
342                    .load(source)
343                    .set_name(format!("@assay/{path}"))
344                    .into_function()?;
345                return Ok(mlua::Value::Function(loader));
346            }
347        }
348
349        Ok(mlua::Value::String(lua.create_string(format!(
350            "no embedded stdlib file: {}",
351            candidates[0]
352        ))?))
353    })?;
354
355    let len = searchers.len()?;
356    searchers.set(len + 1, stdlib_searcher)?;
357
358    Ok(())
359}
360
361fn register_fs_loader(lua: &Lua, global_modules_path: Option<String>) -> mlua::Result<()> {
362    let package: mlua::Table = lua.globals().get("package")?;
363    let searchers: mlua::Table = package.get("searchers")?;
364
365    // Same dotted-path resolution as the stdlib loader: `assay.ory.kratos`
366    // -> "ory/kratos.lua", falling back to "ory/kratos/init.lua".
367    let fs_searcher = lua.create_function(move |lua, module_name: String| {
368        let candidates = match module_candidates(lua, &module_name)? {
369            Some(c) => c,
370            None => return not_an_assay_module(lua, &module_name),
371        };
372
373        let try_load = |dir: &std::path::Path| -> Option<(std::path::PathBuf, String)> {
374            for rel in &candidates {
375                let full = dir.join(rel);
376                if let Ok(source) = std::fs::read_to_string(&full) {
377                    return Some((full, source));
378                }
379            }
380            None
381        };
382
383        // Priority 1: ./modules/<path>.lua (per-project)
384        if let Some((full, source)) = try_load(std::path::Path::new("./modules")) {
385            let loader = lua
386                .load(source)
387                .set_name(format!("@{}", full.display()))
388                .into_function()?;
389            return Ok(mlua::Value::Function(loader));
390        }
391
392        // Priority 2: $ASSAY_MODULES_PATH or ~/.assay/modules/<path>.lua
393        let global_path = if let Some(ref custom_path) = global_modules_path {
394            std::path::PathBuf::from(custom_path)
395        } else if let Ok(modules_env) = std::env::var(MODULES_PATH_ENV) {
396            std::path::PathBuf::from(modules_env)
397        } else if let Ok(home) = std::env::var("HOME") {
398            std::path::Path::new(&home).join(".assay/modules")
399        } else {
400            std::path::PathBuf::new()
401        };
402
403        if !global_path.as_os_str().is_empty()
404            && let Some((full, source)) = try_load(&global_path)
405        {
406            let loader = lua
407                .load(source)
408                .set_name(format!("@{}", full.display()))
409                .into_function()?;
410            return Ok(mlua::Value::Function(loader));
411        }
412
413        // Priority 3: Built-in modules are handled by register_stdlib_loader
414        // Return nil to fall through to the next searcher
415        Ok(mlua::Value::Nil)
416    })?;
417
418    let len = searchers.len()?;
419    searchers.set(len + 1, fs_searcher)?;
420
421    Ok(())
422}
423
424pub fn inject_env(lua: &Lua, env: &std::collections::HashMap<String, String>) -> Result<()> {
425    if env.is_empty() {
426        return Ok(());
427    }
428    let globals = lua.globals();
429    let env_table: mlua::Table = globals.get("env").map_err(lua_err)?;
430    let check_env: mlua::Table = env_table.get("_check_env").map_err(lua_err)?;
431    for (k, v) in env {
432        check_env.set(k.as_str(), v.as_str()).map_err(lua_err)?;
433    }
434    Ok(())
435}