Skip to main content

aios_protocol/
policy.rs

1//! Policy types: capabilities, policy sets, and evaluation results.
2
3use serde::{Deserialize, Serialize};
4
5/// A capability token representing a specific permission.
6///
7/// Capabilities are pattern-based strings like `"fs:read:/session/**"`.
8/// They support glob matching for flexible policy evaluation.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
10pub struct Capability(pub String);
11
12impl Capability {
13    pub fn new(value: impl Into<String>) -> Self {
14        Self(value.into())
15    }
16
17    pub fn fs_read(glob: &str) -> Self {
18        Self(format!("fs:read:{glob}"))
19    }
20
21    pub fn fs_write(glob: &str) -> Self {
22        Self(format!("fs:write:{glob}"))
23    }
24
25    pub fn net_egress(host: &str) -> Self {
26        Self(format!("net:egress:{host}"))
27    }
28
29    pub fn exec(command: &str) -> Self {
30        Self(format!("exec:cmd:{command}"))
31    }
32
33    pub fn secrets(scope: &str) -> Self {
34        Self(format!("secrets:read:{scope}"))
35    }
36
37    pub fn as_str(&self) -> &str {
38        &self.0
39    }
40}
41
42/// A set of policy rules governing agent capabilities.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PolicySet {
45    pub allow_capabilities: Vec<Capability>,
46    pub gate_capabilities: Vec<Capability>,
47    pub max_tool_runtime_secs: u64,
48    pub max_events_per_turn: u64,
49}
50
51impl PolicySet {
52    /// Heavily restricted — anonymous public users. No side-effecting capabilities.
53    ///
54    /// Shell execution (`exec:cmd:*`) is NOT gated (approval queue) — it is
55    /// absent from both `allow_capabilities` and `gate_capabilities`, so the
56    /// policy engine immediately **denies** any bash/shell tool call without
57    /// creating an approval ticket. BRO-216.
58    ///
59    /// 5 events/turn, 30s tool runtime.
60    pub fn anonymous() -> Self {
61        Self {
62            allow_capabilities: vec![Capability::new("fs:read:/session/**")],
63            // exec:cmd:* removed — falls through to denied by StaticPolicyEngine.
64            gate_capabilities: vec![
65                Capability::new("fs:write:**"),
66                Capability::new("net:egress:*"),
67                Capability::new("secrets:read:*"),
68            ],
69            max_tool_runtime_secs: 30,
70            max_events_per_turn: 5,
71        }
72    }
73
74    /// Read + network + limited shell — authenticated free tier users.
75    ///
76    /// Shell execution is restricted to a safe read-only whitelist; unlisted
77    /// commands are denied immediately (not gated). BRO-216.
78    ///
79    /// 15 events/turn, 30s tool runtime.
80    pub fn free() -> Self {
81        Self {
82            allow_capabilities: vec![
83                Capability::new("fs:read:/session/**"),
84                Capability::new("net:egress:*"),
85                // Shell whitelist — safe read-only commands only.
86                Capability::new("exec:cmd:cat"),
87                Capability::new("exec:cmd:ls"),
88                Capability::new("exec:cmd:echo"),
89                Capability::new("exec:cmd:grep"),
90                Capability::new("exec:cmd:jq"),
91                Capability::new("exec:cmd:python3"),
92                Capability::new("exec:cmd:find"),
93                Capability::new("exec:cmd:head"),
94                Capability::new("exec:cmd:tail"),
95                Capability::new("exec:cmd:sort"),
96                Capability::new("exec:cmd:wc"),
97            ],
98            // exec:cmd:* removed — unlisted exec commands fall through to denied.
99            gate_capabilities: vec![
100                Capability::new("fs:write:**"),
101                Capability::new("secrets:read:*"),
102            ],
103            max_tool_runtime_secs: 30,
104            max_events_per_turn: 15,
105        }
106    }
107
108    /// Full access — authenticated Pro subscribers.
109    /// 50 events/turn, 60s tool runtime.
110    pub fn pro() -> Self {
111        Self {
112            allow_capabilities: vec![Capability::new("*")],
113            gate_capabilities: vec![],
114            max_tool_runtime_secs: 60,
115            max_events_per_turn: 50,
116        }
117    }
118
119    /// Fully permissive — Enterprise tenants (custom overrides applied separately).
120    /// 200 events/turn, 120s tool runtime.
121    pub fn enterprise() -> Self {
122        Self {
123            allow_capabilities: vec![Capability::new("*")],
124            gate_capabilities: vec![],
125            max_tool_runtime_secs: 120,
126            max_events_per_turn: 200,
127        }
128    }
129}
130
131impl Default for PolicySet {
132    fn default() -> Self {
133        Self {
134            allow_capabilities: vec![
135                Capability::fs_read("/session/**"),
136                Capability::fs_write("/session/artifacts/**"),
137                Capability::exec("git"),
138            ],
139            gate_capabilities: vec![Capability::new("payments:initiate")],
140            max_tool_runtime_secs: 30,
141            max_events_per_turn: 256,
142        }
143    }
144}
145
146/// Subscription tier for a user or tenant.
147///
148/// Controls session TTLs, rate limits, and capability grants across the
149/// Agent OS (Arcan session store, Lago metering, Praxis tool limits).
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
151#[serde(rename_all = "snake_case")]
152pub enum SubscriptionTier {
153    /// Unauthenticated public access — zero persistence, minimal capabilities.
154    #[default]
155    Anonymous,
156    /// Authenticated free tier — 7-day session TTL.
157    Free,
158    /// Authenticated Pro subscriber — 90-day session TTL, full tool access.
159    Pro,
160    /// Enterprise tenant — no session expiry, custom capability overrides.
161    Enterprise,
162}
163
164/// Result of evaluating capabilities against a policy set.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct PolicyEvaluation {
167    pub allowed: Vec<Capability>,
168    pub requires_approval: Vec<Capability>,
169    pub denied: Vec<Capability>,
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn capability_factory_methods() {
178        assert_eq!(Capability::fs_read("/tmp").as_str(), "fs:read:/tmp");
179        assert_eq!(Capability::fs_write("/out").as_str(), "fs:write:/out");
180        assert_eq!(
181            Capability::net_egress("api.com").as_str(),
182            "net:egress:api.com"
183        );
184        assert_eq!(Capability::exec("git").as_str(), "exec:cmd:git");
185        assert_eq!(Capability::secrets("prod").as_str(), "secrets:read:prod");
186    }
187
188    #[test]
189    fn policy_set_default() {
190        let ps = PolicySet::default();
191        assert_eq!(ps.allow_capabilities.len(), 3);
192        assert_eq!(ps.gate_capabilities.len(), 1);
193        assert_eq!(ps.max_tool_runtime_secs, 30);
194    }
195
196    #[test]
197    fn capability_serde_roundtrip() {
198        let cap = Capability::fs_read("/session/**");
199        let json = serde_json::to_string(&cap).unwrap();
200        let back: Capability = serde_json::from_str(&json).unwrap();
201        assert_eq!(cap, back);
202    }
203
204    #[test]
205    fn policy_set_anonymous() {
206        let ps = PolicySet::anonymous();
207        assert_eq!(ps.allow_capabilities.len(), 1);
208        assert_eq!(ps.allow_capabilities[0].as_str(), "fs:read:/session/**");
209        // exec:cmd:* must NOT be in gate_capabilities — it must be denied outright (BRO-216).
210        assert_eq!(ps.gate_capabilities.len(), 3);
211        assert_eq!(ps.max_tool_runtime_secs, 30);
212        assert_eq!(ps.max_events_per_turn, 5);
213        // anonymous: exec is in neither allow nor gate → immediately denied
214        let exec_cap = Capability::new("exec:cmd:*");
215        assert!(!ps.allow_capabilities.contains(&exec_cap));
216        assert!(!ps.gate_capabilities.contains(&exec_cap));
217    }
218
219    #[test]
220    fn policy_set_free() {
221        let ps = PolicySet::free();
222        // allow: session read + net egress + 11 whitelisted exec commands
223        assert_eq!(ps.allow_capabilities.len(), 13);
224        // gate: fs:write + secrets (exec removed — unlisted exec → denied)
225        assert_eq!(ps.gate_capabilities.len(), 2);
226        assert_eq!(ps.max_tool_runtime_secs, 30);
227        assert_eq!(ps.max_events_per_turn, 15);
228        // free allows net egress
229        assert!(
230            ps.allow_capabilities
231                .contains(&Capability::new("net:egress:*"))
232        );
233        // free has whitelisted exec commands
234        assert!(
235            ps.allow_capabilities
236                .contains(&Capability::new("exec:cmd:cat"))
237        );
238        assert!(
239            ps.allow_capabilities
240                .contains(&Capability::new("exec:cmd:grep"))
241        );
242        // exec:cmd:* wildcard is NOT in gate (unlisted commands → denied immediately)
243        assert!(
244            !ps.gate_capabilities
245                .contains(&Capability::new("exec:cmd:*"))
246        );
247    }
248
249    #[test]
250    fn policy_set_pro() {
251        let ps = PolicySet::pro();
252        assert_eq!(ps.allow_capabilities.len(), 1);
253        assert_eq!(ps.allow_capabilities[0].as_str(), "*");
254        assert_eq!(ps.gate_capabilities.len(), 0);
255        assert_eq!(ps.max_tool_runtime_secs, 60);
256        assert_eq!(ps.max_events_per_turn, 50);
257        // pro allows all via wildcard
258        assert!(ps.allow_capabilities.contains(&Capability::new("*")));
259    }
260
261    #[test]
262    fn policy_set_enterprise() {
263        let ps = PolicySet::enterprise();
264        assert_eq!(ps.allow_capabilities.len(), 1);
265        assert_eq!(ps.allow_capabilities[0].as_str(), "*");
266        assert_eq!(ps.gate_capabilities.len(), 0);
267        assert_eq!(ps.max_tool_runtime_secs, 120);
268        assert_eq!(ps.max_events_per_turn, 200);
269        // enterprise allows all via wildcard
270        assert!(ps.allow_capabilities.contains(&Capability::new("*")));
271    }
272}