1use chrono::{DateTime, Utc};
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value};
7use sha2::{Digest, Sha256};
8use std::collections::HashMap;
9use thiserror::Error;
10use uuid::Uuid;
11
12#[derive(Debug, Error)]
14pub enum ApprovalError {
15 #[error("validation error: {0}")]
16 Validation(String),
17 #[error("conflict: {0}")]
18 Conflict(String),
19 #[error(transparent)]
20 Json(#[from] serde_json::Error),
21}
22
23pub type Result<T> = std::result::Result<T, ApprovalError>;
24
25#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct Origin {
28 pub harness: String,
29 pub session_id: String,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub run_id: Option<String>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub task_id: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub graph_id: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub node_id: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub agent_id: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub profile: Option<String>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub subagent_run_id: Option<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub parent_run_id: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub project: Option<String>,
48}
49
50#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct Presentation {
53 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
54 pub redacted: bool,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub binding_hint: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub details: Option<String>,
59}
60
61#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct Action {
64 pub kind: String,
65 pub name: String,
66 pub summary: String,
67 pub arguments: Map<String, Value>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub resource: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub working_directory: Option<String>,
72 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub effects: Vec<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub presentation: Option<Presentation>,
76}
77
78#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct Risk {
81 pub level: String,
82 pub reasons: Vec<String>,
83}
84
85#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct Choice {
88 pub decision: String,
89 pub scope: String,
90 pub label: String,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub scope_constraints: Option<Map<String, Value>>,
93}
94
95#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct Request {
98 pub id: String,
99 pub created_at: String,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub expires_at: Option<String>,
102 pub status: String,
103 pub origin: Origin,
104 pub action: Action,
105 pub action_digest: String,
106 pub risk: Risk,
107 pub choices: Vec<Choice>,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub extensions: Option<Map<String, Value>>,
110}
111
112#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct Actor {
115 pub id: String,
116 #[serde(rename = "type")]
117 pub actor_type: String,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub display_name: Option<String>,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub authenticated_by: Option<String>,
122}
123
124#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct Decision {
127 pub id: String,
128 pub request_id: String,
129 pub action_digest: String,
130 pub decided_at: String,
131 pub decision: String,
132 pub scope: String,
133 pub actor: Actor,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub extensions: Option<Map<String, Value>>,
136}
137
138#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct Resolution {
141 pub id: String,
142 pub request_id: String,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub decision_id: Option<String>,
145 pub action_digest: String,
146 pub resolved_at: String,
147 pub outcome: String,
148 pub message: String,
149 #[serde(skip_serializing_if = "Option::is_none")]
150 pub effective_scope: Option<String>,
151 #[serde(skip_serializing_if = "Option::is_none")]
152 pub extensions: Option<Map<String, Value>>,
153}
154
155#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct Snapshot {
158 pub as_of_sequence: u64,
159 pub pending: Vec<Request>,
160}
161
162#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct Activity {
165 pub request_id: String,
166 pub message: String,
167 #[serde(skip_serializing_if = "Option::is_none")]
168 pub stage: Option<String>,
169}
170
171#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
172#[serde(deny_unknown_fields)]
173pub struct Envelope {
174 pub aais: String,
175 #[serde(rename = "type")]
176 pub event_type: String,
177 pub id: String,
178 pub occurred_at: String,
179 pub sequence: u64,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub stream: Option<String>,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 pub extensions: Option<Map<String, Value>>,
184 #[serde(skip_serializing_if = "Option::is_none")]
185 pub request: Option<Request>,
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub decision: Option<Decision>,
188 #[serde(skip_serializing_if = "Option::is_none")]
189 pub resolution: Option<Resolution>,
190 #[serde(skip_serializing_if = "Option::is_none")]
191 pub snapshot: Option<Snapshot>,
192 #[serde(skip_serializing_if = "Option::is_none")]
193 pub activity: Option<Activity>,
194}
195
196fn valid_id(value: &str) -> bool {
197 !value.is_empty()
198 && value.len() <= 200
199 && Regex::new(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$")
200 .unwrap()
201 .is_match(value)
202}
203fn parse_time(value: &str) -> Result<DateTime<Utc>> {
204 if !(value.ends_with('Z') || Regex::new(r"[+-]\d\d:\d\d$").unwrap().is_match(value)) {
205 return Err(ApprovalError::Validation(
206 "timestamp requires an explicit offset".into(),
207 ));
208 }
209 DateTime::parse_from_rfc3339(value)
210 .map(|v| v.with_timezone(&Utc))
211 .map_err(|_| ApprovalError::Validation("invalid RFC 3339 timestamp".into()))
212}
213
214pub fn action_digest(action: &Action) -> Result<String> {
216 let canonical =
217 serde_jcs::to_vec(action).map_err(|e| ApprovalError::Validation(e.to_string()))?;
218 Ok(format!("sha256:{:x}", Sha256::digest(canonical)))
219}
220
221pub struct CreateRequestOptions {
223 pub action: Action,
224 pub origin: Origin,
225 pub risk: Risk,
226 pub choices: Vec<Choice>,
227 pub sequence: u64,
228 pub stream: Option<String>,
229 pub request_id: Option<String>,
230 pub event_id: Option<String>,
231 pub created_at: Option<DateTime<Utc>>,
232 pub expires_at: Option<DateTime<Utc>>,
233}
234
235pub fn create_request(options: CreateRequestOptions) -> Result<Envelope> {
237 let created = options.created_at.unwrap_or_else(Utc::now);
238 let request = Request {
239 id: options
240 .request_id
241 .unwrap_or_else(|| format!("apr_{}", Uuid::new_v4().simple())),
242 created_at: created.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true),
243 expires_at: options
244 .expires_at
245 .map(|value| value.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)),
246 status: "pending".into(),
247 origin: options.origin,
248 action_digest: action_digest(&options.action)?,
249 action: options.action,
250 risk: options.risk,
251 choices: options.choices,
252 extensions: None,
253 };
254 let envelope = Envelope {
255 aais: "1.0".into(),
256 event_type: "approval.requested".into(),
257 id: options
258 .event_id
259 .unwrap_or_else(|| format!("evt_{}", Uuid::new_v4().simple())),
260 occurred_at: request.created_at.clone(),
261 sequence: options.sequence,
262 stream: options.stream,
263 extensions: None,
264 request: Some(request),
265 decision: None,
266 resolution: None,
267 snapshot: None,
268 activity: None,
269 };
270 validate(&envelope)?;
271 Ok(envelope)
272}
273
274pub struct CreateDecisionOptions {
276 pub decision: String,
277 pub scope: String,
278 pub actor: Actor,
279 pub sequence: u64,
280 pub stream: Option<String>,
281 pub decision_id: Option<String>,
282 pub event_id: Option<String>,
283 pub decided_at: Option<DateTime<Utc>>,
284}
285
286pub fn create_decision(requested: &Envelope, options: CreateDecisionOptions) -> Result<Envelope> {
288 validate(requested)?;
289 if requested.event_type != "approval.requested" {
290 return Err(ApprovalError::Validation(
291 "create_decision requires approval.requested".into(),
292 ));
293 }
294 let at = options.decided_at.unwrap_or_else(Utc::now);
295 let request = requested.request.as_ref().unwrap();
296 let decision = Decision {
297 id: options
298 .decision_id
299 .unwrap_or_else(|| format!("dec_{}", Uuid::new_v4().simple())),
300 request_id: request.id.clone(),
301 action_digest: request.action_digest.clone(),
302 decided_at: at.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true),
303 decision: options.decision,
304 scope: options.scope,
305 actor: options.actor,
306 extensions: None,
307 };
308 let envelope = Envelope {
309 aais: "1.0".into(),
310 event_type: "approval.decided".into(),
311 id: options
312 .event_id
313 .unwrap_or_else(|| format!("evt_{}", Uuid::new_v4().simple())),
314 occurred_at: decision.decided_at.clone(),
315 sequence: options.sequence,
316 stream: options.stream,
317 extensions: None,
318 request: None,
319 decision: Some(decision),
320 resolution: None,
321 snapshot: None,
322 activity: None,
323 };
324 validate(&envelope)?;
325 Ok(envelope)
326}
327
328pub fn parse(data: &str) -> Result<Envelope> {
330 let envelope: Envelope = serde_json::from_str(data)?;
331 validate(&envelope)?;
332 Ok(envelope)
333}
334
335pub fn validate(e: &Envelope) -> Result<()> {
337 if e.aais != "1.0" || !valid_id(&e.id) {
338 return Err(ApprovalError::Validation(
339 "invalid envelope version or id".into(),
340 ));
341 }
342 parse_time(&e.occurred_at)?;
343 let payloads = [
344 e.request.is_some(),
345 e.decision.is_some(),
346 e.resolution.is_some(),
347 e.snapshot.is_some(),
348 e.activity.is_some(),
349 ]
350 .iter()
351 .filter(|x| **x)
352 .count();
353 if payloads != 1 {
354 return Err(ApprovalError::Validation(
355 "exactly one payload is required".into(),
356 ));
357 }
358 match e.event_type.as_str() {
359 "approval.requested" => validate_request(
360 e.request
361 .as_ref()
362 .ok_or_else(|| ApprovalError::Validation("request payload required".into()))?,
363 ),
364 "approval.decided" => validate_decision(
365 e.decision
366 .as_ref()
367 .ok_or_else(|| ApprovalError::Validation("decision payload required".into()))?,
368 ),
369 "approval.resolved" => validate_resolution(
370 e.resolution
371 .as_ref()
372 .ok_or_else(|| ApprovalError::Validation("resolution payload required".into()))?,
373 ),
374 "approval.snapshot" => {
375 let snapshot = e
376 .snapshot
377 .as_ref()
378 .ok_or_else(|| ApprovalError::Validation("snapshot payload required".into()))?;
379 for r in &snapshot.pending {
380 validate_request(r)?;
381 }
382 Ok(())
383 }
384 "approval.activity" => {
385 let a = e
386 .activity
387 .as_ref()
388 .ok_or_else(|| ApprovalError::Validation("activity payload required".into()))?;
389 if !valid_id(&a.request_id) || a.message.is_empty() {
390 Err(ApprovalError::Validation("invalid activity".into()))
391 } else {
392 Ok(())
393 }
394 }
395 _ => Err(ApprovalError::Validation("unknown event type".into())),
396 }
397}
398
399fn validate_request(r: &Request) -> Result<()> {
400 if !valid_id(&r.id)
401 || r.status != "pending"
402 || !valid_id(&r.origin.harness)
403 || !valid_id(&r.origin.session_id)
404 {
405 return Err(ApprovalError::Validation(
406 "invalid request identity or origin".into(),
407 ));
408 }
409 let created = parse_time(&r.created_at)?;
410 if let Some(expires) = &r.expires_at {
411 if parse_time(expires)? <= created {
412 return Err(ApprovalError::Validation(
413 "expires_at must be later than created_at".into(),
414 ));
415 }
416 }
417 if !valid_id(&r.action.kind) || !valid_id(&r.action.name) || r.action.summary.is_empty() {
418 return Err(ApprovalError::Validation("invalid action".into()));
419 }
420 if r.action
421 .presentation
422 .as_ref()
423 .is_some_and(|p| p.redacted && p.binding_hint.as_deref().unwrap_or("").is_empty())
424 {
425 return Err(ApprovalError::Validation(
426 "redacted action requires binding_hint".into(),
427 ));
428 }
429 if action_digest(&r.action)? != r.action_digest {
430 return Err(ApprovalError::Validation(
431 "action_digest does not match action".into(),
432 ));
433 }
434 if !Regex::new(r"^sha256:[0-9a-f]{64}$")
435 .unwrap()
436 .is_match(&r.action_digest)
437 || !["low", "medium", "high", "critical"].contains(&r.risk.level.as_str())
438 || r.risk.reasons.is_empty()
439 || r.choices.is_empty()
440 {
441 return Err(ApprovalError::Validation(
442 "invalid digest, risk, or choices".into(),
443 ));
444 }
445 let mut exit = false;
446 let mut seen = std::collections::HashSet::new();
447 for c in &r.choices {
448 if !["approve", "deny", "cancel"].contains(&c.decision.as_str())
449 || !["once", "session", "persistent"].contains(&c.scope.as_str())
450 || c.label.is_empty()
451 {
452 return Err(ApprovalError::Validation("invalid choice".into()));
453 }
454 if c.decision != "approve" {
455 exit = true;
456 if c.scope != "once" {
457 return Err(ApprovalError::Validation(
458 "deny/cancel choice invalid".into(),
459 ));
460 }
461 }
462 if !seen.insert((c.decision.as_str(), c.scope.as_str())) {
463 return Err(ApprovalError::Validation(
464 "decision and scope tuples must be unique".into(),
465 ));
466 }
467 if c.scope != "once" && c.scope_constraints.as_ref().is_none_or(Map::is_empty) {
468 return Err(ApprovalError::Validation(
469 "broader scope requires constraints".into(),
470 ));
471 }
472 }
473 if !exit {
474 return Err(ApprovalError::Validation(
475 "at least one deny or cancel choice is required".into(),
476 ));
477 }
478 Ok(())
479}
480fn validate_decision(d: &Decision) -> Result<()> {
481 if !valid_id(&d.id)
482 || !valid_id(&d.request_id)
483 || !Regex::new(r"^sha256:[0-9a-f]{64}$")
484 .unwrap()
485 .is_match(&d.action_digest)
486 || !["approve", "deny", "cancel"].contains(&d.decision.as_str())
487 || !["once", "session", "persistent"].contains(&d.scope.as_str())
488 || !valid_id(&d.actor.id)
489 || !["human", "policy"].contains(&d.actor.actor_type.as_str())
490 {
491 return Err(ApprovalError::Validation("invalid decision".into()));
492 }
493 parse_time(&d.decided_at)?;
494 if d.decision != "approve" && d.scope != "once" {
495 return Err(ApprovalError::Validation(
496 "deny/cancel decision invalid".into(),
497 ));
498 }
499 Ok(())
500}
501fn validate_resolution(r: &Resolution) -> Result<()> {
502 if !valid_id(&r.id)
503 || !valid_id(&r.request_id)
504 || !Regex::new(r"^sha256:[0-9a-f]{64}$")
505 .unwrap()
506 .is_match(&r.action_digest)
507 || ![
508 "approved",
509 "denied",
510 "cancelled",
511 "expired",
512 "stale",
513 "conflict",
514 "invalid",
515 ]
516 .contains(&r.outcome.as_str())
517 || r.message.is_empty()
518 {
519 return Err(ApprovalError::Validation("invalid resolution".into()));
520 }
521 parse_time(&r.resolved_at)?;
522 Ok(())
523}
524
525#[derive(Default)]
527pub struct ApprovalStore {
528 pending: HashMap<String, Envelope>,
529 resolutions: HashMap<String, Envelope>,
530 fingerprints: HashMap<String, String>,
531 pub last_sequence: u64,
532}
533impl ApprovalStore {
534 pub fn new() -> Self {
535 Self::default()
536 }
537 pub fn add(&mut self, e: Envelope) -> Result<()> {
538 validate(&e)?;
539 if e.event_type != "approval.requested" {
540 return Err(ApprovalError::Validation(
541 "add requires approval.requested".into(),
542 ));
543 }
544 let id = e.request.as_ref().unwrap().id.clone();
545 if let Some(previous) = self.pending.get(&id) {
546 if previous != &e {
547 return Err(ApprovalError::Conflict(format!(
548 "request {id} already exists"
549 )));
550 }
551 return Ok(());
552 }
553 self.last_sequence = self.last_sequence.max(e.sequence);
554 self.pending.insert(id, e);
555 Ok(())
556 }
557 pub fn decide(
558 &mut self,
559 e: Envelope,
560 now: DateTime<Utc>,
561 current_action: Option<&Action>,
562 ) -> Result<Envelope> {
563 validate(&e)?;
564 if e.event_type != "approval.decided" {
565 return Err(ApprovalError::Validation(
566 "decide requires approval.decided".into(),
567 ));
568 }
569 let d = e.decision.as_ref().unwrap();
570 let fingerprint = format!(
571 "sha256:{:x}",
572 Sha256::digest(
573 serde_jcs::to_vec(d).map_err(|x| ApprovalError::Validation(x.to_string()))?
574 )
575 );
576 if let Some(previous) = self.resolutions.get(&d.request_id) {
577 if self.fingerprints.get(&d.request_id) == Some(&fingerprint) {
578 return Ok(previous.clone());
579 }
580 return Err(ApprovalError::Conflict("request already resolved".into()));
581 }
582 let requested = self
583 .pending
584 .get(&d.request_id)
585 .ok_or_else(|| ApprovalError::Validation("unknown pending request".into()))?;
586 let r = requested.request.as_ref().unwrap();
587 let (mut outcome, mut message) = match d.decision.as_str() {
588 "approve" => ("approved", "Approval accepted."),
589 "deny" => ("denied", "Action denied."),
590 _ => ("cancelled", "Approval cancelled."),
591 };
592 if r.expires_at
593 .as_ref()
594 .is_some_and(|x| parse_time(x).is_ok_and(|t| now >= t))
595 {
596 outcome = "expired";
597 message = "Approval request expired.";
598 } else if d.action_digest != r.action_digest
599 || current_action.is_some_and(|a| action_digest(a).is_ok_and(|x| x != r.action_digest))
600 {
601 outcome = "stale";
602 message = "The decision does not match the current action.";
603 } else if r
604 .choices
605 .iter()
606 .all(|c| c.decision != d.decision || c.scope != d.scope)
607 {
608 outcome = "invalid";
609 message = "The selected decision and scope were not offered.";
610 }
611 self.last_sequence = (self.last_sequence + 1).max(e.sequence);
612 let at = now.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true);
613 let resolution = Resolution {
614 id: format!("res_{}", d.id),
615 request_id: d.request_id.clone(),
616 decision_id: Some(d.id.clone()),
617 action_digest: r.action_digest.clone(),
618 resolved_at: at.clone(),
619 outcome: outcome.into(),
620 message: message.into(),
621 effective_scope: matches!(outcome, "approved" | "denied").then(|| d.scope.clone()),
622 extensions: None,
623 };
624 let result = Envelope {
625 aais: "1.0".into(),
626 event_type: "approval.resolved".into(),
627 id: format!("evt_res_{}", d.id),
628 occurred_at: at,
629 sequence: self.last_sequence,
630 stream: requested.stream.clone(),
631 extensions: None,
632 request: None,
633 decision: None,
634 resolution: Some(resolution),
635 snapshot: None,
636 activity: None,
637 };
638 validate(&result)?;
639 self.pending.remove(&d.request_id);
640 self.resolutions
641 .insert(d.request_id.clone(), result.clone());
642 self.fingerprints.insert(d.request_id.clone(), fingerprint);
643 Ok(result)
644 }
645 pub fn snapshot(&self, stream: Option<String>, now: DateTime<Utc>) -> Envelope {
646 Envelope {
647 aais: "1.0".into(),
648 event_type: "approval.snapshot".into(),
649 id: format!("evt_{}", Uuid::new_v4().simple()),
650 occurred_at: now.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true),
651 sequence: self.last_sequence,
652 stream,
653 extensions: None,
654 request: None,
655 decision: None,
656 resolution: None,
657 snapshot: Some(Snapshot {
658 as_of_sequence: self.last_sequence,
659 pending: self
660 .pending
661 .values()
662 .filter(|e| {
663 e.request
664 .as_ref()
665 .unwrap()
666 .expires_at
667 .as_ref()
668 .is_none_or(|value| {
669 parse_time(value).is_ok_and(|expires| now < expires)
670 })
671 })
672 .map(|e| e.request.clone().unwrap())
673 .collect(),
674 }),
675 activity: None,
676 }
677 }
678 pub fn from_snapshot(e: &Envelope) -> Result<Self> {
679 validate(e)?;
680 if e.event_type != "approval.snapshot" {
681 return Err(ApprovalError::Validation(
682 "from_snapshot requires approval.snapshot".into(),
683 ));
684 }
685 let mut store = Self::new();
686 store.last_sequence = e.snapshot.as_ref().unwrap().as_of_sequence;
687 for request in &e.snapshot.as_ref().unwrap().pending {
688 let wrapper = Envelope {
689 aais: "1.0".into(),
690 event_type: "approval.requested".into(),
691 id: format!("restore_{}", request.id),
692 occurred_at: request.created_at.clone(),
693 sequence: store.last_sequence,
694 stream: e.stream.clone(),
695 extensions: None,
696 request: Some(request.clone()),
697 decision: None,
698 resolution: None,
699 snapshot: None,
700 activity: None,
701 };
702 store.add(wrapper)?;
703 }
704 Ok(store)
705 }
706}