1use std::collections::{HashMap, HashSet};
14use std::sync::Arc;
15
16use chio_http_session::SessionJournal;
17use chio_kernel::{Guard, GuardContext, KernelError, Verdict};
18
19#[derive(Clone, Debug, Default)]
25pub struct SequencePolicy {
26 pub required_predecessors: HashMap<String, HashSet<String>>,
29 pub forbidden_transitions: Vec<(String, String)>,
32 pub max_consecutive: Option<u32>,
35 pub required_first_tool: Option<String>,
37}
38
39pub struct BehavioralSequenceGuard {
45 journal: Arc<SessionJournal>,
46 policy: SequencePolicy,
47}
48
49impl BehavioralSequenceGuard {
50 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 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 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 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 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 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 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 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 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 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 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 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"); 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 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 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"); let guard = BehavioralSequenceGuard::new(
347 journal,
348 SequencePolicy {
349 required_first_tool: Some("init".to_string()),
350 ..SequencePolicy::default()
351 },
352 );
353
354 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}