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    /// Iterate over (id, request) pairs.
81    pub fn iter(&self) -> impl Iterator<Item = (&String, &CapabilityRequest)> {
82        self.0.iter()
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn request_serde_skips_empty_and_aliases_allow() {
92        let req = CapabilityRequest {
93            constraints: vec![serde_json::json!({ "host": "*" })],
94            ..Default::default()
95        };
96        let v = serde_json::to_value(&req).unwrap();
97        assert_eq!(v, serde_json::json!({ "constraints": [{ "host": "*" }] }));
98
99        // `allow` is accepted on input and lands in `constraints`.
100        let from_allow: CapabilityRequest =
101            serde_json::from_value(serde_json::json!({ "allow": [{ "host": "x" }] })).unwrap();
102        assert_eq!(
103            from_allow.constraints,
104            vec![serde_json::json!({ "host": "x" })]
105        );
106    }
107
108    #[test]
109    fn constraints_as_parses_typed() {
110        use crate::FilesystemAllow;
111        let req = CapabilityRequest {
112            constraints: vec![serde_json::json!({ "path": "/x/**", "mode": "rw" })],
113            ..Default::default()
114        };
115        let parsed = req.constraints_as::<FilesystemAllow>().unwrap();
116        assert_eq!(parsed.len(), 1);
117        assert_eq!(parsed[0].path, "/x/**");
118    }
119
120    #[test]
121    fn description_round_trips_as_bare_string() {
122        // act.toml writes `description = "..."` โ€” a bare string must parse into Plain
123        // and serialize back to a bare string (not {"Plain": "..."}).
124        let req: CapabilityRequest =
125            serde_json::from_value(serde_json::json!({ "description": "hello" })).unwrap();
126        let v = serde_json::to_value(&req).unwrap();
127        assert_eq!(v, serde_json::json!({ "description": "hello" }));
128    }
129
130    #[test]
131    fn capabilities_cbor_is_map_keyed_by_id() {
132        use crate::cbor;
133        let mut caps = Capabilities::default();
134        caps.0.insert(
135            "wasi:filesystem".into(),
136            CapabilityRequest {
137                constraints: vec![serde_json::json!({ "path": "/data/**", "mode": "rw" })],
138                ..Default::default()
139            },
140        );
141
142        let bytes = cbor::to_cbor(&caps);
143        let back: Capabilities = cbor::from_cbor(&bytes).unwrap();
144
145        assert!(back.has("wasi:filesystem"));
146        assert_eq!(
147            back.get("wasi:filesystem").unwrap().constraints,
148            vec![serde_json::json!({ "path": "/data/**", "mode": "rw" })]
149        );
150    }
151}