use indexmap::IndexMap;
use lex_bytecode::program::{DeclaredEffect, EffectArg, Program};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default)]
pub struct Policy {
pub allow_effects: BTreeSet<String>,
pub allow_fs_read: Vec<PathBuf>,
pub allow_fs_write: Vec<PathBuf>,
pub allow_net_host: Vec<String>,
pub allow_proc: Vec<String>,
pub allow_approval: Vec<String>,
pub budget: Option<u64>,
}
pub const KNOWN_EFFECTS: &[(&str, &str)] = &[
("io", "console / stdio"),
(
"net",
"sockets + outbound HTTP; scope to a host (`net(\"host\")`) where possible",
),
("time", "clocks — non-deterministic"),
("llm", "LLM inference"),
("proc", "subprocess execution"),
(
"proc_exit",
"std.process.exit — sets this process's exit status (#754)",
),
("panic", "may abort"),
("fs_read", "filesystem reads; scopable to a path"),
("fs_write", "filesystem writes; scopable to a path"),
(
"budget",
"annotated cost `budget(N)`; checked against `--budget`",
),
("llm_local", "local model inference (#184)"),
("llm_cloud", "cloud model inference (#184)"),
("a2a", "agent-to-agent protocol calls (#184)"),
("mcp", "MCP client calls (#184)"),
(
"env",
"environment-variable access (#216); flat `[env]` is the v1 surface",
),
("sql", "std.sql database access (#362, #379)"),
("random", "crypto.random / crypto.random_str_hex (#382)"),
("chat", "chat.broadcast / chat.send (#359)"),
("log", "std.log structured logging"),
("kv", "std.kv key-value store"),
("stream", "std.stream"),
("fs_walk", "std.fs directory traversal"),
("concurrent", "conc.spawn / conc.ask / conc.tell (#381)"),
("crypto", "std.crypto hashing / signing (#562, #582)"),
("vcs", "std.vcs content-addressed blob store (lex-loom#198)"),
(
"approval",
"std.approval human-in-the-loop boundary; scope checked against `--allow-approval` (#737)",
),
(
"moe",
"std.moe expert-store placement ops — pin/unpin/prefetch_hint/usage_snapshot/stats (lex-moe#25)",
),
];
impl Policy {
pub fn pure() -> Self {
Self::default()
}
pub fn wildcard_scoped_grants(&self) -> Vec<&'static str> {
let mut open = Vec::new();
if self.allow_effects.contains("proc") && self.allow_proc.is_empty() {
open.push("proc");
}
if self.allow_effects.contains("net") && self.allow_net_host.is_empty() {
open.push("net");
}
if self.allow_effects.contains("fs_read") && self.allow_fs_read.is_empty() {
open.push("fs_read");
}
if self.allow_effects.contains("fs_write") && self.allow_fs_write.is_empty() {
open.push("fs_write");
}
if self.allow_effects.contains("approval") && self.allow_approval.is_empty() {
open.push("approval");
}
open
}
pub fn permissive() -> Self {
let mut s = BTreeSet::new();
for (k, _) in KNOWN_EFFECTS {
s.insert(k.to_string());
}
Self {
allow_effects: s,
allow_fs_read: Vec::new(),
allow_fs_write: Vec::new(),
allow_net_host: Vec::new(),
allow_proc: Vec::new(),
allow_approval: Vec::new(),
budget: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
#[error("policy violation: {kind} {detail}")]
pub struct PolicyViolation {
pub kind: String,
pub detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub effect: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub at: Option<String>,
}
impl PolicyViolation {
pub fn effect_not_allowed(effect: &str, at: impl Into<String>) -> Self {
Self {
kind: "effect_not_allowed".into(),
detail: format!("effect `{effect}` not in --allow-effects"),
effect: Some(effect.into()),
path: None,
at: Some(at.into()),
}
}
pub fn fs_path_not_allowed(effect: &str, path: &str, at: impl Into<String>) -> Self {
Self {
kind: "fs_path_not_allowed".into(),
detail: format!("path `{path}` outside --allow-{effect}"),
effect: Some(effect.into()),
path: Some(path.into()),
at: Some(at.into()),
}
}
pub fn budget_exceeded(declared: u64, ceiling: u64) -> Self {
Self {
kind: "budget_exceeded".into(),
detail: format!("declared budget {declared} exceeds ceiling {ceiling}"),
effect: Some("budget".into()),
path: None,
at: None,
}
}
}
pub fn check_program(
program: &Program,
policy: &Policy,
) -> Result<PolicyReport, Vec<PolicyViolation>> {
let mut violations = Vec::new();
let mut total_budget: u64 = 0;
let mut declared_effects: IndexMap<String, Vec<DeclaredEffect>> = IndexMap::new();
for f in &program.functions {
for e in &f.effects {
declared_effects
.entry(f.name.clone())
.or_default()
.push(e.clone());
if !is_effect_allowed(&policy.allow_effects, e) {
violations.push(PolicyViolation::effect_not_allowed(
&declared_effect_pretty(e),
&f.name,
));
continue;
}
if e.kind == "fs_read" || e.kind == "fs_write" {
if let Some(EffectArg::Str(path)) = &e.arg {
let allowlist = if e.kind == "fs_read" {
&policy.allow_fs_read
} else {
&policy.allow_fs_write
};
if !path_under_any(path, allowlist) {
violations
.push(PolicyViolation::fs_path_not_allowed(&e.kind, path, &f.name));
}
}
}
if e.kind == "budget" {
if let Some(EffectArg::Int(n)) = &e.arg {
if *n >= 0 {
total_budget = total_budget.saturating_add(*n as u64);
}
}
}
}
}
if let Some(ceiling) = policy.budget {
if total_budget > ceiling {
violations.push(PolicyViolation::budget_exceeded(total_budget, ceiling));
}
}
if violations.is_empty() {
Ok(PolicyReport {
declared_effects,
total_budget,
})
} else {
Err(violations)
}
}
#[derive(Debug, Clone)]
pub struct PolicyReport {
pub declared_effects: IndexMap<String, Vec<DeclaredEffect>>,
pub total_budget: u64,
}
fn path_under_any(p: &str, list: &[PathBuf]) -> bool {
let candidate = Path::new(p);
list.iter().any(|allowed| candidate.starts_with(allowed))
}
fn declared_effect_pretty(e: &DeclaredEffect) -> String {
match &e.arg {
None => e.kind.clone(),
Some(EffectArg::Str(s)) => format!("{}(\"{}\")", e.kind, s),
Some(EffectArg::Int(n)) => format!("{}({})", e.kind, n),
Some(EffectArg::Ident(s)) => format!("{}({})", e.kind, s),
}
}
pub fn is_effect_allowed(grants: &BTreeSet<String>, e: &DeclaredEffect) -> bool {
grants.iter().any(|g| grant_subsumes(g, e))
}
fn grant_subsumes(grant: &str, e: &DeclaredEffect) -> bool {
let (g_name, g_arg) = parse_grant(grant);
if g_name != e.kind {
return false;
}
match (g_arg, &e.arg) {
(None, _) => true, (Some(_), None) => false, (Some(g), Some(EffectArg::Str(d))) => g == d,
(Some(_), Some(_)) => false,
}
}
fn parse_grant(s: &str) -> (&str, Option<&str>) {
if let Some((name, rest)) = s.split_once('(') {
if let Some(arg) = rest.strip_suffix(')') {
return (name, Some(arg.trim_matches('"')));
}
}
if let Some((name, arg)) = s.split_once(':') {
return (name, Some(arg));
}
(s, None)
}
#[cfg(test)]
mod wildcard_tests {
use super::*;
fn effects(kinds: &[&str]) -> BTreeSet<String> {
kinds.iter().map(|s| s.to_string()).collect()
}
#[test]
fn flags_scoped_grants_left_open() {
let p = Policy {
allow_effects: effects(&["proc", "fs_read", "time"]),
..Policy::default()
};
let open = p.wildcard_scoped_grants();
assert!(
open.contains(&"proc"),
"empty allow_proc + [proc] is wide open"
);
assert!(
open.contains(&"fs_read"),
"empty allow_fs_read + [fs_read] is wide open"
);
assert!(!open.contains(&"time"));
assert!(!open.contains(&"net"));
}
#[test]
fn populated_scope_is_not_flagged() {
let p = Policy {
allow_effects: effects(&["fs_read", "net"]),
allow_fs_read: vec![PathBuf::from("/srv/data")],
allow_net_host: vec!["api.example.com".into()],
..Policy::default()
};
assert!(p.wildcard_scoped_grants().is_empty());
}
#[test]
fn pure_and_unscoped_effects_are_clean() {
assert!(Policy::pure().wildcard_scoped_grants().is_empty());
let p = Policy {
allow_effects: effects(&["time", "random", "panic"]),
..Policy::default()
};
assert!(p.wildcard_scoped_grants().is_empty());
}
}