1use 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}