lex-runtime 0.11.2

Effect handler runtime + capability policy for Lex.
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
//! Capability/policy layer per spec §7.4.
//!
//! Operators specify what effects are allowed before any execution starts.
//! The runtime walks the program's declared effects and aborts with a
//! structured violation if the program would exceed the policy. During
//! execution, individual effect calls are also gated through the same
//! policy so that scoped effects (fs paths, budget consumption) are caught
//! at call time.

use indexmap::IndexMap;
use lex_bytecode::program::{DeclaredEffect, EffectArg, Program};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

/// Policy a program is run under. Empty `allow_effects` = pure-only
/// execution.
///
/// **Wildcard scopes (read before embedding):** the scope lists
/// (`allow_fs_read`, `allow_fs_write`, `allow_net_host`, `allow_proc`)
/// follow an **empty = allow ANY** convention, *not* empty = deny.
/// Granting a scoped effect in `allow_effects` while leaving its scope
/// list empty therefore opens the *unrestricted* form (any path / host
/// / binary). That's intentional for trusted local use (`lex run`),
/// but it's a footgun for embedders that build a `Policy` from
/// untrusted input. Such embedders should populate the scope list for
/// every kind they grant, or call [`Policy::wildcard_scoped_grants`]
/// to detect the wide-open ones and refuse them. (#552)
#[derive(Debug, Clone, Default)]
pub struct Policy {
    pub allow_effects: BTreeSet<String>,
    /// Path scope for the `[fs_read]` effect. **Empty = any path**
    /// (wildcard), not deny — see the type-level note above (#552).
    pub allow_fs_read: Vec<PathBuf>,
    /// Path scope for the `[fs_write]` effect. **Empty = any path**
    /// (wildcard), not deny — see the type-level note above (#552).
    pub allow_fs_write: Vec<PathBuf>,
    /// Per-host scope on the [net] effect. Empty = any host (when
    /// [net] is in `allow_effects`); non-empty = only requests to
    /// these hosts succeed. Hosts compare against the URL's host
    /// substring (port-agnostic). Lets a tool be granted [net] but
    /// scoped to e.g. `api.openai.com` only — without this, [net]
    /// is a blank check to exfiltrate anywhere.
    pub allow_net_host: Vec<String>,
    /// Per-binary scope on the [proc] effect. Empty = ANY binary
    /// allowed once [proc] is granted (treat as a global escape
    /// hatch; only acceptable for trusted code). Non-empty =
    /// `proc.spawn(cmd, args)` must match `cmd` against the
    /// basename portion of one of these entries. Per-arg validation
    /// is the *caller's* responsibility — see SECURITY.md's
    /// "argument injection" note.
    pub allow_proc: Vec<String>,
    /// Per-scope allowlist on the `[approval]` effect. Empty = any
    /// scope allowed once `approval` is granted (treat as a global
    /// human-escalation escape hatch; only acceptable for trusted
    /// code). Non-empty = `approval.request(scope, reason)` must
    /// match `scope` against one of these entries — lets an operator
    /// grant e.g. "payment approvals only" rather than a blanket
    /// human-in-the-loop channel.
    pub allow_approval: Vec<String>,
    pub budget: Option<u64>,
}

/// Every effect kind the stdlib can declare, each with a one-line note.
/// THE single source: `Policy::permissive` grants exactly these, and
/// `lex docs --effects` renders this table for docs/AGENT.md's quick
/// reference (kept current by `lex doc-sync --check` in CI). #399's
/// "keep this set in sync with builtins.rs" used to be a comment-level
/// rule enforced by nobody; adding an effect now means adding one row
/// here, and the doc regenerates from it.
pub const KNOWN_EFFECTS: &[(&str, &str)] = &[
    ("io", "console / stdio"),
    (
        "net",
        "sockets + outbound HTTP; scope to a host (`net(\"host\")`) where possible",
    ),
    ("time", "clocks — non-deterministic"),
    ("llm", "LLM inference"),
    ("proc", "subprocess execution"),
    (
        "proc_exit",
        "std.process.exit — sets this process's exit status (#754)",
    ),
    ("panic", "may abort"),
    ("fs_read", "filesystem reads; scopable to a path"),
    ("fs_write", "filesystem writes; scopable to a path"),
    (
        "budget",
        "annotated cost `budget(N)`; checked against `--budget`",
    ),
    ("llm_local", "local model inference (#184)"),
    ("llm_cloud", "cloud model inference (#184)"),
    ("a2a", "agent-to-agent protocol calls (#184)"),
    ("mcp", "MCP client calls (#184)"),
    (
        "env",
        "environment-variable access (#216); flat `[env]` is the v1 surface",
    ),
    ("sql", "std.sql database access (#362, #379)"),
    ("random", "crypto.random / crypto.random_str_hex (#382)"),
    ("chat", "chat.broadcast / chat.send (#359)"),
    ("log", "std.log structured logging"),
    ("kv", "std.kv key-value store"),
    ("stream", "std.stream"),
    ("fs_walk", "std.fs directory traversal"),
    ("concurrent", "conc.spawn / conc.ask / conc.tell (#381)"),
    ("crypto", "std.crypto hashing / signing (#562, #582)"),
    ("vcs", "std.vcs content-addressed blob store (lex-loom#198)"),
    (
        "approval",
        "std.approval human-in-the-loop boundary; scope checked against `--allow-approval` (#737)",
    ),
    (
        "moe",
        "std.moe expert-store placement ops — pin/unpin/prefetch_hint/usage_snapshot/stats (lex-moe#25)",
    ),
];

impl Policy {
    pub fn pure() -> Self {
        Self::default()
    }

    /// Report the granted *scoped* effects whose scope list is empty —
    /// i.e. the ones the runtime treats as unrestricted ("any"):
    /// `proc` (any binary), `net` (any host), `fs_read` / `fs_write`
    /// (any path). Returns an empty vec when no granted kind is left
    /// wide open.
    ///
    /// Intended for embedders that expose execution to untrusted
    /// callers: build the effective `Policy`, then refuse to run (or
    /// loudly log) if this returns non-empty. Pure / `time` / `rand`
    /// grants never appear here — they have no scope. (#552)
    pub fn wildcard_scoped_grants(&self) -> Vec<&'static str> {
        let mut open = Vec::new();
        if self.allow_effects.contains("proc") && self.allow_proc.is_empty() {
            open.push("proc");
        }
        if self.allow_effects.contains("net") && self.allow_net_host.is_empty() {
            open.push("net");
        }
        if self.allow_effects.contains("fs_read") && self.allow_fs_read.is_empty() {
            open.push("fs_read");
        }
        if self.allow_effects.contains("fs_write") && self.allow_fs_write.is_empty() {
            open.push("fs_write");
        }
        if self.allow_effects.contains("approval") && self.allow_approval.is_empty() {
            open.push("approval");
        }
        open
    }

    pub fn permissive() -> Self {
        let mut s = BTreeSet::new();
        for (k, _) in KNOWN_EFFECTS {
            s.insert(k.to_string());
        }
        Self {
            allow_effects: s,
            allow_fs_read: Vec::new(),
            allow_fs_write: Vec::new(),
            allow_net_host: Vec::new(),
            allow_proc: Vec::new(),
            allow_approval: Vec::new(),
            budget: None,
        }
    }
}

/// Structured policy violation, formatted to match spec §6.7's JSON shape.
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
#[error("policy violation: {kind} {detail}")]
pub struct PolicyViolation {
    pub kind: String,
    pub detail: String,
    /// Effect kind that was disallowed, or `null`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub effect: Option<String>,
    /// Path that fell outside the allowlist, or `null`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub path: Option<String>,
    /// NodeId or function name; precise location of the offense.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub at: Option<String>,
}

impl PolicyViolation {
    pub fn effect_not_allowed(effect: &str, at: impl Into<String>) -> Self {
        Self {
            kind: "effect_not_allowed".into(),
            detail: format!("effect `{effect}` not in --allow-effects"),
            effect: Some(effect.into()),
            path: None,
            at: Some(at.into()),
        }
    }
    pub fn fs_path_not_allowed(effect: &str, path: &str, at: impl Into<String>) -> Self {
        Self {
            kind: "fs_path_not_allowed".into(),
            detail: format!("path `{path}` outside --allow-{effect}"),
            effect: Some(effect.into()),
            path: Some(path.into()),
            at: Some(at.into()),
        }
    }
    pub fn budget_exceeded(declared: u64, ceiling: u64) -> Self {
        Self {
            kind: "budget_exceeded".into(),
            detail: format!("declared budget {declared} exceeds ceiling {ceiling}"),
            effect: Some("budget".into()),
            path: None,
            at: None,
        }
    }
}

/// Walk the program's declared effects (gathered from fn signatures) and
/// verify them against `policy`. Run before any execution.
pub fn check_program(
    program: &Program,
    policy: &Policy,
) -> Result<PolicyReport, Vec<PolicyViolation>> {
    let mut violations = Vec::new();
    let mut total_budget: u64 = 0;
    let mut declared_effects: IndexMap<String, Vec<DeclaredEffect>> = IndexMap::new();

    for f in &program.functions {
        for e in &f.effects {
            declared_effects
                .entry(f.name.clone())
                .or_default()
                .push(e.clone());

            // Effect-kind allowlist (#207). A grant like `mcp:ocpp`
            // permits `[mcp("ocpp")]` only; bare `mcp` permits any
            // `[mcp(...)]`. Subsumption follows the type-system rule
            // in `lex-types::EffectKind::subsumes`. The CLI wire
            // format stays plain strings for backward compat.
            if !is_effect_allowed(&policy.allow_effects, e) {
                violations.push(PolicyViolation::effect_not_allowed(
                    &declared_effect_pretty(e),
                    &f.name,
                ));
                continue;
            }

            // Scoped fs paths.
            if e.kind == "fs_read" || e.kind == "fs_write" {
                if let Some(EffectArg::Str(path)) = &e.arg {
                    let allowlist = if e.kind == "fs_read" {
                        &policy.allow_fs_read
                    } else {
                        &policy.allow_fs_write
                    };
                    if !path_under_any(path, allowlist) {
                        violations
                            .push(PolicyViolation::fs_path_not_allowed(&e.kind, path, &f.name));
                    }
                }
            }

            // Budget aggregation.
            if e.kind == "budget" {
                if let Some(EffectArg::Int(n)) = &e.arg {
                    if *n >= 0 {
                        total_budget = total_budget.saturating_add(*n as u64);
                    }
                }
            }
        }
    }

    if let Some(ceiling) = policy.budget {
        if total_budget > ceiling {
            violations.push(PolicyViolation::budget_exceeded(total_budget, ceiling));
        }
    }

    if violations.is_empty() {
        Ok(PolicyReport {
            declared_effects,
            total_budget,
        })
    } else {
        Err(violations)
    }
}

#[derive(Debug, Clone)]
pub struct PolicyReport {
    pub declared_effects: IndexMap<String, Vec<DeclaredEffect>>,
    pub total_budget: u64,
}

fn path_under_any(p: &str, list: &[PathBuf]) -> bool {
    let candidate = Path::new(p);
    list.iter().any(|allowed| candidate.starts_with(allowed))
}

/// Render a `DeclaredEffect` for diagnostic output, matching the
/// `EffectKind::pretty` form used by the type checker (#207).
fn declared_effect_pretty(e: &DeclaredEffect) -> String {
    match &e.arg {
        None => e.kind.clone(),
        Some(EffectArg::Str(s)) => format!("{}(\"{}\")", e.kind, s),
        Some(EffectArg::Int(n)) => format!("{}({})", e.kind, n),
        Some(EffectArg::Ident(s)) => format!("{}({})", e.kind, s),
    }
}

/// Decide whether `e` is permitted by `grants` (#207).
///
/// Grant strings come from `--allow-effects` and may be either:
///   - `name`           (bare wildcard, accepts any arg)
///   - `name:arg`       (string-arg specific grant — the colon is
///     a CLI-friendly separator)
///   - `name(arg)`      (matches the canonical pretty form for
///     grants written by hand or copy-pasted from
///     error messages)
///
/// Bare absorbs specific; specific matches only an exactly-equal
/// string arg. Int/Ident args on the declaration side are accepted
/// only by their bare-name grants (no CLI form for them in v1 —
/// they're rare in practice and can be added later).
pub fn is_effect_allowed(grants: &BTreeSet<String>, e: &DeclaredEffect) -> bool {
    grants.iter().any(|g| grant_subsumes(g, e))
}

fn grant_subsumes(grant: &str, e: &DeclaredEffect) -> bool {
    // Accept three forms: "name", "name:arg", "name(arg)".
    let (g_name, g_arg) = parse_grant(grant);
    if g_name != e.kind {
        return false;
    }
    match (g_arg, &e.arg) {
        (None, _) => true,        // bare absorbs anything
        (Some(_), None) => false, // specific can't grant bare
        (Some(g), Some(EffectArg::Str(d))) => g == d,
        // Int / Ident args have no CLI form in v1; only bare grants
        // satisfy them (handled by the (None, _) branch above).
        (Some(_), Some(_)) => false,
    }
}

/// Split `"mcp:ocpp"` or `"mcp(ocpp)"` into `("mcp", Some("ocpp"))`.
/// Plain `"mcp"` returns `("mcp", None)`.
fn parse_grant(s: &str) -> (&str, Option<&str>) {
    if let Some((name, rest)) = s.split_once('(') {
        if let Some(arg) = rest.strip_suffix(')') {
            return (name, Some(arg.trim_matches('"')));
        }
    }
    if let Some((name, arg)) = s.split_once(':') {
        return (name, Some(arg));
    }
    (s, None)
}

#[cfg(test)]
mod wildcard_tests {
    use super::*;

    fn effects(kinds: &[&str]) -> BTreeSet<String> {
        kinds.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn flags_scoped_grants_left_open() {
        let p = Policy {
            allow_effects: effects(&["proc", "fs_read", "time"]),
            ..Policy::default()
        };
        let open = p.wildcard_scoped_grants();
        assert!(
            open.contains(&"proc"),
            "empty allow_proc + [proc] is wide open"
        );
        assert!(
            open.contains(&"fs_read"),
            "empty allow_fs_read + [fs_read] is wide open"
        );
        // `time` has no scope and `net` wasn't granted.
        assert!(!open.contains(&"time"));
        assert!(!open.contains(&"net"));
    }

    #[test]
    fn populated_scope_is_not_flagged() {
        let p = Policy {
            allow_effects: effects(&["fs_read", "net"]),
            allow_fs_read: vec![PathBuf::from("/srv/data")],
            allow_net_host: vec!["api.example.com".into()],
            ..Policy::default()
        };
        assert!(p.wildcard_scoped_grants().is_empty());
    }

    #[test]
    fn pure_and_unscoped_effects_are_clean() {
        assert!(Policy::pure().wildcard_scoped_grants().is_empty());
        let p = Policy {
            allow_effects: effects(&["time", "random", "panic"]),
            ..Policy::default()
        };
        assert!(p.wildcard_scoped_grants().is_empty());
    }
}