Skip to main content

everruns_builtins/
error_disclosure.rs

1// Error disclosure capability
2//
3// Controls how much detail about run-blocking errors (provider failures,
4// quota exhaustion, misconfiguration, …) is shown to session viewers:
5//
6// - `generic`: every blocking error collapses into one generic, localizable
7//   message. For public-facing agents where provider/billing state must not
8//   leak.
9// - `standard`: stable error code + structured fields (platform default,
10//   also used when this capability is not enabled).
11// - `detailed`: standard plus the underlying driver error text. For trusted
12//   surfaces such as coding-agent harnesses built on the runtime.
13//
14// Per-message override: `controls.error_disclosure` on the session input
15// message can request a mode, but it is clamped to at most the mode this
16// capability allows (capability absent => `standard` ceiling), so a client
17// can never widen disclosure beyond what the agent operator configured.
18//
19// The applied mode and pre-disclosure error code are recorded in message
20// metadata (`error_disclosure`, `source_error_code`) for tracking.
21
22use crate::capabilities::{Capability, CapabilityLocalization};
23use crate::user_facing_error::ErrorDisclosure;
24use everruns_capability::CapabilityRef as AgentCapabilityConfig;
25
26pub const ERROR_DISCLOSURE_CAPABILITY_ID: &str = "error_disclosure";
27
28pub struct ErrorDisclosureCapability;
29
30impl Capability for ErrorDisclosureCapability {
31    fn id(&self) -> &str {
32        ERROR_DISCLOSURE_CAPABILITY_ID
33    }
34
35    fn name(&self) -> &str {
36        "Error Disclosure"
37    }
38
39    fn description(&self) -> &str {
40        "Controls how much detail about run-blocking errors is shown in the session: \
41         a single generic message (public agents), stable error codes (default), \
42         or full provider error details (trusted surfaces)."
43    }
44
45    fn config_schema(&self) -> Option<serde_json::Value> {
46        Some(serde_json::json!({
47            "type": "object",
48            "properties": {
49                "mode": {
50                    "type": "string",
51                    "title": "Disclosure mode",
52                    "description": "generic: one generic localized message; standard: stable error code + fields; detailed: standard plus the underlying provider error text.",
53                    "enum": ["generic", "standard", "detailed"],
54                    "default": "standard"
55                }
56            }
57        }))
58    }
59
60    fn validate_config(&self, config: &serde_json::Value) -> Result<(), String> {
61        if config.is_null() {
62            return Ok(());
63        }
64        if !config.is_object() {
65            return Err("error_disclosure config must be an object".to_string());
66        }
67        match config.get("mode") {
68            None => Ok(()),
69            Some(serde_json::Value::String(mode)) if ErrorDisclosure::parse(mode).is_some() => {
70                Ok(())
71            }
72            Some(value) => Err(format!(
73                "mode must be one of \"generic\", \"standard\", \"detailed\", got {value}"
74            )),
75        }
76    }
77
78    fn localizations(&self) -> Vec<CapabilityLocalization> {
79        vec![
80            CapabilityLocalization {
81                locale: "en",
82                name: None,
83                description: None,
84                config_description: Some(
85                    "Chooses how much error detail session viewers see when a turn fails.",
86                ),
87                config_overlay: None,
88            },
89            CapabilityLocalization {
90                locale: "uk",
91                name: Some("Розкриття помилок"),
92                description: Some(
93                    "Визначає, скільки деталей про блокуючі помилки показувати в сесії: \
94                     одне загальне повідомлення (публічні агенти), стабільні коди помилок \
95                     (за замовчуванням) або повні деталі помилки провайдера (довірені середовища).",
96                ),
97                config_description: Some(
98                    "Визначає, скільки деталей про помилку бачать користувачі сесії, коли хід завершується невдало.",
99                ),
100                config_overlay: Some(serde_json::json!({
101                    "properties": {
102                        "mode": {
103                            "title": "Режим розкриття",
104                            "description": "generic: одне загальне локалізоване повідомлення; standard: стабільний код помилки з полями; detailed: standard плюс текст помилки провайдера."
105                        }
106                    }
107                })),
108            },
109        ]
110    }
111
112    fn error_disclosure(&self, config: &serde_json::Value) -> Option<ErrorDisclosure> {
113        Some(
114            config
115                .get("mode")
116                .and_then(serde_json::Value::as_str)
117                .and_then(ErrorDisclosure::parse)
118                .unwrap_or_default(),
119        )
120    }
121}
122
123/// Disclosure mode configured on the enabled `error_disclosure` capability,
124/// or `None` when the capability is not enabled for the agent.
125fn configured_mode(configs: &[AgentCapabilityConfig]) -> Option<ErrorDisclosure> {
126    let config = configs
127        .iter()
128        .find(|config| config.capability_id() == ERROR_DISCLOSURE_CAPABILITY_ID)?;
129    Some(
130        config
131            .config_value()
132            .clone()
133            .get("mode")
134            .and_then(|mode| mode.as_str())
135            .and_then(ErrorDisclosure::parse)
136            .unwrap_or_default(),
137    )
138}
139
140/// Resolve the effective error-disclosure mode for a turn.
141///
142/// Precedence: per-message `controls.error_disclosure` override, clamped to
143/// the capability-configured ceiling (capability absent => `standard`).
144///
145/// THREAT[TM-LLM-024]: the clamp is the security boundary — message controls
146/// are client-supplied, so they may only narrow disclosure, never widen it
147/// beyond what the agent operator configured.
148pub fn resolve_error_disclosure(
149    configs: &[AgentCapabilityConfig],
150    requested: Option<&str>,
151) -> ErrorDisclosure {
152    let ceiling = configured_mode(configs).unwrap_or_default();
153    match requested.and_then(ErrorDisclosure::parse) {
154        Some(requested) => requested.min(ceiling),
155        None => ceiling,
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn cap_config(mode: &str) -> AgentCapabilityConfig {
164        AgentCapabilityConfig::with_config(
165            ERROR_DISCLOSURE_CAPABILITY_ID,
166            serde_json::json!({ "mode": mode }),
167        )
168    }
169
170    #[test]
171    fn resolve_defaults_to_standard_without_capability() {
172        assert_eq!(
173            resolve_error_disclosure(&[], None),
174            ErrorDisclosure::Standard
175        );
176    }
177
178    #[test]
179    fn resolve_uses_capability_mode() {
180        assert_eq!(
181            resolve_error_disclosure(&[cap_config("detailed")], None),
182            ErrorDisclosure::Detailed
183        );
184        assert_eq!(
185            resolve_error_disclosure(&[cap_config("generic")], None),
186            ErrorDisclosure::Generic
187        );
188    }
189
190    #[test]
191    fn resolve_capability_without_mode_defaults_to_standard() {
192        let config = AgentCapabilityConfig::new(ERROR_DISCLOSURE_CAPABILITY_ID);
193        assert_eq!(
194            resolve_error_disclosure(&[config], None),
195            ErrorDisclosure::Standard
196        );
197    }
198
199    #[test]
200    fn controls_can_narrow_but_not_widen() {
201        // Detailed ceiling: controls may narrow to generic.
202        assert_eq!(
203            resolve_error_disclosure(&[cap_config("detailed")], Some("generic")),
204            ErrorDisclosure::Generic
205        );
206        // Generic ceiling: controls cannot widen to detailed.
207        assert_eq!(
208            resolve_error_disclosure(&[cap_config("generic")], Some("detailed")),
209            ErrorDisclosure::Generic
210        );
211        // No capability: standard ceiling, detailed request is clamped.
212        assert_eq!(
213            resolve_error_disclosure(&[], Some("detailed")),
214            ErrorDisclosure::Standard
215        );
216        // Unknown values are ignored.
217        assert_eq!(
218            resolve_error_disclosure(&[cap_config("detailed")], Some("everything")),
219            ErrorDisclosure::Detailed
220        );
221    }
222
223    #[test]
224    fn validate_config_accepts_known_modes_only() {
225        let cap = ErrorDisclosureCapability;
226        assert!(cap.validate_config(&serde_json::Value::Null).is_ok());
227        assert!(cap.validate_config(&serde_json::json!({})).is_ok());
228        assert!(
229            cap.validate_config(&serde_json::json!({"mode": "generic"}))
230                .is_ok()
231        );
232        assert!(
233            cap.validate_config(&serde_json::json!({"mode": "loud"}))
234                .is_err()
235        );
236        assert!(cap.validate_config(&serde_json::json!([])).is_err());
237    }
238
239    #[test]
240    fn localizations_resolve_uk() {
241        let cap = ErrorDisclosureCapability;
242        assert_eq!(cap.localized_name(Some("uk-UA")), "Розкриття помилок");
243    }
244}