assay-lua 0.20.14

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
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
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 => {}
    }
    apply_global_blocks(&lua).map_err(lua_err)?;
    Ok(lua)
}

/// Clear every name `ASSAY_BLOCK_GLOBALS` and the policy's `globals.block`
/// name. One application point, and it is deliberately the last thing the
/// constructor does.
///
/// Running any of it earlier is what made blocking a name *weaken* the VM.
/// The mode gates skip a table that is not on `_G` — a feature-gated build
/// legitimately has none — so an earlier pass that deleted `io` meant the
/// `io.popen` stub and the `io.open` write guard were never installed, while
/// the real, ungated table sat in `package.loaded` for `require` to hand
/// back.
///
/// Two things keep a block list monotonic, and the ordering is the lesser
/// of them. `clear_package_entry` is what removes the survivor: a bare name
/// goes from `package.loaded` and `package.preload` as well as `_G`, so no
/// ungated handle is left for the skipped gate to have mattered. Running
/// last then makes the skip unreachable rather than merely harmless. Should
/// the cache clearing ever regress, this ordering would not save you — so
/// treat them as one mechanism, and do not move either half on the
/// assumption that the other covers it. Two tests pin it:
/// `blocking_io_under_readonly_leaves_no_handle_at_all` and
/// `blocking_a_table_never_leaves_an_ungated_one_behind`.
///
/// Call it again after installing a global of your own (the CLI's `arg`),
/// or that global outlives the list that named it.
pub fn apply_global_blocks(lua: &Lua) -> mlua::Result<()> {
    block_globals_from_env(lua)?;
    let Some(policy) = policy::active(lua) else {
        return Ok(());
    };
    for name in policy.blocked_globals() {
        nil_dotted_path(lua, name)?;
    }
    Ok(())
}

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)?;
    Ok(())
}

/// Clear every name in `ASSAY_BLOCK_GLOBALS`.
fn block_globals_from_env(lua: &Lua) -> mlua::Result<()> {
    let Ok(extra) = std::env::var(BLOCK_GLOBALS_ENV) else {
        return Ok(());
    };
    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"`)
/// and set the leaf to nil, everywhere the name is reachable. A bare name
/// (e.g. `"io"`) clears it from `_G` and from `package.loaded` /
/// `package.preload`, because `require` reads that cache before any
/// searcher and would otherwise hand back the library `_G` no longer
/// names. A dotted path clears the field on every table the head resolves
/// to, which for `os` is both assay's replacement on `_G` and Lua's real
/// one behind `require`. Missing tables are silently skipped so a typo in
/// `ASSAY_BLOCK_GLOBALS` or a policy 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();
    let Some((leaf, prefix)) = parts.split_last() else {
        return Ok(());
    };
    if prefix.is_empty() {
        lua.globals().set(*leaf, mlua::Value::Nil)?;
        return clear_package_entry(lua, leaf);
    }
    for root in builtins::gated::tables_for(lua, prefix[0])? {
        let mut current = root;
        let mut reached = true;
        for segment in &prefix[1..] {
            match current.get::<mlua::Value>(*segment)? {
                mlua::Value::Table(t) => current = t,
                _ => {
                    reached = false;
                    break;
                }
            }
        }
        if reached {
            current.set(*leaf, mlua::Value::Nil)?;
        }
    }
    Ok(())
}

/// Drop a name from `require`'s caches, so a cleared global cannot be
/// fetched back through `require("<name>")`.
fn clear_package_entry(lua: &Lua, name: &str) -> mlua::Result<()> {
    let Some(package) = lua.globals().get::<Option<mlua::Table>>("package")? else {
        return Ok(());
    };
    for registry in ["loaded", "preload"] {
        if let Some(sub) = package.get::<Option<mlua::Table>>(registry)? {
            sub.set(name, mlua::Value::Nil)?;
        }
    }
    Ok(())
}

/// 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(())
}