Skip to main content

lex_runtime/
policy.rs

1//! Capability/policy layer per spec §7.4.
2//!
3//! Operators specify what effects are allowed before any execution starts.
4//! The runtime walks the program's declared effects and aborts with a
5//! structured violation if the program would exceed the policy. During
6//! execution, individual effect calls are also gated through the same
7//! policy so that scoped effects (fs paths, budget consumption) are caught
8//! at call time.
9
10use indexmap::IndexMap;
11use lex_bytecode::program::{DeclaredEffect, EffectArg, Program};
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeSet;
14use std::path::{Path, PathBuf};
15
16/// Policy a program is run under. Empty `allow_effects` = pure-only
17/// execution.
18///
19/// **Wildcard scopes (read before embedding):** the scope lists
20/// (`allow_fs_read`, `allow_fs_write`, `allow_net_host`, `allow_proc`)
21/// follow an **empty = allow ANY** convention, *not* empty = deny.
22/// Granting a scoped effect in `allow_effects` while leaving its scope
23/// list empty therefore opens the *unrestricted* form (any path / host
24/// / binary). That's intentional for trusted local use (`lex run`),
25/// but it's a footgun for embedders that build a `Policy` from
26/// untrusted input. Such embedders should populate the scope list for
27/// every kind they grant, or call [`Policy::wildcard_scoped_grants`]
28/// to detect the wide-open ones and refuse them. (#552)
29#[derive(Debug, Clone, Default)]
30pub struct Policy {
31    pub allow_effects: BTreeSet<String>,
32    /// Path scope for the `[fs_read]` effect. **Empty = any path**
33    /// (wildcard), not deny — see the type-level note above (#552).
34    pub allow_fs_read: Vec<PathBuf>,
35    /// Path scope for the `[fs_write]` effect. **Empty = any path**
36    /// (wildcard), not deny — see the type-level note above (#552).
37    pub allow_fs_write: Vec<PathBuf>,
38    /// Per-host scope on the [net] effect. Empty = any host (when
39    /// [net] is in `allow_effects`); non-empty = only requests to
40    /// these hosts succeed. Hosts compare against the URL's host
41    /// substring (port-agnostic). Lets a tool be granted [net] but
42    /// scoped to e.g. `api.openai.com` only — without this, [net]
43    /// is a blank check to exfiltrate anywhere.
44    pub allow_net_host: Vec<String>,
45    /// Per-binary scope on the [proc] effect. Empty = ANY binary
46    /// allowed once [proc] is granted (treat as a global escape
47    /// hatch; only acceptable for trusted code). Non-empty =
48    /// `proc.spawn(cmd, args)` must match `cmd` against the
49    /// basename portion of one of these entries. Per-arg validation
50    /// is the *caller's* responsibility — see SECURITY.md's
51    /// "argument injection" note.
52    pub allow_proc: Vec<String>,
53    /// Per-scope allowlist on the `[approval]` effect. Empty = any
54    /// scope allowed once `approval` is granted (treat as a global
55    /// human-escalation escape hatch; only acceptable for trusted
56    /// code). Non-empty = `approval.request(scope, reason)` must
57    /// match `scope` against one of these entries — lets an operator
58    /// grant e.g. "payment approvals only" rather than a blanket
59    /// human-in-the-loop channel.
60    pub allow_approval: Vec<String>,
61    pub budget: Option<u64>,
62}
63
64/// Every effect kind the stdlib can declare, each with a one-line note.
65/// THE single source: `Policy::permissive` grants exactly these, and
66/// `lex docs --effects` renders this table for docs/AGENT.md's quick
67/// reference (kept current by `lex doc-sync --check` in CI). #399's
68/// "keep this set in sync with builtins.rs" used to be a comment-level
69/// rule enforced by nobody; adding an effect now means adding one row
70/// here, and the doc regenerates from it.
71pub const KNOWN_EFFECTS: &[(&str, &str)] = &[
72    ("io", "console / stdio"),
73    (
74        "net",
75        "sockets + outbound HTTP; scope to a host (`net(\"host\")`) where possible",
76    ),
77    ("time", "clocks — non-deterministic"),
78    ("llm", "LLM inference"),
79    ("proc", "subprocess execution"),
80    (
81        "proc_exit",
82        "std.process.exit — sets this process's exit status (#754)",
83    ),
84    ("panic", "may abort"),
85    ("fs_read", "filesystem reads; scopable to a path"),
86    ("fs_write", "filesystem writes; scopable to a path"),
87    (
88        "budget",
89        "annotated cost `budget(N)`; checked against `--budget`",
90    ),
91    ("llm_local", "local model inference (#184)"),
92    ("llm_cloud", "cloud model inference (#184)"),
93    ("a2a", "agent-to-agent protocol calls (#184)"),
94    ("mcp", "MCP client calls (#184)"),
95    (
96        "env",
97        "environment-variable access (#216); flat `[env]` is the v1 surface",
98    ),
99    ("sql", "std.sql database access (#362, #379)"),
100    ("random", "crypto.random / crypto.random_str_hex (#382)"),
101    ("chat", "chat.broadcast / chat.send (#359)"),
102    ("log", "std.log structured logging"),
103    ("kv", "std.kv key-value store"),
104    ("stream", "std.stream"),
105    ("fs_walk", "std.fs directory traversal"),
106    ("concurrent", "conc.spawn / conc.ask / conc.tell (#381)"),
107    ("crypto", "std.crypto hashing / signing (#562, #582)"),
108    ("vcs", "std.vcs content-addressed blob store (lex-loom#198)"),
109    (
110        "approval",
111        "std.approval human-in-the-loop boundary; scope checked against `--allow-approval` (#737)",
112    ),
113    (
114        "moe",
115        "std.moe expert-store placement ops — pin/unpin/prefetch_hint/usage_snapshot/stats (lex-moe#25)",
116    ),
117];
118
119impl Policy {
120    pub fn pure() -> Self {
121        Self::default()
122    }
123
124    /// Report the granted *scoped* effects whose scope list is empty —
125    /// i.e. the ones the runtime treats as unrestricted ("any"):
126    /// `proc` (any binary), `net` (any host), `fs_read` / `fs_write`
127    /// (any path). Returns an empty vec when no granted kind is left
128    /// wide open.
129    ///
130    /// Intended for embedders that expose execution to untrusted
131    /// callers: build the effective `Policy`, then refuse to run (or
132    /// loudly log) if this returns non-empty. Pure / `time` / `rand`
133    /// grants never appear here — they have no scope. (#552)
134    pub fn wildcard_scoped_grants(&self) -> Vec<&'static str> {
135        let mut open = Vec::new();
136        if self.allow_effects.contains("proc") && self.allow_proc.is_empty() {
137            open.push("proc");
138        }
139        if self.allow_effects.contains("net") && self.allow_net_host.is_empty() {
140            open.push("net");
141        }
142        if self.allow_effects.contains("fs_read") && self.allow_fs_read.is_empty() {
143            open.push("fs_read");
144        }
145        if self.allow_effects.contains("fs_write") && self.allow_fs_write.is_empty() {
146            open.push("fs_write");
147        }
148        if self.allow_effects.contains("approval") && self.allow_approval.is_empty() {
149            open.push("approval");
150        }
151        open
152    }
153
154    pub fn permissive() -> Self {
155        let mut s = BTreeSet::new();
156        for (k, _) in KNOWN_EFFECTS {
157            s.insert(k.to_string());
158        }
159        Self {
160            allow_effects: s,
161            allow_fs_read: Vec::new(),
162            allow_fs_write: Vec::new(),
163            allow_net_host: Vec::new(),
164            allow_proc: Vec::new(),
165            allow_approval: Vec::new(),
166            budget: None,
167        }
168    }
169}
170
171/// Structured policy violation, formatted to match spec §6.7's JSON shape.
172#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
173#[error("policy violation: {kind} {detail}")]
174pub struct PolicyViolation {
175    pub kind: String,
176    pub detail: String,
177    /// Effect kind that was disallowed, or `null`.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub effect: Option<String>,
180    /// Path that fell outside the allowlist, or `null`.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub path: Option<String>,
183    /// NodeId or function name; precise location of the offense.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub at: Option<String>,
186}
187
188impl PolicyViolation {
189    pub fn effect_not_allowed(effect: &str, at: impl Into<String>) -> Self {
190        Self {
191            kind: "effect_not_allowed".into(),
192            detail: format!("effect `{effect}` not in --allow-effects"),
193            effect: Some(effect.into()),
194            path: None,
195            at: Some(at.into()),
196        }
197    }
198    pub fn fs_path_not_allowed(effect: &str, path: &str, at: impl Into<String>) -> Self {
199        Self {
200            kind: "fs_path_not_allowed".into(),
201            detail: format!("path `{path}` outside --allow-{effect}"),
202            effect: Some(effect.into()),
203            path: Some(path.into()),
204            at: Some(at.into()),
205        }
206    }
207    pub fn budget_exceeded(declared: u64, ceiling: u64) -> Self {
208        Self {
209            kind: "budget_exceeded".into(),
210            detail: format!("declared budget {declared} exceeds ceiling {ceiling}"),
211            effect: Some("budget".into()),
212            path: None,
213            at: None,
214        }
215    }
216}
217
218/// Walk the program's declared effects (gathered from fn signatures) and
219/// verify them against `policy`. Run before any execution.
220pub fn check_program(
221    program: &Program,
222    policy: &Policy,
223) -> Result<PolicyReport, Vec<PolicyViolation>> {
224    let mut violations = Vec::new();
225    let mut total_budget: u64 = 0;
226    let mut declared_effects: IndexMap<String, Vec<DeclaredEffect>> = IndexMap::new();
227
228    for f in &program.functions {
229        for e in &f.effects {
230            declared_effects
231                .entry(f.name.clone())
232                .or_default()
233                .push(e.clone());
234
235            // Effect-kind allowlist (#207). A grant like `mcp:ocpp`
236            // permits `[mcp("ocpp")]` only; bare `mcp` permits any
237            // `[mcp(...)]`. Subsumption follows the type-system rule
238            // in `lex-types::EffectKind::subsumes`. The CLI wire
239            // format stays plain strings for backward compat.
240            if !is_effect_allowed(&policy.allow_effects, e) {
241                violations.push(PolicyViolation::effect_not_allowed(
242                    &declared_effect_pretty(e),
243                    &f.name,
244                ));
245                continue;
246            }
247
248            // Scoped fs paths.
249            if e.kind == "fs_read" || e.kind == "fs_write" {
250                if let Some(EffectArg::Str(path)) = &e.arg {
251                    let allowlist = if e.kind == "fs_read" {
252                        &policy.allow_fs_read
253                    } else {
254                        &policy.allow_fs_write
255                    };
256                    if !path_under_any(path, allowlist) {
257                        violations
258                            .push(PolicyViolation::fs_path_not_allowed(&e.kind, path, &f.name));
259                    }
260                }
261            }
262
263            // Budget aggregation.
264            if e.kind == "budget" {
265                if let Some(EffectArg::Int(n)) = &e.arg {
266                    if *n >= 0 {
267                        total_budget = total_budget.saturating_add(*n as u64);
268                    }
269                }
270            }
271        }
272    }
273
274    if let Some(ceiling) = policy.budget {
275        if total_budget > ceiling {
276            violations.push(PolicyViolation::budget_exceeded(total_budget, ceiling));
277        }
278    }
279
280    if violations.is_empty() {
281        Ok(PolicyReport {
282            declared_effects,
283            total_budget,
284        })
285    } else {
286        Err(violations)
287    }
288}
289
290#[derive(Debug, Clone)]
291pub struct PolicyReport {
292    pub declared_effects: IndexMap<String, Vec<DeclaredEffect>>,
293    pub total_budget: u64,
294}
295
296fn path_under_any(p: &str, list: &[PathBuf]) -> bool {
297    let candidate = Path::new(p);
298    list.iter().any(|allowed| candidate.starts_with(allowed))
299}
300
301/// Render a `DeclaredEffect` for diagnostic output, matching the
302/// `EffectKind::pretty` form used by the type checker (#207).
303fn declared_effect_pretty(e: &DeclaredEffect) -> String {
304    match &e.arg {
305        None => e.kind.clone(),
306        Some(EffectArg::Str(s)) => format!("{}(\"{}\")", e.kind, s),
307        Some(EffectArg::Int(n)) => format!("{}({})", e.kind, n),
308        Some(EffectArg::Ident(s)) => format!("{}({})", e.kind, s),
309    }
310}
311
312/// Decide whether `e` is permitted by `grants` (#207).
313///
314/// Grant strings come from `--allow-effects` and may be either:
315///   - `name`           (bare wildcard, accepts any arg)
316///   - `name:arg`       (string-arg specific grant — the colon is
317///     a CLI-friendly separator)
318///   - `name(arg)`      (matches the canonical pretty form for
319///     grants written by hand or copy-pasted from
320///     error messages)
321///
322/// Bare absorbs specific; specific matches only an exactly-equal
323/// string arg. Int/Ident args on the declaration side are accepted
324/// only by their bare-name grants (no CLI form for them in v1 —
325/// they're rare in practice and can be added later).
326pub fn is_effect_allowed(grants: &BTreeSet<String>, e: &DeclaredEffect) -> bool {
327    grants.iter().any(|g| grant_subsumes(g, e))
328}
329
330fn grant_subsumes(grant: &str, e: &DeclaredEffect) -> bool {
331    // Accept three forms: "name", "name:arg", "name(arg)".
332    let (g_name, g_arg) = parse_grant(grant);
333    if g_name != e.kind {
334        return false;
335    }
336    match (g_arg, &e.arg) {
337        (None, _) => true,        // bare absorbs anything
338        (Some(_), None) => false, // specific can't grant bare
339        (Some(g), Some(EffectArg::Str(d))) => g == d,
340        // Int / Ident args have no CLI form in v1; only bare grants
341        // satisfy them (handled by the (None, _) branch above).
342        (Some(_), Some(_)) => false,
343    }
344}
345
346/// Split `"mcp:ocpp"` or `"mcp(ocpp)"` into `("mcp", Some("ocpp"))`.
347/// Plain `"mcp"` returns `("mcp", None)`.
348fn parse_grant(s: &str) -> (&str, Option<&str>) {
349    if let Some((name, rest)) = s.split_once('(') {
350        if let Some(arg) = rest.strip_suffix(')') {
351            return (name, Some(arg.trim_matches('"')));
352        }
353    }
354    if let Some((name, arg)) = s.split_once(':') {
355        return (name, Some(arg));
356    }
357    (s, None)
358}
359
360#[cfg(test)]
361mod wildcard_tests {
362    use super::*;
363
364    fn effects(kinds: &[&str]) -> BTreeSet<String> {
365        kinds.iter().map(|s| s.to_string()).collect()
366    }
367
368    #[test]
369    fn flags_scoped_grants_left_open() {
370        let p = Policy {
371            allow_effects: effects(&["proc", "fs_read", "time"]),
372            ..Policy::default()
373        };
374        let open = p.wildcard_scoped_grants();
375        assert!(
376            open.contains(&"proc"),
377            "empty allow_proc + [proc] is wide open"
378        );
379        assert!(
380            open.contains(&"fs_read"),
381            "empty allow_fs_read + [fs_read] is wide open"
382        );
383        // `time` has no scope and `net` wasn't granted.
384        assert!(!open.contains(&"time"));
385        assert!(!open.contains(&"net"));
386    }
387
388    #[test]
389    fn populated_scope_is_not_flagged() {
390        let p = Policy {
391            allow_effects: effects(&["fs_read", "net"]),
392            allow_fs_read: vec![PathBuf::from("/srv/data")],
393            allow_net_host: vec!["api.example.com".into()],
394            ..Policy::default()
395        };
396        assert!(p.wildcard_scoped_grants().is_empty());
397    }
398
399    #[test]
400    fn pure_and_unscoped_effects_are_clean() {
401        assert!(Policy::pure().wildcard_scoped_grants().is_empty());
402        let p = Policy {
403            allow_effects: effects(&["time", "random", "panic"]),
404            ..Policy::default()
405        };
406        assert!(p.wildcard_scoped_grants().is_empty());
407    }
408}