1use std::sync::Arc;
16
17use parking_lot::RwLock;
18use tracing::debug;
19
20use crate::audit::{AuditEntry, AuditLogger, AuditResult, chrono_now};
21use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput};
22use crate::policy::{PolicyContext, PolicyDecision, PolicyEnforcer};
23use crate::registry::ToolDef;
24
25pub type TrajectoryRiskSlot = Arc<parking_lot::RwLock<u8>>;
32
33pub type RiskSignalSink = Arc<dyn Fn(u8) + Send + Sync>;
38
39pub type RiskSignalQueue = Arc<parking_lot::Mutex<Vec<u8>>>;
45
46pub struct PolicyGateExecutor<T: ToolExecutor> {
51 inner: T,
52 enforcer: Arc<PolicyEnforcer>,
53 context: Arc<RwLock<PolicyContext>>,
54 audit: Option<Arc<AuditLogger>>,
55 trajectory_risk: Option<TrajectoryRiskSlot>,
58 signal_queue: Option<RiskSignalQueue>,
60}
61
62impl<T: ToolExecutor + std::fmt::Debug> std::fmt::Debug for PolicyGateExecutor<T> {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.debug_struct("PolicyGateExecutor")
65 .field("inner", &self.inner)
66 .finish_non_exhaustive()
67 }
68}
69
70impl<T: ToolExecutor> PolicyGateExecutor<T> {
71 #[must_use]
73 pub fn new(
74 inner: T,
75 enforcer: Arc<PolicyEnforcer>,
76 context: Arc<RwLock<PolicyContext>>,
77 ) -> Self {
78 Self {
79 inner,
80 enforcer,
81 context,
82 audit: None,
83 trajectory_risk: None,
84 signal_queue: None,
85 }
86 }
87
88 #[must_use]
90 pub fn with_audit(mut self, audit: Arc<AuditLogger>) -> Self {
91 self.audit = Some(audit);
92 self
93 }
94
95 #[must_use]
100 pub fn with_trajectory_risk(mut self, slot: TrajectoryRiskSlot) -> Self {
101 self.trajectory_risk = Some(slot);
102 self
103 }
104
105 #[must_use]
109 pub fn with_signal_queue(mut self, queue: RiskSignalQueue) -> Self {
110 self.signal_queue = Some(queue);
111 self
112 }
113
114 fn push_signal(&self, code: u8) {
115 if let Some(ref q) = self.signal_queue {
116 q.lock().push(code);
117 }
118 }
119
120 fn read_context(&self) -> PolicyContext {
121 self.context.read().clone()
122 }
123
124 #[cfg(test)]
125 fn trust_level_for_test(&self) -> crate::SkillTrustLevel {
126 self.context.read().trust_level
127 }
128
129 pub fn update_context(&self, new_ctx: PolicyContext) {
141 *self.context.write() = new_ctx;
142 }
143
144 fn is_trajectory_critical(&self) -> bool {
146 self.trajectory_risk
147 .as_ref()
148 .is_some_and(|slot| *slot.read() >= 3)
149 }
150
151 async fn log_audit(&self, call: &ToolCall, result: AuditResult, error_category: Option<&str>) {
152 let Some(audit) = &self.audit else { return };
153 let entry = AuditEntry {
154 timestamp: chrono_now(),
155 tool: call.tool_id.clone(),
156 command: truncate_params(&call.params),
157 result,
158 duration_ms: 0,
159 error_category: error_category.map(str::to_owned),
160 error_domain: error_category.map(|_| "security".to_owned()),
161 error_phase: None,
162 claim_source: None,
163 mcp_server_id: None,
164 injection_flagged: false,
165 embedding_anomalous: false,
166 cross_boundary_mcp_to_acp: false,
167 adversarial_policy_decision: None,
168 exit_code: None,
169 truncated: false,
170 caller_id: call.caller_id.clone(),
171 skill_name: call.skill_name.clone(),
172 policy_match: None,
173 correlation_id: None,
174 vigil_risk: None,
175 execution_env: None,
176 resolved_cwd: None,
177 scope_at_definition: None,
178 scope_at_dispatch: None,
179 };
180 audit.log(&entry).await;
181 }
182
183 async fn check_policy(&self, call: &ToolCall) -> Result<(), ToolError> {
184 if self.is_trajectory_critical() {
186 tracing::warn!(tool = %call.tool_id, "trajectory sentinel at Critical: denied (spec 050)");
187 self.log_audit(
188 call,
189 AuditResult::Blocked {
190 reason: "trajectory_critical_downgrade".to_owned(),
191 },
192 Some("trajectory_critical_downgrade"),
193 )
194 .await;
195 return Err(ToolError::Blocked {
196 command: "Tool call denied by policy".to_owned(),
197 });
198 }
199
200 let ctx = self.read_context();
201 let decision = self
202 .enforcer
203 .evaluate(call.tool_id.as_str(), &call.params, &ctx);
204
205 match &decision {
206 PolicyDecision::Allow { trace } => {
207 debug!(tool = %call.tool_id, trace = %trace, "policy: allow");
208 if let Some(audit) = &self.audit {
209 let entry = AuditEntry {
210 timestamp: chrono_now(),
211 tool: call.tool_id.clone(),
212 command: truncate_params(&call.params),
213 result: AuditResult::Success,
214 duration_ms: 0,
215 error_category: None,
216 error_domain: None,
217 error_phase: None,
218 claim_source: None,
219 mcp_server_id: None,
220 injection_flagged: false,
221 embedding_anomalous: false,
222 cross_boundary_mcp_to_acp: false,
223 adversarial_policy_decision: None,
224 exit_code: None,
225 truncated: false,
226 caller_id: call.caller_id.clone(),
227 skill_name: call.skill_name.clone(),
228 policy_match: Some(trace.clone()),
229 correlation_id: None,
230 vigil_risk: None,
231 execution_env: None,
232 resolved_cwd: None,
233 scope_at_definition: None,
234 scope_at_dispatch: None,
235 };
236 audit.log(&entry).await;
237 }
238 Ok(())
239 }
240 PolicyDecision::Deny { trace } => {
241 debug!(tool = %call.tool_id, trace = %trace, "policy: deny");
242 self.push_signal(1);
244 if let Some(audit) = &self.audit {
245 let entry = AuditEntry {
246 timestamp: chrono_now(),
247 tool: call.tool_id.clone(),
248 command: truncate_params(&call.params),
249 result: AuditResult::Blocked {
250 reason: trace.clone(),
251 },
252 duration_ms: 0,
253 error_category: Some("policy_blocked".to_owned()),
254 error_domain: Some("action".to_owned()),
255 error_phase: None,
256 claim_source: None,
257 mcp_server_id: None,
258 injection_flagged: false,
259 embedding_anomalous: false,
260 cross_boundary_mcp_to_acp: false,
261 adversarial_policy_decision: None,
262 exit_code: None,
263 truncated: false,
264 caller_id: call.caller_id.clone(),
265 skill_name: call.skill_name.clone(),
266 policy_match: Some(trace.clone()),
267 correlation_id: None,
268 vigil_risk: None,
269 execution_env: None,
270 resolved_cwd: None,
271 scope_at_definition: None,
272 scope_at_dispatch: None,
273 };
274 audit.log(&entry).await;
275 }
276 Err(ToolError::Blocked {
278 command: "Tool call denied by policy".to_owned(),
279 })
280 }
281 }
282 }
283}
284
285impl<T: ToolExecutor> ToolExecutor for PolicyGateExecutor<T> {
286 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
289 Err(ToolError::Blocked {
290 command:
291 "legacy unstructured dispatch is not supported when policy enforcement is enabled"
292 .into(),
293 })
294 }
295
296 async fn execute_confirmed(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
297 Err(ToolError::Blocked {
298 command:
299 "legacy unstructured dispatch is not supported when policy enforcement is enabled"
300 .into(),
301 })
302 }
303
304 fn tool_definitions(&self) -> Vec<ToolDef> {
305 self.inner.tool_definitions()
306 }
307
308 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
309 self.check_policy(call).await?;
310 let result = self.inner.execute_tool_call(call).await;
311 if let Ok(Some(ref output)) = result
314 && let Some(colon) = output.tool_name.as_str().find(':')
315 {
316 let server_id = output.tool_name.as_str()[..colon].to_owned();
317 if let Some(audit) = &self.audit {
318 let entry = AuditEntry {
319 timestamp: chrono_now(),
320 tool: call.tool_id.clone(),
321 command: truncate_params(&call.params),
322 result: AuditResult::Success,
323 duration_ms: 0,
324 error_category: None,
325 error_domain: None,
326 error_phase: None,
327 claim_source: None,
328 mcp_server_id: Some(server_id),
329 injection_flagged: false,
330 embedding_anomalous: false,
331 cross_boundary_mcp_to_acp: false,
332 adversarial_policy_decision: None,
333 exit_code: None,
334 truncated: false,
335 caller_id: call.caller_id.clone(),
336 skill_name: call.skill_name.clone(),
337 policy_match: None,
338 correlation_id: None,
339 vigil_risk: None,
340 execution_env: None,
341 resolved_cwd: None,
342 scope_at_definition: None,
343 scope_at_dispatch: None,
344 };
345 audit.log(&entry).await;
346 }
347 }
348 result
349 }
350
351 async fn execute_tool_call_confirmed(
354 &self,
355 call: &ToolCall,
356 ) -> Result<Option<ToolOutput>, ToolError> {
357 self.check_policy(call).await?;
358 self.inner.execute_tool_call_confirmed(call).await
359 }
360
361 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
362 self.inner.set_skill_env(env);
363 }
364
365 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
366 let mut ctx = self.context.write();
370 ctx.trust_level = ctx.trust_level.min_trust(level);
371 let effective = ctx.trust_level;
372 drop(ctx);
373 self.inner.set_effective_trust(effective);
374 }
375
376 fn is_tool_retryable(&self, tool_id: &str) -> bool {
377 self.inner.is_tool_retryable(tool_id)
378 }
379
380 fn is_tool_speculatable(&self, tool_id: &str) -> bool {
381 self.inner.is_tool_speculatable(tool_id)
382 }
383}
384
385fn truncate_params(params: &serde_json::Map<String, serde_json::Value>) -> String {
386 let s = serde_json::to_string(params).unwrap_or_default();
387 if s.chars().count() > 500 {
388 let truncated: String = s.chars().take(497).collect();
389 format!("{truncated}…")
390 } else {
391 s
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use std::collections::HashMap;
398 use std::sync::Arc;
399
400 use super::*;
401 use crate::SkillTrustLevel;
402 use crate::policy::{
403 DefaultEffect, PolicyConfig, PolicyEffect, PolicyEnforcer, PolicyRuleConfig,
404 };
405
406 #[derive(Debug)]
407 struct MockExecutor;
408
409 impl ToolExecutor for MockExecutor {
410 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
411 Ok(None)
412 }
413 async fn execute_tool_call(
414 &self,
415 call: &ToolCall,
416 ) -> Result<Option<ToolOutput>, ToolError> {
417 Ok(Some(ToolOutput {
418 tool_name: call.tool_id.clone(),
419 summary: "ok".into(),
420 blocks_executed: 1,
421 filter_stats: None,
422 diff: None,
423 streamed: false,
424 terminal_id: None,
425 locations: None,
426 raw_response: None,
427 claim_source: None,
428 }))
429 }
430 }
431
432 fn make_gate(config: &PolicyConfig) -> PolicyGateExecutor<MockExecutor> {
433 let enforcer = Arc::new(PolicyEnforcer::compile(config).unwrap());
434 let context = Arc::new(RwLock::new(PolicyContext {
435 trust_level: SkillTrustLevel::Trusted,
436 env: HashMap::new(),
437 }));
438 PolicyGateExecutor::new(MockExecutor, enforcer, context)
439 }
440
441 fn make_call(tool_id: &str) -> ToolCall {
442 ToolCall {
443 tool_id: tool_id.into(),
444 params: serde_json::Map::new(),
445 caller_id: None,
446 context: None,
447
448 tool_call_id: String::new(),
449 skill_name: None,
450 }
451 }
452
453 fn make_call_with_path(tool_id: &str, path: &str) -> ToolCall {
454 let mut params = serde_json::Map::new();
455 params.insert("file_path".into(), serde_json::Value::String(path.into()));
456 ToolCall {
457 tool_id: tool_id.into(),
458 params,
459 caller_id: None,
460 context: None,
461
462 tool_call_id: String::new(),
463 skill_name: None,
464 }
465 }
466
467 #[tokio::test]
468 async fn allow_by_default_when_default_allow() {
469 let config = PolicyConfig {
470 enabled: true,
471 default_effect: DefaultEffect::Allow,
472 rules: vec![],
473 policy_file: None,
474 };
475 let gate = make_gate(&config);
476 let result = gate.execute_tool_call(&make_call("bash")).await;
477 assert!(result.is_ok());
478 }
479
480 #[tokio::test]
481 async fn deny_by_default_when_default_deny() {
482 let config = PolicyConfig {
483 enabled: true,
484 default_effect: DefaultEffect::Deny,
485 rules: vec![],
486 policy_file: None,
487 };
488 let gate = make_gate(&config);
489 let result = gate.execute_tool_call(&make_call("bash")).await;
490 assert!(matches!(result, Err(ToolError::Blocked { .. })));
491 }
492
493 #[tokio::test]
494 async fn deny_rule_blocks_tool() {
495 let config = PolicyConfig {
496 enabled: true,
497 default_effect: DefaultEffect::Allow,
498 rules: vec![PolicyRuleConfig {
499 effect: PolicyEffect::Deny,
500 tool: "shell".into(),
501 paths: vec!["/etc/*".to_owned()],
502 env: vec![],
503 trust_level: None,
504 args_match: None,
505 capabilities: vec![],
506 }],
507 policy_file: None,
508 };
509 let gate = make_gate(&config);
510 let result = gate
511 .execute_tool_call(&make_call_with_path("shell", "/etc/passwd"))
512 .await;
513 assert!(matches!(result, Err(ToolError::Blocked { .. })));
514 }
515
516 #[tokio::test]
517 async fn allow_rule_permits_tool() {
518 let config = PolicyConfig {
519 enabled: true,
520 default_effect: DefaultEffect::Deny,
521 rules: vec![PolicyRuleConfig {
522 effect: PolicyEffect::Allow,
523 tool: "shell".into(),
524 paths: vec!["/tmp/*".to_owned()],
525 env: vec![],
526 trust_level: None,
527 args_match: None,
528 capabilities: vec![],
529 }],
530 policy_file: None,
531 };
532 let gate = make_gate(&config);
533 let result = gate
534 .execute_tool_call(&make_call_with_path("shell", "/tmp/foo.sh"))
535 .await;
536 assert!(result.is_ok());
537 }
538
539 #[tokio::test]
540 async fn error_message_is_generic() {
541 let config = PolicyConfig {
543 enabled: true,
544 default_effect: DefaultEffect::Deny,
545 rules: vec![],
546 policy_file: None,
547 };
548 let gate = make_gate(&config);
549 let err = gate
550 .execute_tool_call(&make_call("bash"))
551 .await
552 .unwrap_err();
553 if let ToolError::Blocked { command } = err {
554 assert!(!command.contains("rule["), "must not leak rule index");
555 assert!(!command.contains("/etc/"), "must not leak path pattern");
556 } else {
557 panic!("expected Blocked error");
558 }
559 }
560
561 #[tokio::test]
562 async fn confirmed_also_enforces_policy() {
563 let config = PolicyConfig {
565 enabled: true,
566 default_effect: DefaultEffect::Deny,
567 rules: vec![],
568 policy_file: None,
569 };
570 let gate = make_gate(&config);
571 let result = gate.execute_tool_call_confirmed(&make_call("bash")).await;
572 assert!(matches!(result, Err(ToolError::Blocked { .. })));
573 }
574
575 #[tokio::test]
577 async fn confirmed_allow_delegates_to_inner() {
578 let config = PolicyConfig {
579 enabled: true,
580 default_effect: DefaultEffect::Allow,
581 rules: vec![],
582 policy_file: None,
583 };
584 let gate = make_gate(&config);
585 let call = make_call("shell");
586 let result = gate.execute_tool_call_confirmed(&call).await;
587 assert!(result.is_ok(), "allow path must not return an error");
588 let output = result.unwrap();
589 assert!(
590 output.is_some(),
591 "inner executor must be invoked and return output on allow"
592 );
593 assert_eq!(
594 output.unwrap().tool_name,
595 "shell",
596 "output tool_name must match the confirmed call"
597 );
598 }
599
600 #[tokio::test]
601 async fn legacy_execute_blocked_when_policy_enabled() {
602 let config = PolicyConfig {
605 enabled: true,
606 default_effect: DefaultEffect::Deny,
607 rules: vec![],
608 policy_file: None,
609 };
610 let gate = make_gate(&config);
611 let result = gate.execute("```bash\necho hi\n```").await;
612 assert!(matches!(result, Err(ToolError::Blocked { .. })));
613 let result_confirmed = gate.execute_confirmed("```bash\necho hi\n```").await;
614 assert!(matches!(result_confirmed, Err(ToolError::Blocked { .. })));
615 }
616
617 #[tokio::test]
620 async fn set_effective_trust_quarantined_blocks_verified_threshold_rule() {
621 let config = PolicyConfig {
625 enabled: true,
626 default_effect: DefaultEffect::Deny,
627 rules: vec![PolicyRuleConfig {
628 effect: PolicyEffect::Allow,
629 tool: "shell".into(),
630 paths: vec![],
631 env: vec![],
632 trust_level: Some(SkillTrustLevel::Verified),
633 args_match: None,
634 capabilities: vec![],
635 }],
636 policy_file: None,
637 };
638 let gate = make_gate(&config);
639 gate.set_effective_trust(SkillTrustLevel::Quarantined);
640 let result = gate.execute_tool_call(&make_call("shell")).await;
641 assert!(
642 matches!(result, Err(ToolError::Blocked { .. })),
643 "Quarantined context must not satisfy a Verified trust threshold allow rule"
644 );
645 }
646
647 #[tokio::test]
648 async fn set_effective_trust_trusted_satisfies_verified_threshold_rule() {
649 let config = PolicyConfig {
653 enabled: true,
654 default_effect: DefaultEffect::Deny,
655 rules: vec![PolicyRuleConfig {
656 effect: PolicyEffect::Allow,
657 tool: "shell".into(),
658 paths: vec![],
659 env: vec![],
660 trust_level: Some(SkillTrustLevel::Verified),
661 args_match: None,
662 capabilities: vec![],
663 }],
664 policy_file: None,
665 };
666 let gate = make_gate(&config);
667 gate.set_effective_trust(SkillTrustLevel::Trusted);
668 let result = gate.execute_tool_call(&make_call("shell")).await;
669 assert!(
670 result.is_ok(),
671 "Trusted context must satisfy a Verified trust threshold allow rule"
672 );
673 }
674
675 #[tokio::test]
677 async fn critical_trajectory_blocks_any_allow() {
678 let config = PolicyConfig {
679 enabled: true,
680 default_effect: DefaultEffect::Allow,
681 rules: vec![],
682 policy_file: None,
683 };
684 let slot: TrajectoryRiskSlot = Arc::new(RwLock::new(3u8)); let gate = make_gate(&config).with_trajectory_risk(slot);
686 let result = gate.execute_tool_call(&make_call("builtin:shell")).await;
687 assert!(
688 matches!(result, Err(ToolError::Blocked { .. })),
689 "Critical trajectory must block even policy-allowed tool calls"
690 );
691 if let Err(ToolError::Blocked { command }) = result {
693 assert!(
694 !command.contains("Critical") && !command.contains("trajectory"),
695 "error message must not leak risk info to LLM: got '{command}'"
696 );
697 }
698 }
699
700 #[tokio::test]
702 async fn high_trajectory_does_not_block_allowed_tool() {
703 let config = PolicyConfig {
704 enabled: true,
705 default_effect: DefaultEffect::Allow,
706 rules: vec![],
707 policy_file: None,
708 };
709 let slot: TrajectoryRiskSlot = Arc::new(RwLock::new(2u8)); let gate = make_gate(&config).with_trajectory_risk(slot);
711 let result = gate.execute_tool_call(&make_call("builtin:shell")).await;
712 assert!(
713 result.is_ok(),
714 "High (not Critical) must not block allowed tool calls"
715 );
716 }
717
718 #[test]
721 fn set_effective_trust_lower_trust_cap_narrows_down() {
722 let config = PolicyConfig {
725 enabled: false,
726 default_effect: DefaultEffect::Allow,
727 rules: vec![],
728 policy_file: None,
729 };
730 let gate = make_gate(&config);
731 gate.set_effective_trust(SkillTrustLevel::Quarantined);
733 assert_eq!(
734 gate.trust_level_for_test(),
735 SkillTrustLevel::Quarantined,
736 "cap with lower trust must narrow executor trust level"
737 );
738 }
739
740 #[test]
741 fn set_effective_trust_higher_trust_cap_does_not_raise() {
742 let config = PolicyConfig {
745 enabled: false,
746 default_effect: DefaultEffect::Allow,
747 rules: vec![],
748 policy_file: None,
749 };
750 let gate = make_gate(&config);
751 gate.update_context(PolicyContext {
753 trust_level: SkillTrustLevel::Quarantined,
754 env: std::collections::HashMap::new(),
755 });
756 gate.set_effective_trust(SkillTrustLevel::Trusted);
758 assert_eq!(
759 gate.trust_level_for_test(),
760 SkillTrustLevel::Quarantined,
761 "cap with higher trust must NOT raise executor trust level"
762 );
763 }
764}