Skip to main content

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        ] {
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/// Structured policy violation, formatted to match spec §6.7's JSON shape.
65#[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    /// Effect kind that was disallowed, or `null`.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub effect: Option<String>,
73    /// Path that fell outside the allowlist, or `null`.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub path: Option<String>,
76    /// NodeId or function name; precise location of the offense.
77    #[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
111/// Walk the program's declared effects (gathered from fn signatures) and
112/// verify them against `policy`. Run before any execution.
113pub 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            // Effect kind allowlist.
123            if !policy.allow_effects.contains(&e.kind) {
124                violations.push(PolicyViolation::effect_not_allowed(&e.kind, &f.name));
125                continue;
126            }
127
128            // Scoped fs paths.
129            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            // Budget aggregation.
143            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}