assay-lua 0.18.8

General-purpose enhanced Lua runtime. Batteries-included scripting, automation, and web services.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
pub mod async_bridge;
pub mod builtins;
pub mod file_source;
pub mod policy;

#[cfg(feature = "server")]
#[allow(unused_imports)]
pub use builtins::LuaAxumRouter;

use anyhow::Result;
use include_dir::{Dir, include_dir};
use mlua::{Lua, LuaOptions, StdLib};

static STDLIB_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/stdlib");

/// Environment variable to override the global module search path.
pub const MODULES_PATH_ENV: &str = "ASSAY_MODULES_PATH";

/// Comma-separated list of additional globals to nil out at VM
/// creation time (defense-in-depth knob for hardened deployments).
/// Names support dotted paths into stdlib tables — e.g.
/// `ASSAY_BLOCK_GLOBALS=dofile,os.execute,debug.getinfo`.
pub const BLOCK_GLOBALS_ENV: &str = "ASSAY_BLOCK_GLOBALS";

/// Set to `1` or `true` to activate read-only mode for every VM the
/// process creates. Mutating builtins stay registered but raise
/// `readonly: <name> blocked` errors instead of executing. The
/// `--readonly` CLI flag activates the same mode per invocation.
pub const READONLY_ENV: &str = "ASSAY_READONLY";

pub fn readonly_from_env() -> bool {
    matches!(
        std::env::var(READONLY_ENV).ok().as_deref().map(str::trim),
        Some("1") | Some("true")
    )
}

/// Set to `1` or `true` to activate approval mode for every VM the process
/// creates. Mutating builtins stay registered but suspend for
/// per-operation approval via the resume flow instead of executing. The
/// `--approval-mode` CLI flag activates the same mode per invocation and
/// takes precedence over read-only mode.
pub const APPROVAL_ENV: &str = "ASSAY_APPROVAL";

/// Comma-separated set of already-approved operation indices for an
/// approval-mode re-run (set by the resume machinery).
pub(crate) const APPROVED_INDICES_ENV: &str = "ASSAY_APPROVED_INDICES";

/// The single operation index to fail terminally on an approval-mode
/// re-run (set by the resume machinery when a decision is `no`).
pub(crate) const DENIED_INDEX_ENV: &str = "ASSAY_DENIED_INDEX";

/// JSON array of `ApprovedOp` records for an approval-mode re-run (set by
/// the resume machinery). Binds each approved index to the operation that
/// was approved, so a replay whose control flow shifted cannot spend a
/// grant on a different operation.
pub(crate) const APPROVED_OPS_ENV: &str = "ASSAY_APPROVED_OPS";

/// Prefix that marks a runtime error as an approval request. The tool-mode
/// runner extracts the JSON payload that follows it to suspend the run.
pub(crate) const APPROVAL_REQUEST_PREFIX: &str = "__assay_approval_request__:";

pub fn approval_from_env() -> bool {
    matches!(
        std::env::var(APPROVAL_ENV).ok().as_deref().map(str::trim),
        Some("1") | Some("true")
    )
}

/// Execution mode selecting which post-registration gate (if any) is
/// applied to a VM.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ExecMode {
    #[default]
    Unrestricted,
    ReadOnly,
    Approval,
}

impl ExecMode {
    pub fn is_readonly(self) -> bool {
        matches!(self, ExecMode::ReadOnly)
    }

    pub fn is_approval(self) -> bool {
        matches!(self, ExecMode::Approval)
    }
}

/// One granted approval: the operation index it was issued for, the
/// operation descriptor that was actually approved (e.g. `http.post`),
/// and — for audit — who authorized it, when the caller supplied an
/// identity. Serialized into resume state and the re-run environment.
/// Crate-internal: travels via `ASSAY_APPROVED_OPS`, never the public
/// `ApprovalConfig` API.
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub(crate) struct ApprovedOp {
    pub index: u64,
    pub op: String,
    /// Digest of the exact call the grant was issued for. A grant without
    /// one is refused: an older resume state cannot silently widen into an
    /// op-name-only approval.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub digest: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub approver: Option<String>,
}

/// Resolve the op bindings for this run's grants from the environment
/// (set by the resume machinery). Empty when absent or malformed —
/// the approval gate then refuses every index-only grant, fail-closed.
pub(crate) fn approved_ops_from_env() -> Vec<ApprovedOp> {
    std::env::var(APPROVED_OPS_ENV)
        .ok()
        .and_then(|raw| serde_json::from_str::<Vec<ApprovedOp>>(&raw).ok())
        .unwrap_or_default()
}

/// Per-run approval state consumed by the approval gate: which operation
/// indices are pre-approved for this run and which single index (if any)
/// must fail terminally.
#[derive(Clone, Debug, Default)]
pub struct ApprovalConfig {
    pub approved_indices: Vec<u64>,
    pub denied_index: Option<u64>,
}

/// Resolve the approval set + denied index from the environment. Empty
/// when the vars are absent, which is the first-run (nothing approved
/// yet) case.
pub fn approval_config_from_env() -> ApprovalConfig {
    let approved_indices = std::env::var(APPROVED_INDICES_ENV)
        .ok()
        .map(|raw| parse_indices(&raw))
        .unwrap_or_default();
    let denied_index = std::env::var(DENIED_INDEX_ENV)
        .ok()
        .and_then(|raw| raw.trim().parse::<u64>().ok());
    ApprovalConfig {
        approved_indices,
        denied_index,
    }
}

fn parse_indices(raw: &str) -> Vec<u64> {
    raw.split(',')
        .filter_map(|part| {
            let trimmed = part.trim();
            if trimmed.is_empty() {
                None
            } else {
                trimmed.parse::<u64>().ok()
            }
        })
        .collect()
}

/// Full VM configuration. New knobs are added here rather than by widening
/// `create_vm_configured`, keeping the older factory signatures stable.
#[derive(Clone, Debug, Default)]
pub struct VmOptions {
    pub global_modules_path: Option<String>,
    pub mode: ExecMode,
    pub approval: ApprovalConfig,
}

fn lua_err(e: mlua::Error) -> anyhow::Error {
    anyhow::anyhow!("{e}")
}

/// An explicit policy wins; otherwise fall back to `ASSAY_POLICY_FILE`, so a
/// deployment can police every VM without touching call sites.
fn resolve_policy(
    explicit: Option<std::sync::Arc<policy::Policy>>,
) -> Result<Option<std::sync::Arc<policy::Policy>>> {
    match explicit {
        Some(policy) => Ok(Some(policy)),
        None => policy::from_env().map_err(|e| anyhow::anyhow!("{e}")),
    }
}

#[allow(dead_code)]
pub fn create_vm(client: reqwest::Client) -> Result<Lua> {
    create_vm_configured(client, None, readonly_from_env())
}

#[allow(dead_code)]
pub fn create_vm_with_lib_path(client: reqwest::Client, lib_path: String) -> Result<Lua> {
    create_vm_configured(client, Some(lib_path), readonly_from_env())
}

#[allow(dead_code)]
pub fn create_vm_with_paths(
    client: reqwest::Client,
    global_modules_path: Option<String>,
) -> Result<Lua> {
    create_vm_configured(client, global_modules_path, readonly_from_env())
}

pub fn create_vm_configured(
    client: reqwest::Client,
    global_modules_path: Option<String>,
    readonly: bool,
) -> Result<Lua> {
    let mode = if readonly {
        ExecMode::ReadOnly
    } else {
        ExecMode::Unrestricted
    };
    create_vm_with_options(
        client,
        VmOptions {
            global_modules_path,
            mode,
            approval: ApprovalConfig::default(),
        },
    )
}

pub fn create_vm_with_options(client: reqwest::Client, options: VmOptions) -> Result<Lua> {
    create_vm_with_policy(client, options, None)
}

/// Same as `create_vm_with_options`, with an explicit policy for embedders
/// that resolve one themselves instead of through `ASSAY_POLICY_FILE`.
pub fn create_vm_with_policy(
    client: reqwest::Client,
    options: VmOptions,
    policy: Option<std::sync::Arc<policy::Policy>>,
) -> Result<Lua> {
    let VmOptions {
        global_modules_path,
        mode,
        approval,
    } = options;
    let libs = StdLib::ALL_SAFE;
    let lua = Lua::new_with(libs, LuaOptions::default()).map_err(lua_err)?;
    lua.set_memory_limit(64 * 1024 * 1024).map_err(lua_err)?;
    // Installed before the builtins register so `env` and the module
    // searchers can consult it on their very first call.
    let policed = resolve_policy(policy)?;
    if let Some(policy) = policed.clone() {
        policy::install(&lua, policy);
    }
    sandbox(&lua).map_err(lua_err)?;
    register_fs_loader(&lua, global_modules_path).map_err(lua_err)?;
    register_stdlib_loader(&lua).map_err(lua_err)?;
    builtins::register_all(&lua, client).map_err(lua_err)?;
    // Before the mode gates, so a gate wrapping an http builtin wraps the
    // policy-guarded version and both checks run.
    if let Some(policy) = policed.as_ref() {
        policy::credential::register(&lua, policy).map_err(lua_err)?;
        policy::apply::apply(&lua).map_err(lua_err)?;
    }
    match mode {
        ExecMode::ReadOnly => builtins::readonly::apply(&lua).map_err(lua_err)?,
        ExecMode::Approval => builtins::approval::apply(&lua, &approval).map_err(lua_err)?,
        ExecMode::Unrestricted => {}
    }
    Ok(lua)
}

fn sandbox(lua: &Lua) -> mlua::Result<()> {
    // Block bytecode-level escape hatches only. Source-level loaders
    // (`load` / `loadfile` / `dofile`) stay available — operator scripts
    // are trusted to compose themselves out of multiple files (seed +
    // init bootstraps, shared helpers, etc.). `string.dump` stays
    // blocked because it produces native bytecode that defeats the
    // memory/CPU caps the runtime relies on.
    let globals = lua.globals();
    let string_lib: mlua::Table = globals.get("string")?;
    string_lib.set("dump", mlua::Value::Nil)?;

    if let Ok(extra) = std::env::var(BLOCK_GLOBALS_ENV) {
        for raw in extra.split(',') {
            let name = raw.trim();
            if name.is_empty() {
                continue;
            }
            nil_dotted_path(lua, name)?;
        }
    }

    Ok(())
}

/// Resolve a dotted Lua path (e.g. `"os.execute"` or `"debug.getinfo"`)
/// against globals and set the leaf to nil. A bare name (e.g.
/// `"dofile"`) clears it from `_G`. Missing intermediate tables are
/// silently skipped so a typo in `ASSAY_BLOCK_GLOBALS` doesn't fail
/// VM creation.
fn nil_dotted_path(lua: &Lua, path: &str) -> mlua::Result<()> {
    let parts: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
    if parts.is_empty() {
        return Ok(());
    }
    let mut current: mlua::Table = lua.globals();
    for segment in &parts[..parts.len() - 1] {
        let next: mlua::Value = current.get(*segment)?;
        match next {
            mlua::Value::Table(t) => current = t,
            _ => return Ok(()),
        }
    }
    current.set(parts[parts.len() - 1], mlua::Value::Nil)
}

/// Both searchers resolve `assay.ory.kratos` to `ory/kratos.lua`, falling
/// back to `ory/kratos/init.lua`, and both refuse a module the policy has
/// not allowed. `None` means the name is not an `assay.*` module at all.
fn module_candidates(lua: &Lua, module_name: &str) -> mlua::Result<Option<[String; 2]>> {
    let Some(rest) = module_name.strip_prefix("assay.") else {
        return Ok(None);
    };
    policy::guard_require(lua, module_name)?;
    let base = rest.replace('.', "/");
    Ok(Some([format!("{base}.lua"), format!("{base}/init.lua")]))
}

fn not_an_assay_module(lua: &Lua, module_name: &str) -> mlua::Result<mlua::Value> {
    Ok(mlua::Value::String(lua.create_string(format!(
        "not an assay.* module: {module_name}"
    ))?))
}

fn register_stdlib_loader(lua: &Lua) -> mlua::Result<()> {
    let package: mlua::Table = lua.globals().get("package")?;
    let searchers: mlua::Table = package.get("searchers")?;

    // Resolves `require("assay.ory.kratos")` -> "ory/kratos.lua" by replacing
    // dots with slashes, matching standard Lua package loading convention.
    // Tries "<path>.lua" first, then falls back to "<path>/init.lua" so
    // both `stdlib/ory.lua` (flat convenience wrapper) and
    // `stdlib/ory/kratos.lua` (nested submodule) resolve correctly.
    let stdlib_searcher = lua.create_function(|lua, module_name: String| {
        let candidates = match module_candidates(lua, &module_name)? {
            Some(c) => c,
            None => return not_an_assay_module(lua, &module_name),
        };

        for path in &candidates {
            if let Some(file) = STDLIB_DIR.get_file(path) {
                let source = file
                    .contents_utf8()
                    .ok_or_else(|| mlua::Error::runtime(format!("stdlib {path}: invalid UTF-8")))?;
                let loader = lua
                    .load(source)
                    .set_name(format!("@assay/{path}"))
                    .into_function()?;
                return Ok(mlua::Value::Function(loader));
            }
        }

        Ok(mlua::Value::String(lua.create_string(format!(
            "no embedded stdlib file: {}",
            candidates[0]
        ))?))
    })?;

    let len = searchers.len()?;
    searchers.set(len + 1, stdlib_searcher)?;

    Ok(())
}

fn register_fs_loader(lua: &Lua, global_modules_path: Option<String>) -> mlua::Result<()> {
    let package: mlua::Table = lua.globals().get("package")?;
    let searchers: mlua::Table = package.get("searchers")?;

    // Same dotted-path resolution as the stdlib loader: `assay.ory.kratos`
    // -> "ory/kratos.lua", falling back to "ory/kratos/init.lua".
    let fs_searcher = lua.create_function(move |lua, module_name: String| {
        let candidates = match module_candidates(lua, &module_name)? {
            Some(c) => c,
            None => return not_an_assay_module(lua, &module_name),
        };

        let try_load = |dir: &std::path::Path| -> Option<(std::path::PathBuf, String)> {
            for rel in &candidates {
                let full = dir.join(rel);
                if let Ok(source) = std::fs::read_to_string(&full) {
                    return Some((full, source));
                }
            }
            None
        };

        // Priority 1: ./modules/<path>.lua (per-project)
        if let Some((full, source)) = try_load(std::path::Path::new("./modules")) {
            let loader = lua
                .load(source)
                .set_name(format!("@{}", full.display()))
                .into_function()?;
            return Ok(mlua::Value::Function(loader));
        }

        // Priority 2: $ASSAY_MODULES_PATH or ~/.assay/modules/<path>.lua
        let global_path = if let Some(ref custom_path) = global_modules_path {
            std::path::PathBuf::from(custom_path)
        } else if let Ok(modules_env) = std::env::var(MODULES_PATH_ENV) {
            std::path::PathBuf::from(modules_env)
        } else if let Ok(home) = std::env::var("HOME") {
            std::path::Path::new(&home).join(".assay/modules")
        } else {
            std::path::PathBuf::new()
        };

        if !global_path.as_os_str().is_empty()
            && let Some((full, source)) = try_load(&global_path)
        {
            let loader = lua
                .load(source)
                .set_name(format!("@{}", full.display()))
                .into_function()?;
            return Ok(mlua::Value::Function(loader));
        }

        // Priority 3: Built-in modules are handled by register_stdlib_loader
        // Return nil to fall through to the next searcher
        Ok(mlua::Value::Nil)
    })?;

    let len = searchers.len()?;
    searchers.set(len + 1, fs_searcher)?;

    Ok(())
}

pub fn inject_env(lua: &Lua, env: &std::collections::HashMap<String, String>) -> Result<()> {
    if env.is_empty() {
        return Ok(());
    }
    let globals = lua.globals();
    let env_table: mlua::Table = globals.get("env").map_err(lua_err)?;
    let check_env: mlua::Table = env_table.get("_check_env").map_err(lua_err)?;
    for (k, v) in env {
        check_env.set(k.as_str(), v.as_str()).map_err(lua_err)?;
    }
    Ok(())
}