Skip to main content

chio_guards/
behavioral_sequence.rs

1//! Behavioral sequence guard -- enforces tool ordering policies using the session journal.
2//!
3//! This guard checks the tool invocation sequence recorded in the session journal
4//! against configurable ordering policies:
5//!
6//! - **Required predecessors**: tool X can only run after tool Y has been invoked.
7//! - **Forbidden sequences**: tool X cannot be invoked immediately after tool Y.
8//! - **Max consecutive**: limits on how many times the same tool can run in a row.
9//! - **Required first tool**: the first tool in a session must match a specific name.
10//!
11//! The guard fails closed: if the session journal is unavailable, access is denied.
12
13use std::collections::{HashMap, HashSet};
14use std::sync::Arc;
15
16use chio_http_session::SessionJournal;
17use chio_kernel::{Guard, GuardContext, KernelError, Verdict};
18
19// ---------------------------------------------------------------------------
20// SequencePolicy
21// ---------------------------------------------------------------------------
22
23/// Policy configuration for the behavioral sequence guard.
24#[derive(Clone, Debug, Default)]
25pub struct SequencePolicy {
26    /// Tools that must have been invoked before a given tool can run.
27    /// Map from tool_name to set of required predecessor tools.
28    pub required_predecessors: HashMap<String, HashSet<String>>,
29    /// Forbidden immediate transitions: (from_tool, to_tool) pairs.
30    /// If the last invoked tool is `from_tool`, then `to_tool` is denied.
31    pub forbidden_transitions: Vec<(String, String)>,
32    /// Maximum consecutive invocations of the same tool.
33    /// None means unlimited.
34    pub max_consecutive: Option<u32>,
35    /// If set, the first tool in the session must match this name.
36    pub required_first_tool: Option<String>,
37}
38
39// ---------------------------------------------------------------------------
40// BehavioralSequenceGuard
41// ---------------------------------------------------------------------------
42
43/// Guard that enforces tool ordering policies using the session journal.
44pub struct BehavioralSequenceGuard {
45    journal: Arc<SessionJournal>,
46    policy: SequencePolicy,
47}
48
49impl BehavioralSequenceGuard {
50    /// Create a new guard with the given journal and policy.
51    pub fn new(journal: Arc<SessionJournal>, policy: SequencePolicy) -> Self {
52        Self { journal, policy }
53    }
54}
55
56impl Guard for BehavioralSequenceGuard {
57    fn name(&self) -> &str {
58        "behavioral-sequence"
59    }
60
61    fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError> {
62        let tool_name = &ctx.request.tool_name;
63
64        let sequence = self.journal.tool_sequence().map_err(|e| {
65            KernelError::Internal(format!(
66                "behavioral-sequence guard journal error (fail-closed): {e}"
67            ))
68        })?;
69
70        // Check required first tool.
71        if sequence.is_empty() {
72            if let Some(ref required_first) = self.policy.required_first_tool {
73                if tool_name != required_first {
74                    return Ok(Verdict::Deny);
75                }
76            }
77        }
78
79        // Check required predecessors.
80        if let Some(required) = self.policy.required_predecessors.get(tool_name) {
81            let invoked: HashSet<&str> = sequence.iter().map(|s| s.as_str()).collect();
82            for req in required {
83                if !invoked.contains(req.as_str()) {
84                    return Ok(Verdict::Deny);
85                }
86            }
87        }
88
89        // Check forbidden transitions.
90        if let Some(last_tool) = sequence.last() {
91            for (from, to) in &self.policy.forbidden_transitions {
92                if last_tool == from && tool_name == to {
93                    return Ok(Verdict::Deny);
94                }
95            }
96        }
97
98        // Check max consecutive.
99        if let Some(max_consec) = self.policy.max_consecutive {
100            let mut count: u32 = 0;
101            for t in sequence.iter().rev() {
102                if t == tool_name {
103                    count = count.saturating_add(1);
104                } else {
105                    break;
106                }
107            }
108            if count >= max_consec {
109                return Ok(Verdict::Deny);
110            }
111        }
112
113        Ok(Verdict::Allow)
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use chio_http_session::RecordParams;
121
122    fn make_journal(session_id: &str) -> Arc<SessionJournal> {
123        Arc::new(SessionJournal::new(session_id.to_string()))
124    }
125
126    fn record(journal: &SessionJournal, tool: &str) {
127        journal
128            .record(RecordParams {
129                tool_name: tool.to_string(),
130                server_id: "srv".to_string(),
131                agent_id: "agent".to_string(),
132                bytes_read: 0,
133                bytes_written: 0,
134                delegation_depth: 0,
135                allowed: true,
136            })
137            .expect("record");
138    }
139
140    fn make_ctx_for_tool(
141        tool_name: &str,
142    ) -> (
143        chio_kernel::ToolCallRequest,
144        chio_core::capability::ChioScope,
145        String,
146        String,
147    ) {
148        let kp = chio_core::crypto::Keypair::generate();
149        let scope = chio_core::capability::ChioScope::default();
150        let agent_id = kp.public_key().to_hex();
151        let server_id = "srv-test".to_string();
152
153        let cap_body = chio_core::capability::CapabilityTokenBody {
154            id: "cap-test".to_string(),
155            issuer: kp.public_key(),
156            subject: kp.public_key(),
157            scope: scope.clone(),
158            issued_at: 0,
159            expires_at: u64::MAX,
160            delegation_chain: vec![],
161        };
162        let cap = chio_core::capability::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
163
164        let request = chio_kernel::ToolCallRequest {
165            request_id: "req-test".to_string(),
166            capability: cap,
167            tool_name: tool_name.to_string(),
168            server_id: server_id.clone(),
169            agent_id: agent_id.clone(),
170            arguments: serde_json::json!({}),
171            dpop_proof: None,
172            governed_intent: None,
173            approval_token: None,
174            model_metadata: None,
175            federated_origin_kernel_id: None,
176        };
177
178        (request, scope, agent_id, server_id)
179    }
180
181    fn guard_ctx<'a>(
182        request: &'a chio_kernel::ToolCallRequest,
183        scope: &'a chio_core::capability::ChioScope,
184        agent_id: &'a String,
185        server_id: &'a String,
186    ) -> chio_kernel::GuardContext<'a> {
187        chio_kernel::GuardContext {
188            request,
189            scope,
190            agent_id,
191            server_id,
192            session_filesystem_roots: None,
193            matched_grant_index: None,
194        }
195    }
196
197    #[test]
198    fn guard_name() {
199        let journal = make_journal("sess-1");
200        let guard = BehavioralSequenceGuard::new(journal, SequencePolicy::default());
201        assert_eq!(guard.name(), "behavioral-sequence");
202    }
203
204    #[test]
205    fn empty_policy_allows_all() {
206        let journal = make_journal("sess-1");
207        record(&journal, "read_file");
208        record(&journal, "bash");
209
210        let guard = BehavioralSequenceGuard::new(journal, SequencePolicy::default());
211        let (request, scope, agent_id, server_id) = make_ctx_for_tool("write_file");
212        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
213        assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Allow);
214    }
215
216    #[test]
217    fn required_predecessor_enforced() {
218        let journal = make_journal("sess-pred");
219        // No tools invoked yet.
220
221        let mut required = HashMap::new();
222        required.insert(
223            "write_file".to_string(),
224            HashSet::from(["read_file".to_string()]),
225        );
226
227        let guard = BehavioralSequenceGuard::new(
228            journal.clone(),
229            SequencePolicy {
230                required_predecessors: required,
231                ..SequencePolicy::default()
232            },
233        );
234
235        // write_file without read_file predecessor should deny.
236        let (request, scope, agent_id, server_id) = make_ctx_for_tool("write_file");
237        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
238        assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Deny);
239
240        // After read_file is invoked, write_file should be allowed.
241        record(&journal, "read_file");
242        let (request2, scope2, agent_id2, server_id2) = make_ctx_for_tool("write_file");
243        let ctx2 = guard_ctx(&request2, &scope2, &agent_id2, &server_id2);
244        assert_eq!(guard.evaluate(&ctx2).expect("ok"), Verdict::Allow);
245    }
246
247    #[test]
248    fn forbidden_transition_enforced() {
249        let journal = make_journal("sess-trans");
250        record(&journal, "bash");
251
252        let guard = BehavioralSequenceGuard::new(
253            journal,
254            SequencePolicy {
255                forbidden_transitions: vec![("bash".to_string(), "write_file".to_string())],
256                ..SequencePolicy::default()
257            },
258        );
259
260        // bash -> write_file is forbidden.
261        let (request, scope, agent_id, server_id) = make_ctx_for_tool("write_file");
262        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
263        assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Deny);
264
265        // bash -> read_file is fine.
266        let (request2, scope2, agent_id2, server_id2) = make_ctx_for_tool("read_file");
267        let ctx2 = guard_ctx(&request2, &scope2, &agent_id2, &server_id2);
268        assert_eq!(guard.evaluate(&ctx2).expect("ok"), Verdict::Allow);
269    }
270
271    #[test]
272    fn max_consecutive_enforced() {
273        let journal = make_journal("sess-consec");
274        record(&journal, "read_file");
275        record(&journal, "read_file");
276        record(&journal, "read_file");
277
278        let guard = BehavioralSequenceGuard::new(
279            journal,
280            SequencePolicy {
281                max_consecutive: Some(3),
282                ..SequencePolicy::default()
283            },
284        );
285
286        // 4th consecutive read_file should be denied.
287        let (request, scope, agent_id, server_id) = make_ctx_for_tool("read_file");
288        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
289        assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Deny);
290
291        // A different tool should be fine.
292        let (request2, scope2, agent_id2, server_id2) = make_ctx_for_tool("write_file");
293        let ctx2 = guard_ctx(&request2, &scope2, &agent_id2, &server_id2);
294        assert_eq!(guard.evaluate(&ctx2).expect("ok"), Verdict::Allow);
295    }
296
297    #[test]
298    fn max_consecutive_resets_on_different_tool() {
299        let journal = make_journal("sess-reset");
300        record(&journal, "read_file");
301        record(&journal, "read_file");
302        record(&journal, "bash"); // Breaks the streak
303        record(&journal, "read_file");
304
305        let guard = BehavioralSequenceGuard::new(
306            journal,
307            SequencePolicy {
308                max_consecutive: Some(3),
309                ..SequencePolicy::default()
310            },
311        );
312
313        // Only 1 consecutive read_file after bash, so this should pass.
314        let (request, scope, agent_id, server_id) = make_ctx_for_tool("read_file");
315        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
316        assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Allow);
317    }
318
319    #[test]
320    fn required_first_tool_enforced() {
321        let journal = make_journal("sess-first");
322
323        let guard = BehavioralSequenceGuard::new(
324            journal,
325            SequencePolicy {
326                required_first_tool: Some("init".to_string()),
327                ..SequencePolicy::default()
328            },
329        );
330
331        // First tool must be "init".
332        let (request, scope, agent_id, server_id) = make_ctx_for_tool("read_file");
333        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
334        assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Deny);
335
336        let (request2, scope2, agent_id2, server_id2) = make_ctx_for_tool("init");
337        let ctx2 = guard_ctx(&request2, &scope2, &agent_id2, &server_id2);
338        assert_eq!(guard.evaluate(&ctx2).expect("ok"), Verdict::Allow);
339    }
340
341    #[test]
342    fn required_first_tool_only_applies_to_first() {
343        let journal = make_journal("sess-first-only");
344        record(&journal, "init"); // First tool is correct.
345
346        let guard = BehavioralSequenceGuard::new(
347            journal,
348            SequencePolicy {
349                required_first_tool: Some("init".to_string()),
350                ..SequencePolicy::default()
351            },
352        );
353
354        // Subsequent tools can be anything.
355        let (request, scope, agent_id, server_id) = make_ctx_for_tool("read_file");
356        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
357        assert_eq!(guard.evaluate(&ctx).expect("ok"), Verdict::Allow);
358    }
359}