Skip to main content

alien_core/
sandbox_capability.rs

1//! Sandbox capabilities: what the manager mints and the agent verifies.
2//!
3//! Lives here because both sides need identical rules, and a mismatch between minting and
4//! verification is a security bug that only shows up as "it works" until it does not.
5//!
6//! A capability is scoped to **one sandbox and one operation class**. Provider ids and hostnames
7//! are guessable, so neither is authorisation.
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::{ErrorData, Result};
12use alien_error::AlienError;
13
14/// What a capability permits. Deliberately coarse: a class, not a method list, so adding a
15/// method cannot silently widen an already-minted capability.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
18#[serde(rename_all = "camelCase")]
19pub enum SandboxOperationClass {
20    /// Running commands and moving files inside an existing sandbox
21    Execute,
22    /// Creating and terminating sandboxes
23    Manage,
24}
25
26/// The claims an agent checks before doing anything.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
29#[serde(rename_all = "camelCase")]
30pub struct SandboxCapabilityClaims {
31    /// Sandbox this capability addresses
32    pub session_id: String,
33    /// Operation class permitted
34    pub operation: SandboxOperationClass,
35    /// Lifecycle generation the sandbox started under
36    pub generation: u64,
37    /// Unix seconds after which the capability is void
38    pub expires_at: i64,
39    /// Key that signed it, so rotation can retain an overlapping ring
40    pub key_id: String,
41}
42
43/// What the agent knows about itself, established at sandbox start.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SandboxSessionIdentity {
46    /// The sandbox this agent serves
47    pub session_id: String,
48    /// The generation it started under
49    pub generation: u64,
50}
51
52impl SandboxCapabilityClaims {
53    /// Verifies claims against the agent's own identity and the current time.
54    ///
55    /// Signature checking happens before this — an unsigned claim never reaches here. What this
56    /// enforces is everything a valid signature does *not* prove: that the capability is for
57    /// this sandbox, this generation, this operation, and still in date.
58    pub fn verify(
59        &self,
60        identity: &SandboxSessionIdentity,
61        required: SandboxOperationClass,
62        now_unix: i64,
63    ) -> Result<()> {
64        // Sandbox first: a capability for another sandbox is the case that matters most, and
65        // reporting expiry for it would tell an attacker the wrong thing.
66        if self.session_id != identity.session_id {
67            return Err(refused("this capability addresses a different sandbox"));
68        }
69
70        // A running agent cannot observe a generation changed outside it, so terminate fences
71        // ingress and this check catches anything that slipped through before the fence closed.
72        if self.generation != identity.generation {
73            return Err(refused(
74                "this capability was issued for a previous lifecycle generation",
75            ));
76        }
77
78        if self.expires_at <= now_unix {
79            return Err(refused("this capability has expired"));
80        }
81
82        // Execute does not imply Manage. Manage does not imply Execute either: the whole point
83        // of the split is that an app which only runs code cannot terminate sandboxes.
84        if self.operation != required {
85            return Err(refused(
86                "this capability does not permit this operation class",
87            ));
88        }
89
90        Ok(())
91    }
92}
93
94fn refused(reason: &str) -> AlienError<ErrorData> {
95    AlienError::new(ErrorData::SandboxCapabilityRefused {
96        reason: reason.to_string(),
97    })
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    const NOW: i64 = 1_000_000;
105
106    fn identity() -> SandboxSessionIdentity {
107        SandboxSessionIdentity {
108            session_id: "s1".to_string(),
109            generation: 2,
110        }
111    }
112
113    fn claims() -> SandboxCapabilityClaims {
114        SandboxCapabilityClaims {
115            session_id: "s1".to_string(),
116            operation: SandboxOperationClass::Execute,
117            generation: 2,
118            expires_at: NOW + 300,
119            key_id: "k1".to_string(),
120        }
121    }
122
123    #[test]
124    fn a_matching_capability_is_accepted() {
125        claims()
126            .verify(&identity(), SandboxOperationClass::Execute, NOW)
127            .expect("a capability for this sandbox, generation and class is valid");
128    }
129
130    /// The case that matters most: provider ids are guessable, so a capability
131    /// minted for one sandbox must be useless against another.
132    #[test]
133    fn a_capability_for_another_session_is_refused() {
134        let mut other = claims();
135        other.session_id = "s2".to_string();
136
137        let error = other
138            .verify(&identity(), SandboxOperationClass::Execute, NOW)
139            .expect_err("sandbox B must not accept sandbox A's capability");
140        assert!(error.to_string().contains("different sandbox"));
141    }
142
143    /// Terminate bumps the generation; anything minted before is void even if
144    /// its signature and expiry are still good.
145    #[test]
146    fn a_capability_from_a_previous_generation_is_refused() {
147        let mut stale = claims();
148        stale.generation = 1;
149
150        let error = stale
151            .verify(&identity(), SandboxOperationClass::Execute, NOW)
152            .expect_err("a previous generation must be refused");
153        assert!(error.to_string().contains("generation"));
154    }
155
156    #[test]
157    fn an_expired_capability_is_refused() {
158        let mut expired = claims();
159        expired.expires_at = NOW;
160
161        expired
162            .verify(&identity(), SandboxOperationClass::Execute, NOW)
163            .expect_err("expiry is inclusive: a capability expiring now is already void");
164    }
165
166    /// The split only means something if it holds in both directions. An execute-only app must
167    /// not terminate sandboxes, and a manage-only component must not read sandbox contents.
168    #[test]
169    fn operation_classes_do_not_imply_each_other() {
170        claims()
171            .verify(&identity(), SandboxOperationClass::Manage, NOW)
172            .expect_err("execute must not permit manage");
173
174        let mut manage = claims();
175        manage.operation = SandboxOperationClass::Manage;
176        manage
177            .verify(&identity(), SandboxOperationClass::Execute, NOW)
178            .expect_err("manage must not permit execute");
179    }
180
181    /// Checked before expiry on purpose: telling a caller "expired" for a capability that was
182    /// never theirs leaks which sandboxes exist.
183    #[test]
184    fn a_wrong_session_is_reported_as_wrong_session_even_when_also_expired() {
185        let mut wrong = claims();
186        wrong.session_id = "s2".to_string();
187        wrong.expires_at = NOW - 1;
188
189        let error = wrong
190            .verify(&identity(), SandboxOperationClass::Execute, NOW)
191            .expect_err("refused");
192        assert!(
193            error.to_string().contains("different sandbox"),
194            "the reason must not reveal that some other sandbox's capability had expired"
195        );
196    }
197
198    #[test]
199    fn claims_round_trip_so_minting_and_verification_cannot_drift() {
200        let json = serde_json::to_string(&claims()).expect("serializes");
201        let restored: SandboxCapabilityClaims = serde_json::from_str(&json).expect("deserializes");
202        assert_eq!(claims(), restored);
203    }
204}