1use std::collections::HashSet;
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use serde_json::json;
14
15use crate::atoms::{
16 PostToolExecHook, PostToolExecHookPriority, PreToolUseDecision, PreToolUseHook,
17};
18use crate::capabilities::{Capability, CapabilityLocalization};
19use crate::guardrail_checks::{
20 CompiledGuardrails, DEFAULT_OUTPUT_REPLACEMENT, DEFAULT_TOOL_OUTPUT_REPLACEMENT,
21 GuardrailAction, GuardrailStage, GuardrailsConfig, MAX_CHECK_ID_LEN, MAX_CHECKS,
22 MAX_ENTRIES_PER_CHECK, MAX_ENTRY_LEN, MAX_JUDGE_PROMPT_LEN, MAX_MCP_REF_LEN,
23 MAX_REPLACEMENT_LEN,
24};
25use crate::mcp_server::mcp_tool_name;
26use crate::output_guardrail::{
27 GuardrailDecision, OutputGuardrail, OutputGuardrailContext, OutputGuardrailRun,
28 PostGenerationOutputContext, PostGenerationOutputGuardrail,
29};
30use crate::tool_types::{ToolCall, ToolDefinition, ToolResult};
31use crate::traits::ToolContext;
32use crate::utility_llm::{UtilityLlmReasoningEffort, UtilityLlmRequest};
33use crate::{LlmMessage, LlmMessageRole};
34
35pub const GUARDRAILS_CAPABILITY_ID: &str = "guardrails";
36
37pub struct GuardrailsCapability;
38
39impl Capability for GuardrailsCapability {
40 fn id(&self) -> &str {
41 GUARDRAILS_CAPABILITY_ID
42 }
43
44 fn name(&self) -> &str {
45 "Guardrails"
46 }
47
48 fn description(&self) -> &str {
49 "Guardrail checks over model output and tool calls: regex and blocklist \
50 matching, tool-call restrictions, an LLM judge, and delegation to an \
51 external guardrail served over scoped MCP. Checks block or log per \
52 configuration; advisory mode logs without enforcing."
53 }
54
55 fn localizations(&self) -> Vec<CapabilityLocalization> {
56 vec![CapabilityLocalization::text(
57 "uk",
58 "Запобіжники",
59 "Детерміновані перевірки виводу моделі та викликів інструментів: \
60 регулярні вирази, списки заборонених слів, обмеження інструментів. \
61 Перевірки блокують або лише журналюють згідно з конфігурацією.",
62 )]
63 }
64
65 fn category(&self) -> Option<&str> {
66 Some("Safety")
67 }
68
69 fn icon(&self) -> Option<&str> {
70 Some("shield")
71 }
72
73 fn is_guardrail(&self) -> bool {
74 true
75 }
76
77 fn config_schema(&self) -> Option<serde_json::Value> {
78 Some(json!({
79 "type": "object",
80 "properties": {
81 "mode": {
82 "type": "string",
83 "enum": ["active", "advisory"],
84 "default": "active",
85 "description": "Advisory runs all checks but only logs hits — use it to tune checks against false positives before enforcing."
86 },
87 "checks": {
88 "type": "array",
89 "maxItems": MAX_CHECKS,
90 "items": {
91 "type": "object",
92 "required": ["stage", "type"],
93 "properties": {
94 "id": {
95 "type": "string",
96 "maxLength": MAX_CHECK_ID_LEN,
97 "description": "Stable identifier surfaced in reason codes and logs."
98 },
99 "stage": {
100 "type": "string",
101 "enum": ["output", "tool_use", "tool_output"],
102 "description": "Where the check runs: streamed model output, tool calls before execution, or tool results before they enter context."
103 },
104 "type": {
105 "type": "string",
106 "enum": ["regex", "blocklist", "tool_pattern", "llm_judge", "mcp", "moderation"],
107 "description": "regex/blocklist match stage text; tool_pattern matches tool names (tool_use stage only); llm_judge evaluates a natural-language policy via the utility LLM (tool_use/tool_output stages only); mcp delegates the decision to an external guardrail served over scoped MCP (tool_use/tool_output stages only — sends stage content off-platform); moderation scores the finalized assistant message via the utility LLM as a content classifier (output stage only — runs on the end-of-message seam, sends the message to the utility model)."
108 },
109 "patterns": {
110 "type": "array",
111 "items": {"type": "string", "maxLength": MAX_ENTRY_LEN},
112 "maxItems": MAX_ENTRIES_PER_CHECK,
113 "description": "Regex patterns (type=regex)."
114 },
115 "words": {
116 "type": "array",
117 "items": {"type": "string", "maxLength": MAX_ENTRY_LEN},
118 "maxItems": MAX_ENTRIES_PER_CHECK,
119 "description": "Words or phrases matched as substrings (type=blocklist)."
120 },
121 "case_sensitive": {
122 "type": "boolean",
123 "default": false,
124 "description": "Blocklist matching case sensitivity."
125 },
126 "tools": {
127 "type": "array",
128 "items": {"type": "string", "maxLength": MAX_ENTRY_LEN},
129 "maxItems": MAX_ENTRIES_PER_CHECK,
130 "description": "Tool name patterns with * wildcards (type=tool_pattern)."
131 },
132 "on_fail": {
133 "type": "string",
134 "enum": ["block", "log"],
135 "default": "block",
136 "description": "block stops the output/tool call; log records the hit and continues."
137 },
138 "prompt": {
139 "type": "string",
140 "maxLength": MAX_JUDGE_PROMPT_LEN,
141 "description": "Natural-language policy prompt for llm_judge. Example: 'Block any tool call that reads files outside /home/user.' Evaluated by the utility LLM; fails open on timeout or error."
142 },
143 "server": {
144 "type": "string",
145 "maxLength": MAX_MCP_REF_LEN,
146 "description": "Scoped-MCP server reference for type=mcp (sanitized server name). Required for mcp checks."
147 },
148 "tool": {
149 "type": "string",
150 "maxLength": MAX_MCP_REF_LEN,
151 "description": "Guardrail tool/method to call on the MCP server for type=mcp. Required for mcp checks. Sends a bounded stage payload off-platform; fails open on timeout, connection error, parse failure, or server-not-configured."
152 },
153 "categories": {
154 "type": "array",
155 "items": {"type": "string", "maxLength": MAX_ENTRY_LEN},
156 "maxItems": MAX_ENTRIES_PER_CHECK,
157 "description": "Moderation categories to score (type=moderation). Defaults to a built-in safety set (hate, harassment, self_harm, sexual, violence, illicit) when omitted."
158 },
159 "threshold": {
160 "type": "integer",
161 "minimum": 0,
162 "maximum": 100,
163 "default": 50,
164 "description": "Moderation block threshold as a percentage (0-100): a category scoring at or above this value trips the check (type=moderation)."
165 },
166 "replacement": {
167 "type": "string",
168 "maxLength": MAX_REPLACEMENT_LEN,
169 "description": "Text shown in place of blocked output or as the user-facing message for blocked tool calls."
170 }
171 }
172 }
173 }
174 }
175 }))
176 }
177
178 fn validate_config(&self, config: &serde_json::Value) -> Result<(), String> {
179 GuardrailsConfig::from_value(config)?.compile().map(|_| ())
180 }
181
182 fn output_guardrails(&self) -> Vec<Arc<dyn OutputGuardrail>> {
183 vec![Arc::new(DeclarativeOutputGuardrail)]
184 }
185
186 fn post_output_guardrails_with_config(
187 &self,
188 config: &serde_json::Value,
189 ) -> Vec<Arc<dyn PostGenerationOutputGuardrail>> {
190 match compile_config_for_stage(config, GuardrailStage::Output) {
194 Some(compiled)
195 if compiled
196 .moderation_checks_for_stage(GuardrailStage::Output)
197 .next()
198 .is_some() =>
199 {
200 vec![Arc::new(ModerationOutputGuardrail { compiled })]
201 }
202 _ => vec![],
203 }
204 }
205
206 fn pre_tool_use_hooks_with_config(
207 &self,
208 config: &serde_json::Value,
209 ) -> Vec<Arc<dyn PreToolUseHook>> {
210 match compile_config_for_stage(config, GuardrailStage::ToolUse) {
211 Some(compiled) => vec![Arc::new(GuardrailPreToolHook { compiled })],
212 None => vec![],
213 }
214 }
215
216 fn post_tool_exec_hooks_with_config(
217 &self,
218 config: &serde_json::Value,
219 ) -> Vec<Arc<dyn PostToolExecHook>> {
220 match compile_config_for_stage(config, GuardrailStage::ToolOutput) {
221 Some(compiled) => vec![Arc::new(GuardrailPostToolHook { compiled })],
222 None => vec![],
223 }
224 }
225}
226
227const JUDGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
229const MAX_JUDGE_CALLS_PER_INVOCATION: usize = 4;
231
232const MCP_CHECK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
235const MAX_MCP_CALLS_PER_INVOCATION: usize = 4;
246const MCP_CONTENT_CAP: usize = 2_000;
249
250const JUDGE_SYSTEM_PROMPT: &str = r#"You are a guardrail policy evaluator for an AI agent runtime.
252Your task: determine whether the provided content violates the given policy.
253Respond ONLY with valid JSON on a single line. No explanation, no prose, no markdown.
254Format: {"verdict":"allow"} or {"verdict":"block","reason":"<concise reason>"}"#;
255
256async fn run_judge_check(
261 service: &dyn crate::UtilityLlmService,
262 check: &crate::guardrail_checks::CompiledJudgeCheck,
263 stage: GuardrailStage,
264 tool_name: &str,
265 content: &str,
266) -> Option<GuardrailAction> {
267 let content_cap = {
269 let mut end = content.len().min(2_000);
270 while end > 0 && !content.is_char_boundary(end) {
271 end -= 1;
272 }
273 end
274 };
275 let user_prompt = format!(
276 "Policy: {}\nStage: {}\nTool: {}\nContent:\n{}",
277 check.prompt,
278 stage.as_str(),
279 xml_escape(tool_name),
280 &content[..content_cap],
281 );
282 let request = UtilityLlmRequest::new(vec![
283 LlmMessage::text(LlmMessageRole::System, JUDGE_SYSTEM_PROMPT),
284 LlmMessage::text(LlmMessageRole::User, user_prompt),
285 ])
286 .with_reasoning_effort(UtilityLlmReasoningEffort::Low)
287 .with_max_tokens(64);
288
289 let response = match tokio::time::timeout(JUDGE_TIMEOUT, service.chat_completion(request)).await
290 {
291 Ok(Ok(r)) => r,
292 Ok(Err(e)) => {
293 tracing::warn!(
294 check = %check.label,
295 error = %e,
296 "guardrails: judge call failed, failing open"
297 );
298 return None;
299 }
300 Err(_) => {
301 tracing::warn!(
302 check = %check.label,
303 "guardrails: judge call timed out, failing open"
304 );
305 return None;
306 }
307 };
308
309 let text = response.text.trim();
310 let Some(fragment) = json_like_fragment(text) else {
311 tracing::warn!(
312 check = %check.label,
313 raw = %text,
314 "guardrails: judge response missing JSON fragment, failing open"
315 );
316 return None;
317 };
318
319 match serde_json::from_str::<serde_json::Value>(fragment) {
320 Ok(v) if v.get("verdict").and_then(|v| v.as_str()) == Some("block") => {
321 tracing::warn!(
322 check = %check.label,
323 reason = v.get("reason").and_then(|r| r.as_str()).unwrap_or(""),
324 "guardrails: judge verdict block"
325 );
326 Some(GuardrailAction::Block)
327 }
328 Ok(_) => Some(GuardrailAction::Log), Err(e) => {
330 tracing::warn!(
331 check = %check.label,
332 parse_error = %e,
333 raw = %fragment,
334 "guardrails: judge response parse failed, failing open"
335 );
336 None }
338 }
339}
340
341const MODERATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
344const MAX_MODERATION_CALLS_PER_INVOCATION: usize = 4;
347const MODERATION_CONTENT_CAP: usize = 4_000;
349
350const MODERATION_SYSTEM_PROMPT: &str = r#"You are a content-moderation classifier for an AI agent runtime.
352Score the provided assistant message for each requested category from 0 to 100, where 0 means clearly absent and 100 means a clear, severe violation.
353Respond ONLY with valid JSON on a single line. No explanation, no prose, no markdown.
354Format: {"scores":{"<category>":<integer 0-100>, ...}} — include every requested category."#;
355
356async fn run_moderation_check(
362 service: &dyn crate::UtilityLlmService,
363 check: &crate::guardrail_checks::CompiledModerationCheck,
364 content: &str,
365) -> Option<GuardrailAction> {
366 let payload = truncate_on_char_boundary(content, MODERATION_CONTENT_CAP);
367 let user_prompt = format!(
368 "Categories: {}\nMessage:\n{}",
369 check.categories.join(", "),
370 payload,
371 );
372 let request = UtilityLlmRequest::new(vec![
373 LlmMessage::text(LlmMessageRole::System, MODERATION_SYSTEM_PROMPT),
374 LlmMessage::text(LlmMessageRole::User, user_prompt),
375 ])
376 .with_reasoning_effort(UtilityLlmReasoningEffort::Low)
377 .with_max_tokens(128);
378
379 let response =
380 match tokio::time::timeout(MODERATION_TIMEOUT, service.chat_completion(request)).await {
381 Ok(Ok(r)) => r,
382 Ok(Err(e)) => {
383 tracing::warn!(
384 check = %check.label,
385 error = %e,
386 "guardrails: moderation call failed, failing open"
387 );
388 return None;
389 }
390 Err(_) => {
391 tracing::warn!(
392 check = %check.label,
393 "guardrails: moderation call timed out, failing open"
394 );
395 return None;
396 }
397 };
398
399 let text = response.text.trim();
401 let start = text.find('{').unwrap_or(0);
402 let end = text.rfind('}').map(|i| i + 1).unwrap_or(text.len());
403 let fragment = &text[start..end];
404 let parsed = match serde_json::from_str::<serde_json::Value>(fragment) {
405 Ok(v) => v,
406 Err(e) => {
407 tracing::warn!(
408 check = %check.label,
409 parse_error = %e,
410 raw = %fragment,
411 "guardrails: moderation response parse failed, failing open"
412 );
413 return None;
414 }
415 };
416 let Some(scores) = parsed.get("scores").and_then(|s| s.as_object()) else {
417 tracing::warn!(
418 check = %check.label,
419 "guardrails: moderation response missing scores field, failing open"
420 );
421 return None;
422 };
423
424 for category in &check.categories {
425 let score = scores
427 .get(category)
428 .and_then(|v| v.as_f64())
429 .map(|s| s.round() as i64);
430 if let Some(score) = score
431 && score >= check.threshold as i64
432 {
433 tracing::warn!(
434 check = %check.label,
435 category = %category,
436 score,
437 threshold = check.threshold,
438 "guardrails: moderation category at/over threshold"
439 );
440 return Some(GuardrailAction::Block);
441 }
442 }
443 Some(GuardrailAction::Log)
444}
445
446fn truncate_on_char_boundary(content: &str, cap: usize) -> &str {
448 let mut end = content.len().min(cap);
449 while end > 0 && !content.is_char_boundary(end) {
450 end -= 1;
451 }
452 &content[..end]
453}
454
455fn json_like_fragment(text: &str) -> Option<&str> {
457 let start = text.find('{')?;
458 let end = text.rfind('}')?.checked_add(1)?;
459 (start < end).then(|| &text[start..end])
460}
461
462fn parse_verdict(value: &serde_json::Value, label: &str) -> Option<GuardrailAction> {
467 let parsed_owned;
470 let verdict_obj = match value {
471 serde_json::Value::String(s) => {
472 let text = s.trim();
473 let Some(fragment) = json_like_fragment(text) else {
474 tracing::warn!(
475 check = %label,
476 "guardrails: mcp response missing JSON fragment, failing open"
477 );
478 return None;
479 };
480 match serde_json::from_str::<serde_json::Value>(fragment) {
481 Ok(v) => {
482 parsed_owned = v;
483 &parsed_owned
484 }
485 Err(e) => {
486 tracing::warn!(
487 check = %label,
488 parse_error = %e,
489 "guardrails: mcp verdict parse failed, failing open"
490 );
491 return None;
492 }
493 }
494 }
495 other => other,
496 };
497 match verdict_obj.get("verdict").and_then(|v| v.as_str()) {
498 Some("block") => {
499 tracing::warn!(
500 check = %label,
501 reason = verdict_obj.get("reason").and_then(|r| r.as_str()).unwrap_or(""),
502 "guardrails: mcp verdict block"
503 );
504 Some(GuardrailAction::Block)
505 }
506 Some(_) => Some(GuardrailAction::Log), None => {
508 tracing::warn!(
510 check = %label,
511 "guardrails: mcp response missing verdict field, failing open"
512 );
513 None
514 }
515 }
516}
517
518async fn run_mcp_check(
523 invoker: &dyn crate::McpToolInvoker,
524 check: &crate::guardrail_checks::CompiledMcpCheck,
525 stage: GuardrailStage,
526 tool_name: &str,
527 content: &str,
528) -> Option<GuardrailAction> {
529 let payload = truncate_on_char_boundary(content, MCP_CONTENT_CAP);
530 let call = ToolCall {
534 id: String::new(),
535 name: mcp_tool_name(&check.server, &check.tool),
536 arguments: json!({
537 "stage": stage.as_str(),
538 "tool": tool_name,
539 "content": payload,
540 }),
541 };
542
543 let result = match tokio::time::timeout(MCP_CHECK_TIMEOUT, invoker.invoke(&call)).await {
544 Ok(Ok(r)) => r,
545 Ok(Err(e)) => {
546 tracing::warn!(
547 check = %check.label,
548 error = %e,
549 "guardrails: mcp call failed, failing open"
550 );
551 return None;
552 }
553 Err(_) => {
554 tracing::warn!(
555 check = %check.label,
556 "guardrails: mcp call timed out, failing open"
557 );
558 return None;
559 }
560 };
561
562 if let Some(error) = &result.error {
565 tracing::warn!(
566 check = %check.label,
567 error = %error,
568 "guardrails: mcp endpoint returned error, failing open"
569 );
570 return None;
571 }
572 let Some(value) = &result.result else {
573 tracing::warn!(
574 check = %check.label,
575 "guardrails: mcp endpoint returned no result, failing open"
576 );
577 return None;
578 };
579 parse_verdict(value, &check.label)
580}
581
582fn xml_escape(s: &str) -> std::borrow::Cow<'_, str> {
583 if s.bytes()
584 .any(|b| matches!(b, b'<' | b'>' | b'&' | b'\'' | b'"'))
585 {
586 std::borrow::Cow::Owned(
587 s.replace('&', "&")
588 .replace('<', "<")
589 .replace('>', ">")
590 .replace('\'', "'")
591 .replace('"', """),
592 )
593 } else {
594 std::borrow::Cow::Borrowed(s)
595 }
596}
597
598fn compile_config_for_stage(
603 config: &serde_json::Value,
604 stage: GuardrailStage,
605) -> Option<Arc<CompiledGuardrails>> {
606 let parsed = match GuardrailsConfig::from_value(config).and_then(|c| c.compile()) {
607 Ok(compiled) => compiled,
608 Err(error) => {
609 tracing::warn!(%error, "guardrails: skipping invalid config");
610 return None;
611 }
612 };
613 parsed.has_stage(stage).then(|| Arc::new(parsed))
614}
615
616struct DeclarativeOutputGuardrail;
621
622impl OutputGuardrail for DeclarativeOutputGuardrail {
623 fn id(&self) -> &str {
624 "guardrail_checks"
625 }
626
627 fn arm(&self, ctx: &OutputGuardrailContext<'_>) -> Option<Box<dyn OutputGuardrailRun>> {
628 let compiled = compile_config_for_stage(ctx.config, GuardrailStage::Output)?;
629 Some(Box::new(DeclarativeOutputRun {
630 compiled,
631 logged: HashSet::new(),
632 }))
633 }
634}
635
636struct DeclarativeOutputRun {
637 compiled: Arc<CompiledGuardrails>,
638 logged: HashSet<usize>,
642}
643
644impl OutputGuardrailRun for DeclarativeOutputRun {
645 fn check(&mut self, accumulated: &str, _delta: &str) -> GuardrailDecision {
646 let logged = &self.logged;
651 let hits = self
652 .compiled
653 .evaluate(GuardrailStage::Output, accumulated, None, &|i| {
654 logged.contains(&i)
655 });
656 for hit in hits {
657 match hit.action {
658 GuardrailAction::Block => {
659 tracing::warn!(
660 check = %hit.check_label,
661 reason_code = %hit.reason_code,
662 "guardrails: blocking model output"
663 );
664 return GuardrailDecision::Block(crate::output_guardrail::GuardrailBlock {
665 reason_code: hit.reason_code,
666 replacement: hit
667 .replacement
668 .unwrap_or_else(|| DEFAULT_OUTPUT_REPLACEMENT.to_string()),
669 });
670 }
671 GuardrailAction::Log => {
672 tracing::warn!(
673 check = %hit.check_label,
674 reason_code = %hit.reason_code,
675 "guardrails: output check hit (log only)"
676 );
677 self.logged.insert(hit.check_index);
678 }
679 }
680 }
681 GuardrailDecision::Pass
682 }
683}
684
685struct ModerationOutputGuardrail {
693 compiled: Arc<CompiledGuardrails>,
694}
695
696#[async_trait]
697impl PostGenerationOutputGuardrail for ModerationOutputGuardrail {
698 fn id(&self) -> &str {
699 "moderation"
700 }
701
702 async fn check_message(&self, ctx: &PostGenerationOutputContext<'_>) -> GuardrailDecision {
703 let Some(service) = ctx.utility_llm_service else {
705 tracing::warn!(
706 "guardrails: moderation skipped — no utility LLM service available (fail-open)"
707 );
708 return GuardrailDecision::Pass;
709 };
710 if !service.is_configured() {
711 tracing::warn!(
712 "guardrails: moderation skipped — utility LLM not configured (fail-open)"
713 );
714 return GuardrailDecision::Pass;
715 }
716
717 for (calls, check) in self
718 .compiled
719 .moderation_checks_for_stage(GuardrailStage::Output)
720 .enumerate()
721 {
722 if calls >= MAX_MODERATION_CALLS_PER_INVOCATION {
723 tracing::warn!(
724 "guardrails: moderation call cap reached ({MAX_MODERATION_CALLS_PER_INVOCATION}); \
725 remaining output checks skipped (fail-open)"
726 );
727 break;
728 }
729 let Some(raw_action) =
730 run_moderation_check(service.as_ref(), check, ctx.message_text).await
731 else {
732 continue; };
734 if raw_action != GuardrailAction::Block {
735 continue; }
737 match self.compiled.async_action(check.on_fail) {
739 GuardrailAction::Block => {
740 tracing::warn!(
741 check = %check.label,
742 reason_code = "guardrail.moderation",
743 "guardrails: blocking model output (moderation)"
744 );
745 return GuardrailDecision::Block(crate::output_guardrail::GuardrailBlock {
746 reason_code: "guardrail.moderation".to_string(),
747 replacement: check
748 .replacement
749 .clone()
750 .unwrap_or_else(|| DEFAULT_OUTPUT_REPLACEMENT.to_string()),
751 });
752 }
753 GuardrailAction::Log => {
754 tracing::warn!(
755 check = %check.label,
756 reason_code = "guardrail.moderation",
757 "guardrails: moderation hit (log only)"
758 );
759 }
760 }
761 }
762 GuardrailDecision::Pass
763 }
764}
765
766struct GuardrailPreToolHook {
771 compiled: Arc<CompiledGuardrails>,
772}
773
774#[async_trait]
775impl PreToolUseHook for GuardrailPreToolHook {
776 async fn before_exec(
777 &self,
778 tool_call: ToolCall,
779 _tool_def: &ToolDefinition,
780 context: &ToolContext,
781 ) -> PreToolUseDecision {
782 let args_text = tool_call.arguments.to_string();
785 let hits = self.compiled.evaluate(
786 GuardrailStage::ToolUse,
787 &args_text,
788 Some(&tool_call.name),
789 &|_| false,
790 );
791 for hit in hits {
792 match hit.action {
793 GuardrailAction::Block => {
794 tracing::warn!(
795 check = %hit.check_label,
796 reason_code = %hit.reason_code,
797 tool = %tool_call.name,
798 "guardrails: blocking tool call"
799 );
800 return PreToolUseDecision::Block {
801 tool_call,
802 reason: format!(
803 "Tool call blocked by guardrail check '{}' ({})",
804 hit.check_label, hit.reason_code
805 ),
806 user_message: hit.replacement,
807 };
808 }
809 GuardrailAction::Log => {
810 tracing::warn!(
811 check = %hit.check_label,
812 reason_code = %hit.reason_code,
813 tool = %tool_call.name,
814 "guardrails: tool call check hit (log only)"
815 );
816 }
817 }
818 }
819 if let Some(service) = &context.utility_llm_service
822 && service.is_configured()
823 {
824 for (calls, check) in self
825 .compiled
826 .judge_checks_for_stage(GuardrailStage::ToolUse)
827 .enumerate()
828 {
829 if calls >= MAX_JUDGE_CALLS_PER_INVOCATION {
830 tracing::warn!(
831 tool = %tool_call.name,
832 "guardrails: judge call cap reached for tool_use, skipping remaining"
833 );
834 break;
835 }
836 let Some(raw_action) = run_judge_check(
837 service.as_ref(),
838 check,
839 GuardrailStage::ToolUse,
840 &tool_call.name,
841 &args_text,
842 )
843 .await
844 else {
845 continue; };
847 let action = self.compiled.judge_action(check.on_fail);
848 match action {
849 GuardrailAction::Block if raw_action == GuardrailAction::Block => {
850 tracing::warn!(
851 check = %check.label,
852 tool = %tool_call.name,
853 "guardrails: judge blocking tool call"
854 );
855 return PreToolUseDecision::Block {
856 tool_call,
857 reason: format!(
858 "Tool call blocked by guardrail check '{}' (guardrail.llm_judge)",
859 check.label
860 ),
861 user_message: check.replacement.clone(),
862 };
863 }
864 _ => {
865 if raw_action == GuardrailAction::Block {
866 tracing::warn!(
867 check = %check.label,
868 tool = %tool_call.name,
869 "guardrails: judge hit (log only)"
870 );
871 }
872 }
873 }
874 }
875 }
876 if let Some(invoker) = &context.mcp_invoker {
879 for (calls, check) in self
880 .compiled
881 .mcp_checks_for_stage(GuardrailStage::ToolUse)
882 .enumerate()
883 {
884 if calls >= MAX_MCP_CALLS_PER_INVOCATION {
885 tracing::warn!(
886 tool = %tool_call.name,
887 "guardrails: mcp call cap reached for tool_use, skipping remaining"
888 );
889 break;
890 }
891 let Some(raw_action) = run_mcp_check(
892 invoker.as_ref(),
893 check,
894 GuardrailStage::ToolUse,
895 &tool_call.name,
896 &args_text,
897 )
898 .await
899 else {
900 continue; };
902 let action = self.compiled.async_action(check.on_fail);
903 match action {
904 GuardrailAction::Block if raw_action == GuardrailAction::Block => {
905 tracing::warn!(
906 check = %check.label,
907 tool = %tool_call.name,
908 "guardrails: mcp blocking tool call"
909 );
910 return PreToolUseDecision::Block {
911 tool_call,
912 reason: format!(
913 "Tool call blocked by guardrail check '{}' (guardrail.mcp)",
914 check.label
915 ),
916 user_message: check.replacement.clone(),
917 };
918 }
919 _ => {
920 if raw_action == GuardrailAction::Block {
921 tracing::warn!(
922 check = %check.label,
923 tool = %tool_call.name,
924 "guardrails: mcp hit (log only)"
925 );
926 }
927 }
928 }
929 }
930 }
931 PreToolUseDecision::Continue(tool_call)
932 }
933}
934
935struct GuardrailPostToolHook {
940 compiled: Arc<CompiledGuardrails>,
941}
942
943#[async_trait]
944impl PostToolExecHook for GuardrailPostToolHook {
945 fn priority(&self) -> PostToolExecHookPriority {
946 PostToolExecHookPriority::Guardrail
947 }
948
949 async fn after_exec(
950 &self,
951 tool_call: &ToolCall,
952 _tool_def: &ToolDefinition,
953 result: &mut ToolResult,
954 context: &ToolContext,
955 ) {
956 let mut haystack = String::new();
957 if let Some(value) = &result.result {
958 match value {
959 serde_json::Value::String(s) => haystack.push_str(s),
960 other => haystack.push_str(&other.to_string()),
961 }
962 }
963 if let Some(error) = &result.error {
964 haystack.push('\n');
965 haystack.push_str(error);
966 }
967 if let Some(raw_output) = &result.raw_output {
972 haystack.push('\n');
973 haystack.push_str(raw_output);
974 }
975 if haystack.is_empty() {
976 return;
977 }
978 let hits = self
979 .compiled
980 .evaluate(GuardrailStage::ToolOutput, &haystack, None, &|_| false);
981 for hit in hits {
982 match hit.action {
983 GuardrailAction::Block => {
984 tracing::warn!(
985 check = %hit.check_label,
986 reason_code = %hit.reason_code,
987 tool = %tool_call.name,
988 "guardrails: withholding tool output"
989 );
990 let notice = hit
991 .replacement
992 .unwrap_or_else(|| DEFAULT_TOOL_OUTPUT_REPLACEMENT.to_string());
993 result.result = Some(serde_json::Value::String(notice));
995 result.error = None;
996 result.images = None;
997 result.raw_output = None;
998 return;
999 }
1000 GuardrailAction::Log => {
1001 tracing::warn!(
1002 check = %hit.check_label,
1003 reason_code = %hit.reason_code,
1004 tool = %tool_call.name,
1005 "guardrails: tool output check hit (log only)"
1006 );
1007 }
1008 }
1009 }
1010 if let Some(service) = &context.utility_llm_service
1012 && service.is_configured()
1013 {
1014 for (calls, check) in self
1015 .compiled
1016 .judge_checks_for_stage(GuardrailStage::ToolOutput)
1017 .enumerate()
1018 {
1019 if calls >= MAX_JUDGE_CALLS_PER_INVOCATION {
1020 tracing::warn!(
1021 tool = %tool_call.name,
1022 "guardrails: judge call cap reached for tool_output, skipping remaining"
1023 );
1024 break;
1025 }
1026 let Some(raw_action) = run_judge_check(
1027 service.as_ref(),
1028 check,
1029 GuardrailStage::ToolOutput,
1030 &tool_call.name,
1031 &haystack,
1032 )
1033 .await
1034 else {
1035 continue; };
1037 let action = self.compiled.judge_action(check.on_fail);
1038 match action {
1039 GuardrailAction::Block if raw_action == GuardrailAction::Block => {
1040 tracing::warn!(
1041 check = %check.label,
1042 tool = %tool_call.name,
1043 "guardrails: judge withholding tool output"
1044 );
1045 let notice = check
1046 .replacement
1047 .clone()
1048 .unwrap_or_else(|| DEFAULT_TOOL_OUTPUT_REPLACEMENT.to_string());
1049 result.result = Some(serde_json::Value::String(notice));
1050 result.error = None;
1051 result.images = None;
1052 result.raw_output = None;
1053 return;
1054 }
1055 _ => {
1056 if raw_action == GuardrailAction::Block {
1057 tracing::warn!(
1058 check = %check.label,
1059 tool = %tool_call.name,
1060 "guardrails: judge tool_output hit (log only)"
1061 );
1062 }
1063 }
1064 }
1065 }
1066 }
1067 if let Some(invoker) = &context.mcp_invoker {
1070 for (calls, check) in self
1071 .compiled
1072 .mcp_checks_for_stage(GuardrailStage::ToolOutput)
1073 .enumerate()
1074 {
1075 if calls >= MAX_MCP_CALLS_PER_INVOCATION {
1076 tracing::warn!(
1077 tool = %tool_call.name,
1078 "guardrails: mcp call cap reached for tool_output, skipping remaining"
1079 );
1080 break;
1081 }
1082 let Some(raw_action) = run_mcp_check(
1083 invoker.as_ref(),
1084 check,
1085 GuardrailStage::ToolOutput,
1086 &tool_call.name,
1087 &haystack,
1088 )
1089 .await
1090 else {
1091 continue; };
1093 let action = self.compiled.async_action(check.on_fail);
1094 match action {
1095 GuardrailAction::Block if raw_action == GuardrailAction::Block => {
1096 tracing::warn!(
1097 check = %check.label,
1098 tool = %tool_call.name,
1099 "guardrails: mcp withholding tool output"
1100 );
1101 let notice = check
1102 .replacement
1103 .clone()
1104 .unwrap_or_else(|| DEFAULT_TOOL_OUTPUT_REPLACEMENT.to_string());
1105 result.result = Some(serde_json::Value::String(notice));
1106 result.error = None;
1107 result.images = None;
1108 result.raw_output = None;
1109 return;
1110 }
1111 _ => {
1112 if raw_action == GuardrailAction::Block {
1113 tracing::warn!(
1114 check = %check.label,
1115 tool = %tool_call.name,
1116 "guardrails: mcp tool_output hit (log only)"
1117 );
1118 }
1119 }
1120 }
1121 }
1122 }
1123 }
1124}
1125
1126#[cfg(test)]
1127mod tests {
1128 use super::*;
1129 use crate::typed_id::SessionId;
1130 use crate::utility_llm::UtilityLlmService;
1131 use crate::{AgentLoopError, LlmCompletionMetadata, LlmResponse, LlmResponseStream};
1132 use async_trait::async_trait;
1133 use serde_json::json;
1134 use std::sync::Arc;
1135
1136 struct StubJudge {
1138 response: String,
1139 }
1140
1141 impl StubJudge {
1142 fn block() -> Arc<Self> {
1143 Arc::new(Self {
1144 response: r#"{"verdict":"block","reason":"test"}"#.to_string(),
1145 })
1146 }
1147 fn allow() -> Arc<Self> {
1148 Arc::new(Self {
1149 response: r#"{"verdict":"allow"}"#.to_string(),
1150 })
1151 }
1152 fn malformed(response: &str) -> Arc<Self> {
1153 Arc::new(Self {
1154 response: response.to_string(),
1155 })
1156 }
1157
1158 fn error() -> Arc<Self> {
1159 Arc::new(Self {
1160 response: "".to_string(), })
1162 }
1163 }
1164
1165 #[async_trait]
1166 impl UtilityLlmService for StubJudge {
1167 fn is_configured(&self) -> bool {
1168 true
1169 }
1170
1171 async fn chat_completion(
1172 &self,
1173 _request: crate::utility_llm::UtilityLlmRequest,
1174 ) -> crate::Result<LlmResponse> {
1175 if self.response.is_empty() {
1176 return Err(AgentLoopError::llm("stub error"));
1177 }
1178 Ok(LlmResponse {
1179 text: self.response.clone(),
1180 thinking: None,
1181 thinking_signature: None,
1182 tool_calls: None,
1183 metadata: LlmCompletionMetadata {
1184 total_tokens: None,
1185 prompt_tokens: None,
1186 completion_tokens: None,
1187 cache_read_tokens: None,
1188 cache_creation_tokens: None,
1189 provider_cost_usd: None,
1190 model: None,
1191 finish_reason: None,
1192 retry_metadata: None,
1193 response_id: None,
1194 phase: None,
1195 },
1196 })
1197 }
1198
1199 async fn chat_completion_stream(
1200 &self,
1201 _request: crate::utility_llm::UtilityLlmRequest,
1202 ) -> crate::Result<LlmResponseStream> {
1203 Err(AgentLoopError::llm("stub: no stream"))
1204 }
1205 }
1206
1207 fn moderation_config(threshold: u8, on_fail: &str, mode: &str) -> serde_json::Value {
1210 json!({
1211 "mode": mode,
1212 "checks": [{
1213 "stage": "output",
1214 "type": "moderation",
1215 "threshold": threshold,
1216 "on_fail": on_fail,
1217 }]
1218 })
1219 }
1220
1221 async fn run_moderation_seam(
1222 config: &serde_json::Value,
1223 service: Option<Arc<dyn UtilityLlmService>>,
1224 text: &str,
1225 ) -> GuardrailDecision {
1226 let providers = GuardrailsCapability.post_output_guardrails_with_config(config);
1227 let provider = providers
1228 .into_iter()
1229 .next()
1230 .expect("moderation provider should be contributed");
1231 let ctx = PostGenerationOutputContext {
1232 system_prompt: "",
1233 message_text: text,
1234 utility_llm_service: service.as_ref(),
1235 };
1236 provider.check_message(&ctx).await
1237 }
1238
1239 fn scores(json_body: &str) -> Arc<StubJudge> {
1240 Arc::new(StubJudge {
1241 response: json_body.to_string(),
1242 })
1243 }
1244
1245 #[tokio::test]
1246 async fn moderation_blocks_when_category_at_or_over_threshold() {
1247 let config = moderation_config(50, "block", "active");
1248 let svc: Arc<dyn UtilityLlmService> = scores(r#"{"scores":{"hate":90,"violence":0}}"#);
1249 let decision = run_moderation_seam(&config, Some(svc), "bad text").await;
1250 match decision {
1251 GuardrailDecision::Block(b) => {
1252 assert_eq!(b.reason_code, "guardrail.moderation");
1253 assert_eq!(b.replacement, DEFAULT_OUTPUT_REPLACEMENT);
1254 }
1255 GuardrailDecision::Pass => panic!("expected block"),
1256 }
1257 }
1258
1259 #[tokio::test]
1260 async fn moderation_blocks_exactly_at_threshold() {
1261 let config = moderation_config(50, "block", "active");
1262 let svc: Arc<dyn UtilityLlmService> = scores(r#"{"scores":{"hate":50}}"#);
1263 assert!(matches!(
1264 run_moderation_seam(&config, Some(svc), "x").await,
1265 GuardrailDecision::Block(_)
1266 ));
1267 }
1268
1269 #[tokio::test]
1270 async fn moderation_allows_when_below_threshold() {
1271 let config = moderation_config(50, "block", "active");
1272 let svc: Arc<dyn UtilityLlmService> = scores(r#"{"scores":{"hate":10,"violence":5}}"#);
1273 assert!(matches!(
1274 run_moderation_seam(&config, Some(svc), "fine").await,
1275 GuardrailDecision::Pass
1276 ));
1277 }
1278
1279 #[tokio::test]
1280 async fn moderation_advisory_logs_without_blocking() {
1281 let config = moderation_config(50, "block", "advisory");
1282 let svc: Arc<dyn UtilityLlmService> = scores(r#"{"scores":{"hate":99}}"#);
1283 assert!(matches!(
1284 run_moderation_seam(&config, Some(svc), "x").await,
1285 GuardrailDecision::Pass
1286 ));
1287 }
1288
1289 #[tokio::test]
1290 async fn moderation_on_fail_log_does_not_block() {
1291 let config = moderation_config(50, "log", "active");
1292 let svc: Arc<dyn UtilityLlmService> = scores(r#"{"scores":{"hate":99}}"#);
1293 assert!(matches!(
1294 run_moderation_seam(&config, Some(svc), "x").await,
1295 GuardrailDecision::Pass
1296 ));
1297 }
1298
1299 #[tokio::test]
1300 async fn moderation_uses_custom_replacement() {
1301 let config = json!({
1302 "checks": [{
1303 "stage": "output",
1304 "type": "moderation",
1305 "threshold": 50,
1306 "replacement": "[removed by policy]",
1307 }]
1308 });
1309 let svc: Arc<dyn UtilityLlmService> = scores(r#"{"scores":{"hate":80}}"#);
1310 match run_moderation_seam(&config, Some(svc), "x").await {
1311 GuardrailDecision::Block(b) => assert_eq!(b.replacement, "[removed by policy]"),
1312 GuardrailDecision::Pass => panic!("expected block"),
1313 }
1314 }
1315
1316 #[tokio::test]
1317 async fn moderation_fails_open_on_service_error() {
1318 let config = moderation_config(50, "block", "active");
1319 let svc: Arc<dyn UtilityLlmService> = StubJudge::error();
1320 assert!(matches!(
1321 run_moderation_seam(&config, Some(svc), "x").await,
1322 GuardrailDecision::Pass
1323 ));
1324 }
1325
1326 #[tokio::test]
1327 async fn moderation_fails_open_without_utility_service() {
1328 let config = moderation_config(50, "block", "active");
1329 assert!(matches!(
1330 run_moderation_seam(&config, None, "x").await,
1331 GuardrailDecision::Pass
1332 ));
1333 }
1334
1335 #[tokio::test]
1336 async fn moderation_fails_open_on_unparseable_response() {
1337 let config = moderation_config(50, "block", "active");
1338 let svc: Arc<dyn UtilityLlmService> = scores("not json at all");
1339 assert!(matches!(
1340 run_moderation_seam(&config, Some(svc), "x").await,
1341 GuardrailDecision::Pass
1342 ));
1343 }
1344
1345 #[tokio::test]
1346 async fn moderation_defaults_categories_when_unspecified() {
1347 let config = moderation_config(50, "block", "active");
1350 let svc: Arc<dyn UtilityLlmService> = scores(r#"{"scores":{"self_harm":75}}"#);
1351 assert!(matches!(
1352 run_moderation_seam(&config, Some(svc), "x").await,
1353 GuardrailDecision::Block(_)
1354 ));
1355 }
1356
1357 #[test]
1358 fn no_moderation_provider_without_output_moderation_check() {
1359 let config = json!({
1362 "checks": [{
1363 "stage": "output",
1364 "type": "regex",
1365 "patterns": ["secret"],
1366 }]
1367 });
1368 assert!(
1369 GuardrailsCapability
1370 .post_output_guardrails_with_config(&config)
1371 .is_empty()
1372 );
1373 assert!(
1375 GuardrailsCapability
1376 .post_output_guardrails_with_config(&json!({}))
1377 .is_empty()
1378 );
1379 }
1380
1381 #[test]
1382 fn moderation_rejected_on_tool_stages() {
1383 for stage in ["tool_use", "tool_output"] {
1384 let config = json!({
1385 "checks": [{ "stage": stage, "type": "moderation", "threshold": 50 }]
1386 });
1387 assert!(
1388 GuardrailsConfig::from_value(&config)
1389 .unwrap()
1390 .compile()
1391 .is_err(),
1392 "moderation on {stage} should be rejected"
1393 );
1394 }
1395 }
1396
1397 enum McpBehavior {
1399 Result(serde_json::Value),
1401 Error(String),
1403 Timeout,
1405 EchoContent,
1408 }
1409
1410 struct StubMcpInvoker {
1412 behavior: McpBehavior,
1413 last_call: std::sync::Mutex<Option<ToolCall>>,
1414 }
1415
1416 impl StubMcpInvoker {
1417 fn new(behavior: McpBehavior) -> Arc<Self> {
1418 Arc::new(Self {
1419 behavior,
1420 last_call: std::sync::Mutex::new(None),
1421 })
1422 }
1423 fn block() -> Arc<Self> {
1424 Self::new(McpBehavior::Result(
1425 json!({"verdict": "block", "reason": "test"}),
1426 ))
1427 }
1428 fn allow() -> Arc<Self> {
1429 Self::new(McpBehavior::Result(json!({"verdict": "allow"})))
1430 }
1431 }
1432
1433 #[async_trait]
1434 impl crate::McpToolInvoker for StubMcpInvoker {
1435 async fn invoke(&self, tool_call: &ToolCall) -> crate::Result<ToolResult> {
1436 *self.last_call.lock().unwrap() = Some(tool_call.clone());
1437 let result = match &self.behavior {
1438 McpBehavior::Result(v) => ToolResult {
1439 tool_call_id: tool_call.id.clone(),
1440 result: Some(v.clone()),
1441 images: None,
1442 error: None,
1443 connection_required: None,
1444 raw_output: None,
1445 },
1446 McpBehavior::Error(msg) => {
1447 return Err(AgentLoopError::tool(msg.clone()));
1448 }
1449 McpBehavior::Timeout => {
1450 tokio::time::sleep(MCP_CHECK_TIMEOUT + std::time::Duration::from_secs(2)).await;
1451 unreachable!("timeout fires before sleep completes")
1452 }
1453 McpBehavior::EchoContent => {
1454 let content = tool_call.arguments["content"].as_str().unwrap_or_default();
1455 ToolResult {
1456 tool_call_id: tool_call.id.clone(),
1457 result: Some(serde_json::Value::String(content.to_string())),
1460 images: None,
1461 error: None,
1462 connection_required: None,
1463 raw_output: None,
1464 }
1465 }
1466 };
1467 Ok(result)
1468 }
1469 }
1470
1471 fn tool_call(name: &str, args: serde_json::Value) -> ToolCall {
1472 ToolCall {
1473 id: "call_1".to_string(),
1474 name: name.to_string(),
1475 arguments: args,
1476 }
1477 }
1478
1479 fn tool_def() -> ToolDefinition {
1480 ToolDefinition::Builtin(crate::tool_types::BuiltinTool {
1481 name: "test_tool".to_string(),
1482 display_name: None,
1483 description: "test".to_string(),
1484 parameters: json!({}),
1485 policy: crate::tool_types::ToolPolicy::Auto,
1486 category: None,
1487 deferrable: crate::tool_types::DeferrablePolicy::Never,
1488 hints: Default::default(),
1489 full_parameters: None,
1490 })
1491 }
1492
1493 fn arm_output(config: serde_json::Value) -> Option<Box<dyn OutputGuardrailRun>> {
1494 let ctx = OutputGuardrailContext {
1495 system_prompt: "irrelevant",
1496 config: &config,
1497 };
1498 DeclarativeOutputGuardrail.arm(&ctx)
1499 }
1500
1501 #[test]
1502 fn validate_config_accepts_valid_and_rejects_invalid() {
1503 let cap = GuardrailsCapability;
1504 assert!(cap.validate_config(&json!({})).is_ok());
1505 assert!(
1506 cap.validate_config(&json!({
1507 "checks": [{"stage": "output", "type": "blocklist", "words": ["x"]}]
1508 }))
1509 .is_ok()
1510 );
1511 assert!(
1512 cap.validate_config(&json!({
1513 "checks": [{"stage": "output", "type": "regex", "patterns": ["("]}]
1514 }))
1515 .is_err()
1516 );
1517 }
1518
1519 #[test]
1520 fn output_guardrail_declines_to_arm_without_output_checks() {
1521 assert!(arm_output(json!({})).is_none());
1522 assert!(
1523 arm_output(json!({
1524 "checks": [{"stage": "tool_use", "type": "tool_pattern", "tools": ["bash*"]}]
1525 }))
1526 .is_none()
1527 );
1528 }
1529
1530 #[test]
1531 fn output_guardrail_blocks_with_custom_replacement() {
1532 let mut run = arm_output(json!({
1533 "checks": [{
1534 "stage": "output", "type": "blocklist", "words": ["forbidden"],
1535 "replacement": "nope"
1536 }]
1537 }))
1538 .expect("armed");
1539 assert!(matches!(
1540 run.check("all good here", "here"),
1541 GuardrailDecision::Pass
1542 ));
1543 match run.check("this is forbidden text", " text") {
1544 GuardrailDecision::Block(b) => {
1545 assert_eq!(b.reason_code, "guardrail.blocklist");
1546 assert_eq!(b.replacement, "nope");
1547 }
1548 other => panic!("expected Block, got {other:?}"),
1549 }
1550 }
1551
1552 #[test]
1553 fn output_guardrail_advisory_logs_once_and_passes() {
1554 let mut run = arm_output(json!({
1555 "mode": "advisory",
1556 "checks": [{"stage": "output", "type": "blocklist", "words": ["forbidden"]}]
1557 }))
1558 .expect("armed");
1559 assert!(matches!(
1560 run.check("forbidden", "forbidden"),
1561 GuardrailDecision::Pass
1562 ));
1563 assert!(matches!(
1565 run.check("forbidden and more", " and more"),
1566 GuardrailDecision::Pass
1567 ));
1568 }
1569
1570 #[tokio::test]
1571 async fn pre_tool_hook_blocks_matching_tool_name() {
1572 let cap = GuardrailsCapability;
1573 let hooks = cap.pre_tool_use_hooks_with_config(&json!({
1574 "checks": [{
1575 "stage": "tool_use", "type": "tool_pattern", "tools": ["bash*"],
1576 "replacement": "Shell access is not allowed for this agent."
1577 }]
1578 }));
1579 assert_eq!(hooks.len(), 1);
1580 let ctx = ToolContext::new(SessionId::new());
1581 let decision = hooks[0]
1582 .before_exec(
1583 tool_call("bashkit_exec", json!({"cmd": "ls"})),
1584 &tool_def(),
1585 &ctx,
1586 )
1587 .await;
1588 match decision {
1589 PreToolUseDecision::Block {
1590 reason,
1591 user_message,
1592 ..
1593 } => {
1594 assert!(reason.contains("guardrail"), "{reason}");
1595 assert_eq!(
1596 user_message.as_deref(),
1597 Some("Shell access is not allowed for this agent.")
1598 );
1599 }
1600 other => panic!("expected Block, got {other:?}"),
1601 }
1602 let decision = hooks[0]
1603 .before_exec(tool_call("read_file", json!({})), &tool_def(), &ctx)
1604 .await;
1605 assert!(matches!(decision, PreToolUseDecision::Continue(_)));
1606 }
1607
1608 #[tokio::test]
1609 async fn pre_tool_hook_matches_arguments_with_regex() {
1610 let cap = GuardrailsCapability;
1611 let hooks = cap.pre_tool_use_hooks_with_config(&json!({
1612 "checks": [{
1613 "stage": "tool_use", "type": "regex",
1614 "patterns": ["(?i)drop\\s+table"]
1615 }]
1616 }));
1617 let ctx = ToolContext::new(SessionId::new());
1618 let decision = hooks[0]
1619 .before_exec(
1620 tool_call("sql_query", json!({"query": "DROP TABLE users"})),
1621 &tool_def(),
1622 &ctx,
1623 )
1624 .await;
1625 assert!(matches!(decision, PreToolUseDecision::Block { .. }));
1626 }
1627
1628 #[tokio::test]
1629 async fn pre_tool_hook_advisory_continues() {
1630 let cap = GuardrailsCapability;
1631 let hooks = cap.pre_tool_use_hooks_with_config(&json!({
1632 "mode": "advisory",
1633 "checks": [{"stage": "tool_use", "type": "tool_pattern", "tools": ["bash*"]}]
1634 }));
1635 let ctx = ToolContext::new(SessionId::new());
1636 let decision = hooks[0]
1637 .before_exec(tool_call("bashkit_exec", json!({})), &tool_def(), &ctx)
1638 .await;
1639 assert!(matches!(decision, PreToolUseDecision::Continue(_)));
1640 }
1641
1642 #[tokio::test]
1643 async fn post_tool_hook_withholds_matching_output() {
1644 let cap = GuardrailsCapability;
1645 let hooks = cap.post_tool_exec_hooks_with_config(&json!({
1646 "checks": [{
1647 "id": "aws_key", "stage": "tool_output", "type": "regex",
1648 "patterns": ["AKIA[0-9A-Z]{16}"]
1649 }]
1650 }));
1651 assert_eq!(hooks.len(), 1);
1652 assert_eq!(hooks[0].priority(), PostToolExecHookPriority::Guardrail);
1653 let ctx = ToolContext::new(SessionId::new());
1654 let mut result = ToolResult {
1655 tool_call_id: "call_1".to_string(),
1656 result: Some(json!("key is AKIAIOSFODNN7EXAMPLE ok")),
1657 images: None,
1658 error: None,
1659 connection_required: None,
1660 raw_output: None,
1661 };
1662 hooks[0]
1663 .after_exec(
1664 &tool_call("web_fetch", json!({})),
1665 &tool_def(),
1666 &mut result,
1667 &ctx,
1668 )
1669 .await;
1670 assert_eq!(
1671 result.result,
1672 Some(json!(DEFAULT_TOOL_OUTPUT_REPLACEMENT)),
1673 "matched output must be replaced with the notice"
1674 );
1675 assert!(result.error.is_none());
1676 }
1677
1678 #[tokio::test]
1679 async fn post_tool_hook_scans_raw_output_persistence_surface() {
1680 let cap = GuardrailsCapability;
1684 let hooks = cap.post_tool_exec_hooks_with_config(&json!({
1685 "checks": [{
1686 "id": "aws_key", "stage": "tool_output", "type": "regex",
1687 "patterns": ["AKIA[0-9A-Z]{16}"]
1688 }]
1689 }));
1690 let ctx = ToolContext::new(SessionId::new());
1691 let mut result = ToolResult {
1692 tool_call_id: "call_1".to_string(),
1693 result: Some(json!("(truncated output)")),
1694 images: None,
1695 error: None,
1696 connection_required: None,
1697 raw_output: Some("full log: AKIAIOSFODNN7EXAMPLE trailing".to_string()),
1698 };
1699 hooks[0]
1700 .after_exec(
1701 &tool_call("bashkit_exec", json!({})),
1702 &tool_def(),
1703 &mut result,
1704 &ctx,
1705 )
1706 .await;
1707 assert_eq!(
1708 result.result,
1709 Some(json!(DEFAULT_TOOL_OUTPUT_REPLACEMENT)),
1710 "a secret only present in raw_output must trigger the block"
1711 );
1712 assert!(
1713 result.raw_output.is_none(),
1714 "raw_output must be cleared on a block so it is not persisted"
1715 );
1716 }
1717
1718 #[tokio::test]
1719 async fn post_tool_hook_leaves_clean_output_untouched() {
1720 let cap = GuardrailsCapability;
1721 let hooks = cap.post_tool_exec_hooks_with_config(&json!({
1722 "checks": [{"stage": "tool_output", "type": "blocklist", "words": ["secret"]}]
1723 }));
1724 let ctx = ToolContext::new(SessionId::new());
1725 let mut result = ToolResult {
1726 tool_call_id: "call_1".to_string(),
1727 result: Some(json!("nothing to see")),
1728 images: None,
1729 error: None,
1730 connection_required: None,
1731 raw_output: None,
1732 };
1733 hooks[0]
1734 .after_exec(
1735 &tool_call("web_fetch", json!({})),
1736 &tool_def(),
1737 &mut result,
1738 &ctx,
1739 )
1740 .await;
1741 assert_eq!(result.result, Some(json!("nothing to see")));
1742 }
1743
1744 #[test]
1745 fn no_hooks_contributed_without_matching_stage_checks() {
1746 let cap = GuardrailsCapability;
1747 assert!(cap.pre_tool_use_hooks_with_config(&json!({})).is_empty());
1748 assert!(cap.post_tool_exec_hooks_with_config(&json!({})).is_empty());
1749 let output_only = json!({
1750 "checks": [{"stage": "output", "type": "blocklist", "words": ["x"]}]
1751 });
1752 assert!(cap.pre_tool_use_hooks_with_config(&output_only).is_empty());
1753 assert!(
1754 cap.post_tool_exec_hooks_with_config(&output_only)
1755 .is_empty()
1756 );
1757 }
1758
1759 #[test]
1760 fn capability_metadata() {
1761 let cap = GuardrailsCapability;
1762 assert_eq!(cap.id(), GUARDRAILS_CAPABILITY_ID);
1763 assert!(cap.is_guardrail());
1764 assert!(cap.config_schema().is_some());
1765 assert_eq!(cap.output_guardrails().len(), 1);
1766 }
1767
1768 #[tokio::test]
1771 async fn judge_pre_tool_hook_blocks_when_judge_says_block() {
1772 let cap = GuardrailsCapability;
1773 let hooks = cap.pre_tool_use_hooks_with_config(&json!({
1774 "checks": [{"stage": "tool_use", "type": "llm_judge",
1775 "prompt": "Block requests to delete data."}]
1776 }));
1777 assert_eq!(hooks.len(), 1);
1778 let ctx = ToolContext::new(SessionId::new()).with_utility_llm_service(StubJudge::block());
1779 let decision = hooks[0]
1780 .before_exec(
1781 tool_call("delete_record", json!({"id": 42})),
1782 &tool_def(),
1783 &ctx,
1784 )
1785 .await;
1786 assert!(
1787 matches!(decision, PreToolUseDecision::Block { .. }),
1788 "judge block verdict should block the tool call"
1789 );
1790 }
1791
1792 #[tokio::test]
1793 async fn judge_pre_tool_hook_continues_when_judge_says_allow() {
1794 let cap = GuardrailsCapability;
1795 let hooks = cap.pre_tool_use_hooks_with_config(&json!({
1796 "checks": [{"stage": "tool_use", "type": "llm_judge",
1797 "prompt": "Block requests to delete data."}]
1798 }));
1799 let ctx = ToolContext::new(SessionId::new()).with_utility_llm_service(StubJudge::allow());
1800 let decision = hooks[0]
1801 .before_exec(tool_call("read_file", json!({})), &tool_def(), &ctx)
1802 .await;
1803 assert!(
1804 matches!(decision, PreToolUseDecision::Continue(_)),
1805 "judge allow verdict should continue"
1806 );
1807 }
1808
1809 #[tokio::test]
1810 async fn judge_pre_tool_hook_fails_open_on_error() {
1811 let cap = GuardrailsCapability;
1812 let hooks = cap.pre_tool_use_hooks_with_config(&json!({
1813 "checks": [{"stage": "tool_use", "type": "llm_judge",
1814 "prompt": "Block bad things."}]
1815 }));
1816 let ctx = ToolContext::new(SessionId::new()).with_utility_llm_service(StubJudge::error());
1817 let decision = hooks[0]
1818 .before_exec(tool_call("any_tool", json!({})), &tool_def(), &ctx)
1819 .await;
1820 assert!(
1821 matches!(decision, PreToolUseDecision::Continue(_)),
1822 "judge error must fail open"
1823 );
1824 }
1825
1826 #[tokio::test]
1827 async fn judge_pre_tool_hook_fails_open_on_malformed_output() {
1828 let cap = GuardrailsCapability;
1829 let hooks = cap.pre_tool_use_hooks_with_config(&json!({
1830 "checks": [{"stage": "tool_use", "type": "llm_judge",
1831 "prompt": "Block bad things."}]
1832 }));
1833
1834 for malformed in ["} bad {", "not json", "{unterminated"] {
1835 let ctx = ToolContext::new(SessionId::new())
1836 .with_utility_llm_service(StubJudge::malformed(malformed));
1837 let decision = hooks[0]
1838 .before_exec(tool_call("any_tool", json!({})), &tool_def(), &ctx)
1839 .await;
1840 assert!(
1841 matches!(decision, PreToolUseDecision::Continue(_)),
1842 "malformed judge output must fail open: {malformed:?}"
1843 );
1844 }
1845 }
1846
1847 #[tokio::test]
1848 async fn judge_pre_tool_hook_skipped_without_utility_llm() {
1849 let cap = GuardrailsCapability;
1850 let hooks = cap.pre_tool_use_hooks_with_config(&json!({
1851 "checks": [{"stage": "tool_use", "type": "llm_judge",
1852 "prompt": "Block everything."}]
1853 }));
1854 let ctx = ToolContext::new(SessionId::new());
1856 let decision = hooks[0]
1857 .before_exec(tool_call("any_tool", json!({})), &tool_def(), &ctx)
1858 .await;
1859 assert!(
1860 matches!(decision, PreToolUseDecision::Continue(_)),
1861 "without utility LLM, judge checks are silently skipped"
1862 );
1863 }
1864
1865 #[tokio::test]
1866 async fn judge_pre_tool_hook_skipped_when_service_not_configured() {
1867 use crate::utility_llm::DisabledUtilityLlmService;
1870 let cap = GuardrailsCapability;
1871 let hooks = cap.pre_tool_use_hooks_with_config(&json!({
1872 "checks": [{"stage": "tool_use", "type": "llm_judge",
1873 "prompt": "Block everything."}]
1874 }));
1875 let ctx = ToolContext::new(SessionId::new())
1876 .with_utility_llm_service(Arc::new(DisabledUtilityLlmService));
1877 let decision = hooks[0]
1878 .before_exec(tool_call("any_tool", json!({})), &tool_def(), &ctx)
1879 .await;
1880 assert!(
1881 matches!(decision, PreToolUseDecision::Continue(_)),
1882 "disabled utility LLM service must skip judge checks without warn logs"
1883 );
1884 }
1885
1886 #[tokio::test]
1887 async fn judge_advisory_mode_continues_even_on_block_verdict() {
1888 let cap = GuardrailsCapability;
1889 let hooks = cap.pre_tool_use_hooks_with_config(&json!({
1890 "mode": "advisory",
1891 "checks": [{"stage": "tool_use", "type": "llm_judge",
1892 "prompt": "Block everything.", "on_fail": "block"}]
1893 }));
1894 let ctx = ToolContext::new(SessionId::new()).with_utility_llm_service(StubJudge::block());
1895 let decision = hooks[0]
1896 .before_exec(tool_call("any_tool", json!({})), &tool_def(), &ctx)
1897 .await;
1898 assert!(
1899 matches!(decision, PreToolUseDecision::Continue(_)),
1900 "advisory mode must not block even when judge says block"
1901 );
1902 }
1903
1904 #[tokio::test]
1905 async fn judge_post_tool_hook_withholds_output_on_block() {
1906 let cap = GuardrailsCapability;
1907 let hooks = cap.post_tool_exec_hooks_with_config(&json!({
1908 "checks": [{"stage": "tool_output", "type": "llm_judge",
1909 "prompt": "Block PII in tool output."}]
1910 }));
1911 assert_eq!(hooks.len(), 1);
1912 let ctx = ToolContext::new(SessionId::new()).with_utility_llm_service(StubJudge::block());
1913 let mut result = ToolResult {
1914 tool_call_id: "call_1".to_string(),
1915 result: Some(json!("user email: alice@example.com")),
1916 images: None,
1917 error: None,
1918 connection_required: None,
1919 raw_output: None,
1920 };
1921 hooks[0]
1922 .after_exec(
1923 &tool_call("web_fetch", json!({})),
1924 &tool_def(),
1925 &mut result,
1926 &ctx,
1927 )
1928 .await;
1929 assert_eq!(
1930 result.result,
1931 Some(json!(DEFAULT_TOOL_OUTPUT_REPLACEMENT)),
1932 "judge block should replace tool output with notice"
1933 );
1934 assert!(result.error.is_none());
1935 }
1936
1937 #[tokio::test]
1938 async fn judge_post_tool_hook_passes_clean_output_on_allow() {
1939 let cap = GuardrailsCapability;
1940 let hooks = cap.post_tool_exec_hooks_with_config(&json!({
1941 "checks": [{"stage": "tool_output", "type": "llm_judge",
1942 "prompt": "Block PII."}]
1943 }));
1944 let ctx = ToolContext::new(SessionId::new()).with_utility_llm_service(StubJudge::allow());
1945 let mut result = ToolResult {
1946 tool_call_id: "call_1".to_string(),
1947 result: Some(json!("no pii here")),
1948 images: None,
1949 error: None,
1950 connection_required: None,
1951 raw_output: None,
1952 };
1953 hooks[0]
1954 .after_exec(
1955 &tool_call("web_fetch", json!({})),
1956 &tool_def(),
1957 &mut result,
1958 &ctx,
1959 )
1960 .await;
1961 assert_eq!(result.result, Some(json!("no pii here")));
1962 }
1963
1964 #[tokio::test]
1965 async fn judge_handles_multibyte_content_without_panic() {
1966 let cap = GuardrailsCapability;
1968 let hooks = cap.pre_tool_use_hooks_with_config(&json!({
1969 "checks": [{"stage": "tool_use", "type": "llm_judge", "prompt": "p"}]
1970 }));
1971 let ctx = ToolContext::new(SessionId::new()).with_utility_llm_service(StubJudge::allow());
1972 let multibyte_args = "€".repeat(700);
1974 let decision = hooks[0]
1975 .before_exec(
1976 tool_call("any_tool", json!({"x": multibyte_args})),
1977 &tool_def(),
1978 &ctx,
1979 )
1980 .await;
1981 assert!(matches!(decision, PreToolUseDecision::Continue(_)));
1983 }
1984
1985 #[test]
1986 fn xml_escape_escapes_special_chars() {
1987 assert_eq!(xml_escape("normal"), "normal");
1988 assert_eq!(xml_escape("<tag>"), "<tag>");
1989 assert_eq!(xml_escape("a&b"), "a&b");
1990 assert_eq!(xml_escape("\"quoted\""), ""quoted"");
1991 assert_eq!(xml_escape("it's"), "it's");
1992 }
1993
1994 #[test]
1995 fn config_schema_includes_llm_judge() {
1996 let cap = GuardrailsCapability;
1997 let schema = cap.config_schema().unwrap();
1998 let type_enum = &schema["properties"]["checks"]["items"]["properties"]["type"]["enum"];
1999 let values: Vec<&str> = type_enum
2000 .as_array()
2001 .unwrap()
2002 .iter()
2003 .filter_map(|v| v.as_str())
2004 .collect();
2005 assert!(
2006 values.contains(&"llm_judge"),
2007 "schema enum must include llm_judge"
2008 );
2009 }
2010
2011 #[test]
2014 fn config_schema_includes_mcp() {
2015 let cap = GuardrailsCapability;
2016 let schema = cap.config_schema().unwrap();
2017 let type_enum = &schema["properties"]["checks"]["items"]["properties"]["type"]["enum"];
2018 let values: Vec<&str> = type_enum
2019 .as_array()
2020 .unwrap()
2021 .iter()
2022 .filter_map(|v| v.as_str())
2023 .collect();
2024 assert!(values.contains(&"mcp"), "schema enum must include mcp");
2025 let props = &schema["properties"]["checks"]["items"]["properties"];
2026 assert!(props["server"].is_object(), "schema must define server");
2027 assert!(props["tool"].is_object(), "schema must define tool");
2028 }
2029
2030 fn mcp_pre_hooks(on_fail: &str, mode: &str) -> Vec<Arc<dyn PreToolUseHook>> {
2031 GuardrailsCapability.pre_tool_use_hooks_with_config(&json!({
2032 "mode": mode,
2033 "checks": [{"stage": "tool_use", "type": "mcp",
2034 "server": "guard", "tool": "screen", "on_fail": on_fail}]
2035 }))
2036 }
2037
2038 #[tokio::test]
2039 async fn mcp_pre_tool_hook_blocks_when_endpoint_says_block() {
2040 let hooks = mcp_pre_hooks("block", "active");
2041 assert_eq!(hooks.len(), 1);
2042 let ctx = ToolContext::new(SessionId::new()).with_mcp_invoker(StubMcpInvoker::block());
2043 let decision = hooks[0]
2044 .before_exec(
2045 tool_call("delete_record", json!({"id": 42})),
2046 &tool_def(),
2047 &ctx,
2048 )
2049 .await;
2050 assert!(
2051 matches!(decision, PreToolUseDecision::Block { .. }),
2052 "mcp block verdict should block the tool call"
2053 );
2054 }
2055
2056 #[tokio::test]
2057 async fn mcp_pre_tool_hook_continues_when_endpoint_says_allow() {
2058 let hooks = mcp_pre_hooks("block", "active");
2059 let ctx = ToolContext::new(SessionId::new()).with_mcp_invoker(StubMcpInvoker::allow());
2060 let decision = hooks[0]
2061 .before_exec(tool_call("read_file", json!({})), &tool_def(), &ctx)
2062 .await;
2063 assert!(matches!(decision, PreToolUseDecision::Continue(_)));
2064 }
2065
2066 #[tokio::test]
2067 async fn mcp_advisory_continues_even_on_block_verdict() {
2068 let hooks = mcp_pre_hooks("block", "advisory");
2069 let ctx = ToolContext::new(SessionId::new()).with_mcp_invoker(StubMcpInvoker::block());
2070 let decision = hooks[0]
2071 .before_exec(tool_call("any_tool", json!({})), &tool_def(), &ctx)
2072 .await;
2073 assert!(
2074 matches!(decision, PreToolUseDecision::Continue(_)),
2075 "advisory mode must not block even when mcp says block"
2076 );
2077 }
2078
2079 #[tokio::test]
2080 async fn mcp_pre_tool_hook_fails_open_on_connection_error() {
2081 let hooks = mcp_pre_hooks("block", "active");
2082 let ctx = ToolContext::new(SessionId::new()).with_mcp_invoker(StubMcpInvoker::new(
2083 McpBehavior::Error("MCP server not found".into()),
2084 ));
2085 let decision = hooks[0]
2086 .before_exec(tool_call("any_tool", json!({})), &tool_def(), &ctx)
2087 .await;
2088 assert!(
2089 matches!(decision, PreToolUseDecision::Continue(_)),
2090 "connection error must fail open"
2091 );
2092 }
2093
2094 #[tokio::test]
2095 async fn mcp_pre_tool_hook_fails_open_on_timeout() {
2096 tokio::time::pause();
2098 let hooks = mcp_pre_hooks("block", "active");
2099 let ctx = ToolContext::new(SessionId::new())
2100 .with_mcp_invoker(StubMcpInvoker::new(McpBehavior::Timeout));
2101 let decision = hooks[0]
2102 .before_exec(tool_call("any_tool", json!({})), &tool_def(), &ctx)
2103 .await;
2104 assert!(
2105 matches!(decision, PreToolUseDecision::Continue(_)),
2106 "timeout must fail open"
2107 );
2108 }
2109
2110 #[tokio::test]
2111 async fn mcp_pre_tool_hook_fails_open_on_unparseable_response() {
2112 let hooks = mcp_pre_hooks("block", "active");
2113 let ctx = ToolContext::new(SessionId::new()).with_mcp_invoker(StubMcpInvoker::new(
2114 McpBehavior::Result(json!("not json at all, no braces")),
2115 ));
2116 let decision = hooks[0]
2117 .before_exec(tool_call("any_tool", json!({})), &tool_def(), &ctx)
2118 .await;
2119 assert!(
2120 matches!(decision, PreToolUseDecision::Continue(_)),
2121 "unparseable response must fail open"
2122 );
2123 }
2124
2125 #[tokio::test]
2126 async fn mcp_pre_tool_hook_skipped_without_invoker() {
2127 let hooks = mcp_pre_hooks("block", "active");
2128 let ctx = ToolContext::new(SessionId::new());
2130 let decision = hooks[0]
2131 .before_exec(tool_call("any_tool", json!({})), &tool_def(), &ctx)
2132 .await;
2133 assert!(
2134 matches!(decision, PreToolUseDecision::Continue(_)),
2135 "without an MCP invoker, mcp checks are silently skipped"
2136 );
2137 }
2138
2139 #[tokio::test]
2140 async fn mcp_call_cap_evaluates_first_n_and_skips_rest() {
2141 let checks: Vec<_> = (0..6)
2144 .map(|i| {
2145 json!({"id": format!("m{i}"), "stage": "tool_use", "type": "mcp",
2146 "server": "guard", "tool": "screen"})
2147 })
2148 .collect();
2149 let hooks = GuardrailsCapability.pre_tool_use_hooks_with_config(&json!({
2150 "checks": checks
2151 }));
2152 struct Counter {
2154 calls: std::sync::atomic::AtomicUsize,
2155 }
2156 #[async_trait]
2157 impl crate::McpToolInvoker for Counter {
2158 async fn invoke(&self, tool_call: &ToolCall) -> crate::Result<ToolResult> {
2159 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2160 Ok(ToolResult {
2161 tool_call_id: tool_call.id.clone(),
2162 result: Some(json!({"verdict": "allow"})),
2163 images: None,
2164 error: None,
2165 connection_required: None,
2166 raw_output: None,
2167 })
2168 }
2169 }
2170 let counter = Arc::new(Counter {
2171 calls: std::sync::atomic::AtomicUsize::new(0),
2172 });
2173 let ctx = ToolContext::new(SessionId::new()).with_mcp_invoker(counter.clone());
2174 let _ = hooks[0]
2175 .before_exec(tool_call("any_tool", json!({})), &tool_def(), &ctx)
2176 .await;
2177 assert_eq!(
2178 counter.calls.load(std::sync::atomic::Ordering::SeqCst),
2179 MAX_MCP_CALLS_PER_INVOCATION,
2180 "only the first N mcp checks should be evaluated"
2181 );
2182 }
2183
2184 #[tokio::test]
2185 async fn mcp_payload_truncated_on_char_boundary() {
2186 let hooks = mcp_pre_hooks("block", "active");
2187 let echo = StubMcpInvoker::new(McpBehavior::EchoContent);
2188 let ctx = ToolContext::new(SessionId::new()).with_mcp_invoker(echo.clone());
2189 let multibyte = "€".repeat(700);
2191 let decision = hooks[0]
2192 .before_exec(
2193 tool_call("any_tool", json!({"x": multibyte})),
2194 &tool_def(),
2195 &ctx,
2196 )
2197 .await;
2198 assert!(matches!(decision, PreToolUseDecision::Continue(_)));
2200 let call = echo
2201 .last_call
2202 .lock()
2203 .unwrap()
2204 .clone()
2205 .expect("invoker called");
2206 let sent = call.arguments["content"].as_str().unwrap();
2207 assert!(sent.len() <= MCP_CONTENT_CAP, "payload must be capped");
2208 assert!(
2209 std::str::from_utf8(sent.as_bytes()).is_ok(),
2210 "payload must remain valid UTF-8 (no mid-char split)"
2211 );
2212 }
2213
2214 #[tokio::test]
2215 async fn mcp_post_tool_hook_withholds_output_on_block() {
2216 let hooks = GuardrailsCapability.post_tool_exec_hooks_with_config(&json!({
2217 "checks": [{"stage": "tool_output", "type": "mcp",
2218 "server": "guard", "tool": "scan"}]
2219 }));
2220 assert_eq!(hooks.len(), 1);
2221 let ctx = ToolContext::new(SessionId::new()).with_mcp_invoker(StubMcpInvoker::block());
2222 let mut result = ToolResult {
2223 tool_call_id: "call_1".to_string(),
2224 result: Some(json!("user email: alice@example.com")),
2225 images: None,
2226 error: None,
2227 connection_required: None,
2228 raw_output: None,
2229 };
2230 hooks[0]
2231 .after_exec(
2232 &tool_call("web_fetch", json!({})),
2233 &tool_def(),
2234 &mut result,
2235 &ctx,
2236 )
2237 .await;
2238 assert_eq!(
2239 result.result,
2240 Some(json!(DEFAULT_TOOL_OUTPUT_REPLACEMENT)),
2241 "mcp block should replace tool output with notice"
2242 );
2243 assert!(result.error.is_none());
2244 }
2245
2246 #[tokio::test]
2247 async fn mcp_post_tool_hook_passes_clean_output_on_allow() {
2248 let hooks = GuardrailsCapability.post_tool_exec_hooks_with_config(&json!({
2249 "checks": [{"stage": "tool_output", "type": "mcp",
2250 "server": "guard", "tool": "scan"}]
2251 }));
2252 let ctx = ToolContext::new(SessionId::new()).with_mcp_invoker(StubMcpInvoker::allow());
2253 let mut result = ToolResult {
2254 tool_call_id: "call_1".to_string(),
2255 result: Some(json!("no pii here")),
2256 images: None,
2257 error: None,
2258 connection_required: None,
2259 raw_output: None,
2260 };
2261 hooks[0]
2262 .after_exec(
2263 &tool_call("web_fetch", json!({})),
2264 &tool_def(),
2265 &mut result,
2266 &ctx,
2267 )
2268 .await;
2269 assert_eq!(result.result, Some(json!("no pii here")));
2270 }
2271}