1use std::{borrow::Cow, fmt};
2
3use jiff::Timestamp;
4use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
5use serde::{Deserialize, Serialize};
6
7use crate::{
8 ApprovalId, PermissionAction, PermissionEffect, SafeCode, SafeErrorMessage, SessionId,
9 Sha256Digest, TreeApprovalGrantId, WildcardPattern,
10};
11
12#[derive(Clone, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize)]
13#[serde(deny_unknown_fields)]
14pub struct OperationFingerprint {
15 digest: Sha256Digest,
16}
17impl OperationFingerprint {
18 const DOMAIN: &'static [u8] = b"cookie-agent.operation-fingerprint.v7\0";
19 #[must_use]
20 pub fn from_prepared_operation(operation: &PreparedOperationIdentity) -> Self {
21 Self {
22 digest: Sha256Digest::hash_domain_separated(
23 Self::DOMAIN,
24 &operation.canonical_fingerprint_bytes(),
25 ),
26 }
27 }
28 #[must_use]
29 pub const fn digest(&self) -> &Sha256Digest {
30 &self.digest
31 }
32}
33impl<'de> Deserialize<'de> for OperationFingerprint {
34 fn deserialize<D>(d: D) -> Result<Self, D::Error>
35 where
36 D: serde::Deserializer<'de>,
37 {
38 #[derive(Deserialize)]
39 #[serde(deny_unknown_fields)]
40 struct Wire {
41 digest: Sha256Digest,
42 }
43 Ok(Self {
44 digest: Wire::deserialize(d)?.digest,
45 })
46 }
47}
48
49#[derive(Clone, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize)]
50#[serde(deny_unknown_fields)]
51pub struct PreparedResourceDigest {
52 digest: Sha256Digest,
53}
54impl PreparedResourceDigest {
55 const DOMAIN: &'static [u8] = b"cookie-agent.prepared-resource-digest.v7\0";
56 #[must_use]
57 pub fn from_canonical_binding_bytes(bytes: &[u8]) -> Self {
58 Self {
59 digest: Sha256Digest::hash_domain_separated(Self::DOMAIN, bytes),
60 }
61 }
62 #[must_use]
63 pub const fn digest(&self) -> &Sha256Digest {
64 &self.digest
65 }
66}
67impl<'de> Deserialize<'de> for PreparedResourceDigest {
68 fn deserialize<D>(d: D) -> Result<Self, D::Error>
69 where
70 D: serde::Deserializer<'de>,
71 {
72 #[derive(Deserialize)]
73 #[serde(deny_unknown_fields)]
74 struct Wire {
75 digest: Sha256Digest,
76 }
77 Ok(Self {
78 digest: Wire::deserialize(d)?.digest,
79 })
80 }
81}
82
83#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
84pub struct PreparedResourceIdentity(String);
85impl PreparedResourceIdentity {
86 pub const MAX_BYTES: usize = 512;
87 pub fn new(value: impl Into<String>) -> Result<Self, ApprovalSchemaError> {
88 let value = value.into();
89 if value.is_empty()
90 || value.len() > Self::MAX_BYTES
91 || !value.bytes().all(|byte| {
92 byte.is_ascii_lowercase()
93 || byte.is_ascii_digit()
94 || matches!(byte, b'-' | b'_' | b'.' | b':')
95 })
96 || value.starts_with("fd:")
97 || value.starts_with("tmp:")
98 || value.starts_with("temp:")
99 {
100 return Err(ApprovalSchemaError::InvalidPreparedIdentity);
101 }
102 Ok(Self(value))
103 }
104 #[must_use]
105 pub fn as_str(&self) -> &str {
106 &self.0
107 }
108}
109impl fmt::Display for PreparedResourceIdentity {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 self.0.fmt(f)
112 }
113}
114impl Serialize for PreparedResourceIdentity {
115 fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
116 where
117 S: serde::Serializer,
118 {
119 s.serialize_str(&self.0)
120 }
121}
122impl<'de> Deserialize<'de> for PreparedResourceIdentity {
123 fn deserialize<D>(d: D) -> Result<Self, D::Error>
124 where
125 D: serde::Deserializer<'de>,
126 {
127 Self::new(String::deserialize(d)?).map_err(serde::de::Error::custom)
128 }
129}
130impl JsonSchema for PreparedResourceIdentity {
131 fn inline_schema() -> bool {
132 true
133 }
134 fn schema_name() -> Cow<'static, str> {
135 Cow::Borrowed("PreparedResourceIdentity")
136 }
137 fn json_schema(_: &mut SchemaGenerator) -> Schema {
138 json_schema!({"type":"string","minLength":1,"maxLength":512,"pattern":"^[a-z0-9][a-z0-9._:-]*$"})
139 }
140}
141
142pub type PreparedCapabilityOperation = PreparedResourceIdentity;
143
144#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
145#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
146pub enum ApprovalBoundary {
147 Exact,
148 CommandPrefix { prefix: String },
149 DelegationTree { root_session_id: SessionId },
150}
151
152#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
153#[serde(rename_all = "snake_case")]
154pub enum ApprovalResourceSource {
155 PrimaryOperation,
156 SecondaryOperation,
157 ModelRequest,
158 DoomLoopGuard,
159}
160#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
161#[serde(rename_all = "snake_case")]
162pub enum PreparedBindingLifetime {
163 ProcessLocal,
164 RestartStable,
165}
166
167#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
168#[serde(deny_unknown_fields)]
169pub struct PreparedApprovalResource {
170 pub capability: PermissionAction,
171 pub canonical: PreparedResourceIdentity,
172 pub binding_digest: PreparedResourceDigest,
173 pub binding_lifetime: PreparedBindingLifetime,
174 pub boundary: ApprovalBoundary,
175 pub source: ApprovalResourceSource,
176}
177
178#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
179#[serde(deny_unknown_fields)]
180pub struct MatchedPermissionRule {
181 pub source_layer: SafeCode,
182 pub action: PermissionAction,
183 pub resource: WildcardPattern,
184 pub effect: PermissionEffect,
185}
186#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
187#[serde(deny_unknown_fields)]
188pub struct DecisionTrace {
189 pub action: PermissionAction,
190 pub normalized_resource: String,
191 pub candidates: Vec<MatchedPermissionRule>,
192 pub effect: PermissionEffect,
193 pub precedence_reason: String,
194}
195#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
196#[serde(deny_unknown_fields)]
197pub struct ApprovalEvaluation {
198 pub resource_digest: PreparedResourceDigest,
199 pub effect: PermissionEffect,
200 pub trace: DecisionTrace,
201}
202
203#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
204#[serde(rename_all = "snake_case")]
205pub enum ApprovalTrigger {
206 PermissionPolicy,
207 ModelToolApproval,
208 InternalAgent,
209 DoomLoop,
210}
211
212#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, JsonSchema, PartialEq, Serialize)]
213#[serde(rename_all = "snake_case")]
214pub enum PermissionMode {
215 #[default]
216 AutoApprove,
217 AutoApproveN,
218 AutoApproveY,
219 Ask,
220 Yolo,
221}
222#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
223#[serde(deny_unknown_fields)]
224pub struct ApprovalCapability {
225 pub action: PermissionAction,
226 pub operation: PreparedCapabilityOperation,
227}
228#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
229#[serde(rename_all = "snake_case")]
230pub enum PreparedCapabilityLifetime {
231 ProcessLocal,
232}
233
234#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize)]
235#[serde(deny_unknown_fields)]
236pub struct PreparedOperationIdentity {
237 normalized_arguments_digest: Sha256Digest,
238 #[schemars(length(min = 1, max = 256))]
239 capabilities: Vec<ApprovalCapability>,
240 #[schemars(length(min = 1, max = 256))]
241 resources: Vec<PreparedApprovalResource>,
242 execution_context_digest: Sha256Digest,
243 capability_lifetime: PreparedCapabilityLifetime,
244}
245impl PreparedOperationIdentity {
246 pub fn new(
247 normalized_arguments_digest: Sha256Digest,
248 capabilities: Vec<ApprovalCapability>,
249 resources: Vec<PreparedApprovalResource>,
250 execution_context_digest: Sha256Digest,
251 ) -> Result<Self, ApprovalSchemaError> {
252 if capabilities.is_empty() || capabilities.len() > 256 {
253 return Err(ApprovalSchemaError::MissingCapability);
254 }
255 if resources.is_empty() {
256 return Err(ApprovalSchemaError::MissingResource);
257 }
258 if resources.len() > 256 {
259 return Err(ApprovalSchemaError::TooManyResources);
260 }
261 if resources.iter().any(|resource| {
262 !capabilities
263 .iter()
264 .any(|capability| capability.action == resource.capability)
265 }) {
266 return Err(ApprovalSchemaError::UnboundResourceCapability);
267 }
268 let mut digests = resources
269 .iter()
270 .map(|resource| resource.binding_digest.digest().as_str())
271 .collect::<Vec<_>>();
272 digests.sort_unstable();
273 if digests.windows(2).any(|pair| pair[0] == pair[1]) {
274 return Err(ApprovalSchemaError::DuplicateResourceBinding);
275 }
276 Ok(Self {
277 normalized_arguments_digest,
278 capabilities,
279 resources,
280 execution_context_digest,
281 capability_lifetime: PreparedCapabilityLifetime::ProcessLocal,
282 })
283 }
284 #[must_use]
285 pub fn capabilities(&self) -> &[ApprovalCapability] {
286 &self.capabilities
287 }
288 #[must_use]
289 pub fn resources(&self) -> &[PreparedApprovalResource] {
290 &self.resources
291 }
292 fn canonical_fingerprint_bytes(&self) -> Vec<u8> {
293 let mut value =
294 serde_json::to_value(self).expect("protocol identity serialization cannot fail");
295 if let Some(resources) = value
296 .get_mut("resources")
297 .and_then(serde_json::Value::as_array_mut)
298 {
299 for resource in &mut *resources {
300 if let Some(object) = resource.as_object_mut() {
301 object.remove("boundary");
302 object.remove("source");
303 }
304 }
305 resources.sort_by_key(serde_json::Value::to_string);
306 }
307 if let Some(capabilities) = value
308 .get_mut("capabilities")
309 .and_then(serde_json::Value::as_array_mut)
310 {
311 capabilities.sort_by_key(serde_json::Value::to_string);
312 }
313 serde_json::to_vec(&value).expect("protocol identity serialization cannot fail")
314 }
315}
316impl<'de> Deserialize<'de> for PreparedOperationIdentity {
317 fn deserialize<D>(d: D) -> Result<Self, D::Error>
318 where
319 D: serde::Deserializer<'de>,
320 {
321 #[derive(Deserialize)]
322 #[serde(deny_unknown_fields)]
323 struct Wire {
324 normalized_arguments_digest: Sha256Digest,
325 capabilities: Vec<ApprovalCapability>,
326 resources: Vec<PreparedApprovalResource>,
327 execution_context_digest: Sha256Digest,
328 capability_lifetime: PreparedCapabilityLifetime,
329 }
330 let w = Wire::deserialize(d)?;
331 let value = Self::new(
332 w.normalized_arguments_digest,
333 w.capabilities,
334 w.resources,
335 w.execution_context_digest,
336 )
337 .map_err(serde::de::Error::custom)?;
338 if w.capability_lifetime != PreparedCapabilityLifetime::ProcessLocal {
339 return Err(serde::de::Error::custom(
340 "prepared capability lifetime must be process_local",
341 ));
342 }
343 Ok(value)
344 }
345}
346
347#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
348#[serde(deny_unknown_fields)]
349pub struct ApprovalConstraints {
350 pub allow_once: bool,
351 pub allow_tree_grant: bool,
352 pub cancellable: bool,
353 #[serde(deserialize_with = "crate::deserialize_required_option")]
354 #[schemars(with = "crate::NullableSchema<Timestamp>", required)]
355 pub expires_at: Option<Timestamp>,
356}
357
358#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
359#[serde(rename_all = "snake_case")]
360pub enum ApprovalReasonCode {
361 PolicyAllowed,
362 PolicyDenied,
363 PolicyRequiresApproval,
364 ModelRequested,
365 InternalAgentAllowed,
366 InternalAgentDenied,
367 AutoApproveNRejected,
368 AutoApproveYApproved,
369 YoloApproved,
370 TreeGrantMatched,
371 UserApprovedOnce,
372 UserApprovedTree,
373 UserRejected,
374 UserCancelled,
375 RequestCancelled,
376 DoomLoopDetected,
377 ConstraintViolation,
378 OperationChanged,
379 PreparedCapabilityLost,
380 UnsupportedPlatform,
381 ApprovalExpired,
382 Escalated,
383 Unattended,
384 SystemError,
385}
386#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
387#[serde(rename_all = "snake_case")]
388pub enum ApprovalDecisionSource {
389 Policy,
390 Model,
391 InternalAgent,
392 PermissionMode,
393 TreeGrant,
394 User,
395 DoomLoopGuard,
396 System,
397}
398#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
399#[serde(rename_all = "snake_case")]
400pub enum ApprovalStatus {
401 Pending,
402 Escalated,
403 Approved,
404 Rejected,
405 Cancelled,
406 Expired,
407}
408
409#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
410#[serde(deny_unknown_fields)]
411pub struct ApprovalFeedback {
412 pub message: SafeErrorMessage,
413}
414#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
415#[serde(rename_all = "snake_case")]
416pub enum ApprovalInternalDecisionKind {
417 Allow,
418 Deny,
419 Ask,
420 Escalate,
421}
422#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize)]
423#[serde(deny_unknown_fields)]
424pub struct ApprovalInternalDecision {
425 pub decision: ApprovalInternalDecisionKind,
426 pub source: ApprovalDecisionSource,
427 pub reason_code: ApprovalReasonCode,
428 #[schemars(length(min = 1, max = 256))]
429 pub evaluations: Vec<ApprovalEvaluation>,
430}
431impl ApprovalInternalDecision {
432 pub fn validate(&self) -> Result<(), ApprovalSchemaError> {
433 if self.evaluations.is_empty() {
434 return Err(ApprovalSchemaError::MissingEvaluation);
435 }
436 if self.evaluations.len() > 256 {
437 return Err(ApprovalSchemaError::TooManyEvaluations);
438 }
439 let coherent = match self.decision {
440 ApprovalInternalDecisionKind::Allow => matches!(
441 self.reason_code,
442 ApprovalReasonCode::PolicyAllowed
443 | ApprovalReasonCode::InternalAgentAllowed
444 | ApprovalReasonCode::YoloApproved
445 | ApprovalReasonCode::TreeGrantMatched
446 ),
447 ApprovalInternalDecisionKind::Deny => matches!(
448 self.reason_code,
449 ApprovalReasonCode::PolicyDenied
450 | ApprovalReasonCode::InternalAgentDenied
451 | ApprovalReasonCode::DoomLoopDetected
452 | ApprovalReasonCode::ConstraintViolation
453 | ApprovalReasonCode::OperationChanged
454 | ApprovalReasonCode::PreparedCapabilityLost
455 | ApprovalReasonCode::UnsupportedPlatform
456 | ApprovalReasonCode::Unattended
457 | ApprovalReasonCode::SystemError
458 ),
459 ApprovalInternalDecisionKind::Ask => matches!(
460 self.reason_code,
461 ApprovalReasonCode::PolicyRequiresApproval | ApprovalReasonCode::ModelRequested
462 ),
463 ApprovalInternalDecisionKind::Escalate => {
464 self.reason_code == ApprovalReasonCode::Escalated
465 }
466 };
467 let source_coherent = match self.reason_code {
468 ApprovalReasonCode::PolicyAllowed
469 | ApprovalReasonCode::PolicyDenied
470 | ApprovalReasonCode::PolicyRequiresApproval
471 | ApprovalReasonCode::YoloApproved => self.source == ApprovalDecisionSource::Policy,
472 ApprovalReasonCode::ModelRequested => self.source == ApprovalDecisionSource::Model,
473 ApprovalReasonCode::InternalAgentAllowed | ApprovalReasonCode::InternalAgentDenied => {
474 self.source == ApprovalDecisionSource::InternalAgent
475 }
476 ApprovalReasonCode::TreeGrantMatched => {
477 self.source == ApprovalDecisionSource::TreeGrant
478 }
479 ApprovalReasonCode::DoomLoopDetected => {
480 self.source == ApprovalDecisionSource::DoomLoopGuard
481 }
482 ApprovalReasonCode::Escalated => !matches!(
483 self.source,
484 ApprovalDecisionSource::TreeGrant
485 | ApprovalDecisionSource::User
486 | ApprovalDecisionSource::PermissionMode
487 | ApprovalDecisionSource::DoomLoopGuard
488 ),
489 _ => self.source == ApprovalDecisionSource::System,
490 };
491 if coherent && source_coherent {
492 Ok(())
493 } else {
494 Err(ApprovalSchemaError::ContradictoryDecision)
495 }
496 }
497}
498impl<'de> Deserialize<'de> for ApprovalInternalDecision {
499 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
500 where
501 D: serde::Deserializer<'de>,
502 {
503 #[derive(Deserialize)]
504 #[serde(deny_unknown_fields)]
505 struct Wire {
506 decision: ApprovalInternalDecisionKind,
507 source: ApprovalDecisionSource,
508 reason_code: ApprovalReasonCode,
509 evaluations: Vec<ApprovalEvaluation>,
510 }
511 let wire = Wire::deserialize(deserializer)?;
512 let value = Self {
513 decision: wire.decision,
514 source: wire.source,
515 reason_code: wire.reason_code,
516 evaluations: wire.evaluations,
517 };
518 value.validate().map_err(serde::de::Error::custom)?;
519 Ok(value)
520 }
521}
522#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
523#[serde(rename_all = "snake_case")]
524pub enum ApprovalUserDecision {
525 ApproveOnce,
526 ApproveTree,
527 Reject,
528 Cancel,
529}
530#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
531#[serde(rename_all = "snake_case")]
532pub enum ApprovalFinalOutcome {
533 Approved,
534 Rejected,
535 Cancelled,
536 Expired,
537}
538#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize)]
539#[serde(deny_unknown_fields)]
540pub struct ApprovalFinalDecision {
541 pub outcome: ApprovalFinalOutcome,
542 pub source: ApprovalDecisionSource,
543 pub reason_code: ApprovalReasonCode,
544 #[serde(deserialize_with = "crate::deserialize_required_option")]
545 #[schemars(with = "crate::NullableSchema<ApprovalFeedback>", required)]
546 pub feedback: Option<ApprovalFeedback>,
547 #[serde(deserialize_with = "crate::deserialize_required_option")]
548 #[schemars(with = "crate::NullableSchema<TreeApprovalGrantId>", required)]
549 pub tree_grant_id: Option<TreeApprovalGrantId>,
550}
551impl ApprovalFinalDecision {
552 pub fn validate(&self) -> Result<(), ApprovalSchemaError> {
553 let outcome_coherent = match self.outcome {
554 ApprovalFinalOutcome::Approved => matches!(
555 self.reason_code,
556 ApprovalReasonCode::PolicyAllowed
557 | ApprovalReasonCode::InternalAgentAllowed
558 | ApprovalReasonCode::AutoApproveYApproved
559 | ApprovalReasonCode::YoloApproved
560 | ApprovalReasonCode::TreeGrantMatched
561 | ApprovalReasonCode::UserApprovedOnce
562 | ApprovalReasonCode::UserApprovedTree
563 ),
564 ApprovalFinalOutcome::Rejected => matches!(
565 self.reason_code,
566 ApprovalReasonCode::PolicyDenied
567 | ApprovalReasonCode::InternalAgentDenied
568 | ApprovalReasonCode::AutoApproveNRejected
569 | ApprovalReasonCode::UserRejected
570 | ApprovalReasonCode::DoomLoopDetected
571 | ApprovalReasonCode::ConstraintViolation
572 | ApprovalReasonCode::UnsupportedPlatform
573 | ApprovalReasonCode::Unattended
574 | ApprovalReasonCode::SystemError
575 ),
576 ApprovalFinalOutcome::Cancelled => matches!(
577 self.reason_code,
578 ApprovalReasonCode::UserCancelled
579 | ApprovalReasonCode::RequestCancelled
580 | ApprovalReasonCode::OperationChanged
581 | ApprovalReasonCode::PreparedCapabilityLost
582 ),
583 ApprovalFinalOutcome::Expired => {
584 self.reason_code == ApprovalReasonCode::ApprovalExpired
585 }
586 };
587 let source_coherent = match self.reason_code {
588 ApprovalReasonCode::UserApprovedOnce
589 | ApprovalReasonCode::UserApprovedTree
590 | ApprovalReasonCode::UserRejected
591 | ApprovalReasonCode::UserCancelled => self.source == ApprovalDecisionSource::User,
592 ApprovalReasonCode::PolicyAllowed
593 | ApprovalReasonCode::PolicyDenied
594 | ApprovalReasonCode::YoloApproved => self.source == ApprovalDecisionSource::Policy,
595 ApprovalReasonCode::InternalAgentAllowed | ApprovalReasonCode::InternalAgentDenied => {
596 self.source == ApprovalDecisionSource::InternalAgent
597 }
598 ApprovalReasonCode::AutoApproveNRejected | ApprovalReasonCode::AutoApproveYApproved => {
599 self.source == ApprovalDecisionSource::PermissionMode
600 }
601 ApprovalReasonCode::TreeGrantMatched => {
602 self.source == ApprovalDecisionSource::TreeGrant
603 }
604 ApprovalReasonCode::DoomLoopDetected => {
605 self.source == ApprovalDecisionSource::DoomLoopGuard
606 }
607 _ => self.source == ApprovalDecisionSource::System,
608 };
609 let grant_coherent = self.tree_grant_id.is_some()
610 == matches!(
611 self.reason_code,
612 ApprovalReasonCode::UserApprovedTree | ApprovalReasonCode::TreeGrantMatched
613 );
614 if outcome_coherent && source_coherent && grant_coherent {
615 Ok(())
616 } else {
617 Err(ApprovalSchemaError::ContradictoryDecision)
618 }
619 }
620}
621impl<'de> Deserialize<'de> for ApprovalFinalDecision {
622 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
623 where
624 D: serde::Deserializer<'de>,
625 {
626 #[derive(Deserialize)]
627 #[serde(deny_unknown_fields)]
628 struct Wire {
629 outcome: ApprovalFinalOutcome,
630 source: ApprovalDecisionSource,
631 reason_code: ApprovalReasonCode,
632 #[serde(deserialize_with = "crate::deserialize_required_option")]
633 feedback: Option<ApprovalFeedback>,
634 #[serde(deserialize_with = "crate::deserialize_required_option")]
635 tree_grant_id: Option<TreeApprovalGrantId>,
636 }
637 let wire = Wire::deserialize(deserializer)?;
638 let value = Self {
639 outcome: wire.outcome,
640 source: wire.source,
641 reason_code: wire.reason_code,
642 feedback: wire.feedback,
643 tree_grant_id: wire.tree_grant_id,
644 };
645 value.validate().map_err(serde::de::Error::custom)?;
646 Ok(value)
647 }
648}
649
650#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize)]
651#[serde(deny_unknown_fields)]
652pub struct ApprovalRequest {
653 approval_id: ApprovalId,
654 #[schemars(range(min = 1))]
655 revision: u64,
656 trigger: ApprovalTrigger,
657 operation_fingerprint: OperationFingerprint,
658 operation: PreparedOperationIdentity,
659 #[schemars(length(min = 1, max = 256))]
660 evaluations: Vec<ApprovalEvaluation>,
661 constraints: ApprovalConstraints,
662}
663impl ApprovalRequest {
664 pub fn new(
665 approval_id: ApprovalId,
666 revision: u64,
667 trigger: ApprovalTrigger,
668 operation: PreparedOperationIdentity,
669 evaluations: Vec<ApprovalEvaluation>,
670 constraints: ApprovalConstraints,
671 ) -> Result<Self, ApprovalSchemaError> {
672 if revision == 0 {
673 return Err(ApprovalSchemaError::ZeroRevision);
674 }
675 if evaluations.is_empty() {
676 return Err(ApprovalSchemaError::MissingEvaluation);
677 }
678 if evaluations.len() > 256 {
679 return Err(ApprovalSchemaError::TooManyEvaluations);
680 }
681 let mut resources = operation
682 .resources()
683 .iter()
684 .map(|resource| resource.binding_digest.digest().as_str())
685 .collect::<Vec<_>>();
686 resources.sort_unstable();
687 let mut evaluated = evaluations
688 .iter()
689 .map(|evaluation| evaluation.resource_digest.digest().as_str())
690 .collect::<Vec<_>>();
691 evaluated.sort_unstable();
692 if resources != evaluated {
693 return Err(ApprovalSchemaError::IncompleteEvaluations);
694 }
695 if constraints.allow_tree_grant
696 && operation.resources().iter().any(|resource| {
697 resource.binding_lifetime == PreparedBindingLifetime::ProcessLocal
698 || matches!(
699 resource.capability,
700 PermissionAction::Read | PermissionAction::Write
701 )
702 })
703 {
704 return Err(ApprovalSchemaError::InvalidTreeGrantConstraint);
705 }
706 let operation_fingerprint = OperationFingerprint::from_prepared_operation(&operation);
707 Ok(Self {
708 approval_id,
709 revision,
710 trigger,
711 operation_fingerprint,
712 operation,
713 evaluations,
714 constraints,
715 })
716 }
717 #[must_use]
718 pub const fn approval_id(&self) -> ApprovalId {
719 self.approval_id
720 }
721 #[must_use]
722 pub const fn operation_fingerprint(&self) -> &OperationFingerprint {
723 &self.operation_fingerprint
724 }
725 #[must_use]
726 pub const fn operation(&self) -> &PreparedOperationIdentity {
727 &self.operation
728 }
729}
730impl<'de> Deserialize<'de> for ApprovalRequest {
731 fn deserialize<D>(d: D) -> Result<Self, D::Error>
732 where
733 D: serde::Deserializer<'de>,
734 {
735 #[derive(Deserialize)]
736 #[serde(deny_unknown_fields)]
737 struct Wire {
738 approval_id: ApprovalId,
739 revision: u64,
740 trigger: ApprovalTrigger,
741 operation_fingerprint: OperationFingerprint,
742 operation: PreparedOperationIdentity,
743 evaluations: Vec<ApprovalEvaluation>,
744 constraints: ApprovalConstraints,
745 }
746 let w = Wire::deserialize(d)?;
747 let fingerprint = w.operation_fingerprint.clone();
748 let value = Self::new(
749 w.approval_id,
750 w.revision,
751 w.trigger,
752 w.operation,
753 w.evaluations,
754 w.constraints,
755 )
756 .map_err(serde::de::Error::custom)?;
757 if value.operation_fingerprint != fingerprint {
758 return Err(serde::de::Error::custom(
759 "approval operation fingerprint mismatch",
760 ));
761 }
762 Ok(value)
763 }
764}
765
766#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize)]
767#[serde(deny_unknown_fields)]
768pub struct TreeApprovalGrant {
769 pub grant_id: TreeApprovalGrantId,
770 pub root_session_id: SessionId,
771 pub approval_id: ApprovalId,
772 pub operation_fingerprint: OperationFingerprint,
773 #[schemars(length(min = 1, max = 256))]
774 pub capabilities: Vec<ApprovalCapability>,
775 #[schemars(length(min = 1, max = 256))]
776 pub resources: Vec<PreparedApprovalResource>,
777 pub created_at: Timestamp,
778}
779impl TreeApprovalGrant {
780 pub fn validate(&self) -> Result<(), ApprovalSchemaError> {
781 if self.capabilities.is_empty() || self.capabilities.len() > 256 {
782 return Err(ApprovalSchemaError::MissingCapability);
783 }
784 if self.resources.is_empty() {
785 return Err(ApprovalSchemaError::MissingResource);
786 }
787 if self.resources.len() > 256 {
788 return Err(ApprovalSchemaError::TooManyResources);
789 }
790 if self
791 .resources
792 .iter()
793 .any(|resource| resource.binding_lifetime != PreparedBindingLifetime::RestartStable)
794 {
795 return Err(ApprovalSchemaError::ProcessLocalTreeGrant);
796 }
797 if self.resources.iter().any(|resource| {
798 matches!(
799 resource.capability,
800 PermissionAction::Read | PermissionAction::Write
801 )
802 }) {
803 return Err(ApprovalSchemaError::FilesystemTreeGrant);
804 }
805 if self.resources.iter().any(|resource| {
806 !self
807 .capabilities
808 .iter()
809 .any(|capability| capability.action == resource.capability)
810 }) {
811 return Err(ApprovalSchemaError::UnboundResourceCapability);
812 }
813 Ok(())
814 }
815}
816impl<'de> Deserialize<'de> for TreeApprovalGrant {
817 fn deserialize<D>(d: D) -> Result<Self, D::Error>
818 where
819 D: serde::Deserializer<'de>,
820 {
821 #[derive(Deserialize)]
822 #[serde(deny_unknown_fields)]
823 struct Wire {
824 grant_id: TreeApprovalGrantId,
825 root_session_id: SessionId,
826 approval_id: ApprovalId,
827 operation_fingerprint: OperationFingerprint,
828 capabilities: Vec<ApprovalCapability>,
829 resources: Vec<PreparedApprovalResource>,
830 created_at: Timestamp,
831 }
832 let w = Wire::deserialize(d)?;
833 let value = Self {
834 grant_id: w.grant_id,
835 root_session_id: w.root_session_id,
836 approval_id: w.approval_id,
837 operation_fingerprint: w.operation_fingerprint,
838 capabilities: w.capabilities,
839 resources: w.resources,
840 created_at: w.created_at,
841 };
842 value.validate().map_err(serde::de::Error::custom)?;
843 Ok(value)
844 }
845}
846
847#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize)]
848#[serde(deny_unknown_fields)]
849pub struct ApprovalRecord {
850 pub session_id: SessionId,
851 pub request: ApprovalRequest,
852 pub status: ApprovalStatus,
853 #[serde(deserialize_with = "crate::deserialize_required_option")]
854 #[schemars(with = "crate::NullableSchema<ApprovalInternalDecision>", required)]
855 pub internal_decision: Option<ApprovalInternalDecision>,
856 #[serde(deserialize_with = "crate::deserialize_required_option")]
857 #[schemars(with = "crate::NullableSchema<ApprovalUserDecision>", required)]
858 pub user_decision: Option<ApprovalUserDecision>,
859 #[serde(deserialize_with = "crate::deserialize_required_option")]
860 #[schemars(with = "crate::NullableSchema<ApprovalFinalDecision>", required)]
861 pub final_decision: Option<ApprovalFinalDecision>,
862}
863impl ApprovalRecord {
864 pub fn validate(&self) -> Result<(), ApprovalSchemaError> {
865 if let Some(internal) = &self.internal_decision {
866 internal.validate()?;
867 }
868 if let Some(final_decision) = &self.final_decision {
869 final_decision.validate()?;
870 }
871 let coherent = match self.status {
872 ApprovalStatus::Pending => {
873 self.user_decision.is_none()
874 && self.final_decision.is_none()
875 && self.internal_decision.as_ref().is_none_or(|decision| {
876 decision.decision == ApprovalInternalDecisionKind::Ask
877 })
878 }
879 ApprovalStatus::Escalated => {
880 self.internal_decision.as_ref().is_some_and(|decision| {
881 decision.decision == ApprovalInternalDecisionKind::Escalate
882 }) && self.user_decision.is_none()
883 && self.final_decision.is_none()
884 }
885 ApprovalStatus::Approved => self
886 .final_decision
887 .as_ref()
888 .is_some_and(|decision| decision.outcome == ApprovalFinalOutcome::Approved),
889 ApprovalStatus::Rejected => self
890 .final_decision
891 .as_ref()
892 .is_some_and(|decision| decision.outcome == ApprovalFinalOutcome::Rejected),
893 ApprovalStatus::Cancelled => self
894 .final_decision
895 .as_ref()
896 .is_some_and(|decision| decision.outcome == ApprovalFinalOutcome::Cancelled),
897 ApprovalStatus::Expired => self
898 .final_decision
899 .as_ref()
900 .is_some_and(|decision| decision.outcome == ApprovalFinalOutcome::Expired),
901 };
902 let decisions_coherent = match &self.final_decision {
903 None => self.user_decision.is_none(),
904 Some(final_decision) if final_decision.source == ApprovalDecisionSource::User => {
905 self.internal_decision.as_ref().is_some_and(|decision| {
906 decision.decision == ApprovalInternalDecisionKind::Escalate
907 }) && self.user_decision.is_some_and(|user| {
908 matches!(
909 (user, final_decision.outcome, final_decision.reason_code),
910 (
911 ApprovalUserDecision::ApproveOnce,
912 ApprovalFinalOutcome::Approved,
913 ApprovalReasonCode::UserApprovedOnce
914 ) | (
915 ApprovalUserDecision::ApproveTree,
916 ApprovalFinalOutcome::Approved,
917 ApprovalReasonCode::UserApprovedTree
918 ) | (
919 ApprovalUserDecision::Reject,
920 ApprovalFinalOutcome::Rejected,
921 ApprovalReasonCode::UserRejected
922 ) | (
923 ApprovalUserDecision::Cancel,
924 ApprovalFinalOutcome::Cancelled,
925 ApprovalReasonCode::UserCancelled
926 )
927 )
928 })
929 }
930 Some(final_decision)
931 if final_decision.source == ApprovalDecisionSource::PermissionMode =>
932 {
933 self.user_decision.is_none()
934 && self.internal_decision.as_ref().is_some_and(|internal| {
935 internal.decision == ApprovalInternalDecisionKind::Escalate
936 && internal.source == ApprovalDecisionSource::InternalAgent
937 && internal.reason_code == ApprovalReasonCode::Escalated
938 })
939 }
940 Some(final_decision)
941 if matches!(
942 final_decision.outcome,
943 ApprovalFinalOutcome::Approved | ApprovalFinalOutcome::Rejected
944 ) =>
945 {
946 self.user_decision.is_none()
947 && self.internal_decision.as_ref().is_some_and(|internal| {
948 internal.source == final_decision.source
949 && internal.reason_code == final_decision.reason_code
950 && matches!(
951 (internal.decision, final_decision.outcome),
952 (
953 ApprovalInternalDecisionKind::Allow,
954 ApprovalFinalOutcome::Approved
955 ) | (
956 ApprovalInternalDecisionKind::Deny,
957 ApprovalFinalOutcome::Rejected
958 )
959 )
960 })
961 }
962 Some(_) => self.user_decision.is_none() && self.internal_decision.is_none(),
963 };
964 if coherent && decisions_coherent {
965 Ok(())
966 } else {
967 Err(ApprovalSchemaError::ContradictoryLifecycle)
968 }
969 }
970}
971impl<'de> Deserialize<'de> for ApprovalRecord {
972 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
973 where
974 D: serde::Deserializer<'de>,
975 {
976 #[derive(Deserialize)]
977 #[serde(deny_unknown_fields)]
978 struct Wire {
979 session_id: SessionId,
980 request: ApprovalRequest,
981 status: ApprovalStatus,
982 #[serde(deserialize_with = "crate::deserialize_required_option")]
983 internal_decision: Option<ApprovalInternalDecision>,
984 #[serde(deserialize_with = "crate::deserialize_required_option")]
985 user_decision: Option<ApprovalUserDecision>,
986 #[serde(deserialize_with = "crate::deserialize_required_option")]
987 final_decision: Option<ApprovalFinalDecision>,
988 }
989 let wire = Wire::deserialize(deserializer)?;
990 let value = Self {
991 session_id: wire.session_id,
992 request: wire.request,
993 status: wire.status,
994 internal_decision: wire.internal_decision,
995 user_decision: wire.user_decision,
996 final_decision: wire.final_decision,
997 };
998 value.validate().map_err(serde::de::Error::custom)?;
999 Ok(value)
1000 }
1001}
1002
1003#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1004pub enum ApprovalSchemaError {
1005 InvalidPreparedIdentity,
1006 MissingCapability,
1007 MissingResource,
1008 TooManyResources,
1009 UnboundResourceCapability,
1010 DuplicateResourceBinding,
1011 ZeroRevision,
1012 MissingEvaluation,
1013 IncompleteEvaluations,
1014 InvalidTreeGrantConstraint,
1015 ProcessLocalTreeGrant,
1016 FilesystemTreeGrant,
1017 TooManyEvaluations,
1018 ContradictoryDecision,
1019 ContradictoryLifecycle,
1020}
1021impl fmt::Display for ApprovalSchemaError {
1022 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1023 f.write_str(match self {
1024 Self::InvalidPreparedIdentity => "invalid stable prepared resource identity",
1025 Self::MissingCapability => "prepared operation requires at least one capability",
1026 Self::MissingResource => "prepared operation requires at least one resource",
1027 Self::TooManyResources => "prepared operation exceeds 256 resources",
1028 Self::UnboundResourceCapability => {
1029 "prepared resource capability is not present in operation capabilities"
1030 }
1031 Self::DuplicateResourceBinding => "prepared resource binding digests must be unique",
1032 Self::ZeroRevision => "approval revision must be positive",
1033 Self::MissingEvaluation => "approval requires at least one evaluation",
1034 Self::IncompleteEvaluations => {
1035 "approval evaluations must exactly cover prepared resources"
1036 }
1037 Self::InvalidTreeGrantConstraint => {
1038 "process-local or filesystem resources cannot allow tree grants"
1039 }
1040 Self::ProcessLocalTreeGrant => "tree grants require restart-stable resources",
1041 Self::FilesystemTreeGrant => "filesystem resources cannot be durable tree grants",
1042 Self::TooManyEvaluations => "approval evaluations exceed 256 entries",
1043 Self::ContradictoryDecision => {
1044 "approval decision source, reason, and outcome contradict"
1045 }
1046 Self::ContradictoryLifecycle => "approval lifecycle status and decisions contradict",
1047 })
1048 }
1049}
1050impl std::error::Error for ApprovalSchemaError {}