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