Skip to main content

act_policy/
grant.rs

1//! Grant and policy config types: `PolicyMode`, capability grant shapes,
2//! and the per-class config structs produced by the mapper functions.
3
4use std::collections::BTreeMap;
5
6use serde::Deserialize;
7
8// ── Error type ────────────────────────────────────────────────────────────────
9
10#[derive(Debug, thiserror::Error)]
11pub enum PolicyError {
12    #[error("invalid {cap} constraint: {source}")]
13    Constraint {
14        cap: &'static str,
15        #[source]
16        source: serde_json::Error,
17    },
18    #[error("invalid policy mode '{0}': expected deny / allowlist / open / ask")]
19    InvalidMode(String),
20    #[error("invalid glob {pat:?}: {source}")]
21    Glob {
22        pat: String,
23        #[source]
24        source: globset::Error,
25    },
26    #[error("capability {cap}: {source}")]
27    Capability {
28        cap: String,
29        #[source]
30        source: Box<PolicyError>,
31    },
32}
33
34// ── Policy mode ───────────────────────────────────────────────────────────────
35
36/// Policy mode, shared by filesystem, HTTP, and sockets.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum PolicyMode {
39    #[default]
40    Deny,
41    Allowlist,
42    Open,
43    /// Prompt the operator on first access to each key, remember the decision
44    /// for the session, and degrade to deny when no prompt channel exists
45    /// (headless / --mcp / non-TTY). A per-op gate layered on top of the
46    /// ceiling intersection.
47    Ask,
48}
49
50impl PolicyMode {
51    pub fn parse(s: &str) -> Result<Self, PolicyError> {
52        match s {
53            "deny" => Ok(Self::Deny),
54            "allowlist" => Ok(Self::Allowlist),
55            "open" => Ok(Self::Open),
56            "ask" => Ok(Self::Ask),
57            other => Err(PolicyError::InvalidMode(other.to_string())),
58        }
59    }
60}
61
62impl std::fmt::Display for PolicyMode {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.write_str(match self {
65            Self::Deny => "deny",
66            Self::Allowlist => "allowlist",
67            Self::Open => "open",
68            Self::Ask => "ask",
69        })
70    }
71}
72
73// ── Resolved config types ─────────────────────────────────────────────────────
74
75/// One entry in a filesystem allow list: a glob pattern plus the access mode
76/// the entry permits.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct FsAllow {
79    pub glob: String,
80    pub mode: act_types::FsMode,
81}
82
83/// Resolved filesystem policy for a component invocation.
84#[derive(Debug, Clone, Default)]
85pub struct FsConfig {
86    pub mode: PolicyMode,
87    pub allow: Vec<FsAllow>,
88    // Consumed by the per-op matcher in Layer 1 Phase C (custom WASI impl).
89    // Kept in the public struct so config + CLI parsing is end-to-end now.
90    #[allow(dead_code)]
91    pub deny: Vec<String>,
92}
93
94impl FsConfig {
95    #[allow(dead_code)]
96    pub fn deny() -> Self {
97        Self {
98            mode: PolicyMode::Deny,
99            ..Default::default()
100        }
101    }
102}
103
104/// Resolved HTTP policy for a component invocation.
105///
106/// `allow` / `deny` rules are consumed by the per-op matcher in Layer 1
107/// Phase C (custom `WasiHttpHooks::send_request`). Kept public so config +
108/// CLI parsing is end-to-end now.
109#[derive(Debug, Clone, Default)]
110pub struct HttpConfig {
111    pub mode: PolicyMode,
112    #[allow(dead_code)]
113    pub allow: Vec<HttpRule>,
114    #[allow(dead_code)]
115    pub deny: Vec<HttpRule>,
116}
117
118/// One allow-or-deny entry in an HTTP policy.
119#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
120pub struct HttpRule {
121    /// Host / port / CIDR fields. Network-level (no HTTP awareness).
122    #[serde(flatten)]
123    pub net: crate::net::NetworkRule,
124    /// Required URI scheme (`"http"` / `"https"`), if set.
125    #[serde(default)]
126    pub scheme: Option<String>,
127    /// Allowed HTTP methods (case-insensitive), if set.
128    #[serde(default)]
129    pub methods: Option<Vec<String>>,
130}
131
132/// Resolved sockets policy for a component invocation.
133#[derive(Debug, Clone, Default)]
134#[allow(dead_code)] // consumed by sockets_policy + Task 5 wiring
135pub struct SocketsConfig {
136    pub mode: PolicyMode,
137    pub allow: Vec<SocketsRule>,
138    pub deny: Vec<SocketsRule>,
139}
140
141/// One allow-or-deny entry in a sockets policy.
142#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
143pub struct SocketsRule {
144    /// Host / port / CIDR fields. Reuses the network-rule shape.
145    #[serde(flatten)]
146    pub net: crate::net::NetworkRule,
147    /// Restrict to specific protocols. None = any (default for user
148    /// rules); declarations always carry an explicit list.
149    #[serde(default)]
150    pub protocols: Option<Vec<act_types::SocketProtocol>>,
151}
152
153// ── Uniform grant types ───────────────────────────────────────────────────────
154
155/// A grant for one capability id or pattern. Constraints are provider-defined
156/// JSON (the `act:core` constraint shape). Modes: deny/allowlist/open.
157#[derive(Debug, Clone, Default)]
158pub struct CapabilityGrant {
159    pub mode: PolicyMode,
160    pub allow: Vec<serde_json::Value>,
161    pub deny: Vec<serde_json::Value>,
162}
163
164/// Resolved host grant policy: a global default + per-id/pattern entries.
165#[derive(Debug, Clone)]
166pub struct GrantPolicy {
167    pub default: PolicyMode,
168    pub entries: BTreeMap<String, CapabilityGrant>,
169}
170
171impl Default for GrantPolicy {
172    fn default() -> Self {
173        Self {
174            // Ask-by-default: an undeclared `[policy] default` resolves to
175            // `ask`, so interactive runs prompt-on-access and headless runs
176            // degrade to deny. The `PolicyMode` enum `Default` stays `Deny`
177            // (used for empty single-layer configs elsewhere).
178            default: PolicyMode::Ask,
179            entries: BTreeMap::new(),
180        }
181    }
182}
183
184impl GrantPolicy {
185    /// Resolve the effective grant for a concrete capability id.
186    /// Priority: exact entry > longest matching `*`-prefix entry > default.
187    pub fn resolve(&self, id: &str) -> CapabilityGrant {
188        if let Some(g) = self.entries.get(id) {
189            return g.clone();
190        }
191        let mut best: Option<(&str, &CapabilityGrant)> = None;
192        for (k, g) in &self.entries {
193            if let Some(prefix) = k.strip_suffix('*')
194                && id.starts_with(prefix)
195                && best.is_none_or(|(bk, _)| prefix.len() > bk.len() - 1)
196            {
197                best = Some((k, g));
198            }
199        }
200        if let Some((_, g)) = best {
201            return g.clone();
202        }
203        CapabilityGrant {
204            mode: self.default,
205            allow: vec![],
206            deny: vec![],
207        }
208    }
209}
210
211// ── Mapper functions ──────────────────────────────────────────────────────────
212
213/// Map the `wasi:filesystem` grant to the enforcement-facing `FsConfig`.
214pub fn to_fs_config(gp: &GrantPolicy) -> Result<FsConfig, PolicyError> {
215    let g = gp.resolve(act_types::constants::CAP_FILESYSTEM);
216    let allow = parse_fs_allow_constraints(&g.allow)?;
217    let deny = parse_fs_deny_constraints(&g.deny)?;
218    Ok(FsConfig {
219        mode: g.mode,
220        allow,
221        deny,
222    })
223}
224
225fn parse_fs_allow_constraints(cs: &[serde_json::Value]) -> Result<Vec<FsAllow>, PolicyError> {
226    cs.iter()
227        .map(|c| {
228            let a: act_types::FilesystemAllow =
229                serde_json::from_value(c.clone()).map_err(|e| PolicyError::Constraint {
230                    cap: "wasi:filesystem",
231                    source: e,
232                })?;
233            Ok(FsAllow {
234                glob: a.path,
235                mode: a.mode,
236            })
237        })
238        .collect()
239}
240
241fn parse_fs_deny_constraints(cs: &[serde_json::Value]) -> Result<Vec<String>, PolicyError> {
242    cs.iter()
243        .map(|c| {
244            let a: act_types::FilesystemAllow =
245                serde_json::from_value(c.clone()).map_err(|e| PolicyError::Constraint {
246                    cap: "wasi:filesystem",
247                    source: e,
248                })?;
249            Ok(a.path)
250        })
251        .collect()
252}
253
254/// Map the `wasi:http` grant to `HttpConfig` (constraints → `HttpRule`).
255pub fn to_http_config(gp: &GrantPolicy) -> Result<HttpConfig, PolicyError> {
256    let g = gp.resolve(act_types::constants::CAP_HTTP);
257    Ok(HttpConfig {
258        mode: g.mode,
259        allow: parse_http_constraints(&g.allow)?,
260        deny: parse_http_constraints(&g.deny)?,
261    })
262}
263
264fn parse_http_constraints(cs: &[serde_json::Value]) -> Result<Vec<HttpRule>, PolicyError> {
265    cs.iter()
266        .map(|c| {
267            serde_json::from_value::<HttpRule>(c.clone()).map_err(|e| PolicyError::Constraint {
268                cap: "wasi:http",
269                source: e,
270            })
271        })
272        .collect()
273}
274
275/// Map the `wasi:sockets` grant to `SocketsConfig` (constraints → `SocketsRule`).
276pub fn to_sockets_config(gp: &GrantPolicy) -> Result<SocketsConfig, PolicyError> {
277    let g = gp.resolve(act_types::constants::CAP_SOCKETS);
278    Ok(SocketsConfig {
279        mode: g.mode,
280        allow: parse_sockets_constraints(&g.allow)?,
281        deny: parse_sockets_constraints(&g.deny)?,
282    })
283}
284
285fn parse_sockets_constraints(cs: &[serde_json::Value]) -> Result<Vec<SocketsRule>, PolicyError> {
286    cs.iter()
287        .map(|c| {
288            serde_json::from_value::<SocketsRule>(c.clone()).map_err(|e| PolicyError::Constraint {
289                cap: "wasi:sockets",
290                source: e,
291            })
292        })
293        .collect()
294}
295
296#[cfg(test)]
297mod tests {
298    use super::PolicyMode;
299
300    #[test]
301    fn policy_mode_display_renders_the_config_spellings() {
302        // These are the exact strings an operator reads in an audit line and
303        // the config file accepts under `[policy]` — they must match.
304        assert_eq!(PolicyMode::Deny.to_string(), "deny");
305        assert_eq!(PolicyMode::Allowlist.to_string(), "allowlist");
306        assert_eq!(PolicyMode::Open.to_string(), "open");
307        assert_eq!(PolicyMode::Ask.to_string(), "ask");
308    }
309}