1use indexmap::IndexMap;
11use lex_bytecode::program::{DeclaredEffect, EffectArg, Program};
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeSet;
14use std::path::{Path, PathBuf};
15
16#[derive(Debug, Clone, Default)]
30pub struct Policy {
31 pub allow_effects: BTreeSet<String>,
32 pub allow_fs_read: Vec<PathBuf>,
35 pub allow_fs_write: Vec<PathBuf>,
38 pub allow_net_host: Vec<String>,
45 pub allow_proc: Vec<String>,
53 pub allow_approval: Vec<String>,
61 pub budget: Option<u64>,
62}
63
64pub 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 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#[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 #[serde(skip_serializing_if = "Option::is_none")]
179 pub effect: Option<String>,
180 #[serde(skip_serializing_if = "Option::is_none")]
182 pub path: Option<String>,
183 #[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
218pub 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 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 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 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
301fn 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
312pub 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 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, (Some(_), None) => false, (Some(g), Some(EffectArg::Str(d))) => g == d,
340 (Some(_), Some(_)) => false,
343 }
344}
345
346fn 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 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}