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 struct PolicyGateExecutor<T: ToolExecutor> {
30 inner: T,
31 enforcer: Arc<PolicyEnforcer>,
32 context: Arc<RwLock<PolicyContext>>,
33 audit: Option<Arc<AuditLogger>>,
34}
35
36impl<T: ToolExecutor + std::fmt::Debug> std::fmt::Debug for PolicyGateExecutor<T> {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 f.debug_struct("PolicyGateExecutor")
39 .field("inner", &self.inner)
40 .finish_non_exhaustive()
41 }
42}
43
44impl<T: ToolExecutor> PolicyGateExecutor<T> {
45 #[must_use]
47 pub fn new(
48 inner: T,
49 enforcer: Arc<PolicyEnforcer>,
50 context: Arc<RwLock<PolicyContext>>,
51 ) -> Self {
52 Self {
53 inner,
54 enforcer,
55 context,
56 audit: None,
57 }
58 }
59
60 #[must_use]
62 pub fn with_audit(mut self, audit: Arc<AuditLogger>) -> Self {
63 self.audit = Some(audit);
64 self
65 }
66
67 fn read_context(&self) -> PolicyContext {
68 self.context.read().clone()
69 }
70
71 pub fn update_context(&self, new_ctx: PolicyContext) {
73 *self.context.write() = new_ctx;
74 }
75
76 async fn check_policy(&self, call: &ToolCall) -> Result<(), ToolError> {
77 let ctx = self.read_context();
78 let decision = self
79 .enforcer
80 .evaluate(call.tool_id.as_str(), &call.params, &ctx);
81
82 match &decision {
83 PolicyDecision::Allow { trace } => {
84 debug!(tool = %call.tool_id, trace = %trace, "policy: allow");
85 if let Some(audit) = &self.audit {
86 let entry = AuditEntry {
87 timestamp: chrono_now(),
88 tool: call.tool_id.clone(),
89 command: truncate_params(&call.params),
90 result: AuditResult::Success,
91 duration_ms: 0,
92 error_category: None,
93 error_domain: None,
94 error_phase: None,
95 claim_source: None,
96 mcp_server_id: None,
97 injection_flagged: false,
98 embedding_anomalous: false,
99 cross_boundary_mcp_to_acp: false,
100 adversarial_policy_decision: None,
101 exit_code: None,
102 truncated: false,
103 caller_id: call.caller_id.clone(),
104 policy_match: Some(trace.clone()),
106 correlation_id: None,
107 vigil_risk: None,
108 };
109 audit.log(&entry).await;
110 }
111 Ok(())
112 }
113 PolicyDecision::Deny { trace } => {
114 debug!(tool = %call.tool_id, trace = %trace, "policy: deny");
115 if let Some(audit) = &self.audit {
116 let entry = AuditEntry {
117 timestamp: chrono_now(),
118 tool: call.tool_id.clone(),
119 command: truncate_params(&call.params),
120 result: AuditResult::Blocked {
121 reason: trace.clone(),
122 },
123 duration_ms: 0,
124 error_category: Some("policy_blocked".to_owned()),
125 error_domain: Some("action".to_owned()),
126 error_phase: None,
127 claim_source: None,
128 mcp_server_id: None,
129 injection_flagged: false,
130 embedding_anomalous: false,
131 cross_boundary_mcp_to_acp: false,
132 adversarial_policy_decision: None,
133 exit_code: None,
134 truncated: false,
135 caller_id: call.caller_id.clone(),
136 policy_match: Some(trace.clone()),
138 correlation_id: None,
139 vigil_risk: None,
140 };
141 audit.log(&entry).await;
142 }
143 Err(ToolError::Blocked {
145 command: "Tool call denied by policy".to_owned(),
146 })
147 }
148 }
149 }
150}
151
152impl<T: ToolExecutor> ToolExecutor for PolicyGateExecutor<T> {
153 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
156 Err(ToolError::Blocked {
157 command:
158 "legacy unstructured dispatch is not supported when policy enforcement is enabled"
159 .into(),
160 })
161 }
162
163 async fn execute_confirmed(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
164 Err(ToolError::Blocked {
165 command:
166 "legacy unstructured dispatch is not supported when policy enforcement is enabled"
167 .into(),
168 })
169 }
170
171 fn tool_definitions(&self) -> Vec<ToolDef> {
172 self.inner.tool_definitions()
173 }
174
175 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
176 self.check_policy(call).await?;
177 let result = self.inner.execute_tool_call(call).await;
178 if let Ok(Some(ref output)) = result
181 && let Some(colon) = output.tool_name.as_str().find(':')
182 {
183 let server_id = output.tool_name.as_str()[..colon].to_owned();
184 if let Some(audit) = &self.audit {
185 let entry = AuditEntry {
186 timestamp: chrono_now(),
187 tool: call.tool_id.clone(),
188 command: truncate_params(&call.params),
189 result: AuditResult::Success,
190 duration_ms: 0,
191 error_category: None,
192 error_domain: None,
193 error_phase: None,
194 claim_source: None,
195 mcp_server_id: Some(server_id),
196 injection_flagged: false,
197 embedding_anomalous: false,
198 cross_boundary_mcp_to_acp: false,
199 adversarial_policy_decision: None,
200 exit_code: None,
201 truncated: false,
202 caller_id: call.caller_id.clone(),
203 policy_match: None,
204 correlation_id: None,
205 vigil_risk: None,
206 };
207 audit.log(&entry).await;
208 }
209 }
210 result
211 }
212
213 async fn execute_tool_call_confirmed(
216 &self,
217 call: &ToolCall,
218 ) -> Result<Option<ToolOutput>, ToolError> {
219 self.check_policy(call).await?;
220 self.inner.execute_tool_call_confirmed(call).await
221 }
222
223 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
224 self.inner.set_skill_env(env);
225 }
226
227 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
228 self.context.write().trust_level = level;
229 self.inner.set_effective_trust(level);
230 }
231
232 fn is_tool_retryable(&self, tool_id: &str) -> bool {
233 self.inner.is_tool_retryable(tool_id)
234 }
235}
236
237fn truncate_params(params: &serde_json::Map<String, serde_json::Value>) -> String {
238 let s = serde_json::to_string(params).unwrap_or_default();
239 if s.chars().count() > 500 {
240 let truncated: String = s.chars().take(497).collect();
241 format!("{truncated}…")
242 } else {
243 s
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use std::collections::HashMap;
250 use std::sync::Arc;
251
252 use super::*;
253 use crate::SkillTrustLevel;
254 use crate::policy::{
255 DefaultEffect, PolicyConfig, PolicyEffect, PolicyEnforcer, PolicyRuleConfig,
256 };
257
258 #[derive(Debug)]
259 struct MockExecutor;
260
261 impl ToolExecutor for MockExecutor {
262 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
263 Ok(None)
264 }
265 async fn execute_tool_call(
266 &self,
267 call: &ToolCall,
268 ) -> Result<Option<ToolOutput>, ToolError> {
269 Ok(Some(ToolOutput {
270 tool_name: call.tool_id.clone(),
271 summary: "ok".into(),
272 blocks_executed: 1,
273 filter_stats: None,
274 diff: None,
275 streamed: false,
276 terminal_id: None,
277 locations: None,
278 raw_response: None,
279 claim_source: None,
280 }))
281 }
282 }
283
284 fn make_gate(config: &PolicyConfig) -> PolicyGateExecutor<MockExecutor> {
285 let enforcer = Arc::new(PolicyEnforcer::compile(config).unwrap());
286 let context = Arc::new(RwLock::new(PolicyContext {
287 trust_level: SkillTrustLevel::Trusted,
288 env: HashMap::new(),
289 }));
290 PolicyGateExecutor::new(MockExecutor, enforcer, context)
291 }
292
293 fn make_call(tool_id: &str) -> ToolCall {
294 ToolCall {
295 tool_id: tool_id.into(),
296 params: serde_json::Map::new(),
297 caller_id: None,
298 }
299 }
300
301 fn make_call_with_path(tool_id: &str, path: &str) -> ToolCall {
302 let mut params = serde_json::Map::new();
303 params.insert("file_path".into(), serde_json::Value::String(path.into()));
304 ToolCall {
305 tool_id: tool_id.into(),
306 params,
307 caller_id: None,
308 }
309 }
310
311 #[tokio::test]
312 async fn allow_by_default_when_default_allow() {
313 let config = PolicyConfig {
314 enabled: true,
315 default_effect: DefaultEffect::Allow,
316 rules: vec![],
317 policy_file: None,
318 };
319 let gate = make_gate(&config);
320 let result = gate.execute_tool_call(&make_call("bash")).await;
321 assert!(result.is_ok());
322 }
323
324 #[tokio::test]
325 async fn deny_by_default_when_default_deny() {
326 let config = PolicyConfig {
327 enabled: true,
328 default_effect: DefaultEffect::Deny,
329 rules: vec![],
330 policy_file: None,
331 };
332 let gate = make_gate(&config);
333 let result = gate.execute_tool_call(&make_call("bash")).await;
334 assert!(matches!(result, Err(ToolError::Blocked { .. })));
335 }
336
337 #[tokio::test]
338 async fn deny_rule_blocks_tool() {
339 let config = PolicyConfig {
340 enabled: true,
341 default_effect: DefaultEffect::Allow,
342 rules: vec![PolicyRuleConfig {
343 effect: PolicyEffect::Deny,
344 tool: "shell".into(),
345 paths: vec!["/etc/*".to_owned()],
346 env: vec![],
347 trust_level: None,
348 args_match: None,
349 capabilities: vec![],
350 }],
351 policy_file: None,
352 };
353 let gate = make_gate(&config);
354 let result = gate
355 .execute_tool_call(&make_call_with_path("shell", "/etc/passwd"))
356 .await;
357 assert!(matches!(result, Err(ToolError::Blocked { .. })));
358 }
359
360 #[tokio::test]
361 async fn allow_rule_permits_tool() {
362 let config = PolicyConfig {
363 enabled: true,
364 default_effect: DefaultEffect::Deny,
365 rules: vec![PolicyRuleConfig {
366 effect: PolicyEffect::Allow,
367 tool: "shell".into(),
368 paths: vec!["/tmp/*".to_owned()],
369 env: vec![],
370 trust_level: None,
371 args_match: None,
372 capabilities: vec![],
373 }],
374 policy_file: None,
375 };
376 let gate = make_gate(&config);
377 let result = gate
378 .execute_tool_call(&make_call_with_path("shell", "/tmp/foo.sh"))
379 .await;
380 assert!(result.is_ok());
381 }
382
383 #[tokio::test]
384 async fn error_message_is_generic() {
385 let config = PolicyConfig {
387 enabled: true,
388 default_effect: DefaultEffect::Deny,
389 rules: vec![],
390 policy_file: None,
391 };
392 let gate = make_gate(&config);
393 let err = gate
394 .execute_tool_call(&make_call("bash"))
395 .await
396 .unwrap_err();
397 if let ToolError::Blocked { command } = err {
398 assert!(!command.contains("rule["), "must not leak rule index");
399 assert!(!command.contains("/etc/"), "must not leak path pattern");
400 } else {
401 panic!("expected Blocked error");
402 }
403 }
404
405 #[tokio::test]
406 async fn confirmed_also_enforces_policy() {
407 let config = PolicyConfig {
409 enabled: true,
410 default_effect: DefaultEffect::Deny,
411 rules: vec![],
412 policy_file: None,
413 };
414 let gate = make_gate(&config);
415 let result = gate.execute_tool_call_confirmed(&make_call("bash")).await;
416 assert!(matches!(result, Err(ToolError::Blocked { .. })));
417 }
418
419 #[tokio::test]
421 async fn confirmed_allow_delegates_to_inner() {
422 let config = PolicyConfig {
423 enabled: true,
424 default_effect: DefaultEffect::Allow,
425 rules: vec![],
426 policy_file: None,
427 };
428 let gate = make_gate(&config);
429 let call = make_call("shell");
430 let result = gate.execute_tool_call_confirmed(&call).await;
431 assert!(result.is_ok(), "allow path must not return an error");
432 let output = result.unwrap();
433 assert!(
434 output.is_some(),
435 "inner executor must be invoked and return output on allow"
436 );
437 assert_eq!(
438 output.unwrap().tool_name,
439 "shell",
440 "output tool_name must match the confirmed call"
441 );
442 }
443
444 #[tokio::test]
445 async fn legacy_execute_blocked_when_policy_enabled() {
446 let config = PolicyConfig {
449 enabled: true,
450 default_effect: DefaultEffect::Deny,
451 rules: vec![],
452 policy_file: None,
453 };
454 let gate = make_gate(&config);
455 let result = gate.execute("```bash\necho hi\n```").await;
456 assert!(matches!(result, Err(ToolError::Blocked { .. })));
457 let result_confirmed = gate.execute_confirmed("```bash\necho hi\n```").await;
458 assert!(matches!(result_confirmed, Err(ToolError::Blocked { .. })));
459 }
460
461 #[tokio::test]
464 async fn set_effective_trust_quarantined_blocks_verified_threshold_rule() {
465 let config = PolicyConfig {
469 enabled: true,
470 default_effect: DefaultEffect::Deny,
471 rules: vec![PolicyRuleConfig {
472 effect: PolicyEffect::Allow,
473 tool: "shell".into(),
474 paths: vec![],
475 env: vec![],
476 trust_level: Some(SkillTrustLevel::Verified),
477 args_match: None,
478 capabilities: vec![],
479 }],
480 policy_file: None,
481 };
482 let gate = make_gate(&config);
483 gate.set_effective_trust(SkillTrustLevel::Quarantined);
484 let result = gate.execute_tool_call(&make_call("shell")).await;
485 assert!(
486 matches!(result, Err(ToolError::Blocked { .. })),
487 "Quarantined context must not satisfy a Verified trust threshold allow rule"
488 );
489 }
490
491 #[tokio::test]
492 async fn set_effective_trust_trusted_satisfies_verified_threshold_rule() {
493 let config = PolicyConfig {
497 enabled: true,
498 default_effect: DefaultEffect::Deny,
499 rules: vec![PolicyRuleConfig {
500 effect: PolicyEffect::Allow,
501 tool: "shell".into(),
502 paths: vec![],
503 env: vec![],
504 trust_level: Some(SkillTrustLevel::Verified),
505 args_match: None,
506 capabilities: vec![],
507 }],
508 policy_file: None,
509 };
510 let gate = make_gate(&config);
511 gate.set_effective_trust(SkillTrustLevel::Trusted);
512 let result = gate.execute_tool_call(&make_call("shell")).await;
513 assert!(
514 result.is_ok(),
515 "Trusted context must satisfy a Verified trust threshold allow rule"
516 );
517 }
518}