1use crate::context::EvaluationContext;
4use crate::decision::{ActionResult, Decision};
5use crate::error::EngineError;
6use crate::ir::{ActionInstruction, RuleEffect};
7use crate::proof::{ProofBinding, ProofEnvelope, ProofEnvelopeV1};
8#[cfg(feature = "pq-proof")]
9use crate::proof::PqProofEnvelope;
10use crate::rules::RuleRegistry;
11use crate::vm::{ActionVm, BytecodeVm, Instruction};
12use crate::{EvaluationRequest, EvaluationResult};
13use crue_dsl::ast::RuleAst;
14use crue_dsl::compiler::{Bytecode, Compiler};
15use std::time::Instant;
16use tracing::{error, info, warn};
17
18#[derive(Debug, Clone)]
20pub struct CompiledPolicyRule {
21 pub id: String,
22 pub version: String,
23 pub policy_hash: String,
24 pub bytecode: Bytecode,
25 pub effects: Vec<RuleEffect>,
26 pub match_program: Vec<Instruction>,
27 pub action_program: Vec<ActionInstruction>,
28}
29
30impl CompiledPolicyRule {
31 pub fn from_ast(ast: &RuleAst) -> Result<Self, EngineError> {
32 let policy_hash = hash_policy_ast(ast)?;
33 let bytecode = Compiler::compile(ast)
34 .map_err(|e| EngineError::CompilationError(e.to_string()))?;
35 let effects = ast
36 .then_clause
37 .clone()
38 .into_iter()
39 .map(RuleEffect::try_from)
40 .collect::<Result<Vec<_>, _>>()?;
41 let action_program = if bytecode.action_instructions.is_empty() {
42 compile_action_program(&effects)
44 } else {
45 bytecode
46 .action_instructions
47 .iter()
48 .cloned()
49 .map(ActionInstruction::try_from)
50 .collect::<Result<Vec<_>, _>>()?
51 };
52 let primary_decision = ActionVm::execute(&action_program)?.decision;
53 let match_program = BytecodeVm::build_match_program(&bytecode, primary_decision)?;
54 Ok(Self {
55 id: ast.id.clone(),
56 version: ast.version.clone(),
57 policy_hash,
58 bytecode,
59 effects,
60 match_program,
61 action_program,
62 })
63 }
64
65 pub fn from_source(source: &str) -> Result<Self, EngineError> {
66 let ast = crue_dsl::parser::parse(source)
67 .map_err(|e| EngineError::CompilationError(e.to_string()))?;
68 Self::from_ast(&ast)
69 }
70
71 pub fn evaluate(&self, ctx: &EvaluationContext) -> Result<bool, EngineError> {
72 BytecodeVm::eval(&self.bytecode, ctx)
73 }
74
75 pub fn evaluate_match_decision(&self, ctx: &EvaluationContext) -> Result<Option<Decision>, EngineError> {
77 BytecodeVm::eval_match_program(&self.match_program, &self.bytecode, ctx)
78 }
79
80 pub fn apply_action(&self) -> ActionResult {
81 ActionVm::execute(&self.action_program)
82 .unwrap_or_else(|_| compiled_actions_to_result(&self.effects))
83 }
84}
85
86pub struct CrueEngine {
88 rule_registry: RuleRegistry,
89 compiled_rules: Vec<CompiledPolicyRule>,
90 strict_mode: bool,
91}
92
93impl CrueEngine {
94 pub fn new() -> Self {
96 CrueEngine {
97 rule_registry: RuleRegistry::new(),
98 compiled_rules: Vec::new(),
99 strict_mode: true,
100 }
101 }
102
103 pub fn load_rules(&mut self, registry: RuleRegistry) {
105 self.rule_registry = registry;
106 }
107
108 pub fn load_compiled_rules(&mut self, rules: Vec<CompiledPolicyRule>) {
110 self.compiled_rules = rules;
111 }
112
113 pub fn register_compiled_rule_ast(&mut self, ast: &RuleAst) -> Result<(), EngineError> {
115 self.compiled_rules.push(CompiledPolicyRule::from_ast(ast)?);
116 Ok(())
117 }
118
119 pub fn register_compiled_rule_source(&mut self, source: &str) -> Result<(), EngineError> {
121 self.compiled_rules.push(CompiledPolicyRule::from_source(source)?);
122 Ok(())
123 }
124
125 pub fn clear_compiled_rules(&mut self) {
127 self.compiled_rules.clear();
128 }
129
130 pub fn evaluate(&self, request: &EvaluationRequest) -> EvaluationResult {
132 self.evaluate_internal(request, None).0
133 }
134
135 pub fn evaluate_with_proof(
141 &self,
142 request: &EvaluationRequest,
143 crypto_backend_id: &str,
144 ) -> (EvaluationResult, Option<ProofBinding>) {
145 self.evaluate_internal(request, Some(crypto_backend_id))
146 }
147
148 pub fn evaluate_with_signed_proof_ed25519(
152 &self,
153 request: &EvaluationRequest,
154 crypto_backend_id: &str,
155 signer_key_id: &str,
156 key_pair: &crypto_core::signature::Ed25519KeyPair,
157 ) -> (EvaluationResult, Option<ProofEnvelope>) {
158 let (result, binding) = self.evaluate_with_proof(request, crypto_backend_id);
159 let Some(binding) = binding else {
160 return (result, None);
161 };
162
163 match ProofEnvelope::sign_ed25519(binding, signer_key_id.to_string(), key_pair) {
164 Ok(envelope) => (result, Some(envelope)),
165 Err(e) => {
166 error!("Failed to sign proof envelope: {}", e);
167 if self.strict_mode {
168 (
169 engine_error_result(
170 request,
171 result.rule_id.clone(),
172 result.rule_version.clone(),
173 &format!("Proof envelope signing error: {}", e),
174 result.evaluation_time_ms,
175 ),
176 None,
177 )
178 } else {
179 (result, None)
180 }
181 }
182 }
183 }
184
185 pub fn evaluate_with_signed_proof_v1_ed25519(
187 &self,
188 request: &EvaluationRequest,
189 crypto_backend_id: &str,
190 signer_key_id: &str,
191 key_pair: &crypto_core::signature::Ed25519KeyPair,
192 ) -> (EvaluationResult, Option<ProofEnvelopeV1>) {
193 let (result, binding) = self.evaluate_with_proof(request, crypto_backend_id);
194 let Some(binding) = binding else {
195 return (result, None);
196 };
197 match ProofEnvelopeV1::sign_ed25519(&binding, signer_key_id, key_pair) {
198 Ok(envelope) => (result, Some(envelope)),
199 Err(e) => {
200 error!("Failed to sign proof envelope v1: {}", e);
201 if self.strict_mode {
202 (
203 engine_error_result(
204 request,
205 result.rule_id.clone(),
206 result.rule_version.clone(),
207 &format!("ProofEnvelopeV1 signing error: {}", e),
208 result.evaluation_time_ms,
209 ),
210 None,
211 )
212 } else {
213 (result, None)
214 }
215 }
216 }
217 }
218
219 #[cfg(feature = "pq-proof")]
224 pub fn evaluate_with_signed_proof_hybrid(
225 &self,
226 request: &EvaluationRequest,
227 signer_key_id: &str,
228 signer: &pqcrypto::hybrid::HybridSigner,
229 keypair: &pqcrypto::hybrid::HybridKeyPair,
230 ) -> (EvaluationResult, Option<PqProofEnvelope>) {
231 let (result, binding) = self.evaluate_with_proof(request, signer.backend_id());
232 let Some(binding) = binding else {
233 return (result, None);
234 };
235
236 match PqProofEnvelope::sign_hybrid(binding, signer_key_id.to_string(), signer, keypair) {
237 Ok(envelope) => (result, Some(envelope)),
238 Err(e) => {
239 error!("Failed to sign PQ proof envelope: {}", e);
240 if self.strict_mode {
241 (
242 engine_error_result(
243 request,
244 result.rule_id.clone(),
245 result.rule_version.clone(),
246 &format!("PQ proof envelope signing error: {}", e),
247 result.evaluation_time_ms,
248 ),
249 None,
250 )
251 } else {
252 (result, None)
253 }
254 }
255 }
256 }
257
258 #[cfg(feature = "pq-proof")]
260 pub fn evaluate_with_signed_proof_v1_hybrid(
261 &self,
262 request: &EvaluationRequest,
263 signer_key_id: &str,
264 signer: &pqcrypto::hybrid::HybridSigner,
265 keypair: &pqcrypto::hybrid::HybridKeyPair,
266 ) -> (EvaluationResult, Option<ProofEnvelopeV1>) {
267 let (result, binding) = self.evaluate_with_proof(request, signer.backend_id());
268 let Some(binding) = binding else {
269 return (result, None);
270 };
271 match ProofEnvelopeV1::sign_hybrid(&binding, signer_key_id, signer, keypair) {
272 Ok(envelope) => (result, Some(envelope)),
273 Err(e) => {
274 error!("Failed to sign proof envelope v1 hybrid: {}", e);
275 if self.strict_mode {
276 (
277 engine_error_result(
278 request,
279 result.rule_id.clone(),
280 result.rule_version.clone(),
281 &format!("ProofEnvelopeV1 hybrid signing error: {}", e),
282 result.evaluation_time_ms,
283 ),
284 None,
285 )
286 } else {
287 (result, None)
288 }
289 }
290 }
291 }
292
293 fn evaluate_internal(
294 &self,
295 request: &EvaluationRequest,
296 proof_backend: Option<&str>,
297 ) -> (EvaluationResult, Option<ProofBinding>) {
298 let start = Instant::now();
299 info!("Evaluating request: {}", request.request_id);
300 let ctx = EvaluationContext::from_request(request);
301
302 if let Some(result) = self.evaluate_compiled(request, &ctx, start, proof_backend) {
303 return result;
304 }
305
306 (self.evaluate_legacy_rules(request, &ctx, start), None)
307 }
308
309 fn evaluate_compiled(
310 &self,
311 request: &EvaluationRequest,
312 ctx: &EvaluationContext,
313 start: Instant,
314 proof_backend: Option<&str>,
315 ) -> Option<(EvaluationResult, Option<ProofBinding>)> {
316 for rule in &self.compiled_rules {
317 match rule.evaluate_match_decision(ctx) {
318 Ok(Some(vm_decision)) => {
319 let mut result = rule.apply_action();
320 if result.decision != vm_decision {
321 let msg = format!(
322 "Compiled VM decision mismatch for rule {}: vm={:?} action={:?}",
323 rule.id, vm_decision, result.decision
324 );
325 error!("{}", msg);
326 if self.strict_mode {
327 return Some((
328 engine_error_result(
329 request,
330 Some(rule.id.clone()),
331 Some(rule.version.clone()),
332 &msg,
333 start.elapsed().as_millis() as u64,
334 ),
335 None,
336 ));
337 }
338 } else {
339 result.decision = vm_decision;
340 }
341 let evaluation_time = start.elapsed().as_millis() as u64;
342 info!(
343 "Request {}: {} by compiled rule {} ({}ms)",
344 request.request_id,
345 format!("{:?}", result.decision),
346 rule.id,
347 evaluation_time
348 );
349 let eval_result = build_eval_result(
350 request,
351 result.clone(),
352 Some(rule.id.clone()),
353 Some(rule.version.clone()),
354 evaluation_time,
355 );
356
357 let binding = if let Some(crypto_backend_id) = proof_backend {
358 match ProofBinding::create_with_policy_hash(
359 &rule.bytecode,
360 request,
361 ctx,
362 result.decision,
363 crypto_backend_id,
364 Some(&rule.policy_hash),
365 ) {
366 Ok(binding) => Some(binding),
367 Err(e) => {
368 error!("Failed to build proof binding for compiled rule {}: {}", rule.id, e);
369 if self.strict_mode {
370 return Some((
371 engine_error_result(
372 request,
373 Some(rule.id.clone()),
374 Some(rule.version.clone()),
375 &format!("Proof binding error: {}", e),
376 evaluation_time,
377 ),
378 None,
379 ));
380 }
381 None
382 }
383 }
384 } else {
385 None
386 };
387 return Some((eval_result, binding));
388 }
389 Ok(None) => {}
390 Err(e) => {
391 if self.strict_mode {
392 error!("Error evaluating compiled rule {}: {}", rule.id, e);
393 return Some((
394 engine_error_result(
395 request,
396 Some(rule.id.clone()),
397 Some(rule.version.clone()),
398 &e.to_string(),
399 start.elapsed().as_millis() as u64,
400 ),
401 None,
402 ));
403 } else {
404 warn!("Non-strict mode: continuing after error in compiled rule {}", rule.id);
405 }
406 }
407 }
408 }
409 None
410 }
411
412 fn evaluate_legacy_rules(
413 &self,
414 request: &EvaluationRequest,
415 ctx: &EvaluationContext,
416 start: Instant,
417 ) -> EvaluationResult {
418 let rules = self.rule_registry.get_active_rules();
419
420 for rule in rules {
421 if !rule.is_valid_now() {
422 continue;
423 }
424
425 match rule.evaluate(ctx) {
426 Ok(true) => {
427 let result = rule.apply_action(ctx);
428 let evaluation_time = start.elapsed().as_millis() as u64;
429 info!(
430 "Request {}: {} by rule {} ({}ms)",
431 request.request_id,
432 format!("{:?}", result.decision),
433 rule.id,
434 evaluation_time
435 );
436 return build_eval_result(
437 request,
438 result,
439 Some(rule.id.clone()),
440 Some(rule.version.clone()),
441 evaluation_time,
442 );
443 }
444 Ok(false) => {}
445 Err(e) => {
446 if self.strict_mode {
447 error!("Error evaluating rule {}: {}", rule.id, e);
448 return engine_error_result(
449 request,
450 Some(rule.id.clone()),
451 Some(rule.version.clone()),
452 &e.to_string(),
453 start.elapsed().as_millis() as u64,
454 );
455 } else {
456 warn!("Non-strict mode: continuing after error in rule {}", rule.id);
457 }
458 }
459 }
460 }
461
462 EvaluationResult {
463 request_id: request.request_id.clone(),
464 decision: Decision::Allow,
465 evaluated_at: chrono::Utc::now().to_rfc3339(),
466 evaluation_time_ms: start.elapsed().as_millis() as u64,
467 ..Default::default()
468 }
469 }
470
471 pub fn set_strict_mode(&mut self, strict: bool) {
473 self.strict_mode = strict;
474 }
475
476 pub fn rule_count(&self) -> usize {
478 self.compiled_rules.len() + self.rule_registry.len()
479 }
480
481 pub fn compiled_rule_count(&self) -> usize {
483 self.compiled_rules.len()
484 }
485}
486
487impl Default for CrueEngine {
488 fn default() -> Self {
489 Self::new()
490 }
491}
492
493fn compiled_actions_to_result(actions: &[RuleEffect]) -> ActionResult {
494 let has_soc_alert = actions.iter().any(RuleEffect::is_alert_only);
495 let primary = actions
496 .iter()
497 .find(|a| !a.is_alert_only())
498 .cloned()
499 .unwrap_or(RuleEffect::Log);
500
501 let mut result = match primary {
502 RuleEffect::Block { code, message } => ActionResult::block(
503 &code,
504 message.as_deref().unwrap_or("Access denied"),
505 ),
506 RuleEffect::Warn { code } => ActionResult::warn(&code, "Policy warning"),
507 RuleEffect::RequireApproval { code, timeout_minutes } => {
508 ActionResult::approval_required(&code, timeout_minutes)
509 }
510 RuleEffect::Log | RuleEffect::AlertSoc => ActionResult::allow(),
511 };
512
513 if has_soc_alert {
514 result = result.with_soc_alert();
515 }
516 result
517}
518
519fn compile_action_program(actions: &[RuleEffect]) -> Vec<ActionInstruction> {
520 let has_soc_alert = actions.iter().any(RuleEffect::is_alert_only);
521 let primary = actions
522 .iter()
523 .find(|a| !a.is_alert_only())
524 .cloned()
525 .unwrap_or(RuleEffect::Log);
526
527 let mut program = Vec::new();
528 match primary {
529 RuleEffect::Block { code, message } => {
530 program.push(ActionInstruction::SetDecision(Decision::Block));
531 program.push(ActionInstruction::SetErrorCode(code));
532 program.push(ActionInstruction::SetMessage(
533 message.unwrap_or_else(|| "Access denied".to_string()),
534 ));
535 }
536 RuleEffect::Warn { code } => {
537 program.push(ActionInstruction::SetDecision(Decision::Warn));
538 program.push(ActionInstruction::SetErrorCode(code));
539 program.push(ActionInstruction::SetMessage("Policy warning".to_string()));
540 }
541 RuleEffect::RequireApproval {
542 code,
543 timeout_minutes,
544 } => {
545 program.push(ActionInstruction::SetDecision(Decision::ApprovalRequired));
546 program.push(ActionInstruction::SetErrorCode(code));
547 program.push(ActionInstruction::SetApprovalTimeout(timeout_minutes));
548 }
549 RuleEffect::Log | RuleEffect::AlertSoc => {
550 program.push(ActionInstruction::SetDecision(Decision::Allow));
551 }
552 }
553
554 if has_soc_alert {
555 program.push(ActionInstruction::SetAlertSoc(true));
556 }
557 program.push(ActionInstruction::Halt);
558 program
559}
560
561fn hash_policy_ast(ast: &RuleAst) -> Result<String, EngineError> {
562 let bytes = serde_json::to_vec(ast)
563 .map_err(|e| EngineError::CompilationError(format!("Policy AST serialization error: {}", e)))?;
564 Ok(crypto_core::hash::hex_encode(&crypto_core::hash::sha256(&bytes)))
565}
566
567fn build_eval_result(
568 request: &EvaluationRequest,
569 result: ActionResult,
570 rule_id: Option<String>,
571 rule_version: Option<String>,
572 evaluation_time_ms: u64,
573) -> EvaluationResult {
574 EvaluationResult {
575 request_id: request.request_id.clone(),
576 decision: result.decision,
577 error_code: result.error_code,
578 message: result.message,
579 rule_id,
580 rule_version,
581 evaluated_at: chrono::Utc::now().to_rfc3339(),
582 evaluation_time_ms,
583 }
584}
585
586fn engine_error_result(
587 request: &EvaluationRequest,
588 rule_id: Option<String>,
589 rule_version: Option<String>,
590 msg: &str,
591 evaluation_time_ms: u64,
592) -> EvaluationResult {
593 EvaluationResult {
594 request_id: request.request_id.clone(),
595 decision: Decision::Block,
596 error_code: Some("ENGINE_ERROR".to_string()),
597 message: Some(format!("Rule evaluation error: {}", msg)),
598 rule_id,
599 rule_version,
600 evaluated_at: chrono::Utc::now().to_rfc3339(),
601 evaluation_time_ms,
602 }
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 #[test]
610 fn test_engine_default() {
611 let engine = CrueEngine::new();
612 assert!(engine.rule_count() > 0);
613 }
614
615 #[test]
616 fn test_evaluate_default_allow() {
617 let mut engine = CrueEngine::new();
618 engine.load_rules(RuleRegistry::empty());
619
620 let request = EvaluationRequest {
621 request_id: "test_001".to_string(),
622 agent_id: "AGENT_001".to_string(),
623 agent_org: "DGFiP".to_string(),
624 agent_level: "standard".to_string(),
625 mission_id: None,
626 mission_type: None,
627 query_type: None,
628 justification: None,
629 export_format: None,
630 result_limit: None,
631 requests_last_hour: 0,
632 requests_last_24h: 0,
633 results_last_query: 0,
634 account_department: None,
635 allowed_departments: vec![],
636 request_hour: 12,
637 is_within_mission_hours: true,
638 };
639
640 let result = engine.evaluate(&request);
641 assert_eq!(result.decision, Decision::Allow);
642 }
643
644 #[test]
645 fn test_evaluate_compiled_rule_path() {
646 let source = r#"
647RULE CRUE_900 VERSION 1.0
648WHEN
649 agent.requests_last_hour >= 50
650THEN
651 BLOCK WITH CODE "VOLUME_EXCEEDED"
652"#;
653 let mut engine = CrueEngine::new();
654 engine.load_rules(RuleRegistry::empty());
655 engine.register_compiled_rule_source(source).unwrap();
656 assert_eq!(engine.compiled_rule_count(), 1);
657
658 let request = EvaluationRequest {
659 request_id: "req".to_string(),
660 agent_id: "A".to_string(),
661 agent_org: "O".to_string(),
662 agent_level: "L".to_string(),
663 mission_id: None,
664 mission_type: None,
665 query_type: None,
666 justification: Some("sufficient".to_string()),
667 export_format: None,
668 result_limit: None,
669 requests_last_hour: 51,
670 requests_last_24h: 100,
671 results_last_query: 1,
672 account_department: None,
673 allowed_departments: vec![],
674 request_hour: 8,
675 is_within_mission_hours: true,
676 };
677 let result = engine.evaluate(&request);
678 assert_eq!(result.decision, Decision::Block);
679 assert_eq!(result.rule_id.as_deref(), Some("CRUE_900"));
680 let compiled = &engine.compiled_rules[0];
681 assert!(!compiled.action_program.is_empty());
682 assert!(!compiled.bytecode.action_instructions.is_empty());
683 }
684
685 #[test]
686 fn test_evaluate_with_proof_returns_binding_for_compiled_path() {
687 let source = r#"
688RULE CRUE_901 VERSION 1.0
689WHEN
690 agent.requests_last_hour >= 50
691THEN
692 BLOCK WITH CODE "VOLUME_EXCEEDED"
693"#;
694 let mut engine = CrueEngine::new();
695 engine.load_rules(RuleRegistry::empty());
696 engine.register_compiled_rule_source(source).unwrap();
697
698 let request = EvaluationRequest {
699 request_id: "req".to_string(),
700 agent_id: "A".to_string(),
701 agent_org: "O".to_string(),
702 agent_level: "L".to_string(),
703 mission_id: None,
704 mission_type: None,
705 query_type: None,
706 justification: Some("sufficient".to_string()),
707 export_format: None,
708 result_limit: None,
709 requests_last_hour: 60,
710 requests_last_24h: 100,
711 results_last_query: 1,
712 account_department: None,
713 allowed_departments: vec![],
714 request_hour: 8,
715 is_within_mission_hours: true,
716 };
717 let (result, binding) = engine.evaluate_with_proof(&request, "mock-crypto");
718 assert_eq!(result.decision, Decision::Block);
719 let binding = binding.expect("compiled path should produce binding");
720 let ctx = EvaluationContext::from_request(&request);
721 assert!(binding
722 .verify_recompute(
723 &engine.compiled_rules[0].bytecode,
724 &request,
725 &ctx,
726 result.decision,
727 "mock-crypto",
728 )
729 .unwrap());
730 }
731
732 #[test]
733 fn test_compiled_path_falls_back_to_legacy_rules() {
734 let source = r#"
735RULE CRUE_900 VERSION 1.0
736WHEN
737 agent.requests_last_hour >= 500
738THEN
739 BLOCK WITH CODE "NEVER"
740"#;
741 let mut engine = CrueEngine::new();
742 engine.register_compiled_rule_source(source).unwrap();
743
744 let request = EvaluationRequest {
745 request_id: "req".to_string(),
746 agent_id: "A".to_string(),
747 agent_org: "O".to_string(),
748 agent_level: "L".to_string(),
749 mission_id: None,
750 mission_type: None,
751 query_type: None,
752 justification: Some("ok justification".to_string()),
753 export_format: None,
754 result_limit: None,
755 requests_last_hour: 60, requests_last_24h: 100,
757 results_last_query: 1,
758 account_department: None,
759 allowed_departments: vec![],
760 request_hour: 8,
761 is_within_mission_hours: true,
762 };
763 let result = engine.evaluate(&request);
764 assert_eq!(result.decision, Decision::Block);
765 assert_eq!(result.rule_id.as_deref(), Some("CRUE_001"));
766
767 let (result2, binding) = engine.evaluate_with_proof(&request, "mock-crypto");
768 assert_eq!(result2.decision, Decision::Block);
769 assert_eq!(result2.rule_id.as_deref(), Some("CRUE_001"));
770 assert!(binding.is_none());
771 }
772
773 #[test]
774 fn test_evaluate_with_signed_proof_ed25519_compiled_path() {
775 let source = r#"
776RULE CRUE_902 VERSION 1.0
777WHEN
778 agent.requests_last_hour >= 50
779THEN
780 BLOCK WITH CODE "VOLUME_EXCEEDED"
781"#;
782 let mut engine = CrueEngine::new();
783 engine.load_rules(RuleRegistry::empty());
784 engine.register_compiled_rule_source(source).unwrap();
785
786 let request = EvaluationRequest {
787 request_id: "req".to_string(),
788 agent_id: "A".to_string(),
789 agent_org: "O".to_string(),
790 agent_level: "L".to_string(),
791 mission_id: None,
792 mission_type: None,
793 query_type: None,
794 justification: Some("sufficient".to_string()),
795 export_format: None,
796 result_limit: None,
797 requests_last_hour: 70,
798 requests_last_24h: 100,
799 results_last_query: 1,
800 account_department: None,
801 allowed_departments: vec![],
802 request_hour: 8,
803 is_within_mission_hours: true,
804 };
805 let kp = crypto_core::signature::Ed25519KeyPair::generate().unwrap();
806 let pk = kp.verifying_key();
807 let (result, envelope) = engine.evaluate_with_signed_proof_ed25519(
808 &request,
809 "mock-crypto",
810 "proof-key-1",
811 &kp,
812 );
813 assert_eq!(result.decision, Decision::Block);
814 let envelope = envelope.expect("compiled path should produce signed envelope");
815 assert_eq!(envelope.binding.crypto_backend_id, "mock-crypto");
816 assert!(envelope.verify_ed25519(&pk).unwrap());
817 }
818
819 #[test]
820 fn test_evaluate_with_signed_proof_v1_ed25519_compiled_path() {
821 let source = r#"
822RULE CRUE_902B VERSION 1.0
823WHEN
824 agent.requests_last_hour >= 50
825THEN
826 BLOCK WITH CODE "VOLUME_EXCEEDED"
827"#;
828 let mut engine = CrueEngine::new();
829 engine.load_rules(RuleRegistry::empty());
830 engine.register_compiled_rule_source(source).unwrap();
831
832 let request = EvaluationRequest {
833 request_id: "req".to_string(),
834 agent_id: "A".to_string(),
835 agent_org: "O".to_string(),
836 agent_level: "L".to_string(),
837 mission_id: None,
838 mission_type: None,
839 query_type: None,
840 justification: Some("sufficient".to_string()),
841 export_format: None,
842 result_limit: None,
843 requests_last_hour: 70,
844 requests_last_24h: 100,
845 results_last_query: 1,
846 account_department: None,
847 allowed_departments: vec![],
848 request_hour: 8,
849 is_within_mission_hours: true,
850 };
851 let kp = crypto_core::signature::Ed25519KeyPair::generate().unwrap();
852 let pk = kp.verifying_key();
853 let (result, envelope) =
854 engine.evaluate_with_signed_proof_v1_ed25519(&request, "mock-crypto", "proof-key-v1", &kp);
855 assert_eq!(result.decision, Decision::Block);
856 let envelope = envelope.expect("compiled path should produce v1 envelope");
857 assert_eq!(envelope.decision().unwrap(), Decision::Block);
858 assert!(envelope.verify_ed25519(&pk).unwrap());
859 assert!(!envelope.canonical_bytes().unwrap().is_empty());
860 }
861
862 #[cfg(feature = "pq-proof")]
863 #[test]
864 fn test_evaluate_with_signed_proof_hybrid_compiled_path() {
865 let source = r#"
866RULE CRUE_903 VERSION 1.0
867WHEN
868 agent.requests_last_hour >= 50
869THEN
870 BLOCK WITH CODE "VOLUME_EXCEEDED"
871"#;
872 let mut engine = CrueEngine::new();
873 engine.load_rules(RuleRegistry::empty());
874 engine.register_compiled_rule_source(source).unwrap();
875
876 let request = EvaluationRequest {
877 request_id: "req".to_string(),
878 agent_id: "A".to_string(),
879 agent_org: "O".to_string(),
880 agent_level: "L".to_string(),
881 mission_id: None,
882 mission_type: None,
883 query_type: None,
884 justification: Some("sufficient".to_string()),
885 export_format: None,
886 result_limit: None,
887 requests_last_hour: 70,
888 requests_last_24h: 100,
889 results_last_query: 1,
890 account_department: None,
891 allowed_departments: vec![],
892 request_hour: 8,
893 is_within_mission_hours: true,
894 };
895 let signer = pqcrypto::hybrid::HybridSigner::new(pqcrypto::DilithiumLevel::Dilithium2);
896 let keypair = signer.generate_keypair().unwrap();
897 let public_key = keypair.public_key();
898 let (result, envelope) =
899 engine.evaluate_with_signed_proof_hybrid(&request, "pq-proof-key-1", &signer, &keypair);
900 assert_eq!(result.decision, Decision::Block);
901 let envelope = envelope.expect("compiled path should produce signed PQ envelope");
902 assert_eq!(envelope.pq_backend_id, signer.backend_id());
903 assert!(envelope.verify_hybrid(&public_key).unwrap());
904 }
905
906 #[cfg(feature = "pq-proof")]
907 #[test]
908 fn test_evaluate_with_signed_proof_v1_hybrid_compiled_path() {
909 let source = r#"
910RULE CRUE_903B VERSION 1.0
911WHEN
912 agent.requests_last_hour >= 50
913THEN
914 BLOCK WITH CODE "VOLUME_EXCEEDED"
915"#;
916 let mut engine = CrueEngine::new();
917 engine.load_rules(RuleRegistry::empty());
918 engine.register_compiled_rule_source(source).unwrap();
919
920 let request = EvaluationRequest {
921 request_id: "req".to_string(),
922 agent_id: "A".to_string(),
923 agent_org: "O".to_string(),
924 agent_level: "L".to_string(),
925 mission_id: None,
926 mission_type: None,
927 query_type: None,
928 justification: Some("sufficient".to_string()),
929 export_format: None,
930 result_limit: None,
931 requests_last_hour: 70,
932 requests_last_24h: 100,
933 results_last_query: 1,
934 account_department: None,
935 allowed_departments: vec![],
936 request_hour: 8,
937 is_within_mission_hours: true,
938 };
939 let signer = pqcrypto::hybrid::HybridSigner::new(pqcrypto::DilithiumLevel::Dilithium2);
940 let keypair = signer.generate_keypair().unwrap();
941 let public_key = keypair.public_key();
942 let (result, envelope) =
943 engine.evaluate_with_signed_proof_v1_hybrid(&request, "proof-key-v1-pq", &signer, &keypair);
944 assert_eq!(result.decision, Decision::Block);
945 let envelope = envelope.expect("compiled path should produce v1 hybrid envelope");
946 assert_eq!(envelope.decision().unwrap(), Decision::Block);
947 assert!(envelope.verify_hybrid(&public_key).unwrap());
948 assert!(!envelope.canonical_bytes().unwrap().is_empty());
949 }
950
951 #[test]
952 fn test_compile_action_program_warn_soc() {
953 let program = compile_action_program(&[
954 RuleEffect::Warn {
955 code: "WARN_1".to_string(),
956 },
957 RuleEffect::AlertSoc,
958 ]);
959 let result = ActionVm::execute(&program).unwrap();
960 assert_eq!(result.decision, Decision::Warn);
961 assert_eq!(result.error_code.as_deref(), Some("WARN_1"));
962 assert!(result.alert_soc);
963 }
964
965 #[test]
966 fn test_compiled_rule_prefers_dsl_emitted_action_program() {
967 let source = r#"
968RULE CRUE_904 VERSION 1.0
969WHEN
970 agent.requests_last_hour >= 1
971THEN
972 BLOCK WITH CODE "MANUAL_REVIEW"
973"#;
974 let rule = CompiledPolicyRule::from_source(source).unwrap();
975 assert!(!rule.bytecode.action_instructions.is_empty());
976 let result = ActionVm::execute(&rule.action_program).unwrap();
977 assert_eq!(result.decision, Decision::Block);
978 assert_eq!(result.error_code.as_deref(), Some("MANUAL_REVIEW"));
979 }
980}