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