1use chio_kernel::{Guard, GuardContext, KernelError, Verdict};
9
10pub 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 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 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 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}