Skip to main content

aidens_permit_kit/
lib.rs

1//! Approval and permit model.
2
3use aidens_contracts::{
4    ApprovalDecisionV1, ApprovalRequestV1, ArtifactId, CanonicalToolSideEffectClass, PermitGrantV1,
5    PermitUseReportV1,
6};
7use std::collections::BTreeSet;
8
9const UNKNOWN_PERMIT_SCOPE_TOKEN: &str = "unknown";
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum PermitDecisionV1 {
13    Allow,
14    Deny(String),
15    RequiresApproval,
16}
17
18pub type PermitV1 = PermitGrantV1;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct PermitCheckContextV1 {
22    pub tool_id: String,
23    pub risk_class: CanonicalToolSideEffectClass,
24    pub sandbox_root: String,
25    pub run_id: Option<ArtifactId>,
26    pub attempt_id: Option<ArtifactId>,
27}
28
29impl PermitCheckContextV1 {
30    pub fn new(
31        tool_id: impl Into<String>,
32        risk_class: CanonicalToolSideEffectClass,
33        sandbox_root: impl Into<String>,
34    ) -> Self {
35        Self {
36            tool_id: tool_id.into(),
37            risk_class,
38            sandbox_root: sandbox_root.into(),
39            run_id: None,
40            attempt_id: None,
41        }
42    }
43
44    pub fn with_run_attempt(
45        mut self,
46        run_id: Option<ArtifactId>,
47        attempt_id: Option<ArtifactId>,
48    ) -> Self {
49        self.run_id = run_id;
50        self.attempt_id = attempt_id;
51        self
52    }
53}
54
55#[derive(Debug, Clone, Default, PartialEq, Eq)]
56pub struct PermitPolicyV1 {
57    granted_risks: BTreeSet<CanonicalToolSideEffectClass>,
58    grants: Vec<PermitGrantV1>,
59}
60
61impl PermitPolicyV1 {
62    pub fn with_permit(mut self, permit: &PermitV1) -> Self {
63        self.granted_risks.insert(permit.risk_class.clone());
64        self.grants.push(permit.clone());
65        self
66    }
67
68    pub fn with_grant(mut self, grant: PermitGrantV1) -> Self {
69        self.granted_risks.insert(grant.risk_class.clone());
70        self.grants.push(grant);
71        self
72    }
73
74    pub fn decision_for_risk(&self, risk: &CanonicalToolSideEffectClass) -> PermitDecisionV1 {
75        if matches!(risk, CanonicalToolSideEffectClass::ReadOnly)
76            || self.granted_risks.contains(risk)
77        {
78            PermitDecisionV1::Allow
79        } else {
80            PermitDecisionV1::RequiresApproval
81        }
82    }
83
84    pub fn decision_for_context(&self, context: &PermitCheckContextV1) -> PermitDecisionV1 {
85        if matches!(context.risk_class, CanonicalToolSideEffectClass::ReadOnly) {
86            return PermitDecisionV1::Allow;
87        }
88        if self.grant_for_context(context).is_some() {
89            PermitDecisionV1::Allow
90        } else {
91            PermitDecisionV1::RequiresApproval
92        }
93    }
94
95    pub fn grant_for_risk(&self, risk: &CanonicalToolSideEffectClass) -> Option<&PermitGrantV1> {
96        self.grants.iter().find(|grant| &grant.risk_class == risk)
97    }
98
99    pub fn grant_for_context(&self, context: &PermitCheckContextV1) -> Option<&PermitGrantV1> {
100        self.grants.iter().find(|grant| {
101            grant.matches_scope(
102                &context.risk_class,
103                &context.tool_id,
104                &context.sandbox_root,
105                context.run_id.as_ref(),
106                context.attempt_id.as_ref(),
107            )
108        })
109    }
110
111    pub fn permit_use_receipt_for_context(
112        &self,
113        context: &PermitCheckContextV1,
114    ) -> Option<PermitUseReportV1> {
115        self.grant_for_context(context).map(|grant| {
116            PermitUseReportV1::allowed(
117                grant,
118                context.tool_id.clone(),
119                context.sandbox_root.clone(),
120                context.run_id.clone(),
121                context.attempt_id.clone(),
122            )
123        })
124    }
125
126    pub fn approval_request_for_tool(
127        &self,
128        tool_id: impl Into<String>,
129        risk: CanonicalToolSideEffectClass,
130        scope: impl Into<String>,
131    ) -> Option<ApprovalRequestV1> {
132        if self.decision_for_risk(&risk) == PermitDecisionV1::RequiresApproval {
133            Some(ApprovalRequestV1::new(
134                tool_id,
135                risk,
136                scope,
137                "side-effect tool requires explicit permit",
138            ))
139        } else {
140            None
141        }
142    }
143
144    pub fn approval_request_for_context(
145        &self,
146        context: &PermitCheckContextV1,
147    ) -> Option<ApprovalRequestV1> {
148        if self.decision_for_context(context) == PermitDecisionV1::RequiresApproval {
149            let mut request = ApprovalRequestV1::scoped(
150                context.tool_id.clone(),
151                context.risk_class.clone(),
152                context.sandbox_root.clone(),
153                "side-effect tool requires explicit scoped permit",
154            );
155            request.run_id = context.run_id.clone();
156            request.attempt_id = context.attempt_id.clone();
157            if context.run_id.is_some() || context.attempt_id.is_some() {
158                request.scope = format!(
159                    "{};run={};attempt={}",
160                    request.scope,
161                    context
162                        .run_id
163                        .as_ref()
164                        .map(|id| id.0.as_str())
165                        .unwrap_or(UNKNOWN_PERMIT_SCOPE_TOKEN),
166                    context
167                        .attempt_id
168                        .as_ref()
169                        .map(|id| id.0.as_str())
170                        .unwrap_or(UNKNOWN_PERMIT_SCOPE_TOKEN)
171                );
172            }
173            Some(request)
174        } else {
175            None
176        }
177    }
178
179    pub fn deny_request(
180        &self,
181        request_id: ArtifactId,
182        decided_by: impl Into<String>,
183        reason: impl Into<String>,
184    ) -> ApprovalDecisionV1 {
185        ApprovalDecisionV1::denied(request_id, decided_by, reason)
186    }
187}
188
189pub fn default_decision(risk: &CanonicalToolSideEffectClass) -> PermitDecisionV1 {
190    match risk {
191        CanonicalToolSideEffectClass::ReadOnly => PermitDecisionV1::Allow,
192        _ => PermitDecisionV1::RequiresApproval,
193    }
194}
195
196pub fn requires_permit(risk: &CanonicalToolSideEffectClass) -> bool {
197    !matches!(risk, CanonicalToolSideEffectClass::ReadOnly)
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn write_requires_approval_by_default() {
206        assert_eq!(
207            default_decision(&CanonicalToolSideEffectClass::Write),
208            PermitDecisionV1::RequiresApproval
209        );
210    }
211
212    #[test]
213    fn explicit_permit_allows_matching_risk() {
214        let permit = PermitV1::new(CanonicalToolSideEffectClass::Admin, "repo", "test");
215        let policy = PermitPolicyV1::default().with_permit(&permit);
216
217        assert_eq!(
218            policy.decision_for_risk(&CanonicalToolSideEffectClass::Admin),
219            PermitDecisionV1::Allow
220        );
221        assert_eq!(
222            policy.decision_for_risk(&CanonicalToolSideEffectClass::Write),
223            PermitDecisionV1::RequiresApproval
224        );
225        assert_eq!(
226            policy
227                .grant_for_risk(&CanonicalToolSideEffectClass::Admin)
228                .map(|grant| grant.permit_id.clone()),
229            Some(permit.permit_id)
230        );
231    }
232
233    #[test]
234    fn scoped_permit_requires_matching_tool_and_sandbox() {
235        let permit = PermitV1::scoped(
236            CanonicalToolSideEffectClass::Write,
237            "aidens:file-write:1",
238            "/repo",
239            "test",
240        );
241        let policy = PermitPolicyV1::default().with_permit(&permit);
242        let matching = PermitCheckContextV1::new(
243            "aidens:file-write:1",
244            CanonicalToolSideEffectClass::Write,
245            "/repo",
246        );
247        let wrong_tool = PermitCheckContextV1::new(
248            "aidens:shell:1",
249            CanonicalToolSideEffectClass::Write,
250            "/repo",
251        );
252        let wrong_root = PermitCheckContextV1::new(
253            "aidens:file-write:1",
254            CanonicalToolSideEffectClass::Write,
255            "/other",
256        );
257
258        assert_eq!(
259            policy.decision_for_context(&matching),
260            PermitDecisionV1::Allow
261        );
262        assert_eq!(
263            policy.decision_for_context(&wrong_tool),
264            PermitDecisionV1::RequiresApproval
265        );
266        assert_eq!(
267            policy.decision_for_context(&wrong_root),
268            PermitDecisionV1::RequiresApproval
269        );
270        let receipt = policy
271            .permit_use_receipt_for_context(&matching)
272            .expect("matching grant emits use receipt");
273        assert!(receipt.allowed);
274        assert_eq!(receipt.permit_id, permit.permit_id);
275    }
276
277    #[test]
278    fn approval_request_for_context_marks_missing_ids_explicitly() {
279        let policy = PermitPolicyV1::default();
280        let context = PermitCheckContextV1::new(
281            "aidens:file-write:1",
282            CanonicalToolSideEffectClass::Write,
283            "repo",
284        )
285        .with_run_attempt(Some(ArtifactId("run-id:example".into())), None);
286
287        let request = policy
288            .approval_request_for_context(&context)
289            .expect("write requests permit approval by default");
290
291        assert!(request.scope.contains("run=run-id:example"));
292        assert!(request.scope.contains("attempt=unknown"));
293        assert!(request.scope.contains("tool=aidens:file-write:1"));
294        assert!(!request.scope.contains("run=*"));
295        assert!(!request.scope.contains("attempt=*"));
296    }
297
298    #[test]
299    fn side_effect_risks_request_approval() {
300        let policy = PermitPolicyV1::default();
301        let request = policy
302            .approval_request_for_tool(
303                "aidens:file-write:1",
304                CanonicalToolSideEffectClass::Write,
305                "repo",
306            )
307            .expect("file-write requires approval");
308
309        assert_eq!(request.tool_id, "aidens:file-write:1");
310        assert_eq!(request.risk_class, CanonicalToolSideEffectClass::Write);
311        assert!(requires_permit(&CanonicalToolSideEffectClass::Admin));
312    }
313
314    #[test]
315    fn default_permit_policy_matches_reference_interpreter() {
316        for risk_class in aidens_testkit::all_risk_classes() {
317            let case = aidens_testkit::reference_permit_case(risk_class.clone());
318            let decision = match default_decision(&risk_class) {
319                PermitDecisionV1::Allow => "allow",
320                PermitDecisionV1::RequiresApproval => "requires-approval",
321                PermitDecisionV1::Deny(_) => "deny",
322            };
323            let reason_codes = if requires_permit(&risk_class) {
324                vec!["approval-required"]
325            } else {
326                vec!["read-only-risk"]
327            };
328            let actual = serde_json::json!({
329                "risk_class": aidens_testkit::json_string(&risk_class),
330                "permit_required": requires_permit(&risk_class),
331                "decision": decision,
332                "reason_codes": reason_codes
333            });
334            let report = aidens_testkit::compare_case_to_actual(
335                &case,
336                "aidens-permit-kit::default_decision",
337                actual,
338            );
339
340            assert!(
341                report.passed,
342                "{}",
343                report
344                    .findings
345                    .iter()
346                    .map(|finding| finding.human_diff.as_str())
347                    .collect::<Vec<_>>()
348                    .join("\n")
349            );
350        }
351    }
352}