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    pub budget: Option<u64>,
54}
55
56impl Policy {
57    pub fn pure() -> Self { Self::default() }
58
59    /// Report the granted *scoped* effects whose scope list is empty —
60    /// i.e. the ones the runtime treats as unrestricted ("any"):
61    /// `proc` (any binary), `net` (any host), `fs_read` / `fs_write`
62    /// (any path). Returns an empty vec when no granted kind is left
63    /// wide open.
64    ///
65    /// Intended for embedders that expose execution to untrusted
66    /// callers: build the effective `Policy`, then refuse to run (or
67    /// loudly log) if this returns non-empty. Pure / `time` / `rand`
68    /// grants never appear here — they have no scope. (#552)
69    pub fn wildcard_scoped_grants(&self) -> Vec<&'static str> {
70        let mut open = Vec::new();
71        if self.allow_effects.contains("proc") && self.allow_proc.is_empty() {
72            open.push("proc");
73        }
74        if self.allow_effects.contains("net") && self.allow_net_host.is_empty() {
75            open.push("net");
76        }
77        if self.allow_effects.contains("fs_read") && self.allow_fs_read.is_empty() {
78            open.push("fs_read");
79        }
80        if self.allow_effects.contains("fs_write") && self.allow_fs_write.is_empty() {
81            open.push("fs_write");
82        }
83        open
84    }
85
86    pub fn permissive() -> Self {
87        let mut s = BTreeSet::new();
88        for k in [
89            "io", "net", "time", "rand", "llm", "proc", "panic",
90            "fs_read", "fs_write", "budget",
91            // #184: agent-runtime effects.
92            "llm_local", "llm_cloud", "a2a", "mcp",
93            // #216: env-var access. Per-var scoping (`[env(NAME)]`)
94            // arrives with the per-capability effect parameterization
95            // work (#207); the flat `[env]` is the v1 surface.
96            "env",
97            // #399: keep this set in sync with every effect declared
98            // in `crates/lex-types/src/builtins.rs`. The "permissive"
99            // contract is "everything stdlib knows about"; missing
100            // entries here cause valid stdlib calls to fail under
101            // `lex test` / `lex repl` / any other consumer that opts
102            // into the permissive policy.
103            "sql",         // std.sql (#362, #379)
104            "random",      // crypto.random / crypto.random_str_hex (#382)
105            "chat",        // chat.broadcast / chat.send (#359)
106            "log",         // std.log structured logging
107            "kv",          // std.kv key-value store
108            "stream",      // std.stream
109            "fs_walk",     // std.fs directory traversal
110            "concurrent",  // conc.spawn / conc.ask / conc.tell (#381)
111            "crypto",      // std.crypto hashing / signing (#562, #582)
112        ] {
113            s.insert(k.to_string());
114        }
115        Self {
116            allow_effects: s,
117            allow_fs_read: Vec::new(),
118            allow_fs_write: Vec::new(),
119            allow_net_host: Vec::new(),
120            allow_proc: Vec::new(),
121            budget: None,
122        }
123    }
124}
125
126/// Structured policy violation, formatted to match spec §6.7's JSON shape.
127#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
128#[error("policy violation: {kind} {detail}")]
129pub struct PolicyViolation {
130    pub kind: String,
131    pub detail: String,
132    /// Effect kind that was disallowed, or `null`.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub effect: Option<String>,
135    /// Path that fell outside the allowlist, or `null`.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub path: Option<String>,
138    /// NodeId or function name; precise location of the offense.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub at: Option<String>,
141}
142
143impl PolicyViolation {
144    pub fn effect_not_allowed(effect: &str, at: impl Into<String>) -> Self {
145        Self {
146            kind: "effect_not_allowed".into(),
147            detail: format!("effect `{effect}` not in --allow-effects"),
148            effect: Some(effect.into()),
149            path: None,
150            at: Some(at.into()),
151        }
152    }
153    pub fn fs_path_not_allowed(effect: &str, path: &str, at: impl Into<String>) -> Self {
154        Self {
155            kind: "fs_path_not_allowed".into(),
156            detail: format!("path `{path}` outside --allow-{effect}"),
157            effect: Some(effect.into()),
158            path: Some(path.into()),
159            at: Some(at.into()),
160        }
161    }
162    pub fn budget_exceeded(declared: u64, ceiling: u64) -> Self {
163        Self {
164            kind: "budget_exceeded".into(),
165            detail: format!("declared budget {declared} exceeds ceiling {ceiling}"),
166            effect: Some("budget".into()),
167            path: None,
168            at: None,
169        }
170    }
171}
172
173/// Walk the program's declared effects (gathered from fn signatures) and
174/// verify them against `policy`. Run before any execution.
175pub fn check_program(program: &Program, policy: &Policy) -> Result<PolicyReport, Vec<PolicyViolation>> {
176    let mut violations = Vec::new();
177    let mut total_budget: u64 = 0;
178    let mut declared_effects: IndexMap<String, Vec<DeclaredEffect>> = IndexMap::new();
179
180    for f in &program.functions {
181        for e in &f.effects {
182            declared_effects.entry(f.name.clone()).or_default().push(e.clone());
183
184            // Effect-kind allowlist (#207). A grant like `mcp:ocpp`
185            // permits `[mcp("ocpp")]` only; bare `mcp` permits any
186            // `[mcp(...)]`. Subsumption follows the type-system rule
187            // in `lex-types::EffectKind::subsumes`. The CLI wire
188            // format stays plain strings for backward compat.
189            if !is_effect_allowed(&policy.allow_effects, e) {
190                violations.push(PolicyViolation::effect_not_allowed(
191                    &declared_effect_pretty(e), &f.name));
192                continue;
193            }
194
195            // Scoped fs paths.
196            if e.kind == "fs_read" || e.kind == "fs_write" {
197                if let Some(EffectArg::Str(path)) = &e.arg {
198                    let allowlist = if e.kind == "fs_read" {
199                        &policy.allow_fs_read
200                    } else {
201                        &policy.allow_fs_write
202                    };
203                    if !path_under_any(path, allowlist) {
204                        violations.push(PolicyViolation::fs_path_not_allowed(&e.kind, path, &f.name));
205                    }
206                }
207            }
208
209            // Budget aggregation.
210            if e.kind == "budget" {
211                if let Some(EffectArg::Int(n)) = &e.arg {
212                    if *n >= 0 { total_budget = total_budget.saturating_add(*n as u64); }
213                }
214            }
215        }
216    }
217
218    if let Some(ceiling) = policy.budget {
219        if total_budget > ceiling {
220            violations.push(PolicyViolation::budget_exceeded(total_budget, ceiling));
221        }
222    }
223
224    if violations.is_empty() {
225        Ok(PolicyReport { declared_effects, total_budget })
226    } else {
227        Err(violations)
228    }
229}
230
231#[derive(Debug, Clone)]
232pub struct PolicyReport {
233    pub declared_effects: IndexMap<String, Vec<DeclaredEffect>>,
234    pub total_budget: u64,
235}
236
237fn path_under_any(p: &str, list: &[PathBuf]) -> bool {
238    let candidate = Path::new(p);
239    list.iter().any(|allowed| candidate.starts_with(allowed))
240}
241
242/// Render a `DeclaredEffect` for diagnostic output, matching the
243/// `EffectKind::pretty` form used by the type checker (#207).
244fn declared_effect_pretty(e: &DeclaredEffect) -> String {
245    match &e.arg {
246        None => e.kind.clone(),
247        Some(EffectArg::Str(s)) => format!("{}(\"{}\")", e.kind, s),
248        Some(EffectArg::Int(n)) => format!("{}({})", e.kind, n),
249        Some(EffectArg::Ident(s)) => format!("{}({})", e.kind, s),
250    }
251}
252
253/// Decide whether `e` is permitted by `grants` (#207).
254///
255/// Grant strings come from `--allow-effects` and may be either:
256///   - `name`           (bare wildcard, accepts any arg)
257///   - `name:arg`       (string-arg specific grant — the colon is
258///     a CLI-friendly separator)
259///   - `name(arg)`      (matches the canonical pretty form for
260///     grants written by hand or copy-pasted from
261///     error messages)
262///
263/// Bare absorbs specific; specific matches only an exactly-equal
264/// string arg. Int/Ident args on the declaration side are accepted
265/// only by their bare-name grants (no CLI form for them in v1 —
266/// they're rare in practice and can be added later).
267pub fn is_effect_allowed(grants: &BTreeSet<String>, e: &DeclaredEffect) -> bool {
268    grants.iter().any(|g| grant_subsumes(g, e))
269}
270
271fn grant_subsumes(grant: &str, e: &DeclaredEffect) -> bool {
272    // Accept three forms: "name", "name:arg", "name(arg)".
273    let (g_name, g_arg) = parse_grant(grant);
274    if g_name != e.kind { return false; }
275    match (g_arg, &e.arg) {
276        (None, _) => true,                                 // bare absorbs anything
277        (Some(_), None) => false,                          // specific can't grant bare
278        (Some(g), Some(EffectArg::Str(d))) => g == d,
279        // Int / Ident args have no CLI form in v1; only bare grants
280        // satisfy them (handled by the (None, _) branch above).
281        (Some(_), Some(_)) => false,
282    }
283}
284
285/// Split `"mcp:ocpp"` or `"mcp(ocpp)"` into `("mcp", Some("ocpp"))`.
286/// Plain `"mcp"` returns `("mcp", None)`.
287fn parse_grant(s: &str) -> (&str, Option<&str>) {
288    if let Some((name, rest)) = s.split_once('(') {
289        if let Some(arg) = rest.strip_suffix(')') {
290            return (name, Some(arg.trim_matches('"')));
291        }
292    }
293    if let Some((name, arg)) = s.split_once(':') {
294        return (name, Some(arg));
295    }
296    (s, None)
297}
298
299#[cfg(test)]
300mod wildcard_tests {
301    use super::*;
302
303    fn effects(kinds: &[&str]) -> BTreeSet<String> {
304        kinds.iter().map(|s| s.to_string()).collect()
305    }
306
307    #[test]
308    fn flags_scoped_grants_left_open() {
309        let p = Policy {
310            allow_effects: effects(&["proc", "fs_read", "time"]),
311            ..Policy::default()
312        };
313        let open = p.wildcard_scoped_grants();
314        assert!(open.contains(&"proc"), "empty allow_proc + [proc] is wide open");
315        assert!(open.contains(&"fs_read"), "empty allow_fs_read + [fs_read] is wide open");
316        // `time` has no scope and `net` wasn't granted.
317        assert!(!open.contains(&"time"));
318        assert!(!open.contains(&"net"));
319    }
320
321    #[test]
322    fn populated_scope_is_not_flagged() {
323        let p = Policy {
324            allow_effects: effects(&["fs_read", "net"]),
325            allow_fs_read: vec![PathBuf::from("/srv/data")],
326            allow_net_host: vec!["api.example.com".into()],
327            ..Policy::default()
328        };
329        assert!(p.wildcard_scoped_grants().is_empty());
330    }
331
332    #[test]
333    fn pure_and_unscoped_effects_are_clean() {
334        assert!(Policy::pure().wildcard_scoped_grants().is_empty());
335        let p = Policy {
336            allow_effects: effects(&["time", "rand", "panic"]),
337            ..Policy::default()
338        };
339        assert!(p.wildcard_scoped_grants().is_empty());
340    }
341}