Skip to main content

everruns_core/capabilities/
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::capability_types::AgentCapabilityConfig;
24use crate::user_facing_error::ErrorDisclosure;
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
113/// Disclosure mode configured on the enabled `error_disclosure` capability,
114/// or `None` when the capability is not enabled for the agent.
115fn configured_mode(configs: &[AgentCapabilityConfig]) -> Option<ErrorDisclosure> {
116    let config = configs
117        .iter()
118        .find(|config| config.capability_id() == ERROR_DISCLOSURE_CAPABILITY_ID)?;
119    Some(
120        config
121            .config
122            .get("mode")
123            .and_then(|mode| mode.as_str())
124            .and_then(ErrorDisclosure::parse)
125            .unwrap_or_default(),
126    )
127}
128
129/// Resolve the effective error-disclosure mode for a turn.
130///
131/// Precedence: per-message `controls.error_disclosure` override, clamped to
132/// the capability-configured ceiling (capability absent => `standard`).
133///
134/// THREAT[TM-LLM-024]: the clamp is the security boundary — message controls
135/// are client-supplied, so they may only narrow disclosure, never widen it
136/// beyond what the agent operator configured.
137pub fn resolve_error_disclosure(
138    configs: &[AgentCapabilityConfig],
139    requested: Option<&str>,
140) -> ErrorDisclosure {
141    let ceiling = configured_mode(configs).unwrap_or_default();
142    match requested.and_then(ErrorDisclosure::parse) {
143        Some(requested) => requested.min(ceiling),
144        None => ceiling,
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    fn cap_config(mode: &str) -> AgentCapabilityConfig {
153        AgentCapabilityConfig {
154            capability_ref: ERROR_DISCLOSURE_CAPABILITY_ID.into(),
155            config: serde_json::json!({ "mode": mode }),
156        }
157    }
158
159    #[test]
160    fn resolve_defaults_to_standard_without_capability() {
161        assert_eq!(
162            resolve_error_disclosure(&[], None),
163            ErrorDisclosure::Standard
164        );
165    }
166
167    #[test]
168    fn resolve_uses_capability_mode() {
169        assert_eq!(
170            resolve_error_disclosure(&[cap_config("detailed")], None),
171            ErrorDisclosure::Detailed
172        );
173        assert_eq!(
174            resolve_error_disclosure(&[cap_config("generic")], None),
175            ErrorDisclosure::Generic
176        );
177    }
178
179    #[test]
180    fn resolve_capability_without_mode_defaults_to_standard() {
181        let config = AgentCapabilityConfig::new(ERROR_DISCLOSURE_CAPABILITY_ID);
182        assert_eq!(
183            resolve_error_disclosure(&[config], None),
184            ErrorDisclosure::Standard
185        );
186    }
187
188    #[test]
189    fn controls_can_narrow_but_not_widen() {
190        // Detailed ceiling: controls may narrow to generic.
191        assert_eq!(
192            resolve_error_disclosure(&[cap_config("detailed")], Some("generic")),
193            ErrorDisclosure::Generic
194        );
195        // Generic ceiling: controls cannot widen to detailed.
196        assert_eq!(
197            resolve_error_disclosure(&[cap_config("generic")], Some("detailed")),
198            ErrorDisclosure::Generic
199        );
200        // No capability: standard ceiling, detailed request is clamped.
201        assert_eq!(
202            resolve_error_disclosure(&[], Some("detailed")),
203            ErrorDisclosure::Standard
204        );
205        // Unknown values are ignored.
206        assert_eq!(
207            resolve_error_disclosure(&[cap_config("detailed")], Some("everything")),
208            ErrorDisclosure::Detailed
209        );
210    }
211
212    #[test]
213    fn validate_config_accepts_known_modes_only() {
214        let cap = ErrorDisclosureCapability;
215        assert!(cap.validate_config(&serde_json::Value::Null).is_ok());
216        assert!(cap.validate_config(&serde_json::json!({})).is_ok());
217        assert!(
218            cap.validate_config(&serde_json::json!({"mode": "generic"}))
219                .is_ok()
220        );
221        assert!(
222            cap.validate_config(&serde_json::json!({"mode": "loud"}))
223                .is_err()
224        );
225        assert!(cap.validate_config(&serde_json::json!([])).is_err());
226    }
227
228    #[test]
229    fn localizations_resolve_uk() {
230        let cap = ErrorDisclosureCapability;
231        assert_eq!(cap.localized_name(Some("uk-UA")), "Розкриття помилок");
232    }
233}