1use std::sync::Arc;
16
17use tracing::debug;
18
19use crate::audit::{AuditEntry, AuditLogger, AuditResult, chrono_now};
20use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput};
21use crate::policy::{PolicyContext, PolicyDecision, PolicyEnforcer};
22use crate::registry::ToolDef;
23
24pub struct PolicyGateExecutor<T: ToolExecutor> {
29 inner: T,
30 enforcer: Arc<PolicyEnforcer>,
31 context: Arc<std::sync::RwLock<PolicyContext>>,
32 audit: Option<Arc<AuditLogger>>,
33}
34
35impl<T: ToolExecutor + std::fmt::Debug> std::fmt::Debug for PolicyGateExecutor<T> {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 f.debug_struct("PolicyGateExecutor")
38 .field("inner", &self.inner)
39 .finish_non_exhaustive()
40 }
41}
42
43impl<T: ToolExecutor> PolicyGateExecutor<T> {
44 #[must_use]
46 pub fn new(
47 inner: T,
48 enforcer: Arc<PolicyEnforcer>,
49 context: Arc<std::sync::RwLock<PolicyContext>>,
50 ) -> Self {
51 Self {
52 inner,
53 enforcer,
54 context,
55 audit: None,
56 }
57 }
58
59 #[must_use]
61 pub fn with_audit(mut self, audit: Arc<AuditLogger>) -> Self {
62 self.audit = Some(audit);
63 self
64 }
65
66 fn read_context(&self) -> PolicyContext {
67 match self.context.read() {
70 Ok(ctx) => ctx.clone(),
71 Err(poisoned) => {
72 tracing::warn!("PolicyContext RwLock poisoned; using poisoned value");
73 poisoned.into_inner().clone()
74 }
75 }
76 }
77
78 pub fn update_context(&self, new_ctx: PolicyContext) {
80 match self.context.write() {
81 Ok(mut ctx) => *ctx = new_ctx,
82 Err(poisoned) => {
83 tracing::warn!("PolicyContext RwLock poisoned on write; overwriting");
84 *poisoned.into_inner() = new_ctx;
85 }
86 }
87 }
88
89 async fn check_policy(&self, call: &ToolCall) -> Result<(), ToolError> {
90 let ctx = self.read_context();
91 let decision = self.enforcer.evaluate(&call.tool_id, &call.params, &ctx);
92
93 match &decision {
94 PolicyDecision::Allow { trace } => {
95 debug!(tool = %call.tool_id, trace = %trace, "policy: allow");
96 if let Some(audit) = &self.audit {
97 let entry = AuditEntry {
98 timestamp: chrono_now(),
99 tool: call.tool_id.clone(),
100 command: truncate_params(&call.params),
101 result: AuditResult::Success,
102 duration_ms: 0,
103 error_category: None,
104 error_domain: None,
105 error_phase: None,
106 claim_source: None,
107 mcp_server_id: None,
108 injection_flagged: false,
109 embedding_anomalous: false,
110 cross_boundary_mcp_to_acp: false,
111 adversarial_policy_decision: None,
112 exit_code: None,
113 truncated: false,
114 };
115 audit.log(&entry).await;
116 }
117 Ok(())
118 }
119 PolicyDecision::Deny { trace } => {
120 debug!(tool = %call.tool_id, trace = %trace, "policy: deny");
121 if let Some(audit) = &self.audit {
122 let entry = AuditEntry {
123 timestamp: chrono_now(),
124 tool: call.tool_id.clone(),
125 command: truncate_params(&call.params),
126 result: AuditResult::Blocked {
127 reason: trace.clone(),
128 },
129 duration_ms: 0,
130 error_category: Some("policy_blocked".to_owned()),
131 error_domain: Some("action".to_owned()),
132 error_phase: None,
133 claim_source: None,
134 mcp_server_id: None,
135 injection_flagged: false,
136 embedding_anomalous: false,
137 cross_boundary_mcp_to_acp: false,
138 adversarial_policy_decision: None,
139 exit_code: None,
140 truncated: false,
141 };
142 audit.log(&entry).await;
143 }
144 Err(ToolError::Blocked {
146 command: "Tool call denied by policy".to_owned(),
147 })
148 }
149 }
150 }
151}
152
153impl<T: ToolExecutor> ToolExecutor for PolicyGateExecutor<T> {
154 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
156 self.inner.execute(response).await
157 }
158
159 async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
160 self.inner.execute_confirmed(response).await
161 }
162
163 fn tool_definitions(&self) -> Vec<ToolDef> {
164 self.inner.tool_definitions()
165 }
166
167 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
168 self.check_policy(call).await?;
169 let result = self.inner.execute_tool_call(call).await;
170 if let Ok(Some(ref output)) = result
173 && let Some(colon) = output.tool_name.find(':')
174 {
175 let server_id = output.tool_name[..colon].to_owned();
176 if let Some(audit) = &self.audit {
177 let entry = AuditEntry {
178 timestamp: chrono_now(),
179 tool: call.tool_id.clone(),
180 command: truncate_params(&call.params),
181 result: AuditResult::Success,
182 duration_ms: 0,
183 error_category: None,
184 error_domain: None,
185 error_phase: None,
186 claim_source: None,
187 mcp_server_id: Some(server_id),
188 injection_flagged: false,
189 embedding_anomalous: false,
190 cross_boundary_mcp_to_acp: false,
191 adversarial_policy_decision: None,
192 exit_code: None,
193 truncated: false,
194 };
195 audit.log(&entry).await;
196 }
197 }
198 result
199 }
200
201 async fn execute_tool_call_confirmed(
204 &self,
205 call: &ToolCall,
206 ) -> Result<Option<ToolOutput>, ToolError> {
207 self.check_policy(call).await?;
208 self.inner.execute_tool_call_confirmed(call).await
209 }
210
211 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
212 self.inner.set_skill_env(env);
213 }
214
215 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
216 match self.context.write() {
217 Ok(mut ctx) => ctx.trust_level = level,
218 Err(poisoned) => {
219 tracing::warn!("PolicyContext RwLock poisoned on trust update; overwriting");
220 poisoned.into_inner().trust_level = level;
221 }
222 }
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(std::sync::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 }
292 }
293
294 fn make_call_with_path(tool_id: &str, path: &str) -> ToolCall {
295 let mut params = serde_json::Map::new();
296 params.insert("file_path".into(), serde_json::Value::String(path.into()));
297 ToolCall {
298 tool_id: tool_id.into(),
299 params,
300 }
301 }
302
303 #[tokio::test]
304 async fn allow_by_default_when_default_allow() {
305 let config = PolicyConfig {
306 enabled: true,
307 default_effect: DefaultEffect::Allow,
308 rules: vec![],
309 policy_file: None,
310 };
311 let gate = make_gate(&config);
312 let result = gate.execute_tool_call(&make_call("bash")).await;
313 assert!(result.is_ok());
314 }
315
316 #[tokio::test]
317 async fn deny_by_default_when_default_deny() {
318 let config = PolicyConfig {
319 enabled: true,
320 default_effect: DefaultEffect::Deny,
321 rules: vec![],
322 policy_file: None,
323 };
324 let gate = make_gate(&config);
325 let result = gate.execute_tool_call(&make_call("bash")).await;
326 assert!(matches!(result, Err(ToolError::Blocked { .. })));
327 }
328
329 #[tokio::test]
330 async fn deny_rule_blocks_tool() {
331 let config = PolicyConfig {
332 enabled: true,
333 default_effect: DefaultEffect::Allow,
334 rules: vec![PolicyRuleConfig {
335 effect: PolicyEffect::Deny,
336 tool: "shell".to_owned(),
337 paths: vec!["/etc/*".to_owned()],
338 env: vec![],
339 trust_level: None,
340 args_match: None,
341 }],
342 policy_file: None,
343 };
344 let gate = make_gate(&config);
345 let result = gate
346 .execute_tool_call(&make_call_with_path("shell", "/etc/passwd"))
347 .await;
348 assert!(matches!(result, Err(ToolError::Blocked { .. })));
349 }
350
351 #[tokio::test]
352 async fn allow_rule_permits_tool() {
353 let config = PolicyConfig {
354 enabled: true,
355 default_effect: DefaultEffect::Deny,
356 rules: vec![PolicyRuleConfig {
357 effect: PolicyEffect::Allow,
358 tool: "shell".to_owned(),
359 paths: vec!["/tmp/*".to_owned()],
360 env: vec![],
361 trust_level: None,
362 args_match: None,
363 }],
364 policy_file: None,
365 };
366 let gate = make_gate(&config);
367 let result = gate
368 .execute_tool_call(&make_call_with_path("shell", "/tmp/foo.sh"))
369 .await;
370 assert!(result.is_ok());
371 }
372
373 #[tokio::test]
374 async fn error_message_is_generic() {
375 let config = PolicyConfig {
377 enabled: true,
378 default_effect: DefaultEffect::Deny,
379 rules: vec![],
380 policy_file: None,
381 };
382 let gate = make_gate(&config);
383 let err = gate
384 .execute_tool_call(&make_call("bash"))
385 .await
386 .unwrap_err();
387 if let ToolError::Blocked { command } = err {
388 assert!(!command.contains("rule["), "must not leak rule index");
389 assert!(!command.contains("/etc/"), "must not leak path pattern");
390 } else {
391 panic!("expected Blocked error");
392 }
393 }
394
395 #[tokio::test]
396 async fn confirmed_also_enforces_policy() {
397 let config = PolicyConfig {
399 enabled: true,
400 default_effect: DefaultEffect::Deny,
401 rules: vec![],
402 policy_file: None,
403 };
404 let gate = make_gate(&config);
405 let result = gate.execute_tool_call_confirmed(&make_call("bash")).await;
406 assert!(matches!(result, Err(ToolError::Blocked { .. })));
407 }
408
409 #[tokio::test]
411 async fn confirmed_allow_delegates_to_inner() {
412 let config = PolicyConfig {
413 enabled: true,
414 default_effect: DefaultEffect::Allow,
415 rules: vec![],
416 policy_file: None,
417 };
418 let gate = make_gate(&config);
419 let call = make_call("shell");
420 let result = gate.execute_tool_call_confirmed(&call).await;
421 assert!(result.is_ok(), "allow path must not return an error");
422 let output = result.unwrap();
423 assert!(
424 output.is_some(),
425 "inner executor must be invoked and return output on allow"
426 );
427 assert_eq!(
428 output.unwrap().tool_name,
429 "shell",
430 "output tool_name must match the confirmed call"
431 );
432 }
433
434 #[tokio::test]
435 async fn legacy_execute_bypasses_policy() {
436 let config = PolicyConfig {
438 enabled: true,
439 default_effect: DefaultEffect::Deny,
440 rules: vec![],
441 policy_file: None,
442 };
443 let gate = make_gate(&config);
444 let result = gate.execute("```bash\necho hi\n```").await;
445 assert!(result.is_ok());
447 }
448
449 #[tokio::test]
452 async fn set_effective_trust_quarantined_blocks_verified_threshold_rule() {
453 let config = PolicyConfig {
457 enabled: true,
458 default_effect: DefaultEffect::Deny,
459 rules: vec![PolicyRuleConfig {
460 effect: PolicyEffect::Allow,
461 tool: "shell".to_owned(),
462 paths: vec![],
463 env: vec![],
464 trust_level: Some(SkillTrustLevel::Verified),
465 args_match: None,
466 }],
467 policy_file: None,
468 };
469 let gate = make_gate(&config);
470 gate.set_effective_trust(SkillTrustLevel::Quarantined);
471 let result = gate.execute_tool_call(&make_call("shell")).await;
472 assert!(
473 matches!(result, Err(ToolError::Blocked { .. })),
474 "Quarantined context must not satisfy a Verified trust threshold allow rule"
475 );
476 }
477
478 #[tokio::test]
479 async fn set_effective_trust_trusted_satisfies_verified_threshold_rule() {
480 let config = PolicyConfig {
484 enabled: true,
485 default_effect: DefaultEffect::Deny,
486 rules: vec![PolicyRuleConfig {
487 effect: PolicyEffect::Allow,
488 tool: "shell".to_owned(),
489 paths: vec![],
490 env: vec![],
491 trust_level: Some(SkillTrustLevel::Verified),
492 args_match: None,
493 }],
494 policy_file: None,
495 };
496 let gate = make_gate(&config);
497 gate.set_effective_trust(SkillTrustLevel::Trusted);
498 let result = gate.execute_tool_call(&make_call("shell")).await;
499 assert!(
500 result.is_ok(),
501 "Trusted context must satisfy a Verified trust threshold allow rule"
502 );
503 }
504}