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