Skip to main content

everruns_core/capabilities/
guardrails.rs

1// Declarative guardrails capability.
2//
3// Attaches the deterministic check engine (`crate::guardrail_checks`) to the
4// existing interception seams — streaming output guardrails and pre/post
5// tool hooks — driven entirely by per-agent config. No checks configured
6// means no hooks contributed: an agent without this capability (or with an
7// empty config) runs exactly as before. See specs/guardrails.md.
8
9use 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        // Contribute the model-backed moderation provider only when an
191        // output-stage moderation check is configured. Deterministic output
192        // checks run on the streaming seam (`output_guardrails`), not here.
193        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
227/// Timeout for a single LLM judge call. Fail-open on expiry.
228const JUDGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
229/// Maximum judge checks evaluated per single tool call invocation.
230const MAX_JUDGE_CALLS_PER_INVOCATION: usize = 4;
231
232/// Timeout for a single MCP guardrail call. Fail-open on expiry. Mirrors
233/// `JUDGE_TIMEOUT` — an external guardrail outage must never wedge a turn.
234const MCP_CHECK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
235/// Maximum MCP guardrail checks evaluated per single tool call invocation.
236/// Mirrors `MAX_JUDGE_CALLS_PER_INVOCATION` to bound added latency (TM-DOS).
237///
238/// NOTE: this cap is per-check-type. `llm_judge` and `mcp` checks run serially
239/// in the same hook, so when both are configured on a stage the additive
240/// worst-case latency is
241/// `(MAX_JUDGE_CALLS_PER_INVOCATION + MAX_MCP_CALLS_PER_INVOCATION) × 10 s`
242/// (= 80 s today), not 40 s. There is intentionally no shared cross-type
243/// budget yet — each async check type bounds itself independently and fails
244/// open on its own timeout. See TM-DOS-020.
245const MAX_MCP_CALLS_PER_INVOCATION: usize = 4;
246/// Bound on the stage payload (UTF-8 char-boundary safe) sent to an MCP
247/// guardrail endpoint. Mirrors the judge content cap.
248const MCP_CONTENT_CAP: usize = 2_000;
249
250/// System prompt sent to the utility LLM for all judge evaluations.
251const 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
256/// Evaluate one llm_judge check against `content` via the utility LLM.
257/// Returns `Some(GuardrailAction)` on a block/log verdict, `None` on error
258/// (fail-open). The caller is responsible for applying advisory-mode
259/// downgrade via `compiled.judge_action()`.
260async 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    // Bound content sent to judge; find a safe UTF-8 char boundary.
268    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), // "allow" or unrecognized → no-op
329        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 // fail-open
337        }
338    }
339}
340
341/// Timeout for a single moderation classifier call. Fail-open on expiry —
342/// a utility-model outage must never wedge a turn.
343const MODERATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
344/// Maximum moderation checks evaluated per finalized message (TM-DOS: bounds
345/// added latency on the end-of-message seam).
346const MAX_MODERATION_CALLS_PER_INVOCATION: usize = 4;
347/// Bytes of assistant text sent to the moderation classifier.
348const MODERATION_CONTENT_CAP: usize = 4_000;
349
350/// System prompt sent to the utility LLM for moderation scoring.
351const 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
356/// Evaluate one moderation check against `content` via the utility LLM as a
357/// classifier. Returns `Some(GuardrailAction::Block)` when any requested
358/// category scores at or above the check's threshold, `Some(Log)` when all
359/// score below it, and `None` on any error/timeout/parse failure (fail-open).
360/// The caller applies advisory-mode downgrade via `compiled.async_action()`.
361async 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    // Parse the scores object from the first JSON-like fragment.
400    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        // Accept both integer and float scores; ignore unparseable entries.
426        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
446/// Truncate `content` to at most `cap` bytes on a UTF-8 char boundary.
447fn 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
455/// Extract the first JSON-like object fragment from `text`.
456fn 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
462/// Parse a `{"verdict":"allow"|"block","reason":"..."}` verdict out of a JSON
463/// value (or a string holding such JSON). Mirrors the judge verdict shape.
464/// Returns `Some(Block)` on an explicit block verdict, `Some(Log)` for allow /
465/// unrecognized, and `None` (fail-open) when no verdict can be parsed.
466fn parse_verdict(value: &serde_json::Value, label: &str) -> Option<GuardrailAction> {
467    // The MCP result may be a JSON object directly, or a string carrying JSON
468    // (servers that return text content). Handle both.
469    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), // "allow" → no-op
507        None => {
508            // No recognizable verdict field — fail open rather than guess.
509            tracing::warn!(
510                check = %label,
511                "guardrails: mcp response missing verdict field, failing open"
512            );
513            None
514        }
515    }
516}
517
518/// Evaluate one `mcp` check against `content` by calling the configured
519/// scoped-MCP guardrail tool. Returns `Some(GuardrailAction)` on a parsed
520/// verdict, `None` on any failure (fail-open). The caller applies advisory-mode
521/// downgrade via `compiled.async_action()`.
522async 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    // The guardrail tool receives a structured payload describing the stage
531    // under inspection. Tenant scoping is enforced by the host's per-session
532    // connection resolver, which only resolves servers scoped to this session.
533    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    // A tool-level error from the endpoint (server not found, transport error)
563    // fails open — never block execution on a guardrail outage.
564    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('&', "&amp;")
588                .replace('<', "&lt;")
589                .replace('>', "&gt;")
590                .replace('\'', "&#39;")
591                .replace('"', "&quot;"),
592        )
593    } else {
594        std::borrow::Cow::Borrowed(s)
595    }
596}
597
598/// Compile `config` and return it only when at least one check targets
599/// `stage`. Invalid configs (possible only if persisted before validation
600/// existed) are logged and treated as no checks — guardrails must never
601/// take down the turn pipeline.
602fn 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
616// ============================================================================
617// Output stage: streaming output guardrail
618// ============================================================================
619
620struct 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    /// Checks already reported as log-only hits for this stream. Without
639    /// this, an advisory hit would re-log on every subsequent delta because
640    /// evaluation always sees the full accumulated text.
641    logged: HashSet<usize>,
642}
643
644impl OutputGuardrailRun for DeclarativeOutputRun {
645    fn check(&mut self, accumulated: &str, _delta: &str) -> GuardrailDecision {
646        // Evaluating against the full accumulated text keeps matches that
647        // span delta boundaries correct. Cost is O(|accumulated|) per delta
648        // — same asymptotics the canary guardrail accepts — and bounded by
649        // assistant message size.
650        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
685// ============================================================================
686// Output stage: end-of-message moderation seam (EVE-573)
687// ============================================================================
688
689/// Async, model-backed output guardrail run once on the finalized assistant
690/// message. Holds the compiled output-stage moderation checks; the streaming
691/// deterministic checks are handled separately by `DeclarativeOutputGuardrail`.
692struct 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        // Model-backed: needs the utility LLM. Without it, fail open.
704        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; // fail-open on error/timeout/parse failure
733            };
734            if raw_action != GuardrailAction::Block {
735                continue; // scored below threshold → allow
736            }
737            // Apply advisory-mode / on_fail downgrade.
738            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
766// ============================================================================
767// Tool-use stage: pre-tool hook
768// ============================================================================
769
770struct 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        // tool_pattern rules match the tool name; regex/blocklist rules
783        // match the serialized arguments.
784        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        // LLM-judge checks run after deterministic checks; skipped when
820        // the utility LLM is absent or disabled.
821        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; // fail-open
846                };
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        // MCP-served checks run after judge checks; skipped when no scoped-MCP
877        // invoker is wired into the context.
878        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; // fail-open
901                };
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
935// ============================================================================
936// Tool-output stage: post-tool hook
937// ============================================================================
938
939struct 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        // Exec-style tools budget the visible `result` JSON but keep the full,
968        // untruncated content in `raw_output`, which is persisted to `/outputs`.
969        // Include it in the haystack so sensitive content that only survives in
970        // `raw_output` is caught before this hook clears it on a block.
971        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                    // The original content never reaches model context.
994                    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        // LLM-judge checks for tool_output; skipped when utility LLM is absent or disabled.
1011        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; // fail-open
1036                };
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        // MCP-served checks for tool_output; skipped when no scoped-MCP invoker
1068        // is wired into the context.
1069        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; // fail-open
1092                };
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    /// Stub utility LLM that returns a fixed verdict string.
1137    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(), // unused; chat_completion errors
1161            })
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    // ---- Moderation output seam (EVE-573) ----
1208
1209    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        // No `categories` → built-in set is used; the model scoring a default
1348        // category over threshold still trips.
1349        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        // A config with only a deterministic output check contributes no
1360        // post-generation provider (that check runs on the streaming seam).
1361        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        // Empty config: no provider either.
1374        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    /// What a stubbed MCP guardrail endpoint should do for a call.
1398    enum McpBehavior {
1399        /// Return a result `Value` (object or JSON string).
1400        Result(serde_json::Value),
1401        /// Return a tool-level error.
1402        Error(String),
1403        /// Never respond in time (sleep past the timeout).
1404        Timeout,
1405        /// Return the call's `content` argument back as the verdict-bearing
1406        /// result string — used to assert payload truncation.
1407        EchoContent,
1408    }
1409
1410    /// Stub scoped-MCP invoker returning a fixed verdict, recording calls.
1411    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                        // Not a valid verdict — fails open — but the recorded
1458                        // call's content is what the truncation test inspects.
1459                        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        // Subsequent deltas keep passing (and the hit is not re-reported).
1564        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        // Exec-style tools keep the full, untruncated content in `raw_output`
1681        // (persisted to /outputs) while the visible `result` is budgeted. A
1682        // secret that only survives in `raw_output` must still be blocked.
1683        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    // --- llm_judge hook tests ---
1769
1770    #[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        // No utility LLM service configured → judge checks are skipped
1855        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        // Service is present in the context but reports is_configured() == false
1868        // (e.g. DisabledUtilityLlmService). Judge checks must be silently skipped.
1869        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        // Verifies the 2 000-byte content cap doesn't slice mid-char.
1967        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        // 700 × 3-byte chars = 2 100 bytes, boundary at 2 000 falls mid-char
1973        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        // Just must not panic; allow verdict continues
1982        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>"), "&lt;tag&gt;");
1989        assert_eq!(xml_escape("a&b"), "a&amp;b");
1990        assert_eq!(xml_escape("\"quoted\""), "&quot;quoted&quot;");
1991        assert_eq!(xml_escape("it's"), "it&#39;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    // --- mcp hook tests ---
2012
2013    #[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        // Pause time so the 10 s timeout fires instantly.
2097        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        // No MCP invoker wired into the context → mcp checks are skipped.
2129        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        // 6 active block checks, cap is 4. The recording invoker counts how many
2142        // times it is called; allow-verdict so none actually block.
2143        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        // A counting invoker.
2153        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        // 700 × 3-byte chars = 2 100 bytes; the 2 000-byte cap falls mid-char.
2190        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        // Must not panic; echo result is not a valid verdict so it fails open.
2199        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}