1use std::collections::{HashMap, HashSet};
14use std::sync::Arc;
15
16use chio_http_session::SessionJournal;
17#[cfg(test)]
18use chio_kernel::Verdict;
19use chio_kernel::{Guard, GuardContext, GuardDecision, KernelError};
20
21#[derive(Clone, Debug, Default)]
27pub struct SequencePolicy {
28 pub required_predecessors: HashMap<String, HashSet<String>>,
31 pub forbidden_transitions: Vec<(String, String)>,
34 pub max_consecutive: Option<u32>,
37 pub required_first_tool: Option<String>,
39}
40
41pub struct BehavioralSequenceGuard {
47 journal: Arc<SessionJournal>,
48 policy: SequencePolicy,
49}
50
51impl BehavioralSequenceGuard {
52 pub fn new(journal: Arc<SessionJournal>, policy: SequencePolicy) -> Self {
54 Self { journal, policy }
55 }
56}
57
58impl Guard for BehavioralSequenceGuard {
59 fn name(&self) -> &str {
60 "behavioral-sequence"
61 }
62
63 fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
64 let tool_name = &ctx.request.tool_name;
65
66 let snapshot = self.journal.snapshot().map_err(|e| {
67 KernelError::Internal(format!(
68 "behavioral-sequence guard journal error (fail-closed): {e}"
69 ))
70 })?;
71
72 if snapshot.current_streak_tool.is_none() {
80 if let Some(ref required_first) = self.policy.required_first_tool {
81 if tool_name != required_first {
82 return Ok(GuardDecision::deny(Vec::new()));
83 }
84 }
85 }
86
87 if let Some(required) = self.policy.required_predecessors.get(tool_name) {
101 for req in required {
102 if !snapshot.tool_counts.contains_key(req) {
103 return Ok(GuardDecision::deny(Vec::new()));
104 }
105 }
106 }
107
108 if let Some(last_tool) = snapshot.current_streak_tool.as_deref() {
117 for (from, to) in &self.policy.forbidden_transitions {
118 if last_tool == from && tool_name == to {
119 return Ok(GuardDecision::deny(Vec::new()));
120 }
121 }
122 }
123
124 if let Some(max_consec) = self.policy.max_consecutive {
135 let prior_streak =
136 if snapshot.current_streak_tool.as_deref() == Some(tool_name.as_str()) {
137 snapshot.current_streak_len
138 } else {
139 0
140 };
141 if prior_streak >= u64::from(max_consec) {
142 return Ok(GuardDecision::deny(Vec::new()));
143 }
144 }
145
146 Ok(GuardDecision::allow())
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use chio_http_session::RecordParams;
154
155 fn make_journal(session_id: &str) -> Arc<SessionJournal> {
156 Arc::new(SessionJournal::new(session_id.to_string()))
157 }
158
159 fn record(journal: &SessionJournal, tool: &str) {
160 journal
161 .record(RecordParams {
162 tool_name: tool.to_string(),
163 server_id: "srv".to_string(),
164 agent_id: "agent".to_string(),
165 bytes_read: 0,
166 bytes_written: 0,
167 delegation_depth: 0,
168 allowed: true,
169 })
170 .expect("record");
171 }
172
173 fn make_ctx_for_tool(
174 tool_name: &str,
175 ) -> (
176 chio_kernel::ToolCallRequest,
177 chio_core::capability::scope::ChioScope,
178 String,
179 String,
180 ) {
181 let kp = chio_core::crypto::Keypair::generate();
182 let scope = chio_core::capability::scope::ChioScope::default();
183 let agent_id = kp.public_key().to_hex();
184 let server_id = "srv-test".to_string();
185
186 let cap_body = chio_core::capability::token::CapabilityTokenBody {
187 id: "cap-test".to_string(),
188 issuer: kp.public_key(),
189 subject: kp.public_key(),
190 scope: scope.clone(),
191 issued_at: 0,
192 expires_at: u64::MAX,
193 delegation_chain: vec![],
194 aggregate_invocation_budget: None,
195 };
196 let cap =
197 chio_core::capability::token::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
198
199 let request = chio_kernel::ToolCallRequest {
200 request_id: "req-test".to_string(),
201 capability: cap,
202 tool_name: tool_name.to_string(),
203 server_id: server_id.clone(),
204 agent_id: agent_id.clone(),
205 arguments: serde_json::json!({}),
206 dpop_proof: None,
207 execution_nonce: None,
208 governed_intent: None,
209 approval_token: None,
210 approval_tokens: Vec::new(),
211 threshold_approval_proposal: None,
212 supplemental_authorization: None,
213 model_metadata: None,
214 federated_origin_kernel_id: None,
215 };
216
217 (request, scope, agent_id, server_id)
218 }
219
220 fn guard_ctx<'a>(
221 request: &'a chio_kernel::ToolCallRequest,
222 scope: &'a chio_core::capability::scope::ChioScope,
223 agent_id: &'a String,
224 server_id: &'a String,
225 ) -> chio_kernel::GuardContext<'a> {
226 chio_kernel::GuardContext {
227 request,
228 scope,
229 agent_id,
230 server_id,
231 session_filesystem_roots: None,
232 matched_grant_index: None,
233 }
234 }
235
236 #[test]
237 fn guard_name() {
238 let journal = make_journal("sess-1");
239 let guard = BehavioralSequenceGuard::new(journal, SequencePolicy::default());
240 assert_eq!(guard.name(), "behavioral-sequence");
241 }
242
243 #[test]
244 fn empty_policy_allows_all() {
245 let journal = make_journal("sess-1");
246 record(&journal, "read_file");
247 record(&journal, "bash");
248
249 let guard = BehavioralSequenceGuard::new(journal, SequencePolicy::default());
250 let (request, scope, agent_id, server_id) = make_ctx_for_tool("write_file");
251 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
252 assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Allow);
253 }
254
255 #[test]
256 fn required_predecessor_enforced() {
257 let journal = make_journal("sess-pred");
258 let mut required = HashMap::new();
261 required.insert(
262 "write_file".to_string(),
263 HashSet::from(["read_file".to_string()]),
264 );
265
266 let guard = BehavioralSequenceGuard::new(
267 journal.clone(),
268 SequencePolicy {
269 required_predecessors: required,
270 ..SequencePolicy::default()
271 },
272 );
273
274 let (request, scope, agent_id, server_id) = make_ctx_for_tool("write_file");
276 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
277 assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Deny);
278
279 record(&journal, "read_file");
281 let (request2, scope2, agent_id2, server_id2) = make_ctx_for_tool("write_file");
282 let ctx2 = guard_ctx(&request2, &scope2, &agent_id2, &server_id2);
283 assert_eq!(guard.evaluate(&ctx2).expect("ok"), Verdict::Allow);
284 }
285
286 #[test]
287 fn required_predecessor_survives_journal_ring_eviction() {
288 let cap = 4;
295 let journal = Arc::new(SessionJournal::with_entry_cap(
296 "sess-evict".to_string(),
297 cap,
298 ));
299 record(&journal, "read_file");
301 for _ in 0..(cap * 3) {
303 record(&journal, "bash");
304 }
305
306 let mut required = HashMap::new();
307 required.insert(
308 "write_file".to_string(),
309 HashSet::from(["read_file".to_string()]),
310 );
311 let guard = BehavioralSequenceGuard::new(
312 journal.clone(),
313 SequencePolicy {
314 required_predecessors: required,
315 ..SequencePolicy::default()
316 },
317 );
318
319 let snapshot = journal.snapshot().expect("snapshot");
322 assert!(
323 !snapshot.tool_sequence.iter().any(|t| t == "read_file"),
324 "test precondition: read_file must have been evicted from the ring"
325 );
326 assert!(
327 snapshot.tool_counts.contains_key("read_file"),
328 "cumulative tool_counts must still record the evicted predecessor"
329 );
330
331 let (request, scope, agent_id, server_id) = make_ctx_for_tool("write_file");
335 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
336 assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Allow);
337 }
338
339 #[test]
340 fn required_predecessor_denies_when_predecessor_overflowed_tool_counts_cap() {
341 let journal = Arc::new(SessionJournal::with_caps(
347 "sess-overflow".to_string(),
348 1024,
349 1,
350 ));
351 record(&journal, "filler");
355 record(&journal, "read_file");
356
357 let snapshot = journal.snapshot().expect("snapshot");
358 assert!(
359 snapshot.tool_sequence.iter().any(|t| t == "read_file"),
360 "test precondition: read_file ran and is in the sequence ring"
361 );
362 assert!(
363 !snapshot.tool_counts.contains_key("read_file"),
364 "test precondition: read_file overflowed the distinct-key cap"
365 );
366
367 let mut required = HashMap::new();
368 required.insert(
369 "write_file".to_string(),
370 HashSet::from(["read_file".to_string()]),
371 );
372 let guard = BehavioralSequenceGuard::new(
373 journal,
374 SequencePolicy {
375 required_predecessors: required,
376 ..SequencePolicy::default()
377 },
378 );
379
380 let (request, scope, agent_id, server_id) = make_ctx_for_tool("write_file");
383 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
384 assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Deny);
385 }
386
387 #[test]
388 fn required_predecessor_within_tool_counts_cap_still_allows() {
389 let journal = Arc::new(SessionJournal::with_caps(
393 "sess-within-cap".to_string(),
394 1024,
395 8,
396 ));
397 record(&journal, "read_file");
398 assert!(journal
399 .snapshot()
400 .expect("snapshot")
401 .tool_counts
402 .contains_key("read_file"));
403
404 let mut required = HashMap::new();
405 required.insert(
406 "write_file".to_string(),
407 HashSet::from(["read_file".to_string()]),
408 );
409 let guard = BehavioralSequenceGuard::new(
410 journal,
411 SequencePolicy {
412 required_predecessors: required,
413 ..SequencePolicy::default()
414 },
415 );
416
417 let (request, scope, agent_id, server_id) = make_ctx_for_tool("write_file");
418 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
419 assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Allow);
420 }
421
422 #[test]
423 fn forbidden_transition_enforced() {
424 let journal = make_journal("sess-trans");
425 record(&journal, "bash");
426
427 let guard = BehavioralSequenceGuard::new(
428 journal,
429 SequencePolicy {
430 forbidden_transitions: vec![("bash".to_string(), "write_file".to_string())],
431 ..SequencePolicy::default()
432 },
433 );
434
435 let (request, scope, agent_id, server_id) = make_ctx_for_tool("write_file");
437 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
438 assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Deny);
439
440 let (request2, scope2, agent_id2, server_id2) = make_ctx_for_tool("read_file");
442 let ctx2 = guard_ctx(&request2, &scope2, &agent_id2, &server_id2);
443 assert_eq!(guard.evaluate(&ctx2).expect("ok"), Verdict::Allow);
444 }
445
446 #[test]
447 fn forbidden_transition_enforced_at_zero_entry_cap() {
448 let journal = Arc::new(SessionJournal::with_entry_cap(
455 "sess-zero-cap".to_string(),
456 0,
457 ));
458 record(&journal, "bash");
459
460 let snapshot = journal.snapshot().expect("snapshot");
463 assert!(
464 snapshot.tool_sequence.is_empty(),
465 "entry_cap 0 must leave the bounded tool_sequence empty for this test to bite"
466 );
467 assert_eq!(snapshot.current_streak_tool.as_deref(), Some("bash"));
469
470 let guard = BehavioralSequenceGuard::new(
471 Arc::clone(&journal),
472 SequencePolicy {
473 forbidden_transitions: vec![("bash".to_string(), "write_file".to_string())],
474 ..SequencePolicy::default()
475 },
476 );
477
478 let (request, scope, agent_id, server_id) = make_ctx_for_tool("write_file");
479 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
480 assert_eq!(
481 guard.evaluate(&ctx).expect("ok"),
482 Verdict::Deny,
483 "forbidden transition bash -> write_file must fire even at entry_cap 0"
484 );
485
486 let (request2, scope2, agent_id2, server_id2) = make_ctx_for_tool("read_file");
488 let ctx2 = guard_ctx(&request2, &scope2, &agent_id2, &server_id2);
489 assert_eq!(guard.evaluate(&ctx2).expect("ok"), Verdict::Allow);
490 }
491
492 #[test]
493 fn max_consecutive_enforced() {
494 let journal = make_journal("sess-consec");
495 record(&journal, "read_file");
496 record(&journal, "read_file");
497 record(&journal, "read_file");
498
499 let guard = BehavioralSequenceGuard::new(
500 journal,
501 SequencePolicy {
502 max_consecutive: Some(3),
503 ..SequencePolicy::default()
504 },
505 );
506
507 let (request, scope, agent_id, server_id) = make_ctx_for_tool("read_file");
509 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
510 assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Deny);
511
512 let (request2, scope2, agent_id2, server_id2) = make_ctx_for_tool("write_file");
514 let ctx2 = guard_ctx(&request2, &scope2, &agent_id2, &server_id2);
515 assert_eq!(guard.evaluate(&ctx2).expect("ok"), Verdict::Allow);
516 }
517
518 #[test]
519 fn max_consecutive_resets_on_different_tool() {
520 let journal = make_journal("sess-reset");
521 record(&journal, "read_file");
522 record(&journal, "read_file");
523 record(&journal, "bash"); record(&journal, "read_file");
525
526 let guard = BehavioralSequenceGuard::new(
527 journal,
528 SequencePolicy {
529 max_consecutive: Some(3),
530 ..SequencePolicy::default()
531 },
532 );
533
534 let (request, scope, agent_id, server_id) = make_ctx_for_tool("read_file");
536 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
537 assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Allow);
538 }
539
540 #[test]
541 fn max_consecutive_survives_journal_ring_eviction() {
542 let cap = 4;
548 let journal = Arc::new(SessionJournal::with_entry_cap(
549 "sess-streak-evict".to_string(),
550 cap,
551 ));
552 for _ in 0..10 {
555 record(&journal, "read_file");
556 }
557
558 let snapshot = journal.snapshot().expect("snapshot");
561 assert_eq!(
562 snapshot.tool_sequence.len(),
563 cap,
564 "test precondition: the ring must have evicted the older streak prefix"
565 );
566 assert_eq!(snapshot.current_streak_tool.as_deref(), Some("read_file"));
567 assert_eq!(snapshot.current_streak_len, 10);
568
569 let guard = BehavioralSequenceGuard::new(
570 journal,
571 SequencePolicy {
572 max_consecutive: Some(10),
573 ..SequencePolicy::default()
574 },
575 );
576
577 let (request, scope, agent_id, server_id) = make_ctx_for_tool("read_file");
580 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
581 assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Deny);
582 }
583
584 #[test]
585 fn required_first_tool_enforced() {
586 let journal = make_journal("sess-first");
587
588 let guard = BehavioralSequenceGuard::new(
589 journal,
590 SequencePolicy {
591 required_first_tool: Some("init".to_string()),
592 ..SequencePolicy::default()
593 },
594 );
595
596 let (request, scope, agent_id, server_id) = make_ctx_for_tool("read_file");
598 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
599 assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Deny);
600
601 let (request2, scope2, agent_id2, server_id2) = make_ctx_for_tool("init");
602 let ctx2 = guard_ctx(&request2, &scope2, &agent_id2, &server_id2);
603 assert_eq!(guard.evaluate(&ctx2).expect("ok"), Verdict::Allow);
604 }
605
606 #[test]
607 fn required_first_tool_only_applies_to_first() {
608 let journal = make_journal("sess-first-only");
609 record(&journal, "init"); let guard = BehavioralSequenceGuard::new(
612 journal,
613 SequencePolicy {
614 required_first_tool: Some("init".to_string()),
615 ..SequencePolicy::default()
616 },
617 );
618
619 let (request, scope, agent_id, server_id) = make_ctx_for_tool("read_file");
621 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
622 assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Allow);
623 }
624}