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 tool_call_id: String::new(),
424 }
425 }
426
427 fn make_call_with_path(tool_id: &str, path: &str) -> ToolCall {
428 let mut params = serde_json::Map::new();
429 params.insert("file_path".into(), serde_json::Value::String(path.into()));
430 ToolCall {
431 tool_id: tool_id.into(),
432 params,
433 caller_id: None,
434 context: None,
435
436 tool_call_id: String::new(),
437 }
438 }
439
440 #[tokio::test]
441 async fn allow_by_default_when_default_allow() {
442 let config = PolicyConfig {
443 enabled: true,
444 default_effect: DefaultEffect::Allow,
445 rules: vec![],
446 policy_file: None,
447 };
448 let gate = make_gate(&config);
449 let result = gate.execute_tool_call(&make_call("bash")).await;
450 assert!(result.is_ok());
451 }
452
453 #[tokio::test]
454 async fn deny_by_default_when_default_deny() {
455 let config = PolicyConfig {
456 enabled: true,
457 default_effect: DefaultEffect::Deny,
458 rules: vec![],
459 policy_file: None,
460 };
461 let gate = make_gate(&config);
462 let result = gate.execute_tool_call(&make_call("bash")).await;
463 assert!(matches!(result, Err(ToolError::Blocked { .. })));
464 }
465
466 #[tokio::test]
467 async fn deny_rule_blocks_tool() {
468 let config = PolicyConfig {
469 enabled: true,
470 default_effect: DefaultEffect::Allow,
471 rules: vec![PolicyRuleConfig {
472 effect: PolicyEffect::Deny,
473 tool: "shell".into(),
474 paths: vec!["/etc/*".to_owned()],
475 env: vec![],
476 trust_level: None,
477 args_match: None,
478 capabilities: vec![],
479 }],
480 policy_file: None,
481 };
482 let gate = make_gate(&config);
483 let result = gate
484 .execute_tool_call(&make_call_with_path("shell", "/etc/passwd"))
485 .await;
486 assert!(matches!(result, Err(ToolError::Blocked { .. })));
487 }
488
489 #[tokio::test]
490 async fn allow_rule_permits_tool() {
491 let config = PolicyConfig {
492 enabled: true,
493 default_effect: DefaultEffect::Deny,
494 rules: vec![PolicyRuleConfig {
495 effect: PolicyEffect::Allow,
496 tool: "shell".into(),
497 paths: vec!["/tmp/*".to_owned()],
498 env: vec![],
499 trust_level: None,
500 args_match: None,
501 capabilities: vec![],
502 }],
503 policy_file: None,
504 };
505 let gate = make_gate(&config);
506 let result = gate
507 .execute_tool_call(&make_call_with_path("shell", "/tmp/foo.sh"))
508 .await;
509 assert!(result.is_ok());
510 }
511
512 #[tokio::test]
513 async fn error_message_is_generic() {
514 let config = PolicyConfig {
516 enabled: true,
517 default_effect: DefaultEffect::Deny,
518 rules: vec![],
519 policy_file: None,
520 };
521 let gate = make_gate(&config);
522 let err = gate
523 .execute_tool_call(&make_call("bash"))
524 .await
525 .unwrap_err();
526 if let ToolError::Blocked { command } = err {
527 assert!(!command.contains("rule["), "must not leak rule index");
528 assert!(!command.contains("/etc/"), "must not leak path pattern");
529 } else {
530 panic!("expected Blocked error");
531 }
532 }
533
534 #[tokio::test]
535 async fn confirmed_also_enforces_policy() {
536 let config = PolicyConfig {
538 enabled: true,
539 default_effect: DefaultEffect::Deny,
540 rules: vec![],
541 policy_file: None,
542 };
543 let gate = make_gate(&config);
544 let result = gate.execute_tool_call_confirmed(&make_call("bash")).await;
545 assert!(matches!(result, Err(ToolError::Blocked { .. })));
546 }
547
548 #[tokio::test]
550 async fn confirmed_allow_delegates_to_inner() {
551 let config = PolicyConfig {
552 enabled: true,
553 default_effect: DefaultEffect::Allow,
554 rules: vec![],
555 policy_file: None,
556 };
557 let gate = make_gate(&config);
558 let call = make_call("shell");
559 let result = gate.execute_tool_call_confirmed(&call).await;
560 assert!(result.is_ok(), "allow path must not return an error");
561 let output = result.unwrap();
562 assert!(
563 output.is_some(),
564 "inner executor must be invoked and return output on allow"
565 );
566 assert_eq!(
567 output.unwrap().tool_name,
568 "shell",
569 "output tool_name must match the confirmed call"
570 );
571 }
572
573 #[tokio::test]
574 async fn legacy_execute_blocked_when_policy_enabled() {
575 let config = PolicyConfig {
578 enabled: true,
579 default_effect: DefaultEffect::Deny,
580 rules: vec![],
581 policy_file: None,
582 };
583 let gate = make_gate(&config);
584 let result = gate.execute("```bash\necho hi\n```").await;
585 assert!(matches!(result, Err(ToolError::Blocked { .. })));
586 let result_confirmed = gate.execute_confirmed("```bash\necho hi\n```").await;
587 assert!(matches!(result_confirmed, Err(ToolError::Blocked { .. })));
588 }
589
590 #[tokio::test]
593 async fn set_effective_trust_quarantined_blocks_verified_threshold_rule() {
594 let config = PolicyConfig {
598 enabled: true,
599 default_effect: DefaultEffect::Deny,
600 rules: vec![PolicyRuleConfig {
601 effect: PolicyEffect::Allow,
602 tool: "shell".into(),
603 paths: vec![],
604 env: vec![],
605 trust_level: Some(SkillTrustLevel::Verified),
606 args_match: None,
607 capabilities: vec![],
608 }],
609 policy_file: None,
610 };
611 let gate = make_gate(&config);
612 gate.set_effective_trust(SkillTrustLevel::Quarantined);
613 let result = gate.execute_tool_call(&make_call("shell")).await;
614 assert!(
615 matches!(result, Err(ToolError::Blocked { .. })),
616 "Quarantined context must not satisfy a Verified trust threshold allow rule"
617 );
618 }
619
620 #[tokio::test]
621 async fn set_effective_trust_trusted_satisfies_verified_threshold_rule() {
622 let config = PolicyConfig {
626 enabled: true,
627 default_effect: DefaultEffect::Deny,
628 rules: vec![PolicyRuleConfig {
629 effect: PolicyEffect::Allow,
630 tool: "shell".into(),
631 paths: vec![],
632 env: vec![],
633 trust_level: Some(SkillTrustLevel::Verified),
634 args_match: None,
635 capabilities: vec![],
636 }],
637 policy_file: None,
638 };
639 let gate = make_gate(&config);
640 gate.set_effective_trust(SkillTrustLevel::Trusted);
641 let result = gate.execute_tool_call(&make_call("shell")).await;
642 assert!(
643 result.is_ok(),
644 "Trusted context must satisfy a Verified trust threshold allow rule"
645 );
646 }
647
648 #[tokio::test]
650 async fn critical_trajectory_blocks_any_allow() {
651 let config = PolicyConfig {
652 enabled: true,
653 default_effect: DefaultEffect::Allow,
654 rules: vec![],
655 policy_file: None,
656 };
657 let slot: TrajectoryRiskSlot = Arc::new(RwLock::new(3u8)); let gate = make_gate(&config).with_trajectory_risk(slot);
659 let result = gate.execute_tool_call(&make_call("builtin:shell")).await;
660 assert!(
661 matches!(result, Err(ToolError::Blocked { .. })),
662 "Critical trajectory must block even policy-allowed tool calls"
663 );
664 if let Err(ToolError::Blocked { command }) = result {
666 assert!(
667 !command.contains("Critical") && !command.contains("trajectory"),
668 "error message must not leak risk info to LLM: got '{command}'"
669 );
670 }
671 }
672
673 #[tokio::test]
675 async fn high_trajectory_does_not_block_allowed_tool() {
676 let config = PolicyConfig {
677 enabled: true,
678 default_effect: DefaultEffect::Allow,
679 rules: vec![],
680 policy_file: None,
681 };
682 let slot: TrajectoryRiskSlot = Arc::new(RwLock::new(2u8)); let gate = make_gate(&config).with_trajectory_risk(slot);
684 let result = gate.execute_tool_call(&make_call("builtin:shell")).await;
685 assert!(
686 result.is_ok(),
687 "High (not Critical) must not block allowed tool calls"
688 );
689 }
690}