Skip to main content

tf_types/
guard.rs

1#![allow(clippy::type_complexity)]
2//! AgentGuard — Rust mirror of `tools/tf-types-ts/src/core/guard.ts`.
3//!
4//! Accepts a parsed agent-contract value (as serde_json::Value so the Rust
5//! crate doesn't need to generate a full typed binding here), and answers
6//! guard queries with a structured GuardDecision.
7
8use 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    /// Cryptographic, key-derived caller URI. Authoritative.
121    pub actor: Option<String>,
122    /// Self-claimed peer_hint URI. Advisory; matched alongside `actor`
123    /// against `allow_actors` / `deny_actors`.
124    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    /// Replace the negative capability list (e.g. on policy reload).
297    pub fn set_negative_capabilities(&mut self, caps: Vec<NegativeCapability>) {
298        self.negative_capabilities = caps;
299    }
300
301    /// Replace the enforcement level (e.g. when shadow-mode toggles).
302    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    /// Every action declared in the bound contract, keyed by action name.
318    /// Callers use this to present contract contents in UIs or to enumerate
319    /// which actions exist before invoking the guard.
320    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    /// Run the rule logic without applying the EnforcementLevel filter.
348    pub fn check_raw(&self, query: &GuardQuery) -> GuardDecision {
349        // 1. Negative capabilities take absolute precedence.
350        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        // Actor-scope: deny_actors > allow_actors. Matched against both the
383        // cryptographic actor URI and the self-claimed peer_hint URI.
384        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
495/// Apply the EnforcementLevel filter described in DECISIONS.md
496/// "Progressive enforcement levels are core". Maps the raw rule
497/// decision to the actual decision the caller will execute against.
498pub 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    // Glob-match the action name (post-B8). Pre-B8 it was `==`, which
637    // only blocked exact action strings.
638    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}