1use std::sync::Arc;
11
12use chio_http_session::SessionJournal;
13#[cfg(test)]
14use chio_kernel::Verdict;
15use chio_kernel::{Guard, GuardContext, GuardDecision, KernelError};
16
17#[derive(Clone, Debug, Default)]
23pub struct DataFlowConfig {
24 pub max_bytes_read: Option<u64>,
26 pub max_bytes_written: Option<u64>,
28 pub max_bytes_total: Option<u64>,
30}
31
32pub struct DataFlowGuard {
41 journal: Arc<SessionJournal>,
42 config: DataFlowConfig,
43}
44
45impl DataFlowGuard {
46 pub fn new(journal: Arc<SessionJournal>, config: DataFlowConfig) -> Self {
48 Self { journal, config }
49 }
50}
51
52impl Guard for DataFlowGuard {
53 fn name(&self) -> &str {
54 "data-flow"
55 }
56
57 fn evaluate(&self, _ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
58 let snapshot = self.journal.snapshot().map_err(|e| {
59 KernelError::Internal(format!("data-flow guard journal error (fail-closed): {e}"))
60 })?;
61 let flow = snapshot.data_flow;
62
63 if let Some(max_read) = self.config.max_bytes_read {
65 if flow.total_bytes_read >= max_read {
66 return Ok(GuardDecision::deny(Vec::new()));
67 }
68 }
69
70 if let Some(max_written) = self.config.max_bytes_written {
72 if flow.total_bytes_written >= max_written {
73 return Ok(GuardDecision::deny(Vec::new()));
74 }
75 }
76
77 if let Some(max_total) = self.config.max_bytes_total {
79 let total = flow
80 .total_bytes_read
81 .saturating_add(flow.total_bytes_written);
82 if total >= max_total {
83 return Ok(GuardDecision::deny(Vec::new()));
84 }
85 }
86
87 Ok(GuardDecision::allow())
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use chio_http_session::RecordParams;
95
96 fn make_journal(session_id: &str) -> Arc<SessionJournal> {
97 Arc::new(SessionJournal::new(session_id.to_string()))
98 }
99
100 fn make_ctx() -> (
101 chio_kernel::ToolCallRequest,
102 chio_core::capability::scope::ChioScope,
103 String,
104 String,
105 ) {
106 let kp = chio_core::crypto::Keypair::generate();
107 let scope = chio_core::capability::scope::ChioScope::default();
108 let agent_id = kp.public_key().to_hex();
109 let server_id = "srv-test".to_string();
110
111 let cap_body = chio_core::capability::token::CapabilityTokenBody {
112 id: "cap-test".to_string(),
113 issuer: kp.public_key(),
114 subject: kp.public_key(),
115 scope: scope.clone(),
116 issued_at: 0,
117 expires_at: u64::MAX,
118 delegation_chain: vec![],
119 aggregate_invocation_budget: None,
120 };
121 let cap =
122 chio_core::capability::token::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
123
124 let request = chio_kernel::ToolCallRequest {
125 request_id: "req-test".to_string(),
126 capability: cap,
127 tool_name: "read_file".to_string(),
128 server_id: server_id.clone(),
129 agent_id: agent_id.clone(),
130 arguments: serde_json::json!({"path": "/app/src/main.rs"}),
131 dpop_proof: None,
132 execution_nonce: None,
133 governed_intent: None,
134 approval_token: None,
135 approval_tokens: Vec::new(),
136 threshold_approval_proposal: None,
137 supplemental_authorization: None,
138 model_metadata: None,
139 federated_origin_kernel_id: None,
140 };
141
142 (request, scope, agent_id, server_id)
143 }
144
145 fn guard_ctx<'a>(
146 request: &'a chio_kernel::ToolCallRequest,
147 scope: &'a chio_core::capability::scope::ChioScope,
148 agent_id: &'a String,
149 server_id: &'a String,
150 ) -> chio_kernel::GuardContext<'a> {
151 chio_kernel::GuardContext {
152 request,
153 scope,
154 agent_id,
155 server_id,
156 session_filesystem_roots: None,
157 matched_grant_index: None,
158 }
159 }
160
161 #[test]
162 fn guard_name() {
163 let journal = make_journal("sess-1");
164 let guard = DataFlowGuard::new(journal, DataFlowConfig::default());
165 assert_eq!(guard.name(), "data-flow");
166 }
167
168 #[test]
169 fn unlimited_allows_all() {
170 let journal = make_journal("sess-1");
171 journal
173 .record(RecordParams {
174 tool_name: "read_file".to_string(),
175 server_id: "srv".to_string(),
176 agent_id: "agent".to_string(),
177 bytes_read: 1_000_000,
178 bytes_written: 500_000,
179 delegation_depth: 0,
180 allowed: true,
181 })
182 .expect("record");
183
184 let guard = DataFlowGuard::new(journal, DataFlowConfig::default());
185 let (request, scope, agent_id, server_id) = make_ctx();
186 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
187 assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Allow);
188 }
189
190 #[test]
191 fn denies_when_bytes_read_exceeded() {
192 let journal = make_journal("sess-read");
193 journal
194 .record(RecordParams {
195 tool_name: "read_file".to_string(),
196 server_id: "srv".to_string(),
197 agent_id: "agent".to_string(),
198 bytes_read: 500,
199 bytes_written: 0,
200 delegation_depth: 0,
201 allowed: true,
202 })
203 .expect("record");
204
205 let guard = DataFlowGuard::new(
206 journal,
207 DataFlowConfig {
208 max_bytes_read: Some(500),
209 ..DataFlowConfig::default()
210 },
211 );
212
213 let (request, scope, agent_id, server_id) = make_ctx();
214 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
215 assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Deny);
216 }
217
218 #[test]
219 fn denies_when_bytes_written_exceeded() {
220 let journal = make_journal("sess-write");
221 journal
222 .record(RecordParams {
223 tool_name: "write_file".to_string(),
224 server_id: "srv".to_string(),
225 agent_id: "agent".to_string(),
226 bytes_read: 0,
227 bytes_written: 1000,
228 delegation_depth: 0,
229 allowed: true,
230 })
231 .expect("record");
232
233 let guard = DataFlowGuard::new(
234 journal,
235 DataFlowConfig {
236 max_bytes_written: Some(999),
237 ..DataFlowConfig::default()
238 },
239 );
240
241 let (request, scope, agent_id, server_id) = make_ctx();
242 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
243 assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Deny);
244 }
245
246 #[test]
247 fn denies_when_total_exceeded() {
248 let journal = make_journal("sess-total");
249 journal
250 .record(RecordParams {
251 tool_name: "read_file".to_string(),
252 server_id: "srv".to_string(),
253 agent_id: "agent".to_string(),
254 bytes_read: 300,
255 bytes_written: 200,
256 delegation_depth: 0,
257 allowed: true,
258 })
259 .expect("record");
260
261 let guard = DataFlowGuard::new(
262 journal,
263 DataFlowConfig {
264 max_bytes_total: Some(500),
265 ..DataFlowConfig::default()
266 },
267 );
268
269 let (request, scope, agent_id, server_id) = make_ctx();
270 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
271 assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Deny);
272 }
273
274 #[test]
275 fn allows_when_under_limit() {
276 let journal = make_journal("sess-under");
277 journal
278 .record(RecordParams {
279 tool_name: "read_file".to_string(),
280 server_id: "srv".to_string(),
281 agent_id: "agent".to_string(),
282 bytes_read: 100,
283 bytes_written: 50,
284 delegation_depth: 0,
285 allowed: true,
286 })
287 .expect("record");
288
289 let guard = DataFlowGuard::new(
290 journal,
291 DataFlowConfig {
292 max_bytes_read: Some(1000),
293 max_bytes_written: Some(1000),
294 max_bytes_total: Some(2000),
295 },
296 );
297
298 let (request, scope, agent_id, server_id) = make_ctx();
299 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
300 assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Allow);
301 }
302
303 #[test]
304 fn cumulative_across_multiple_entries() {
305 let journal = make_journal("sess-cumulative");
306 for _ in 0..5 {
307 journal
308 .record(RecordParams {
309 tool_name: "read_file".to_string(),
310 server_id: "srv".to_string(),
311 agent_id: "agent".to_string(),
312 bytes_read: 200,
313 bytes_written: 0,
314 delegation_depth: 0,
315 allowed: true,
316 })
317 .expect("record");
318 }
319 let guard = DataFlowGuard::new(
322 journal,
323 DataFlowConfig {
324 max_bytes_read: Some(999),
325 ..DataFlowConfig::default()
326 },
327 );
328
329 let (request, scope, agent_id, server_id) = make_ctx();
330 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
331 assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Deny);
332 }
333}