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)]
18pub struct Policy {
19 pub allow_effects: BTreeSet<String>,
20 pub allow_fs_read: Vec<PathBuf>,
21 pub allow_fs_write: Vec<PathBuf>,
22 pub allow_net_host: Vec<String>,
29 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 "llm_local", "llm_cloud", "a2a", "mcp",
50 "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#[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 #[serde(skip_serializing_if = "Option::is_none")]
76 pub effect: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub path: Option<String>,
80 #[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
115pub 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 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 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 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
184fn 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
195pub 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 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, (Some(_), None) => false, (Some(g), Some(EffectArg::Str(d))) => g == d,
221 (Some(_), Some(_)) => false,
224 }
225}
226
227fn 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}