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}