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 // #399: keep this set in sync with every effect declared
55 // in `crates/lex-types/src/builtins.rs`. The "permissive"
56 // contract is "everything stdlib knows about"; missing
57 // entries here cause valid stdlib calls to fail under
58 // `lex test` / `lex repl` / any other consumer that opts
59 // into the permissive policy.
60 "sql", // std.sql (#362, #379)
61 "random", // crypto.random / crypto.random_str_hex (#382)
62 "chat", // chat.broadcast / chat.send (#359)
63 "log", // std.log structured logging
64 "kv", // std.kv key-value store
65 "stream", // std.stream
66 "fs_walk", // std.fs directory traversal
67 ] {
68 s.insert(k.to_string());
69 }
70 Self {
71 allow_effects: s,
72 allow_fs_read: Vec::new(),
73 allow_fs_write: Vec::new(),
74 allow_net_host: Vec::new(),
75 allow_proc: Vec::new(),
76 budget: None,
77 }
78 }
79}
80
81/// Structured policy violation, formatted to match spec §6.7's JSON shape.
82#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
83#[error("policy violation: {kind} {detail}")]
84pub struct PolicyViolation {
85 pub kind: String,
86 pub detail: String,
87 /// Effect kind that was disallowed, or `null`.
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub effect: Option<String>,
90 /// Path that fell outside the allowlist, or `null`.
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub path: Option<String>,
93 /// NodeId or function name; precise location of the offense.
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub at: Option<String>,
96}
97
98impl PolicyViolation {
99 pub fn effect_not_allowed(effect: &str, at: impl Into<String>) -> Self {
100 Self {
101 kind: "effect_not_allowed".into(),
102 detail: format!("effect `{effect}` not in --allow-effects"),
103 effect: Some(effect.into()),
104 path: None,
105 at: Some(at.into()),
106 }
107 }
108 pub fn fs_path_not_allowed(effect: &str, path: &str, at: impl Into<String>) -> Self {
109 Self {
110 kind: "fs_path_not_allowed".into(),
111 detail: format!("path `{path}` outside --allow-{effect}"),
112 effect: Some(effect.into()),
113 path: Some(path.into()),
114 at: Some(at.into()),
115 }
116 }
117 pub fn budget_exceeded(declared: u64, ceiling: u64) -> Self {
118 Self {
119 kind: "budget_exceeded".into(),
120 detail: format!("declared budget {declared} exceeds ceiling {ceiling}"),
121 effect: Some("budget".into()),
122 path: None,
123 at: None,
124 }
125 }
126}
127
128/// Walk the program's declared effects (gathered from fn signatures) and
129/// verify them against `policy`. Run before any execution.
130pub fn check_program(program: &Program, policy: &Policy) -> Result<PolicyReport, Vec<PolicyViolation>> {
131 let mut violations = Vec::new();
132 let mut total_budget: u64 = 0;
133 let mut declared_effects: IndexMap<String, Vec<DeclaredEffect>> = IndexMap::new();
134
135 for f in &program.functions {
136 for e in &f.effects {
137 declared_effects.entry(f.name.clone()).or_default().push(e.clone());
138
139 // Effect-kind allowlist (#207). A grant like `mcp:ocpp`
140 // permits `[mcp("ocpp")]` only; bare `mcp` permits any
141 // `[mcp(...)]`. Subsumption follows the type-system rule
142 // in `lex-types::EffectKind::subsumes`. The CLI wire
143 // format stays plain strings for backward compat.
144 if !is_effect_allowed(&policy.allow_effects, e) {
145 violations.push(PolicyViolation::effect_not_allowed(
146 &declared_effect_pretty(e), &f.name));
147 continue;
148 }
149
150 // Scoped fs paths.
151 if e.kind == "fs_read" || e.kind == "fs_write" {
152 if let Some(EffectArg::Str(path)) = &e.arg {
153 let allowlist = if e.kind == "fs_read" {
154 &policy.allow_fs_read
155 } else {
156 &policy.allow_fs_write
157 };
158 if !path_under_any(path, allowlist) {
159 violations.push(PolicyViolation::fs_path_not_allowed(&e.kind, path, &f.name));
160 }
161 }
162 }
163
164 // Budget aggregation.
165 if e.kind == "budget" {
166 if let Some(EffectArg::Int(n)) = &e.arg {
167 if *n >= 0 { total_budget = total_budget.saturating_add(*n as u64); }
168 }
169 }
170 }
171 }
172
173 if let Some(ceiling) = policy.budget {
174 if total_budget > ceiling {
175 violations.push(PolicyViolation::budget_exceeded(total_budget, ceiling));
176 }
177 }
178
179 if violations.is_empty() {
180 Ok(PolicyReport { declared_effects, total_budget })
181 } else {
182 Err(violations)
183 }
184}
185
186#[derive(Debug, Clone)]
187pub struct PolicyReport {
188 pub declared_effects: IndexMap<String, Vec<DeclaredEffect>>,
189 pub total_budget: u64,
190}
191
192fn path_under_any(p: &str, list: &[PathBuf]) -> bool {
193 let candidate = Path::new(p);
194 list.iter().any(|allowed| candidate.starts_with(allowed))
195}
196
197/// Render a `DeclaredEffect` for diagnostic output, matching the
198/// `EffectKind::pretty` form used by the type checker (#207).
199fn declared_effect_pretty(e: &DeclaredEffect) -> String {
200 match &e.arg {
201 None => e.kind.clone(),
202 Some(EffectArg::Str(s)) => format!("{}(\"{}\")", e.kind, s),
203 Some(EffectArg::Int(n)) => format!("{}({})", e.kind, n),
204 Some(EffectArg::Ident(s)) => format!("{}({})", e.kind, s),
205 }
206}
207
208/// Decide whether `e` is permitted by `grants` (#207).
209///
210/// Grant strings come from `--allow-effects` and may be either:
211/// - `name` (bare wildcard, accepts any arg)
212/// - `name:arg` (string-arg specific grant — the colon is
213/// a CLI-friendly separator)
214/// - `name(arg)` (matches the canonical pretty form for
215/// grants written by hand or copy-pasted from
216/// error messages)
217///
218/// Bare absorbs specific; specific matches only an exactly-equal
219/// string arg. Int/Ident args on the declaration side are accepted
220/// only by their bare-name grants (no CLI form for them in v1 —
221/// they're rare in practice and can be added later).
222pub fn is_effect_allowed(grants: &BTreeSet<String>, e: &DeclaredEffect) -> bool {
223 grants.iter().any(|g| grant_subsumes(g, e))
224}
225
226fn grant_subsumes(grant: &str, e: &DeclaredEffect) -> bool {
227 // Accept three forms: "name", "name:arg", "name(arg)".
228 let (g_name, g_arg) = parse_grant(grant);
229 if g_name != e.kind { return false; }
230 match (g_arg, &e.arg) {
231 (None, _) => true, // bare absorbs anything
232 (Some(_), None) => false, // specific can't grant bare
233 (Some(g), Some(EffectArg::Str(d))) => g == d,
234 // Int / Ident args have no CLI form in v1; only bare grants
235 // satisfy them (handled by the (None, _) branch above).
236 (Some(_), Some(_)) => false,
237 }
238}
239
240/// Split `"mcp:ocpp"` or `"mcp(ocpp)"` into `("mcp", Some("ocpp"))`.
241/// Plain `"mcp"` returns `("mcp", None)`.
242fn parse_grant(s: &str) -> (&str, Option<&str>) {
243 if let Some((name, rest)) = s.split_once('(') {
244 if let Some(arg) = rest.strip_suffix(')') {
245 return (name, Some(arg.trim_matches('"')));
246 }
247 }
248 if let Some((name, arg)) = s.split_once(':') {
249 return (name, Some(arg));
250 }
251 (s, None)
252}