Skip to main content

abyss_agent_hook/config/
mod.rs

1//! Runtime policy snapshots and producer context for Harness audit hooks.
2
3mod content;
4mod harness;
5
6use std::sync::Arc;
7
8use arc_swap::ArcSwap;
9use serde::{Deserialize, Serialize};
10
11pub use content::HarnessUsageContentConfig;
12pub use harness::{HarnessConfig, HarnessMatcherConfig, HarnessUsageConfig};
13
14/// Dynamic configuration for all compiled-in MITM hooks.
15#[derive(Debug, Clone, Default, Deserialize, Serialize)]
16#[serde(deny_unknown_fields)]
17pub struct HooksConfig {
18    /// Harness usage audit hook configuration.
19    #[serde(default, alias = "agent_usage")]
20    pub harness_usage: HookConfig<HarnessUsageConfig>,
21}
22
23/// Common envelope for one compiled-in hook.
24#[derive(Debug, Clone, Deserialize, Serialize)]
25#[serde(deny_unknown_fields)]
26pub struct HookConfig<T> {
27    /// Whether this hook should produce side effects.
28    #[serde(default = "enabled_by_default")]
29    pub enabled: bool,
30    /// Hook-owned behavior configuration.
31    #[serde(default)]
32    pub config: T,
33}
34
35/// Shared dynamic hooks configuration.
36#[derive(Debug, Clone)]
37pub struct HooksRuntimeConfig {
38    inner: Arc<ArcSwap<HooksConfig>>,
39}
40
41/// Device identity attached to produced Agent events.
42#[derive(Debug, Clone)]
43#[non_exhaustive]
44pub struct DeviceIdentity {
45    /// Optional hostname for operator-friendly dashboards.
46    pub hostname: Option<String>,
47    /// Optional platform name such as `windows`, `macos`, or `linux`.
48    pub platform: Option<String>,
49    /// Optional operating-system version string.
50    pub os_version: Option<String>,
51}
52
53/// Immutable producer context for Harness usage events.
54#[derive(Debug, Clone)]
55#[non_exhaustive]
56pub struct HarnessUsageHookConfig {
57    /// Device identity attached to every generated event.
58    pub device: DeviceIdentity,
59}
60
61impl<T> Default for HookConfig<T>
62where
63    T: Default,
64{
65    fn default() -> Self {
66        Self {
67            enabled: true,
68            config: T::default(),
69        }
70    }
71}
72
73impl HooksRuntimeConfig {
74    /// Creates a dynamic hook config handle with the supplied initial snapshot.
75    #[must_use]
76    pub fn new(config: HooksConfig) -> Self {
77        Self {
78            inner: Arc::new(ArcSwap::from_pointee(config)),
79        }
80    }
81
82    /// Creates a dynamic hook config handle with built-in defaults.
83    #[must_use]
84    pub fn default_enabled() -> Self {
85        Self::new(HooksConfig::default())
86    }
87
88    /// Returns the current hooks configuration snapshot.
89    #[must_use]
90    pub fn snapshot(&self) -> Arc<HooksConfig> {
91        self.inner.load_full()
92    }
93
94    /// Atomically replaces the hooks configuration for future hook invocations.
95    #[must_use]
96    pub fn update(&self, config: HooksConfig) -> HooksConfig {
97        self.inner.store(Arc::new(config.clone()));
98        config
99    }
100}
101
102impl DeviceIdentity {
103    /// Creates an empty device identity.
104    #[must_use]
105    pub const fn new() -> Self {
106        Self {
107            hostname: None,
108            platform: None,
109            os_version: None,
110        }
111    }
112}
113
114impl Default for DeviceIdentity {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120impl HarnessUsageHookConfig {
121    /// Builds a producer configuration from explicit device identity.
122    #[must_use]
123    pub const fn new(device: DeviceIdentity) -> Self {
124        Self { device }
125    }
126
127    /// Builds a producer configuration using local platform identity.
128    #[must_use]
129    pub fn from_platform() -> Self {
130        Self::new(platform_device_identity())
131    }
132}
133
134/// Returns the local platform identity used by Harness events.
135#[must_use]
136pub fn platform_device_identity() -> DeviceIdentity {
137    let mut device = DeviceIdentity::new();
138    device.hostname = system_hostname();
139    device.platform = Some(std::env::consts::OS.to_owned());
140    device
141}
142
143const fn enabled_by_default() -> bool {
144    true
145}
146
147fn normalize_hostname(value: &str) -> Option<String> {
148    let value = value.trim().trim_end_matches('.').to_owned();
149    if value.is_empty() { None } else { Some(value) }
150}
151
152#[cfg(unix)]
153fn system_hostname() -> Option<String> {
154    nix::unistd::gethostname()
155        .ok()
156        .and_then(|value| value.into_string().ok())
157        .and_then(|value| normalize_hostname(&value))
158}
159
160#[cfg(not(unix))]
161fn system_hostname() -> Option<String> {
162    std::env::var_os("COMPUTERNAME")
163        .map(|value| value.to_string_lossy().into_owned())
164        .and_then(|value| normalize_hostname(&value))
165}
166
167#[cfg(test)]
168mod tests {
169    use crate::harness::{BuiltInHarness, HarnessId};
170
171    use super::{
172        DeviceIdentity, HarnessConfig, HarnessMatcherConfig, HarnessUsageConfig,
173        HarnessUsageContentConfig, HarnessUsageHookConfig, HookConfig, HooksConfig,
174        HooksRuntimeConfig,
175    };
176
177    #[test]
178    fn hook_config_contains_only_producer_identity() {
179        let mut device = DeviceIdentity::new();
180        device.hostname = Some("test-host".to_owned());
181        let config = HarnessUsageHookConfig::new(device);
182
183        assert_eq!(config.device.hostname.as_deref(), Some("test-host"));
184    }
185
186    #[test]
187    fn hooks_config_defaults_to_enabled_plaintext_harness_usage() {
188        let config = serde_json::from_str::<HooksConfig>("{}")
189            .expect("empty hooks config should use defaults");
190
191        assert!(config.harness_usage.enabled);
192        let content = config
193            .harness_usage
194            .config
195            .content_for_harness(BuiltInHarness::Codex.id());
196        assert!(content.token_usage);
197        assert!(content.conversation_text);
198        assert!(content.tool_calls);
199        assert!(content.images);
200
201        let value = serde_json::to_value(config).expect("default hooks config should serialize");
202        assert_eq!(
203            value["harness_usage"]["config"]["content"],
204            serde_json::json!({
205                "token_usage": true,
206                "conversation_text": true,
207                "tool_calls": true,
208                "images": true,
209            })
210        );
211    }
212
213    #[test]
214    fn harness_usage_config_supports_custom_harnesses() {
215        let config = serde_json::from_str::<HooksConfig>(
216            r#"{
217                "harness_usage": {
218                    "config": {
219                        "harnesses": {
220                            "acme-agent": {
221                                "enabled": true,
222                                "matchers": [{
223                                    "process_names": ["acme-agent"],
224                                    "application_ids": ["com.acme.agent"]
225                                }]
226                            }
227                        }
228                    }
229                }
230            }"#,
231        )
232        .expect("custom Harness configuration should parse");
233
234        let harness = config
235            .harness_usage
236            .config
237            .harnesses
238            .get("acme-agent")
239            .expect("custom Harness should be retained");
240        assert_eq!(harness.enabled, Some(true));
241        assert_eq!(harness.matchers.len(), 1);
242    }
243
244    #[test]
245    fn harness_usage_config_supports_per_harness_overrides() {
246        let mut config = HarnessUsageConfig {
247            content: HarnessUsageContentConfig {
248                token_usage: true,
249                conversation_text: false,
250                tool_calls: false,
251                images: false,
252            },
253            harnesses: std::collections::BTreeMap::new(),
254        };
255        config.harnesses.insert(
256            HarnessId::from(BuiltInHarness::Codex),
257            HarnessConfig {
258                enabled: Some(false),
259                content: Some(HarnessUsageContentConfig {
260                    token_usage: false,
261                    conversation_text: true,
262                    tool_calls: true,
263                    images: true,
264                }),
265                matchers: Vec::new(),
266            },
267        );
268
269        assert!(!config.enabled_for_harness(BuiltInHarness::Codex.id()));
270        assert!(
271            !config
272                .content_for_harness(BuiltInHarness::Codex.id())
273                .token_usage
274        );
275        let claude = config.content_for_harness(BuiltInHarness::ClaudeCode.id());
276        assert!(claude.token_usage);
277        assert!(!claude.conversation_text);
278    }
279
280    #[test]
281    fn legacy_agent_keys_migrate_during_deserialization() {
282        let config = serde_json::from_str::<HooksConfig>(
283            r#"{"agent_usage":{"config":{"agents":{"claude-cli":{"enabled":false}}}}}"#,
284        )
285        .expect("legacy Harness configuration should migrate");
286
287        assert!(
288            !config
289                .harness_usage
290                .config
291                .enabled_for_harness(BuiltInHarness::ClaudeCode.id())
292        );
293        let value = serde_json::to_value(config).expect("migrated config should serialize");
294        assert!(value.get("agent_usage").is_none());
295        assert!(
296            value["harness_usage"]["config"]["harnesses"]
297                .as_object()
298                .expect("Harness map should serialize as an object")
299                .contains_key("claude-code")
300        );
301    }
302
303    #[test]
304    fn content_defaults_omitted_independent_controls() {
305        let content =
306            serde_json::from_str::<HarnessUsageContentConfig>(r#"{"conversation_text":false}"#)
307                .expect("partial independent content policy should parse");
308
309        assert!(content.token_usage);
310        assert!(!content.conversation_text);
311        assert!(content.tool_calls);
312        assert!(content.images);
313    }
314
315    #[test]
316    fn hooks_config_rejects_unknown_fields() {
317        let unknown_hook =
318            serde_json::from_str::<HooksConfig>(r#"{"future_hook":{"enabled":true}}"#);
319        assert!(unknown_hook.is_err());
320
321        let unknown_content = serde_json::from_str::<HooksConfig>(
322            r#"{"harness_usage":{"config":{"content":{"future_content":true}}}}"#,
323        );
324        assert!(unknown_content.is_err());
325
326        let unknown_matcher = serde_json::from_str::<HooksConfig>(
327            r#"{"harness_usage":{"config":{"harnesses":{"acme":{"matchers":[{"future_matcher":["value"]}]}}}}}"#,
328        );
329        assert!(unknown_matcher.is_err());
330    }
331
332    #[test]
333    fn hooks_runtime_config_updates_snapshots_without_mutating_old_handles() {
334        let runtime = HooksRuntimeConfig::default_enabled();
335        let before = runtime.snapshot();
336
337        let updated = runtime.update(HooksConfig {
338            harness_usage: HookConfig {
339                enabled: false,
340                config: HarnessUsageConfig::default(),
341            },
342        });
343
344        assert!(!updated.harness_usage.enabled);
345        assert!(before.harness_usage.enabled);
346        assert!(!runtime.snapshot().harness_usage.enabled);
347    }
348
349    #[test]
350    fn matcher_config_defaults_to_empty_selectors() {
351        let matcher = serde_json::from_str::<HarnessMatcherConfig>("{}")
352            .expect("empty matcher DTO should deserialize");
353        assert!(matcher.process_names.is_empty());
354        assert!(matcher.application_ids.is_empty());
355    }
356
357    #[test]
358    fn custom_harness_requires_a_non_empty_matcher() {
359        for input in [
360            r#"{"harness_usage":{"config":{"harnesses":{"acme":{}}}}}"#,
361            r#"{"harness_usage":{"config":{"harnesses":{"acme":{"matchers":[{}]}}}}}"#,
362        ] {
363            assert!(serde_json::from_str::<HooksConfig>(input).is_err());
364        }
365    }
366
367    #[test]
368    fn harness_id_uses_the_documented_wire_format() {
369        for harness_id in ["", "Uppercase", "contains space"] {
370            let input = format!(
371                r#"{{"harness_usage":{{"config":{{"harnesses":{{"{harness_id}":{{"matchers":[{{"process_names":["acme"]}}]}}}}}}}}}}"#
372            );
373            assert!(serde_json::from_str::<HooksConfig>(&input).is_err());
374        }
375    }
376
377    #[test]
378    fn built_in_harness_cannot_be_redefined_with_custom_matchers() {
379        let input = r#"{
380            "harness_usage": {
381                "config": {
382                    "harnesses": {
383                        "codex": {"matchers": [{"process_names": ["other"]}]}
384                    }
385                }
386            }
387        }"#;
388
389        assert!(serde_json::from_str::<HooksConfig>(input).is_err());
390    }
391}