Skip to main content

agentmux_srv/backend/wconfig/
redact.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Redact secret settings keys from the full-config JSON before it is sent to
5//! any renderer/frontend connection.
6//!
7//! Found in PR #2751 review: `voice:groqApiKey`'s own doc comment claims
8//! "Read server-side only, never sent to the renderer," but `get_full_config()`'s
9//! outbound JSON (the initial `config` event, the `getfullconfig` RPC, and the
10//! post-`setconfig` broadcast — all three call sites in `server/websocket.rs`)
11//! was never actually filtered, so the real plaintext key has always reached
12//! every renderer connection's JS memory. PR #2751 introduced a masked-dot
13//! Settings UI for this key (`MaskedKeyField`) that visually implies the
14//! opposite. This module makes the doc comment's claim actually true.
15//!
16//! The renderer's UI only needs to know a value IS set, never what it is, so
17//! redaction replaces the real value with a fixed non-empty placeholder
18//! (distinguishable from "unset") rather than merely blanking it. The write
19//! path (`setconfig` → `merge_settings_to_disk`/`merge_settings_into_current`)
20//! is untouched — this only filters the server → renderer read direction.
21
22use serde_json::Value;
23
24/// Settings keys whose values must never reach a renderer connection.
25/// Extend this list as more secret-shaped fields get a Settings UI (e.g. the
26/// messaging-bridge bot tokens proposed in
27/// docs/specs/SPEC_SETTINGS_MESSAGING_BRIDGES_SECTION_2026_08_22.md).
28const REDACTED_SETTINGS_KEYS: &[&str] = &["voice:groqApiKey"];
29
30/// Placeholder written in place of a redacted value. Any renderer code that
31/// merely checks "is a key set" (e.g. `MaskedKeyField`'s locked-state gate)
32/// keeps working unchanged; nothing should ever display this string as if it
33/// were the real value.
34const REDACTED_PLACEHOLDER: &str = "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}";
35
36/// Redact known-secret keys in-place on an already-serialized full-config
37/// JSON value. Call this on every path that sends config to a renderer;
38/// never on the in-memory `ConfigState` state or the on-disk file.
39pub fn redact_full_config_for_renderer(v: &mut Value) {
40    let Some(settings) = v.get_mut("settings").and_then(|s| s.as_object_mut()) else {
41        return;
42    };
43    for key in REDACTED_SETTINGS_KEYS {
44        let is_set = settings
45            .get(*key)
46            .map(|existing| match existing {
47                Value::String(s) => !s.is_empty(),
48                Value::Null => false,
49                _ => true,
50            })
51            .unwrap_or(false);
52        if is_set {
53            settings.insert((*key).to_string(), Value::String(REDACTED_PLACEHOLDER.to_string()));
54        }
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use serde_json::json;
62
63    #[test]
64    fn redacts_a_set_secret_key() {
65        let mut v = json!({ "settings": { "voice:groqApiKey": "sk-real-secret-value" } });
66        redact_full_config_for_renderer(&mut v);
67        assert_eq!(v["settings"]["voice:groqApiKey"], REDACTED_PLACEHOLDER);
68        assert_ne!(v["settings"]["voice:groqApiKey"], "sk-real-secret-value");
69    }
70
71    #[test]
72    fn leaves_an_unset_secret_key_alone() {
73        let mut v = json!({ "settings": { "term:fontsize": 14 } });
74        redact_full_config_for_renderer(&mut v);
75        assert!(v["settings"].get("voice:groqApiKey").is_none());
76    }
77
78    #[test]
79    fn does_not_touch_unrelated_settings() {
80        let mut v = json!({ "settings": { "voice:groqApiKey": "sk-real", "term:fontsize": 14 } });
81        redact_full_config_for_renderer(&mut v);
82        assert_eq!(v["settings"]["term:fontsize"], 14);
83    }
84
85    #[test]
86    fn handles_missing_settings_object_gracefully() {
87        let mut v = json!({ "mimetypes": {} });
88        redact_full_config_for_renderer(&mut v); // must not panic
89        assert_eq!(v, json!({ "mimetypes": {} }));
90    }
91
92    #[test]
93    fn treats_empty_string_as_unset() {
94        let mut v = json!({ "settings": { "voice:groqApiKey": "" } });
95        redact_full_config_for_renderer(&mut v);
96        assert_eq!(v["settings"]["voice:groqApiKey"], "");
97    }
98}