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