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