Skip to main content

assay/lua/policy/
mod.rs

1//! Capability policy: what a script may require, read from the environment,
2//! and send over HTTP, enforced inside the builtins.
3//!
4//! Orthogonal to `ExecMode` — a policy narrows what is reachable, the mode
5//! decides whether a mutating operation runs, suspends, or is refused. With
6//! no policy loaded every check passes and behaviour is unchanged.
7
8pub mod apply;
9mod glob;
10mod redact;
11mod schema;
12
13use std::collections::HashSet;
14use std::sync::Arc;
15
16use mlua::Lua;
17
18pub use redact::{is_redacted_header, redact_json_text};
19pub use schema::Classify;
20use schema::{HttpRule, PolicyFile};
21
22/// Path to a policy file applied to every VM this process creates. Follows
23/// the same env-driven pattern as the other sandbox knobs.
24pub const POLICY_FILE_ENV: &str = "ASSAY_POLICY_FILE";
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Classification {
28    Read,
29    Write,
30}
31
32#[derive(Debug, Default)]
33pub struct Policy {
34    module_allow: Option<HashSet<String>>,
35    env_allow: Option<HashSet<String>>,
36    http_rules: Option<Vec<HttpRule>>,
37    max_response_bytes: Option<usize>,
38    redact: Vec<String>,
39}
40
41#[derive(Clone)]
42pub struct PolicyHandle(pub Arc<Policy>);
43
44/// The active policy for this VM, or `None` when the process runs unpoliced.
45pub fn active(lua: &Lua) -> Option<Arc<Policy>> {
46    lua.app_data_ref::<PolicyHandle>()
47        .map(|handle| Arc::clone(&handle.0))
48}
49
50pub fn install(lua: &Lua, policy: Arc<Policy>) {
51    lua.set_app_data(PolicyHandle(policy));
52}
53
54pub fn env_visible(lua: &Lua, key: &str) -> bool {
55    active(lua).is_none_or(|p| p.env_visible(key))
56}
57
58pub fn guard_require(lua: &Lua, module: &str) -> mlua::Result<()> {
59    match active(lua) {
60        Some(p) if !p.module_allowed(module) => Err(mlua::Error::runtime(format!(
61            "policy: module '{module}' is not in the allowed set"
62        ))),
63        _ => Ok(()),
64    }
65}
66
67pub fn guard_http(lua: &Lua, method: &str, url: &str) -> mlua::Result<()> {
68    match active(lua) {
69        Some(p) => p
70            .check_http(method, url)
71            .map(|_| ())
72            .map_err(mlua::Error::runtime),
73        None => Ok(()),
74    }
75}
76
77/// Whether the policy treats this request as a read. Drives the gates, so a
78/// declared authentication POST can proceed under read-only mode.
79pub fn is_read(lua: &Lua, method: &str, url: &str) -> bool {
80    active(lua)
81        .and_then(|p| p.check_http(method, url).ok())
82        .is_some_and(|c| c == Classification::Read)
83}
84
85pub fn response_limit(lua: &Lua) -> Option<usize> {
86    active(lua).and_then(|p| p.max_response_bytes())
87}
88
89pub fn redact_keys(lua: &Lua) -> Vec<String> {
90    active(lua)
91        .map(|p| p.redact_keys().to_vec())
92        .unwrap_or_default()
93}
94
95pub fn from_env() -> Result<Option<Arc<Policy>>, String> {
96    let Some(path) = std::env::var(POLICY_FILE_ENV)
97        .ok()
98        .filter(|p| !p.is_empty())
99    else {
100        return Ok(None);
101    };
102    Ok(Some(Arc::new(Policy::load(&path)?)))
103}
104
105impl Policy {
106    pub fn load(path: &str) -> Result<Self, String> {
107        let source = std::fs::read_to_string(path)
108            .map_err(|e| format!("policy: cannot read {path}: {e}"))?;
109        Self::parse(&source)
110    }
111
112    pub fn parse(source: &str) -> Result<Self, String> {
113        let file = PolicyFile::parse(source)?;
114        let http = file.http;
115        Ok(Policy {
116            module_allow: file
117                .modules
118                .and_then(|m| m.allow)
119                .map(|list| list.into_iter().collect()),
120            env_allow: file
121                .env
122                .map(|e| e.allow.into_iter().collect::<HashSet<String>>()),
123            max_response_bytes: http.as_ref().and_then(|h| h.max_response_bytes),
124            redact: http.as_ref().map(|h| h.redact.clone()).unwrap_or_default(),
125            http_rules: http.and_then(|h| h.rules),
126        })
127    }
128
129    pub fn module_allowed(&self, module: &str) -> bool {
130        match &self.module_allow {
131            Some(allow) => allow.contains(module),
132            None => true,
133        }
134    }
135
136    pub fn env_visible(&self, key: &str) -> bool {
137        match &self.env_allow {
138            Some(allow) => allow.contains(key),
139            None => true,
140        }
141    }
142
143    pub fn max_response_bytes(&self) -> Option<usize> {
144        self.max_response_bytes
145    }
146
147    pub fn redact_keys(&self) -> &[String] {
148        &self.redact
149    }
150
151    pub fn check_http(&self, method: &str, url: &str) -> Result<Classification, String> {
152        let Some(rules) = &self.http_rules else {
153            return Ok(default_classification(method));
154        };
155        let parsed = url::Url::parse(url)
156            .map_err(|e| format!("policy: cannot parse request URL '{url}': {e}"))?;
157        let host = parsed
158            .host_str()
159            .ok_or_else(|| format!("policy: request URL '{url}' has no host"))?;
160        let path = parsed.path();
161
162        for rule in rules {
163            if rule_matches(rule, host, method, path) {
164                return Ok(match rule.classify {
165                    Some(Classify::Read) => Classification::Read,
166                    Some(Classify::Write) => Classification::Write,
167                    None => default_classification(method),
168                });
169            }
170        }
171        Err(format!(
172            "policy: {} {host}{path} is not allowed by any http rule",
173            method.to_ascii_uppercase()
174        ))
175    }
176}
177
178fn rule_matches(rule: &HttpRule, host: &str, method: &str, path: &str) -> bool {
179    let host_ok = rule.hosts.iter().any(|h| glob::host_matches(h, host));
180    let method_ok = rule.methods.is_empty()
181        || rule
182            .methods
183            .iter()
184            .any(|m| m.trim().eq_ignore_ascii_case(method));
185    let path_ok = rule.paths.is_empty() || rule.paths.iter().any(|p| glob::path_matches(p, path));
186    host_ok && method_ok && path_ok
187}
188
189fn default_classification(method: &str) -> Classification {
190    if method.eq_ignore_ascii_case("get") {
191        Classification::Read
192    } else {
193        Classification::Write
194    }
195}