Skip to main content

act_types/
capability.rs

1//! Uniform capability model (act:core ยง3.1).
2//!
3//! One envelope (`CapabilityRequest`) describes every capability class โ€”
4//! filesystem, http, sockets, inter-component, semantic, or plugin-provided.
5//! The per-class difference lives in `constraints` (a provider-defined,
6//! opaque-at-this-layer JSON predicate), not in separate Rust types.
7
8use std::collections::BTreeMap;
9
10use serde_json::Value;
11
12use crate::{LocalizedString, constants::CAP_FILESYSTEM};
13
14/// A provider-defined constraint predicate, opaque at the `act:core` layer.
15/// Each capability provider supplies the JSON Schema that validates it.
16/// E.g. filesystem `{ "path": "...", "mode": "ro" }`, http `{ "host": "..." }`,
17/// a semantic class `{ "database": "staging_*" }`.
18pub type Constraint = Value;
19
20/// Uniform capability request โ€” one entry per capability class in the
21/// `act:component` `std.capabilities` map.
22#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
23pub struct CapabilityRequest {
24    /// Human/LLM-facing rationale (drives the enrollment UI and audit).
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub description: Option<LocalizedString>,
27    /// Class-specific scalar parameters, e.g. filesystem `mount-root`.
28    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
29    pub params: BTreeMap<String, Value>,
30    /// The self-declared ceiling (allow-only; deny lives on the host grant).
31    /// `allow` is accepted as an alias so existing `act.toml` parses.
32    #[serde(default, alias = "allow", skip_serializing_if = "Vec::is_empty")]
33    pub constraints: Vec<Constraint>,
34}
35
36impl CapabilityRequest {
37    /// Parse this request's constraints into a typed constraint schema
38    /// (e.g. `FilesystemAllow`). Used by host providers.
39    pub fn constraints_as<T: serde::de::DeserializeOwned>(
40        &self,
41    ) -> Result<Vec<T>, serde_json::Error> {
42        self.constraints
43            .iter()
44            .map(|c| serde_json::from_value::<T>(c.clone()))
45            .collect()
46    }
47}
48
49/// Capability declarations from the `std.capabilities` map in `act:component`.
50/// Serializes transparently as a CBOR/JSON map keyed by capability id.
51#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
52#[serde(transparent)]
53pub struct Capabilities(pub BTreeMap<String, CapabilityRequest>);
54
55impl Capabilities {
56    /// True if no capabilities are declared.
57    pub fn is_empty(&self) -> bool {
58        self.0.is_empty()
59    }
60
61    /// Whether a capability id is declared.
62    pub fn has(&self, id: &str) -> bool {
63        self.0.contains_key(id)
64    }
65
66    /// The request for a capability id, if declared.
67    pub fn get(&self, id: &str) -> Option<&CapabilityRequest> {
68        self.0.get(id)
69    }
70
71    /// The `mount-root` param of `wasi:filesystem`, if present.
72    pub fn fs_mount_root(&self) -> Option<&str> {
73        self.0
74            .get(CAP_FILESYSTEM)?
75            .params
76            .get("mount-root")?
77            .as_str()
78    }
79
80    /// Parse `wasi:filesystem` `params.mounts` into typed entries.
81    /// Returns an empty vec when the cap or the `mounts` param is absent.
82    pub fn fs_mounts(&self) -> Result<Vec<crate::FilesystemMount>, serde_json::Error> {
83        match self
84            .0
85            .get(CAP_FILESYSTEM)
86            .and_then(|r| r.params.get("mounts"))
87        {
88            Some(v) => serde_json::from_value(v.clone()),
89            None => Ok(Vec::new()),
90        }
91    }
92
93    /// Iterate over (id, request) pairs.
94    pub fn iter(&self) -> impl Iterator<Item = (&String, &CapabilityRequest)> {
95        self.0.iter()
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn request_serde_skips_empty_and_aliases_allow() {
105        let req = CapabilityRequest {
106            constraints: vec![serde_json::json!({ "host": "*" })],
107            ..Default::default()
108        };
109        let v = serde_json::to_value(&req).unwrap();
110        assert_eq!(v, serde_json::json!({ "constraints": [{ "host": "*" }] }));
111
112        // `allow` is accepted on input and lands in `constraints`.
113        let from_allow: CapabilityRequest =
114            serde_json::from_value(serde_json::json!({ "allow": [{ "host": "x" }] })).unwrap();
115        assert_eq!(
116            from_allow.constraints,
117            vec![serde_json::json!({ "host": "x" })]
118        );
119    }
120
121    #[test]
122    fn constraints_as_parses_typed() {
123        use crate::FilesystemAllow;
124        let req = CapabilityRequest {
125            constraints: vec![serde_json::json!({ "path": "/x/**", "mode": "rw" })],
126            ..Default::default()
127        };
128        let parsed = req.constraints_as::<FilesystemAllow>().unwrap();
129        assert_eq!(parsed.len(), 1);
130        assert_eq!(parsed[0].path, "/x/**");
131    }
132
133    #[test]
134    fn description_round_trips_as_bare_string() {
135        // act.toml writes `description = "..."` โ€” a bare string must parse into Plain
136        // and serialize back to a bare string (not {"Plain": "..."}).
137        let req: CapabilityRequest =
138            serde_json::from_value(serde_json::json!({ "description": "hello" })).unwrap();
139        let v = serde_json::to_value(&req).unwrap();
140        assert_eq!(v, serde_json::json!({ "description": "hello" }));
141    }
142
143    #[test]
144    fn fs_mounts_parses_params_mounts() {
145        use crate::MountType;
146        let mut caps = Capabilities::default();
147        caps.0.insert(
148            "wasi:filesystem".into(),
149            CapabilityRequest {
150                params: {
151                    let mut p = BTreeMap::new();
152                    p.insert(
153                        "mounts".to_string(),
154                        serde_json::json!([{ "guest": "/ows", "host": "~/.ows" }]),
155                    );
156                    p
157                },
158                ..Default::default()
159            },
160        );
161        let mounts = caps.fs_mounts().unwrap();
162        assert_eq!(mounts.len(), 1);
163        assert_eq!(mounts[0].kind, MountType::Bind);
164        assert_eq!(mounts[0].guest.as_deref(), Some("/ows"));
165    }
166
167    #[test]
168    fn fs_mounts_absent_is_empty() {
169        let caps = Capabilities::default();
170        assert!(caps.fs_mounts().unwrap().is_empty());
171    }
172
173    #[test]
174    fn capabilities_cbor_is_map_keyed_by_id() {
175        use crate::cbor;
176        let mut caps = Capabilities::default();
177        caps.0.insert(
178            "wasi:filesystem".into(),
179            CapabilityRequest {
180                constraints: vec![serde_json::json!({ "path": "/data/**", "mode": "rw" })],
181                ..Default::default()
182            },
183        );
184
185        let bytes = cbor::to_cbor(&caps);
186        let back: Capabilities = cbor::from_cbor(&bytes).unwrap();
187
188        assert!(back.has("wasi:filesystem"));
189        assert_eq!(
190            back.get("wasi:filesystem").unwrap().constraints,
191            vec![serde_json::json!({ "path": "/data/**", "mode": "rw" })]
192        );
193    }
194}