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