Skip to main content

chio_guards/
pipeline.rs

1//! Guard pipeline -- runs guards in sequence, fail-closed.
2//!
3//! The pipeline evaluates registered guards in order. If any guard returns
4//! `Verdict::Deny` or an error, the pipeline short-circuits and returns
5//! `Verdict::Deny`.  Only if all guards return `Verdict::Allow` does the
6//! pipeline allow the request.
7
8use chio_core::receipt::metadata::GuardEvidence;
9use chio_kernel::{Guard, GuardContext, GuardDecision, KernelError, Verdict};
10
11/// A pipeline of guards evaluated in registration order.
12///
13/// This is the primary integration point for wiring guards into the Chio
14/// kernel.  Construct a `GuardPipeline`, add guards, then register it as a
15/// single `Guard` on the kernel via `kernel.add_guard(Box::new(pipeline))`.
16pub struct GuardPipeline {
17    guards: Vec<Box<dyn Guard>>,
18}
19
20impl GuardPipeline {
21    pub fn new() -> Self {
22        Self { guards: Vec::new() }
23    }
24
25    pub fn add(&mut self, guard: Box<dyn Guard>) {
26        self.guards.push(guard);
27    }
28
29    pub fn len(&self) -> usize {
30        self.guards.len()
31    }
32
33    pub fn is_empty(&self) -> bool {
34        self.guards.is_empty()
35    }
36
37    /// Create a default pipeline with all implemented guards using their
38    /// default configurations.
39    pub fn default_pipeline() -> Self {
40        let mut pipeline = Self::new();
41        pipeline.add(Box::new(crate::ForbiddenPathGuard::new()));
42        pipeline.add(Box::new(crate::ShellCommandGuard::new()));
43        pipeline.add(Box::new(crate::EgressAllowlistGuard::new()));
44        pipeline.add(Box::new(crate::PathAllowlistGuard::new()));
45        pipeline.add(Box::new(crate::McpToolGuard::new()));
46        pipeline.add(Box::new(crate::SecretLeakGuard::new()));
47        pipeline.add(Box::new(crate::PatchIntegrityGuard::new()));
48        pipeline
49    }
50}
51
52impl Default for GuardPipeline {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58impl Guard for GuardPipeline {
59    fn name(&self) -> &str {
60        "guard-pipeline"
61    }
62
63    fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
64        let mut final_verdict = Verdict::Allow;
65        let mut evidence = Vec::new();
66        for guard in &self.guards {
67            match guard.evaluate(ctx) {
68                Ok(decision) => {
69                    evidence.extend(decision.evidence);
70                    match decision.verdict {
71                        Verdict::Allow => continue,
72                        Verdict::PendingApproval => {
73                            // `PendingApproval` is a sticky escalation state. Keep
74                            // iterating so another guard can still short-circuit to
75                            // Deny, but propagate the pending verdict up the stack if
76                            // no deny occurs.
77                            final_verdict = Verdict::PendingApproval;
78                        }
79                        Verdict::Deny => {
80                            evidence.push(GuardEvidence {
81                                guard_name: guard.name().to_string(),
82                                verdict: false,
83                                details: Some(
84                                    "action=deny; reason=guard denied request".to_string(),
85                                ),
86                            });
87                            return Ok(GuardDecision::deny(evidence));
88                        }
89                    }
90                }
91                Err(e) => {
92                    // Fail closed: guard errors are treated as denials.
93                    evidence.push(GuardEvidence {
94                        guard_name: guard.name().to_string(),
95                        verdict: false,
96                        details: Some(format!("action=error; reason=fail-closed; error={e}")),
97                    });
98                    return Ok(GuardDecision::deny(evidence));
99                }
100            }
101        }
102        Ok(GuardDecision {
103            verdict: final_verdict,
104            evidence,
105        })
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    struct AllowGuard;
114    impl Guard for AllowGuard {
115        fn name(&self) -> &str {
116            "allow-all"
117        }
118        fn evaluate(&self, _ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
119            Ok(GuardDecision::allow())
120        }
121    }
122
123    struct DenyGuard;
124    impl Guard for DenyGuard {
125        fn name(&self) -> &str {
126            "deny-all"
127        }
128        fn evaluate(&self, _ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
129            Ok(GuardDecision::deny(Vec::new()))
130        }
131    }
132
133    struct ErrorGuard;
134    impl Guard for ErrorGuard {
135        fn name(&self) -> &str {
136            "error-guard"
137        }
138        fn evaluate(&self, _ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
139            Err(KernelError::Internal("boom".to_string()))
140        }
141    }
142
143    fn make_ctx() -> (
144        chio_kernel::ToolCallRequest,
145        chio_core::capability::scope::ChioScope,
146        chio_kernel::AgentId,
147        chio_kernel::ServerId,
148    ) {
149        let kp = chio_core::crypto::Keypair::generate();
150        let scope = chio_core::capability::scope::ChioScope::default();
151        let agent_id = kp.public_key().to_hex();
152        let server_id = "srv-test".to_string();
153
154        let cap_body = chio_core::capability::token::CapabilityTokenBody {
155            id: "cap-test".to_string(),
156            issuer: kp.public_key(),
157            subject: kp.public_key(),
158            scope: scope.clone(),
159            issued_at: 0,
160            expires_at: u64::MAX,
161            delegation_chain: vec![],
162            aggregate_invocation_budget: None,
163        };
164        let cap =
165            chio_core::capability::token::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
166
167        let request = chio_kernel::ToolCallRequest {
168            request_id: "req-test".to_string(),
169            capability: cap,
170            tool_name: "read_file".to_string(),
171            server_id: server_id.clone(),
172            agent_id: agent_id.clone(),
173            arguments: serde_json::json!({"path": "/app/src/main.rs"}),
174            dpop_proof: None,
175            execution_nonce: None,
176            governed_intent: None,
177            approval_token: None,
178            approval_tokens: Vec::new(),
179            threshold_approval_proposal: None,
180            supplemental_authorization: None,
181            model_metadata: None,
182            federated_origin_kernel_id: None,
183        };
184
185        (request, scope, agent_id, server_id)
186    }
187
188    #[test]
189    fn all_allow_means_pipeline_allows() {
190        let mut pipeline = GuardPipeline::new();
191        pipeline.add(Box::new(AllowGuard));
192        pipeline.add(Box::new(AllowGuard));
193
194        let (request, scope, agent_id, server_id) = make_ctx();
195        let ctx = GuardContext {
196            request: &request,
197            scope: &scope,
198            agent_id: &agent_id,
199            server_id: &server_id,
200            session_filesystem_roots: None,
201            matched_grant_index: None,
202        };
203
204        let result = pipeline.evaluate(&ctx);
205        assert!(matches!(
206            result,
207            Ok(decision) if decision.verdict == Verdict::Allow
208        ));
209    }
210
211    #[test]
212    fn one_deny_means_pipeline_denies() {
213        let mut pipeline = GuardPipeline::new();
214        pipeline.add(Box::new(AllowGuard));
215        pipeline.add(Box::new(DenyGuard));
216        pipeline.add(Box::new(AllowGuard));
217
218        let (request, scope, agent_id, server_id) = make_ctx();
219        let ctx = GuardContext {
220            request: &request,
221            scope: &scope,
222            agent_id: &agent_id,
223            server_id: &server_id,
224            session_filesystem_roots: None,
225            matched_grant_index: None,
226        };
227
228        let result = pipeline.evaluate(&ctx).expect("pipeline decision");
229        assert_eq!(result.verdict, Verdict::Deny);
230        assert_eq!(result.evidence.len(), 1);
231        assert_eq!(result.evidence[0].guard_name, "deny-all");
232    }
233
234    #[test]
235    fn error_treated_as_deny() {
236        let mut pipeline = GuardPipeline::new();
237        pipeline.add(Box::new(AllowGuard));
238        pipeline.add(Box::new(ErrorGuard));
239
240        let (request, scope, agent_id, server_id) = make_ctx();
241        let ctx = GuardContext {
242            request: &request,
243            scope: &scope,
244            agent_id: &agent_id,
245            server_id: &server_id,
246            session_filesystem_roots: None,
247            matched_grant_index: None,
248        };
249
250        let result = pipeline.evaluate(&ctx).expect("pipeline decision");
251        assert_eq!(result.verdict, Verdict::Deny);
252        assert_eq!(result.evidence.len(), 1);
253        assert_eq!(result.evidence[0].guard_name, "error-guard");
254        let details = result.evidence[0].details.as_deref().unwrap_or_default();
255        assert!(details.contains("fail-closed"), "got: {details}");
256    }
257
258    #[test]
259    fn empty_pipeline_allows() {
260        let pipeline = GuardPipeline::new();
261
262        let (request, scope, agent_id, server_id) = make_ctx();
263        let ctx = GuardContext {
264            request: &request,
265            scope: &scope,
266            agent_id: &agent_id,
267            server_id: &server_id,
268            session_filesystem_roots: None,
269            matched_grant_index: None,
270        };
271
272        let result = pipeline.evaluate(&ctx);
273        assert!(matches!(
274            result,
275            Ok(decision) if decision.verdict == Verdict::Allow
276        ));
277    }
278}