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 ] {
51 s.insert(k.to_string());
52 }
53 Self {
54 allow_effects: s,
55 allow_fs_read: Vec::new(),
56 allow_fs_write: Vec::new(),
57 allow_net_host: Vec::new(),
58 allow_proc: Vec::new(),
59 budget: None,
60 }
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
66#[error("policy violation: {kind} {detail}")]
67pub struct PolicyViolation {
68 pub kind: String,
69 pub detail: String,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub effect: Option<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub path: Option<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub at: Option<String>,
79}
80
81impl PolicyViolation {
82 pub fn effect_not_allowed(effect: &str, at: impl Into<String>) -> Self {
83 Self {
84 kind: "effect_not_allowed".into(),
85 detail: format!("effect `{effect}` not in --allow-effects"),
86 effect: Some(effect.into()),
87 path: None,
88 at: Some(at.into()),
89 }
90 }
91 pub fn fs_path_not_allowed(effect: &str, path: &str, at: impl Into<String>) -> Self {
92 Self {
93 kind: "fs_path_not_allowed".into(),
94 detail: format!("path `{path}` outside --allow-{effect}"),
95 effect: Some(effect.into()),
96 path: Some(path.into()),
97 at: Some(at.into()),
98 }
99 }
100 pub fn budget_exceeded(declared: u64, ceiling: u64) -> Self {
101 Self {
102 kind: "budget_exceeded".into(),
103 detail: format!("declared budget {declared} exceeds ceiling {ceiling}"),
104 effect: Some("budget".into()),
105 path: None,
106 at: None,
107 }
108 }
109}
110
111pub fn check_program(program: &Program, policy: &Policy) -> Result<PolicyReport, Vec<PolicyViolation>> {
114 let mut violations = Vec::new();
115 let mut total_budget: u64 = 0;
116 let mut declared_effects: IndexMap<String, Vec<DeclaredEffect>> = IndexMap::new();
117
118 for f in &program.functions {
119 for e in &f.effects {
120 declared_effects.entry(f.name.clone()).or_default().push(e.clone());
121
122 if !policy.allow_effects.contains(&e.kind) {
124 violations.push(PolicyViolation::effect_not_allowed(&e.kind, &f.name));
125 continue;
126 }
127
128 if e.kind == "fs_read" || e.kind == "fs_write" {
130 if let Some(EffectArg::Str(path)) = &e.arg {
131 let allowlist = if e.kind == "fs_read" {
132 &policy.allow_fs_read
133 } else {
134 &policy.allow_fs_write
135 };
136 if !path_under_any(path, allowlist) {
137 violations.push(PolicyViolation::fs_path_not_allowed(&e.kind, path, &f.name));
138 }
139 }
140 }
141
142 if e.kind == "budget" {
144 if let Some(EffectArg::Int(n)) = &e.arg {
145 if *n >= 0 { total_budget = total_budget.saturating_add(*n as u64); }
146 }
147 }
148 }
149 }
150
151 if let Some(ceiling) = policy.budget {
152 if total_budget > ceiling {
153 violations.push(PolicyViolation::budget_exceeded(total_budget, ceiling));
154 }
155 }
156
157 if violations.is_empty() {
158 Ok(PolicyReport { declared_effects, total_budget })
159 } else {
160 Err(violations)
161 }
162}
163
164#[derive(Debug, Clone)]
165pub struct PolicyReport {
166 pub declared_effects: IndexMap<String, Vec<DeclaredEffect>>,
167 pub total_budget: u64,
168}
169
170fn path_under_any(p: &str, list: &[PathBuf]) -> bool {
171 let candidate = Path::new(p);
172 list.iter().any(|allowed| candidate.starts_with(allowed))
173}