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::assert_matches;
398 use std::collections::HashMap;
399 use std::sync::Arc;
400
401 use zeph_config::ProviderName;
402
403 use super::*;
404 use crate::SkillTrustLevel;
405 use crate::policy::{
406 DefaultEffect, PolicyConfig, PolicyEffect, PolicyEnforcer, PolicyRuleConfig,
407 };
408
409 #[derive(Debug)]
410 struct MockExecutor;
411
412 impl ToolExecutor for MockExecutor {
413 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
414 Ok(None)
415 }
416 async fn execute_tool_call(
417 &self,
418 call: &ToolCall,
419 ) -> Result<Option<ToolOutput>, ToolError> {
420 Ok(Some(ToolOutput {
421 tool_name: call.tool_id.clone(),
422 summary: "ok".into(),
423 blocks_executed: 1,
424 filter_stats: None,
425 diff: None,
426 streamed: false,
427 terminal_id: None,
428 locations: None,
429 raw_response: None,
430 claim_source: None,
431 }))
432 }
433 }
434
435 fn make_gate(config: &PolicyConfig) -> PolicyGateExecutor<MockExecutor> {
436 let enforcer = Arc::new(PolicyEnforcer::compile(config).unwrap());
437 let context = Arc::new(RwLock::new(PolicyContext {
438 trust_level: SkillTrustLevel::Trusted,
439 env: HashMap::new(),
440 }));
441 PolicyGateExecutor::new(MockExecutor, enforcer, context)
442 }
443
444 fn make_call(tool_id: &str) -> ToolCall {
445 ToolCall {
446 tool_id: tool_id.into(),
447 params: serde_json::Map::new(),
448 caller_id: None,
449 context: None,
450
451 tool_call_id: String::new(),
452 skill_name: None,
453 }
454 }
455
456 fn make_call_with_path(tool_id: &str, path: &str) -> ToolCall {
457 let mut params = serde_json::Map::new();
458 params.insert("file_path".into(), serde_json::Value::String(path.into()));
459 ToolCall {
460 tool_id: tool_id.into(),
461 params,
462 caller_id: None,
463 context: None,
464
465 tool_call_id: String::new(),
466 skill_name: None,
467 }
468 }
469
470 #[tokio::test]
471 async fn allow_by_default_when_default_allow() {
472 let config = PolicyConfig {
473 enabled: true,
474 default_effect: DefaultEffect::Allow,
475 rules: vec![],
476 policy_file: None,
477 policy_provider: ProviderName::default(),
478 };
479 let gate = make_gate(&config);
480 let result = gate.execute_tool_call(&make_call("bash")).await;
481 assert!(result.is_ok());
482 }
483
484 #[tokio::test]
485 async fn deny_by_default_when_default_deny() {
486 let config = PolicyConfig {
487 enabled: true,
488 default_effect: DefaultEffect::Deny,
489 rules: vec![],
490 policy_file: None,
491 policy_provider: ProviderName::default(),
492 };
493 let gate = make_gate(&config);
494 let result = gate.execute_tool_call(&make_call("bash")).await;
495 assert_matches!(result, Err(ToolError::Blocked { .. }));
496 }
497
498 #[tokio::test]
499 async fn deny_rule_blocks_tool() {
500 let config = PolicyConfig {
501 enabled: true,
502 default_effect: DefaultEffect::Allow,
503 rules: vec![PolicyRuleConfig {
504 effect: PolicyEffect::Deny,
505 tool: "shell".into(),
506 paths: vec!["/etc/*".to_owned()],
507 env: vec![],
508 trust_level: None,
509 args_match: None,
510 capabilities: vec![],
511 }],
512 policy_file: None,
513 policy_provider: ProviderName::default(),
514 };
515 let gate = make_gate(&config);
516 let result = gate
517 .execute_tool_call(&make_call_with_path("shell", "/etc/passwd"))
518 .await;
519 assert_matches!(result, Err(ToolError::Blocked { .. }));
520 }
521
522 #[tokio::test]
523 async fn allow_rule_permits_tool() {
524 let config = PolicyConfig {
525 enabled: true,
526 default_effect: DefaultEffect::Deny,
527 rules: vec![PolicyRuleConfig {
528 effect: PolicyEffect::Allow,
529 tool: "shell".into(),
530 paths: vec!["/tmp/*".to_owned()],
531 env: vec![],
532 trust_level: None,
533 args_match: None,
534 capabilities: vec![],
535 }],
536 policy_file: None,
537 policy_provider: ProviderName::default(),
538 };
539 let gate = make_gate(&config);
540 let result = gate
541 .execute_tool_call(&make_call_with_path("shell", "/tmp/foo.sh"))
542 .await;
543 assert!(result.is_ok());
544 }
545
546 #[tokio::test]
547 async fn error_message_is_generic() {
548 let config = PolicyConfig {
550 enabled: true,
551 default_effect: DefaultEffect::Deny,
552 rules: vec![],
553 policy_file: None,
554 policy_provider: ProviderName::default(),
555 };
556 let gate = make_gate(&config);
557 let err = gate
558 .execute_tool_call(&make_call("bash"))
559 .await
560 .unwrap_err();
561 if let ToolError::Blocked { command } = err {
562 assert!(!command.contains("rule["), "must not leak rule index");
563 assert!(!command.contains("/etc/"), "must not leak path pattern");
564 } else {
565 panic!("expected Blocked error");
566 }
567 }
568
569 #[tokio::test]
570 async fn confirmed_also_enforces_policy() {
571 let config = PolicyConfig {
573 enabled: true,
574 default_effect: DefaultEffect::Deny,
575 rules: vec![],
576 policy_file: None,
577 policy_provider: ProviderName::default(),
578 };
579 let gate = make_gate(&config);
580 let result = gate.execute_tool_call_confirmed(&make_call("bash")).await;
581 assert_matches!(result, Err(ToolError::Blocked { .. }));
582 }
583
584 #[tokio::test]
586 async fn confirmed_allow_delegates_to_inner() {
587 let config = PolicyConfig {
588 enabled: true,
589 default_effect: DefaultEffect::Allow,
590 rules: vec![],
591 policy_file: None,
592 policy_provider: ProviderName::default(),
593 };
594 let gate = make_gate(&config);
595 let call = make_call("shell");
596 let result = gate.execute_tool_call_confirmed(&call).await;
597 assert!(result.is_ok(), "allow path must not return an error");
598 let output = result.unwrap();
599 assert!(
600 output.is_some(),
601 "inner executor must be invoked and return output on allow"
602 );
603 assert_eq!(
604 output.unwrap().tool_name,
605 "shell",
606 "output tool_name must match the confirmed call"
607 );
608 }
609
610 #[tokio::test]
611 async fn legacy_execute_blocked_when_policy_enabled() {
612 let config = PolicyConfig {
615 enabled: true,
616 default_effect: DefaultEffect::Deny,
617 rules: vec![],
618 policy_file: None,
619 policy_provider: ProviderName::default(),
620 };
621 let gate = make_gate(&config);
622 let result = gate.execute("```bash\necho hi\n```").await;
623 assert_matches!(result, Err(ToolError::Blocked { .. }));
624 let result_confirmed = gate.execute_confirmed("```bash\necho hi\n```").await;
625 assert_matches!(result_confirmed, Err(ToolError::Blocked { .. }));
626 }
627
628 #[tokio::test]
631 async fn set_effective_trust_quarantined_blocks_verified_threshold_rule() {
632 let config = PolicyConfig {
636 enabled: true,
637 default_effect: DefaultEffect::Deny,
638 rules: vec![PolicyRuleConfig {
639 effect: PolicyEffect::Allow,
640 tool: "shell".into(),
641 paths: vec![],
642 env: vec![],
643 trust_level: Some(SkillTrustLevel::Verified),
644 args_match: None,
645 capabilities: vec![],
646 }],
647 policy_file: None,
648 policy_provider: ProviderName::default(),
649 };
650 let gate = make_gate(&config);
651 gate.set_effective_trust(SkillTrustLevel::Quarantined);
652 let result = gate.execute_tool_call(&make_call("shell")).await;
653 assert!(
654 matches!(result, Err(ToolError::Blocked { .. })),
655 "Quarantined context must not satisfy a Verified trust threshold allow rule"
656 );
657 }
658
659 #[tokio::test]
660 async fn set_effective_trust_trusted_satisfies_verified_threshold_rule() {
661 let config = PolicyConfig {
665 enabled: true,
666 default_effect: DefaultEffect::Deny,
667 rules: vec![PolicyRuleConfig {
668 effect: PolicyEffect::Allow,
669 tool: "shell".into(),
670 paths: vec![],
671 env: vec![],
672 trust_level: Some(SkillTrustLevel::Verified),
673 args_match: None,
674 capabilities: vec![],
675 }],
676 policy_file: None,
677 policy_provider: ProviderName::default(),
678 };
679 let gate = make_gate(&config);
680 gate.set_effective_trust(SkillTrustLevel::Trusted);
681 let result = gate.execute_tool_call(&make_call("shell")).await;
682 assert!(
683 result.is_ok(),
684 "Trusted context must satisfy a Verified trust threshold allow rule"
685 );
686 }
687
688 #[tokio::test]
690 async fn critical_trajectory_blocks_any_allow() {
691 let config = PolicyConfig {
692 enabled: true,
693 default_effect: DefaultEffect::Allow,
694 rules: vec![],
695 policy_file: None,
696 policy_provider: ProviderName::default(),
697 };
698 let slot: TrajectoryRiskSlot = Arc::new(RwLock::new(3u8)); let gate = make_gate(&config).with_trajectory_risk(slot);
700 let result = gate.execute_tool_call(&make_call("builtin:shell")).await;
701 assert!(
702 matches!(result, Err(ToolError::Blocked { .. })),
703 "Critical trajectory must block even policy-allowed tool calls"
704 );
705 if let Err(ToolError::Blocked { command }) = result {
707 assert!(
708 !command.contains("Critical") && !command.contains("trajectory"),
709 "error message must not leak risk info to LLM: got '{command}'"
710 );
711 }
712 }
713
714 #[tokio::test]
716 async fn high_trajectory_does_not_block_allowed_tool() {
717 let config = PolicyConfig {
718 enabled: true,
719 default_effect: DefaultEffect::Allow,
720 rules: vec![],
721 policy_file: None,
722 policy_provider: ProviderName::default(),
723 };
724 let slot: TrajectoryRiskSlot = Arc::new(RwLock::new(2u8)); let gate = make_gate(&config).with_trajectory_risk(slot);
726 let result = gate.execute_tool_call(&make_call("builtin:shell")).await;
727 assert!(
728 result.is_ok(),
729 "High (not Critical) must not block allowed tool calls"
730 );
731 }
732
733 #[test]
736 fn set_effective_trust_lower_trust_cap_narrows_down() {
737 let config = PolicyConfig {
740 enabled: false,
741 default_effect: DefaultEffect::Allow,
742 rules: vec![],
743 policy_file: None,
744 policy_provider: ProviderName::default(),
745 };
746 let gate = make_gate(&config);
747 gate.set_effective_trust(SkillTrustLevel::Quarantined);
749 assert_eq!(
750 gate.trust_level_for_test(),
751 SkillTrustLevel::Quarantined,
752 "cap with lower trust must narrow executor trust level"
753 );
754 }
755
756 #[test]
757 fn set_effective_trust_higher_trust_cap_does_not_raise() {
758 let config = PolicyConfig {
761 enabled: false,
762 default_effect: DefaultEffect::Allow,
763 rules: vec![],
764 policy_file: None,
765 policy_provider: ProviderName::default(),
766 };
767 let gate = make_gate(&config);
768 gate.update_context(PolicyContext {
770 trust_level: SkillTrustLevel::Quarantined,
771 env: std::collections::HashMap::new(),
772 });
773 gate.set_effective_trust(SkillTrustLevel::Trusted);
775 assert_eq!(
776 gate.trust_level_for_test(),
777 SkillTrustLevel::Quarantined,
778 "cap with higher trust must NOT raise executor trust level"
779 );
780 }
781}