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 pub fn update_context(&self, new_ctx: PolicyContext) {
126 *self.context.write() = new_ctx;
127 }
128
129 fn is_trajectory_critical(&self) -> bool {
131 self.trajectory_risk
132 .as_ref()
133 .is_some_and(|slot| *slot.read() >= 3)
134 }
135
136 async fn log_audit(&self, call: &ToolCall, result: AuditResult, error_category: Option<&str>) {
137 let Some(audit) = &self.audit else { return };
138 let entry = AuditEntry {
139 timestamp: chrono_now(),
140 tool: call.tool_id.clone(),
141 command: truncate_params(&call.params),
142 result,
143 duration_ms: 0,
144 error_category: error_category.map(str::to_owned),
145 error_domain: error_category.map(|_| "security".to_owned()),
146 error_phase: None,
147 claim_source: None,
148 mcp_server_id: None,
149 injection_flagged: false,
150 embedding_anomalous: false,
151 cross_boundary_mcp_to_acp: false,
152 adversarial_policy_decision: None,
153 exit_code: None,
154 truncated: false,
155 caller_id: call.caller_id.clone(),
156 policy_match: None,
157 correlation_id: None,
158 vigil_risk: None,
159 execution_env: None,
160 resolved_cwd: None,
161 scope_at_definition: None,
162 scope_at_dispatch: None,
163 };
164 audit.log(&entry).await;
165 }
166
167 async fn check_policy(&self, call: &ToolCall) -> Result<(), ToolError> {
168 if self.is_trajectory_critical() {
170 tracing::warn!(tool = %call.tool_id, "trajectory sentinel at Critical: denied (spec 050)");
171 self.log_audit(
172 call,
173 AuditResult::Blocked {
174 reason: "trajectory_critical_downgrade".to_owned(),
175 },
176 Some("trajectory_critical_downgrade"),
177 )
178 .await;
179 return Err(ToolError::Blocked {
180 command: "Tool call denied by policy".to_owned(),
181 });
182 }
183
184 let ctx = self.read_context();
185 let decision = self
186 .enforcer
187 .evaluate(call.tool_id.as_str(), &call.params, &ctx);
188
189 match &decision {
190 PolicyDecision::Allow { trace } => {
191 debug!(tool = %call.tool_id, trace = %trace, "policy: allow");
192 if let Some(audit) = &self.audit {
193 let entry = AuditEntry {
194 timestamp: chrono_now(),
195 tool: call.tool_id.clone(),
196 command: truncate_params(&call.params),
197 result: AuditResult::Success,
198 duration_ms: 0,
199 error_category: None,
200 error_domain: None,
201 error_phase: None,
202 claim_source: None,
203 mcp_server_id: None,
204 injection_flagged: false,
205 embedding_anomalous: false,
206 cross_boundary_mcp_to_acp: false,
207 adversarial_policy_decision: None,
208 exit_code: None,
209 truncated: false,
210 caller_id: call.caller_id.clone(),
211 policy_match: Some(trace.clone()),
212 correlation_id: None,
213 vigil_risk: None,
214 execution_env: None,
215 resolved_cwd: None,
216 scope_at_definition: None,
217 scope_at_dispatch: None,
218 };
219 audit.log(&entry).await;
220 }
221 Ok(())
222 }
223 PolicyDecision::Deny { trace } => {
224 debug!(tool = %call.tool_id, trace = %trace, "policy: deny");
225 self.push_signal(1);
227 if let Some(audit) = &self.audit {
228 let entry = AuditEntry {
229 timestamp: chrono_now(),
230 tool: call.tool_id.clone(),
231 command: truncate_params(&call.params),
232 result: AuditResult::Blocked {
233 reason: trace.clone(),
234 },
235 duration_ms: 0,
236 error_category: Some("policy_blocked".to_owned()),
237 error_domain: Some("action".to_owned()),
238 error_phase: None,
239 claim_source: None,
240 mcp_server_id: None,
241 injection_flagged: false,
242 embedding_anomalous: false,
243 cross_boundary_mcp_to_acp: false,
244 adversarial_policy_decision: None,
245 exit_code: None,
246 truncated: false,
247 caller_id: call.caller_id.clone(),
248 policy_match: Some(trace.clone()),
249 correlation_id: None,
250 vigil_risk: None,
251 execution_env: None,
252 resolved_cwd: None,
253 scope_at_definition: None,
254 scope_at_dispatch: None,
255 };
256 audit.log(&entry).await;
257 }
258 Err(ToolError::Blocked {
260 command: "Tool call denied by policy".to_owned(),
261 })
262 }
263 }
264 }
265}
266
267impl<T: ToolExecutor> ToolExecutor for PolicyGateExecutor<T> {
268 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
271 Err(ToolError::Blocked {
272 command:
273 "legacy unstructured dispatch is not supported when policy enforcement is enabled"
274 .into(),
275 })
276 }
277
278 async fn execute_confirmed(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
279 Err(ToolError::Blocked {
280 command:
281 "legacy unstructured dispatch is not supported when policy enforcement is enabled"
282 .into(),
283 })
284 }
285
286 fn tool_definitions(&self) -> Vec<ToolDef> {
287 self.inner.tool_definitions()
288 }
289
290 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
291 self.check_policy(call).await?;
292 let result = self.inner.execute_tool_call(call).await;
293 if let Ok(Some(ref output)) = result
296 && let Some(colon) = output.tool_name.as_str().find(':')
297 {
298 let server_id = output.tool_name.as_str()[..colon].to_owned();
299 if let Some(audit) = &self.audit {
300 let entry = AuditEntry {
301 timestamp: chrono_now(),
302 tool: call.tool_id.clone(),
303 command: truncate_params(&call.params),
304 result: AuditResult::Success,
305 duration_ms: 0,
306 error_category: None,
307 error_domain: None,
308 error_phase: None,
309 claim_source: None,
310 mcp_server_id: Some(server_id),
311 injection_flagged: false,
312 embedding_anomalous: false,
313 cross_boundary_mcp_to_acp: false,
314 adversarial_policy_decision: None,
315 exit_code: None,
316 truncated: false,
317 caller_id: call.caller_id.clone(),
318 policy_match: None,
319 correlation_id: None,
320 vigil_risk: None,
321 execution_env: None,
322 resolved_cwd: None,
323 scope_at_definition: None,
324 scope_at_dispatch: None,
325 };
326 audit.log(&entry).await;
327 }
328 }
329 result
330 }
331
332 async fn execute_tool_call_confirmed(
335 &self,
336 call: &ToolCall,
337 ) -> Result<Option<ToolOutput>, ToolError> {
338 self.check_policy(call).await?;
339 self.inner.execute_tool_call_confirmed(call).await
340 }
341
342 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
343 self.inner.set_skill_env(env);
344 }
345
346 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
347 self.context.write().trust_level = level;
348 self.inner.set_effective_trust(level);
349 }
350
351 fn is_tool_retryable(&self, tool_id: &str) -> bool {
352 self.inner.is_tool_retryable(tool_id)
353 }
354
355 fn is_tool_speculatable(&self, tool_id: &str) -> bool {
356 self.inner.is_tool_speculatable(tool_id)
357 }
358}
359
360fn truncate_params(params: &serde_json::Map<String, serde_json::Value>) -> String {
361 let s = serde_json::to_string(params).unwrap_or_default();
362 if s.chars().count() > 500 {
363 let truncated: String = s.chars().take(497).collect();
364 format!("{truncated}…")
365 } else {
366 s
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use std::collections::HashMap;
373 use std::sync::Arc;
374
375 use super::*;
376 use crate::SkillTrustLevel;
377 use crate::policy::{
378 DefaultEffect, PolicyConfig, PolicyEffect, PolicyEnforcer, PolicyRuleConfig,
379 };
380
381 #[derive(Debug)]
382 struct MockExecutor;
383
384 impl ToolExecutor for MockExecutor {
385 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
386 Ok(None)
387 }
388 async fn execute_tool_call(
389 &self,
390 call: &ToolCall,
391 ) -> Result<Option<ToolOutput>, ToolError> {
392 Ok(Some(ToolOutput {
393 tool_name: call.tool_id.clone(),
394 summary: "ok".into(),
395 blocks_executed: 1,
396 filter_stats: None,
397 diff: None,
398 streamed: false,
399 terminal_id: None,
400 locations: None,
401 raw_response: None,
402 claim_source: None,
403 }))
404 }
405 }
406
407 fn make_gate(config: &PolicyConfig) -> PolicyGateExecutor<MockExecutor> {
408 let enforcer = Arc::new(PolicyEnforcer::compile(config).unwrap());
409 let context = Arc::new(RwLock::new(PolicyContext {
410 trust_level: SkillTrustLevel::Trusted,
411 env: HashMap::new(),
412 }));
413 PolicyGateExecutor::new(MockExecutor, enforcer, context)
414 }
415
416 fn make_call(tool_id: &str) -> ToolCall {
417 ToolCall {
418 tool_id: tool_id.into(),
419 params: serde_json::Map::new(),
420 caller_id: None,
421 context: None,
422 }
423 }
424
425 fn make_call_with_path(tool_id: &str, path: &str) -> ToolCall {
426 let mut params = serde_json::Map::new();
427 params.insert("file_path".into(), serde_json::Value::String(path.into()));
428 ToolCall {
429 tool_id: tool_id.into(),
430 params,
431 caller_id: None,
432 context: None,
433 }
434 }
435
436 #[tokio::test]
437 async fn allow_by_default_when_default_allow() {
438 let config = PolicyConfig {
439 enabled: true,
440 default_effect: DefaultEffect::Allow,
441 rules: vec![],
442 policy_file: None,
443 };
444 let gate = make_gate(&config);
445 let result = gate.execute_tool_call(&make_call("bash")).await;
446 assert!(result.is_ok());
447 }
448
449 #[tokio::test]
450 async fn deny_by_default_when_default_deny() {
451 let config = PolicyConfig {
452 enabled: true,
453 default_effect: DefaultEffect::Deny,
454 rules: vec![],
455 policy_file: None,
456 };
457 let gate = make_gate(&config);
458 let result = gate.execute_tool_call(&make_call("bash")).await;
459 assert!(matches!(result, Err(ToolError::Blocked { .. })));
460 }
461
462 #[tokio::test]
463 async fn deny_rule_blocks_tool() {
464 let config = PolicyConfig {
465 enabled: true,
466 default_effect: DefaultEffect::Allow,
467 rules: vec![PolicyRuleConfig {
468 effect: PolicyEffect::Deny,
469 tool: "shell".into(),
470 paths: vec!["/etc/*".to_owned()],
471 env: vec![],
472 trust_level: None,
473 args_match: None,
474 capabilities: vec![],
475 }],
476 policy_file: None,
477 };
478 let gate = make_gate(&config);
479 let result = gate
480 .execute_tool_call(&make_call_with_path("shell", "/etc/passwd"))
481 .await;
482 assert!(matches!(result, Err(ToolError::Blocked { .. })));
483 }
484
485 #[tokio::test]
486 async fn allow_rule_permits_tool() {
487 let config = PolicyConfig {
488 enabled: true,
489 default_effect: DefaultEffect::Deny,
490 rules: vec![PolicyRuleConfig {
491 effect: PolicyEffect::Allow,
492 tool: "shell".into(),
493 paths: vec!["/tmp/*".to_owned()],
494 env: vec![],
495 trust_level: None,
496 args_match: None,
497 capabilities: vec![],
498 }],
499 policy_file: None,
500 };
501 let gate = make_gate(&config);
502 let result = gate
503 .execute_tool_call(&make_call_with_path("shell", "/tmp/foo.sh"))
504 .await;
505 assert!(result.is_ok());
506 }
507
508 #[tokio::test]
509 async fn error_message_is_generic() {
510 let config = PolicyConfig {
512 enabled: true,
513 default_effect: DefaultEffect::Deny,
514 rules: vec![],
515 policy_file: None,
516 };
517 let gate = make_gate(&config);
518 let err = gate
519 .execute_tool_call(&make_call("bash"))
520 .await
521 .unwrap_err();
522 if let ToolError::Blocked { command } = err {
523 assert!(!command.contains("rule["), "must not leak rule index");
524 assert!(!command.contains("/etc/"), "must not leak path pattern");
525 } else {
526 panic!("expected Blocked error");
527 }
528 }
529
530 #[tokio::test]
531 async fn confirmed_also_enforces_policy() {
532 let config = PolicyConfig {
534 enabled: true,
535 default_effect: DefaultEffect::Deny,
536 rules: vec![],
537 policy_file: None,
538 };
539 let gate = make_gate(&config);
540 let result = gate.execute_tool_call_confirmed(&make_call("bash")).await;
541 assert!(matches!(result, Err(ToolError::Blocked { .. })));
542 }
543
544 #[tokio::test]
546 async fn confirmed_allow_delegates_to_inner() {
547 let config = PolicyConfig {
548 enabled: true,
549 default_effect: DefaultEffect::Allow,
550 rules: vec![],
551 policy_file: None,
552 };
553 let gate = make_gate(&config);
554 let call = make_call("shell");
555 let result = gate.execute_tool_call_confirmed(&call).await;
556 assert!(result.is_ok(), "allow path must not return an error");
557 let output = result.unwrap();
558 assert!(
559 output.is_some(),
560 "inner executor must be invoked and return output on allow"
561 );
562 assert_eq!(
563 output.unwrap().tool_name,
564 "shell",
565 "output tool_name must match the confirmed call"
566 );
567 }
568
569 #[tokio::test]
570 async fn legacy_execute_blocked_when_policy_enabled() {
571 let config = PolicyConfig {
574 enabled: true,
575 default_effect: DefaultEffect::Deny,
576 rules: vec![],
577 policy_file: None,
578 };
579 let gate = make_gate(&config);
580 let result = gate.execute("```bash\necho hi\n```").await;
581 assert!(matches!(result, Err(ToolError::Blocked { .. })));
582 let result_confirmed = gate.execute_confirmed("```bash\necho hi\n```").await;
583 assert!(matches!(result_confirmed, Err(ToolError::Blocked { .. })));
584 }
585
586 #[tokio::test]
589 async fn set_effective_trust_quarantined_blocks_verified_threshold_rule() {
590 let config = PolicyConfig {
594 enabled: true,
595 default_effect: DefaultEffect::Deny,
596 rules: vec![PolicyRuleConfig {
597 effect: PolicyEffect::Allow,
598 tool: "shell".into(),
599 paths: vec![],
600 env: vec![],
601 trust_level: Some(SkillTrustLevel::Verified),
602 args_match: None,
603 capabilities: vec![],
604 }],
605 policy_file: None,
606 };
607 let gate = make_gate(&config);
608 gate.set_effective_trust(SkillTrustLevel::Quarantined);
609 let result = gate.execute_tool_call(&make_call("shell")).await;
610 assert!(
611 matches!(result, Err(ToolError::Blocked { .. })),
612 "Quarantined context must not satisfy a Verified trust threshold allow rule"
613 );
614 }
615
616 #[tokio::test]
617 async fn set_effective_trust_trusted_satisfies_verified_threshold_rule() {
618 let config = PolicyConfig {
622 enabled: true,
623 default_effect: DefaultEffect::Deny,
624 rules: vec![PolicyRuleConfig {
625 effect: PolicyEffect::Allow,
626 tool: "shell".into(),
627 paths: vec![],
628 env: vec![],
629 trust_level: Some(SkillTrustLevel::Verified),
630 args_match: None,
631 capabilities: vec![],
632 }],
633 policy_file: None,
634 };
635 let gate = make_gate(&config);
636 gate.set_effective_trust(SkillTrustLevel::Trusted);
637 let result = gate.execute_tool_call(&make_call("shell")).await;
638 assert!(
639 result.is_ok(),
640 "Trusted context must satisfy a Verified trust threshold allow rule"
641 );
642 }
643
644 #[tokio::test]
646 async fn critical_trajectory_blocks_any_allow() {
647 let config = PolicyConfig {
648 enabled: true,
649 default_effect: DefaultEffect::Allow,
650 rules: vec![],
651 policy_file: None,
652 };
653 let slot: TrajectoryRiskSlot = Arc::new(RwLock::new(3u8)); let gate = make_gate(&config).with_trajectory_risk(slot);
655 let result = gate.execute_tool_call(&make_call("builtin:shell")).await;
656 assert!(
657 matches!(result, Err(ToolError::Blocked { .. })),
658 "Critical trajectory must block even policy-allowed tool calls"
659 );
660 if let Err(ToolError::Blocked { command }) = result {
662 assert!(
663 !command.contains("Critical") && !command.contains("trajectory"),
664 "error message must not leak risk info to LLM: got '{command}'"
665 );
666 }
667 }
668
669 #[tokio::test]
671 async fn high_trajectory_does_not_block_allowed_tool() {
672 let config = PolicyConfig {
673 enabled: true,
674 default_effect: DefaultEffect::Allow,
675 rules: vec![],
676 policy_file: None,
677 };
678 let slot: TrajectoryRiskSlot = Arc::new(RwLock::new(2u8)); let gate = make_gate(&config).with_trajectory_risk(slot);
680 let result = gate.execute_tool_call(&make_call("builtin:shell")).await;
681 assert!(
682 result.is_ok(),
683 "High (not Critical) must not block allowed tool calls"
684 );
685 }
686}