1use chio_core::receipt::metadata::GuardEvidence;
9use chio_kernel::{Guard, GuardContext, GuardDecision, KernelError, Verdict};
10
11pub 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 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 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 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}