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 allowlist = pure-only execution.
17#[derive(Debug, Clone, Default)]
18pub struct Policy {
19    pub allow_effects: BTreeSet<String>,
20    pub allow_fs_read: Vec<PathBuf>,
21    pub allow_fs_write: Vec<PathBuf>,
22    /// Per-host scope on the [net] effect. Empty = any host (when
23    /// [net] is in `allow_effects`); non-empty = only requests to
24    /// these hosts succeed. Hosts compare against the URL's host
25    /// substring (port-agnostic). Lets a tool be granted [net] but
26    /// scoped to e.g. `api.openai.com` only — without this, [net]
27    /// is a blank check to exfiltrate anywhere.
28    pub allow_net_host: Vec<String>,
29    /// Per-binary scope on the [proc] effect. Empty = ANY binary
30    /// allowed once [proc] is granted (treat as a global escape
31    /// hatch; only acceptable for trusted code). Non-empty =
32    /// `proc.spawn(cmd, args)` must match `cmd` against the
33    /// basename portion of one of these entries. Per-arg validation
34    /// is the *caller's* responsibility — see SECURITY.md's
35    /// "argument injection" note.
36    pub allow_proc: Vec<String>,
37    pub budget: Option<u64>,
38}
39
40impl Policy {
41    pub fn pure() -> Self { Self::default() }
42
43    pub fn permissive() -> Self {
44        let mut s = BTreeSet::new();
45        for k in [
46            "io", "net", "time", "rand", "llm", "proc", "panic",
47            "fs_read", "fs_write", "budget",
48            // #184: agent-runtime effects.
49            "llm_local", "llm_cloud", "a2a", "mcp",
50            // #216: env-var access. Per-var scoping (`[env(NAME)]`)
51            // arrives with the per-capability effect parameterization
52            // work (#207); the flat `[env]` is the v1 surface.
53            "env",
54        ] {
55            s.insert(k.to_string());
56        }
57        Self {
58            allow_effects: s,
59            allow_fs_read: Vec::new(),
60            allow_fs_write: Vec::new(),
61            allow_net_host: Vec::new(),
62            allow_proc: Vec::new(),
63            budget: None,
64        }
65    }
66}
67
68/// Structured policy violation, formatted to match spec §6.7's JSON shape.
69#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
70#[error("policy violation: {kind} {detail}")]
71pub struct PolicyViolation {
72    pub kind: String,
73    pub detail: String,
74    /// Effect kind that was disallowed, or `null`.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub effect: Option<String>,
77    /// Path that fell outside the allowlist, or `null`.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub path: Option<String>,
80    /// NodeId or function name; precise location of the offense.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub at: Option<String>,
83}
84
85impl PolicyViolation {
86    pub fn effect_not_allowed(effect: &str, at: impl Into<String>) -> Self {
87        Self {
88            kind: "effect_not_allowed".into(),
89            detail: format!("effect `{effect}` not in --allow-effects"),
90            effect: Some(effect.into()),
91            path: None,
92            at: Some(at.into()),
93        }
94    }
95    pub fn fs_path_not_allowed(effect: &str, path: &str, at: impl Into<String>) -> Self {
96        Self {
97            kind: "fs_path_not_allowed".into(),
98            detail: format!("path `{path}` outside --allow-{effect}"),
99            effect: Some(effect.into()),
100            path: Some(path.into()),
101            at: Some(at.into()),
102        }
103    }
104    pub fn budget_exceeded(declared: u64, ceiling: u64) -> Self {
105        Self {
106            kind: "budget_exceeded".into(),
107            detail: format!("declared budget {declared} exceeds ceiling {ceiling}"),
108            effect: Some("budget".into()),
109            path: None,
110            at: None,
111        }
112    }
113}
114
115/// Walk the program's declared effects (gathered from fn signatures) and
116/// verify them against `policy`. Run before any execution.
117pub fn check_program(program: &Program, policy: &Policy) -> Result<PolicyReport, Vec<PolicyViolation>> {
118    let mut violations = Vec::new();
119    let mut total_budget: u64 = 0;
120    let mut declared_effects: IndexMap<String, Vec<DeclaredEffect>> = IndexMap::new();
121
122    for f in &program.functions {
123        for e in &f.effects {
124            declared_effects.entry(f.name.clone()).or_default().push(e.clone());
125
126            // Effect-kind allowlist (#207). A grant like `mcp:ocpp`
127            // permits `[mcp("ocpp")]` only; bare `mcp` permits any
128            // `[mcp(...)]`. Subsumption follows the type-system rule
129            // in `lex-types::EffectKind::subsumes`. The CLI wire
130            // format stays plain strings for backward compat.
131            if !is_effect_allowed(&policy.allow_effects, e) {
132                violations.push(PolicyViolation::effect_not_allowed(
133                    &declared_effect_pretty(e), &f.name));
134                continue;
135            }
136
137            // Scoped fs paths.
138            if e.kind == "fs_read" || e.kind == "fs_write" {
139                if let Some(EffectArg::Str(path)) = &e.arg {
140                    let allowlist = if e.kind == "fs_read" {
141                        &policy.allow_fs_read
142                    } else {
143                        &policy.allow_fs_write
144                    };
145                    if !path_under_any(path, allowlist) {
146                        violations.push(PolicyViolation::fs_path_not_allowed(&e.kind, path, &f.name));
147                    }
148                }
149            }
150
151            // Budget aggregation.
152            if e.kind == "budget" {
153                if let Some(EffectArg::Int(n)) = &e.arg {
154                    if *n >= 0 { total_budget = total_budget.saturating_add(*n as u64); }
155                }
156            }
157        }
158    }
159
160    if let Some(ceiling) = policy.budget {
161        if total_budget > ceiling {
162            violations.push(PolicyViolation::budget_exceeded(total_budget, ceiling));
163        }
164    }
165
166    if violations.is_empty() {
167        Ok(PolicyReport { declared_effects, total_budget })
168    } else {
169        Err(violations)
170    }
171}
172
173#[derive(Debug, Clone)]
174pub struct PolicyReport {
175    pub declared_effects: IndexMap<String, Vec<DeclaredEffect>>,
176    pub total_budget: u64,
177}
178
179fn path_under_any(p: &str, list: &[PathBuf]) -> bool {
180    let candidate = Path::new(p);
181    list.iter().any(|allowed| candidate.starts_with(allowed))
182}
183
184/// Render a `DeclaredEffect` for diagnostic output, matching the
185/// `EffectKind::pretty` form used by the type checker (#207).
186fn declared_effect_pretty(e: &DeclaredEffect) -> String {
187    match &e.arg {
188        None => e.kind.clone(),
189        Some(EffectArg::Str(s)) => format!("{}(\"{}\")", e.kind, s),
190        Some(EffectArg::Int(n)) => format!("{}({})", e.kind, n),
191        Some(EffectArg::Ident(s)) => format!("{}({})", e.kind, s),
192    }
193}
194
195/// Decide whether `e` is permitted by `grants` (#207).
196///
197/// Grant strings come from `--allow-effects` and may be either:
198///   - `name`           (bare wildcard, accepts any arg)
199///   - `name:arg`       (string-arg specific grant — the colon is
200///     a CLI-friendly separator)
201///   - `name(arg)`      (matches the canonical pretty form for
202///     grants written by hand or copy-pasted from
203///     error messages)
204///
205/// Bare absorbs specific; specific matches only an exactly-equal
206/// string arg. Int/Ident args on the declaration side are accepted
207/// only by their bare-name grants (no CLI form for them in v1 —
208/// they're rare in practice and can be added later).
209pub fn is_effect_allowed(grants: &BTreeSet<String>, e: &DeclaredEffect) -> bool {
210    grants.iter().any(|g| grant_subsumes(g, e))
211}
212
213fn grant_subsumes(grant: &str, e: &DeclaredEffect) -> bool {
214    // Accept three forms: "name", "name:arg", "name(arg)".
215    let (g_name, g_arg) = parse_grant(grant);
216    if g_name != e.kind { return false; }
217    match (g_arg, &e.arg) {
218        (None, _) => true,                                 // bare absorbs anything
219        (Some(_), None) => false,                          // specific can't grant bare
220        (Some(g), Some(EffectArg::Str(d))) => g == d,
221        // Int / Ident args have no CLI form in v1; only bare grants
222        // satisfy them (handled by the (None, _) branch above).
223        (Some(_), Some(_)) => false,
224    }
225}
226
227/// Split `"mcp:ocpp"` or `"mcp(ocpp)"` into `("mcp", Some("ocpp"))`.
228/// Plain `"mcp"` returns `("mcp", None)`.
229fn parse_grant(s: &str) -> (&str, Option<&str>) {
230    if let Some((name, rest)) = s.split_once('(') {
231        if let Some(arg) = rest.strip_suffix(')') {
232            return (name, Some(arg.trim_matches('"')));
233        }
234    }
235    if let Some((name, arg)) = s.split_once(':') {
236        return (name, Some(arg));
237    }
238    (s, None)
239}