1#![allow(clippy::type_complexity)]
2use std::collections::HashMap;
9
10use crate::glob::glob_match;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(tag = "kind", rename_all = "kebab-case")]
16pub enum GuardDecision {
17 Allow {
18 danger_tags: Vec<String>,
19 },
20 ApprovalRequired {
21 approval: String,
22 reason: String,
23 danger_tags: Vec<String>,
24 },
25 Escalate {
26 reason: String,
27 danger_tags: Vec<String>,
28 },
29 Deny {
30 reason: String,
31 danger_tags: Vec<String>,
32 },
33 LogOnly {
34 reason: String,
35 danger_tags: Vec<String>,
36 },
37}
38
39#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
40pub enum EnforcementLevel {
41 E0,
42 E1,
43 E2,
44 E3,
45 #[default]
46 E4,
47 E5,
48}
49
50impl EnforcementLevel {
51 pub fn parse(s: &str) -> Option<EnforcementLevel> {
52 match s {
53 "E0" => Some(Self::E0),
54 "E1" => Some(Self::E1),
55 "E2" => Some(Self::E2),
56 "E3" => Some(Self::E3),
57 "E4" => Some(Self::E4),
58 "E5" => Some(Self::E5),
59 _ => None,
60 }
61 }
62
63 pub fn as_str(&self) -> &'static str {
64 match self {
65 Self::E0 => "E0",
66 Self::E1 => "E1",
67 Self::E2 => "E2",
68 Self::E3 => "E3",
69 Self::E4 => "E4",
70 Self::E5 => "E5",
71 }
72 }
73}
74
75#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
76pub struct NegativeCapability {
77 pub name: String,
78 #[serde(skip_serializing_if = "Option::is_none", default)]
79 pub target: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none", default)]
81 pub reason: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none", default)]
83 pub overrides: Option<Vec<String>>,
84}
85
86impl GuardDecision {
87 pub fn kind(&self) -> &'static str {
88 match self {
89 GuardDecision::Allow { .. } => "allow",
90 GuardDecision::ApprovalRequired { .. } => "approval-required",
91 GuardDecision::Escalate { .. } => "escalate",
92 GuardDecision::Deny { .. } => "deny",
93 GuardDecision::LogOnly { .. } => "log-only",
94 }
95 }
96
97 pub fn danger_tags(&self) -> &[String] {
98 match self {
99 GuardDecision::Allow { danger_tags }
100 | GuardDecision::ApprovalRequired { danger_tags, .. }
101 | GuardDecision::Escalate { danger_tags, .. }
102 | GuardDecision::Deny { danger_tags, .. }
103 | GuardDecision::LogOnly { danger_tags, .. } => danger_tags,
104 }
105 }
106
107 pub fn reason(&self) -> Option<&str> {
108 match self {
109 GuardDecision::Allow { .. } => None,
110 GuardDecision::ApprovalRequired { reason, .. }
111 | GuardDecision::Escalate { reason, .. }
112 | GuardDecision::Deny { reason, .. }
113 | GuardDecision::LogOnly { reason, .. } => Some(reason),
114 }
115 }
116}
117
118#[derive(Clone, Debug, Default)]
119pub struct GuardQuery {
120 pub actor: Option<String>,
122 pub actor_claim: Option<String>,
125 pub action: String,
126 pub target: Option<String>,
127}
128
129#[derive(Clone, Debug, Serialize, Deserialize)]
130pub struct GuardEventStub {
131 #[serde(rename = "type")]
132 pub kind: String,
133 pub actor: String,
134 pub action: String,
135 #[serde(skip_serializing_if = "Option::is_none")]
136 pub target: Option<String>,
137 pub decision: String,
138 pub danger_tags: Vec<String>,
139 #[serde(skip_serializing_if = "Option::is_none", default)]
140 pub enforcement_level: Option<String>,
141}
142
143#[derive(Clone, Debug)]
144pub struct IndexedAction {
145 pub name: String,
146 pub approval: Option<String>,
147 pub danger_tags: Vec<String>,
148 pub allow_targets: Vec<String>,
149 pub deny_targets: Vec<String>,
150 pub allow_actors: Vec<String>,
151 pub deny_actors: Vec<String>,
152}
153
154const ESCALATE_TAGS: &[&str] = &[
155 "destructive",
156 "irreversible",
157 "financial",
158 "security-sensitive",
159 "legal-exposure",
160];
161
162pub struct AgentGuard {
163 action_by_name: HashMap<String, IndexedAction>,
164 forbidden_by_name: HashMap<String, String>,
165 target_sets: HashMap<String, Vec<String>>,
166 on_event: Option<Box<dyn Fn(&GuardEventStub) + Send + Sync>>,
167 enforcement_level: EnforcementLevel,
168 negative_capabilities: Vec<NegativeCapability>,
169}
170
171impl AgentGuard {
172 pub fn from_contract(contract: &Value) -> Self {
173 let empty_arr = Vec::<Value>::new();
174 let actions_val = contract
175 .get("actions")
176 .and_then(|v| v.as_array())
177 .unwrap_or(&empty_arr);
178 let mut actions = HashMap::new();
179 for a in actions_val {
180 let name = a
181 .get("name")
182 .and_then(|v| v.as_str())
183 .unwrap_or_default()
184 .to_string();
185 let approval = a
186 .get("approval")
187 .and_then(|v| v.as_str())
188 .map(str::to_string);
189 let danger_tags = a
190 .get("danger_tags")
191 .and_then(|v| v.as_array())
192 .map(|arr| {
193 arr.iter()
194 .filter_map(|t| t.as_str())
195 .map(str::to_string)
196 .collect::<Vec<_>>()
197 })
198 .unwrap_or_default();
199 let allow_targets = a
200 .get("allow_targets")
201 .and_then(|v| v.as_array())
202 .map(|arr| {
203 arr.iter()
204 .filter_map(|t| t.as_str())
205 .map(str::to_string)
206 .collect::<Vec<_>>()
207 })
208 .unwrap_or_default();
209 let deny_targets = a
210 .get("deny_targets")
211 .and_then(|v| v.as_array())
212 .map(|arr| {
213 arr.iter()
214 .filter_map(|t| t.as_str())
215 .map(str::to_string)
216 .collect::<Vec<_>>()
217 })
218 .unwrap_or_default();
219 let allow_actors = a
220 .get("allow_actors")
221 .and_then(|v| v.as_array())
222 .map(|arr| {
223 arr.iter()
224 .filter_map(|t| t.as_str())
225 .map(str::to_string)
226 .collect::<Vec<_>>()
227 })
228 .unwrap_or_default();
229 let deny_actors = a
230 .get("deny_actors")
231 .and_then(|v| v.as_array())
232 .map(|arr| {
233 arr.iter()
234 .filter_map(|t| t.as_str())
235 .map(str::to_string)
236 .collect::<Vec<_>>()
237 })
238 .unwrap_or_default();
239 actions.insert(
240 name.clone(),
241 IndexedAction {
242 name,
243 approval,
244 danger_tags,
245 allow_targets,
246 deny_targets,
247 allow_actors,
248 deny_actors,
249 },
250 );
251 }
252
253 let mut forbidden = HashMap::new();
254 for f in contract
255 .get("forbidden")
256 .and_then(|v| v.as_array())
257 .unwrap_or(&empty_arr)
258 {
259 let name = f
260 .get("action")
261 .and_then(|v| v.as_str())
262 .unwrap_or_default()
263 .to_string();
264 let reason = f
265 .get("reason")
266 .and_then(|v| v.as_str())
267 .unwrap_or_default()
268 .to_string();
269 forbidden.insert(name, reason);
270 }
271
272 let mut target_sets = HashMap::new();
273 if let Some(Value::Object(map)) = contract.get("target_sets") {
274 for (k, v) in map {
275 if let Some(arr) = v.as_array() {
276 let patterns: Vec<String> = arr
277 .iter()
278 .filter_map(|t| t.as_str())
279 .map(str::to_string)
280 .collect();
281 target_sets.insert(k.clone(), patterns);
282 }
283 }
284 }
285
286 AgentGuard {
287 action_by_name: actions,
288 forbidden_by_name: forbidden,
289 target_sets,
290 on_event: None,
291 enforcement_level: EnforcementLevel::default(),
292 negative_capabilities: Vec::new(),
293 }
294 }
295
296 pub fn set_negative_capabilities(&mut self, caps: Vec<NegativeCapability>) {
298 self.negative_capabilities = caps;
299 }
300
301 pub fn set_enforcement_level(&mut self, level: EnforcementLevel) {
303 self.enforcement_level = level;
304 }
305
306 pub fn enforcement_level(&self) -> EnforcementLevel {
307 self.enforcement_level
308 }
309
310 pub fn set_event_listener<F>(&mut self, f: F)
311 where
312 F: Fn(&GuardEventStub) + Send + Sync + 'static,
313 {
314 self.on_event = Some(Box::new(f));
315 }
316
317 pub fn actions(&self) -> impl Iterator<Item = &IndexedAction> {
321 self.action_by_name.values()
322 }
323
324 pub fn action_by_name(&self, name: &str) -> Option<&IndexedAction> {
325 self.action_by_name.get(name)
326 }
327
328 pub fn forbidden_actions(&self) -> impl Iterator<Item = (&String, &String)> {
329 self.forbidden_by_name.iter()
330 }
331
332 pub fn target_sets(&self) -> impl Iterator<Item = (&String, &Vec<String>)> {
333 self.target_sets.iter()
334 }
335
336 pub fn check(&self, query: &GuardQuery) -> GuardDecision {
337 let raw = self.check_raw(query);
338 let adjusted = apply_enforcement_level(raw, self.enforcement_level);
339 let actor = query
340 .actor
341 .clone()
342 .unwrap_or_else(|| "tf:actor:process:local/unknown".to_string());
343 self.emit(&adjusted, &actor, query);
344 adjusted
345 }
346
347 pub fn check_raw(&self, query: &GuardQuery) -> GuardDecision {
349 for neg in &self.negative_capabilities {
351 if negative_matches(neg, query) {
352 let reason = neg.reason.clone().unwrap_or_else(|| {
353 format!("action {} is denied by negative_capability", query.action)
354 });
355 return GuardDecision::Deny {
356 reason,
357 danger_tags: vec!["explicit-denial".to_string()],
358 };
359 }
360 }
361
362 if let Some(reason) = self.forbidden_by_name.get(&query.action) {
363 return GuardDecision::Deny {
364 reason: if reason.is_empty() {
365 "action listed in forbidden".to_string()
366 } else {
367 reason.clone()
368 },
369 danger_tags: Vec::new(),
370 };
371 }
372
373 let Some(action) = self.action_by_name.get(&query.action) else {
374 return GuardDecision::Deny {
375 reason: format!("action \"{}\" is not declared", query.action),
376 danger_tags: Vec::new(),
377 };
378 };
379
380 let tags = action.danger_tags.clone();
381
382 if let Some(actor) = query.actor.as_deref() {
385 for pattern in &action.deny_actors {
386 if glob_match(pattern, actor)
387 || query
388 .actor_claim
389 .as_deref()
390 .map(|c| glob_match(pattern, c))
391 .unwrap_or(false)
392 {
393 return GuardDecision::Deny {
394 reason: format!("actor {} matches deny_actors ({})", actor, pattern),
395 danger_tags: tags.clone(),
396 };
397 }
398 }
399 if !action.allow_actors.is_empty() {
400 let matches = action.allow_actors.iter().any(|p| {
401 glob_match(p, actor)
402 || query
403 .actor_claim
404 .as_deref()
405 .map(|c| glob_match(p, c))
406 .unwrap_or(false)
407 });
408 if !matches {
409 return GuardDecision::Deny {
410 reason: format!("actor {} not in allow_actors", actor),
411 danger_tags: tags.clone(),
412 };
413 }
414 }
415 } else if !action.allow_actors.is_empty() {
416 return GuardDecision::Deny {
417 reason: format!("action {} requires an authenticated actor", action.name),
418 danger_tags: tags.clone(),
419 };
420 }
421
422 if let Some(target) = &query.target {
423 for pattern in &action.deny_targets {
424 if self.match_target(pattern, target) {
425 return GuardDecision::Deny {
426 reason: format!("target {} is in deny_targets ({})", target, pattern),
427 danger_tags: tags.clone(),
428 };
429 }
430 }
431 if !action.allow_targets.is_empty() {
432 let allowed = action
433 .allow_targets
434 .iter()
435 .any(|p| self.match_target(p, target));
436 if !allowed {
437 return GuardDecision::Deny {
438 reason: format!("target {} is not in allow_targets", target),
439 danger_tags: tags.clone(),
440 };
441 }
442 }
443 }
444
445 let should_escalate = tags.iter().any(|t| ESCALATE_TAGS.contains(&t.as_str()));
446 if should_escalate {
447 let escalating: Vec<&str> = tags
448 .iter()
449 .filter(|t| ESCALATE_TAGS.contains(&t.as_str()))
450 .map(String::as_str)
451 .collect();
452 return GuardDecision::Escalate {
453 reason: format!("danger_tags require escalation: {}", escalating.join(", ")),
454 danger_tags: tags.clone(),
455 };
456 }
457
458 match action.approval.as_deref() {
459 Some("required") | Some("quorum") => {
460 let approval = action.approval.clone().unwrap();
461 GuardDecision::ApprovalRequired {
462 approval,
463 reason: format!("action \"{}\" requires approval", query.action),
464 danger_tags: tags,
465 }
466 }
467 _ => GuardDecision::Allow { danger_tags: tags },
468 }
469 }
470
471 fn match_target(&self, pattern: &str, value: &str) -> bool {
472 if let Some(rest) = pattern.strip_prefix('@') {
473 let Some(set) = self.target_sets.get(rest) else {
474 return false;
475 };
476 return set.iter().any(|p| glob_match(p, value));
477 }
478 glob_match(pattern, value)
479 }
480
481 fn emit(&self, decision: &GuardDecision, actor: &str, query: &GuardQuery) {
482 let Some(f) = &self.on_event else { return };
483 f(&GuardEventStub {
484 kind: "guard.check".to_string(),
485 actor: actor.to_string(),
486 action: query.action.clone(),
487 target: query.target.clone(),
488 decision: decision.kind().to_string(),
489 danger_tags: decision.danger_tags().to_vec(),
490 enforcement_level: Some(self.enforcement_level.as_str().to_string()),
491 });
492 }
493}
494
495pub fn apply_enforcement_level(raw: GuardDecision, level: EnforcementLevel) -> GuardDecision {
499 match level {
500 EnforcementLevel::E0 => match raw {
501 GuardDecision::Deny {
502 reason,
503 mut danger_tags,
504 }
505 | GuardDecision::Escalate {
506 reason,
507 mut danger_tags,
508 }
509 | GuardDecision::ApprovalRequired {
510 reason,
511 mut danger_tags,
512 ..
513 } => {
514 danger_tags.push("shadow".to_string());
515 GuardDecision::LogOnly {
516 reason: format!("[shadow] would have decided: {}", reason),
517 danger_tags,
518 }
519 }
520 other => other,
521 },
522 EnforcementLevel::E1 => match raw {
523 GuardDecision::Deny {
524 reason,
525 mut danger_tags,
526 } => {
527 danger_tags.push("warn".to_string());
528 danger_tags.push(format!("would-deny:{}", reason));
529 GuardDecision::Allow { danger_tags }
530 }
531 GuardDecision::Escalate {
532 reason,
533 mut danger_tags,
534 } => {
535 danger_tags.push("warn".to_string());
536 GuardDecision::LogOnly {
537 reason: format!("[warn] {}", reason),
538 danger_tags,
539 }
540 }
541 other => other,
542 },
543 EnforcementLevel::E2 => tag_decision(raw, "proof-log-required"),
544 EnforcementLevel::E3 => match raw {
545 GuardDecision::Allow { danger_tags } if !danger_tags.is_empty() => {
546 GuardDecision::Escalate {
547 reason: format!(
548 "E3 escalates allow with danger tags: {}",
549 danger_tags.join(", ")
550 ),
551 danger_tags,
552 }
553 }
554 other => other,
555 },
556 EnforcementLevel::E4 => raw,
557 EnforcementLevel::E5 => match raw {
558 GuardDecision::Escalate {
559 reason,
560 danger_tags,
561 }
562 | GuardDecision::ApprovalRequired {
563 reason,
564 danger_tags,
565 ..
566 } => GuardDecision::Deny {
567 reason: format!("E5 fail-closed: {}", reason),
568 danger_tags,
569 },
570 GuardDecision::Allow { danger_tags } if !danger_tags.is_empty() => {
571 GuardDecision::Deny {
572 reason: format!(
573 "E5 fail-closed: allow with danger tags {} blocked",
574 danger_tags.join(", ")
575 ),
576 danger_tags,
577 }
578 }
579 other => other,
580 },
581 }
582}
583
584fn tag_decision(d: GuardDecision, tag: &str) -> GuardDecision {
585 match d {
586 GuardDecision::Allow { mut danger_tags } => {
587 danger_tags.push(tag.to_string());
588 GuardDecision::Allow { danger_tags }
589 }
590 GuardDecision::ApprovalRequired {
591 approval,
592 reason,
593 mut danger_tags,
594 } => {
595 danger_tags.push(tag.to_string());
596 GuardDecision::ApprovalRequired {
597 approval,
598 reason,
599 danger_tags,
600 }
601 }
602 GuardDecision::Escalate {
603 reason,
604 mut danger_tags,
605 } => {
606 danger_tags.push(tag.to_string());
607 GuardDecision::Escalate {
608 reason,
609 danger_tags,
610 }
611 }
612 GuardDecision::Deny {
613 reason,
614 mut danger_tags,
615 } => {
616 danger_tags.push(tag.to_string());
617 GuardDecision::Deny {
618 reason,
619 danger_tags,
620 }
621 }
622 GuardDecision::LogOnly {
623 reason,
624 mut danger_tags,
625 } => {
626 danger_tags.push(tag.to_string());
627 GuardDecision::LogOnly {
628 reason,
629 danger_tags,
630 }
631 }
632 }
633}
634
635fn negative_matches(neg: &NegativeCapability, q: &GuardQuery) -> bool {
636 if !glob_match(&neg.name, &q.action) {
639 return false;
640 }
641 let Some(target_pattern) = neg.target.as_deref() else {
642 return true;
643 };
644 let Some(query_target) = q.target.as_deref() else {
645 return false;
646 };
647 glob_match(target_pattern, query_target)
648}