Skip to main content

agent_effects/
redaction.rs

1//! Keeping secrets out of the store.
2//!
3//! Everything the runtime persists can leak: the input (kept for identity,
4//! audit and handler recovery), the output (replayed to later callers),
5//! audit payloads, and error messages, which often echo tokens back. Two
6//! tools keep secrets out:
7//!
8//! - [`Secret<T>`], the default. It serializes as `"[REDACTED]"`, so a
9//!   secret field in an input or output never reaches the store. Its `Debug`
10//!   and `Display` print `[REDACTED]` too. The action sees the real value
11//!   (it holds it in memory); what is read back from the store is a redacted
12//!   `Secret` ([`Secret::expose`] returns `None`).
13//! - A [`Redactor`] on the runtime
14//!   ([`RuntimeBuilder::redactor`](crate::RuntimeBuilder::redactor)). It
15//!   rewrites every input, output, audit payload and error message before it
16//!   is written. [`RedactKeys`] masks fields by name at any depth.
17//!
18//! Redaction happens before the input is fingerprinted, so secrets are not
19//! part of an effect's identity and are never stored, not even hashed.
20//!
21//! What is redacted cannot be replayed or resumed:
22//!
23//! - a later caller gets the redacted output;
24//! - a handler resumed by recovery gets the redacted input.
25//!
26//! Keep credentials in the action's captured state or in the handler, not in
27//! inputs.
28
29use std::collections::HashSet;
30use std::fmt;
31
32use serde::de::{Deserialize, Deserializer, IgnoredAny};
33use serde::ser::{Serialize, Serializer};
34use serde_json::Value;
35
36use crate::id::EffectName;
37
38/// What redacted values are replaced with.
39pub const REDACTED: &str = "[REDACTED]";
40
41/// A value that must never be stored or logged.
42///
43/// ```
44/// use agent_effects::redaction::Secret;
45///
46/// #[derive(serde::Serialize)]
47/// struct Charge {
48///     account: String,
49///     card_token: Secret<String>,
50/// }
51///
52/// let charge = Charge { account: "acct_1".into(), card_token: Secret::new("tok_live_x".into()) };
53/// assert_eq!(
54///     serde_json::to_string(&charge).unwrap(),
55///     r#"{"account":"acct_1","card_token":"[REDACTED]"}"#
56/// );
57/// assert_eq!(format!("{:?}", charge.card_token), "[REDACTED]");
58/// assert_eq!(charge.card_token.expose().map(String::as_str), Some("tok_live_x"));
59/// ```
60#[derive(Clone, Default, PartialEq, Eq)]
61pub struct Secret<T>(Option<T>);
62
63impl<T> Secret<T> {
64    /// Wraps `value`.
65    pub const fn new(value: T) -> Self {
66        Self(Some(value))
67    }
68
69    /// The value, or `None` if this `Secret` was read back from storage,
70    /// where only `"[REDACTED]"` was kept.
71    pub const fn expose(&self) -> Option<&T> {
72        self.0.as_ref()
73    }
74
75    /// The value, consuming the wrapper; `None` as for [`Self::expose`].
76    pub fn into_inner(self) -> Option<T> {
77        self.0
78    }
79
80    /// Whether the value is gone because it was read back from storage.
81    pub const fn is_redacted(&self) -> bool {
82        self.0.is_none()
83    }
84}
85
86impl<T> From<T> for Secret<T> {
87    fn from(value: T) -> Self {
88        Self::new(value)
89    }
90}
91
92impl<T> fmt::Debug for Secret<T> {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        f.write_str(REDACTED)
95    }
96}
97
98impl<T> fmt::Display for Secret<T> {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        f.write_str(REDACTED)
101    }
102}
103
104impl<T> Serialize for Secret<T> {
105    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
106        serializer.serialize_str(REDACTED)
107    }
108}
109
110/// Reading a `Secret` back always yields a redacted one: the value was never
111/// stored.
112impl<'de, T> Deserialize<'de> for Secret<T> {
113    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
114        IgnoredAny::deserialize(deserializer)?;
115        Ok(Self(None))
116    }
117}
118
119/// Which persisted value a [`Redactor`] is looking at.
120#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
121#[non_exhaustive]
122pub enum Field {
123    /// An effect's input, before it is fingerprinted and stored.
124    Input,
125    /// An action's or verification's output, or an operator's
126    /// `Resolution::Applied` output.
127    Output,
128    /// An audit event's payload, e.g. an operator's note.
129    AuditPayload,
130    /// An error message, as a JSON string.
131    ErrorMessage,
132}
133
134/// Rewrites values before the runtime stores them. Applies to every effect
135/// the runtime runs, every transition it records and every operator
136/// decision.
137///
138/// Any `Fn(Field, &EffectName, &mut Value)` is a redactor.
139pub trait Redactor: Send + Sync + 'static {
140    /// Rewrites `value`, of `field`, of an effect named `effect`, in place.
141    fn redact(&self, field: Field, effect: &EffectName, value: &mut Value);
142}
143
144impl<F> Redactor for F
145where
146    F: Fn(Field, &EffectName, &mut Value) + Send + Sync + 'static,
147{
148    fn redact(&self, field: Field, effect: &EffectName, value: &mut Value) {
149        self(field, effect, value);
150    }
151}
152
153/// Replaces the value of every object field with one of these names
154/// (case-insensitively, at any depth) with `"[REDACTED]"`.
155///
156/// ```
157/// use agent_effects::redaction::{Field, RedactKeys, Redactor};
158/// use agent_effects::EffectName;
159/// use serde_json::json;
160///
161/// let redact = RedactKeys::new(["password", "card_number"]);
162/// let mut value = json!({ "user": "ada", "auth": { "Password": "hunter2" }, "cards": [{ "card_number": "4242" }] });
163/// redact.redact(Field::Input, &EffectName::new("signup").unwrap(), &mut value);
164/// assert_eq!(value, json!({ "user": "ada", "auth": { "Password": "[REDACTED]" }, "cards": [{ "card_number": "[REDACTED]" }] }));
165/// ```
166#[derive(Clone, Debug, Default)]
167pub struct RedactKeys {
168    keys: HashSet<String>,
169}
170
171impl RedactKeys {
172    /// Masks fields with any of `keys`.
173    pub fn new<K: AsRef<str>>(keys: impl IntoIterator<Item = K>) -> Self {
174        Self {
175            keys: keys
176                .into_iter()
177                .map(|k| k.as_ref().to_ascii_lowercase())
178                .collect(),
179        }
180    }
181
182    fn mask(&self, value: &mut Value) {
183        match value {
184            Value::Object(map) => {
185                for (key, value) in map.iter_mut() {
186                    if self.keys.contains(&key.to_ascii_lowercase()) {
187                        *value = Value::String(REDACTED.into());
188                    } else {
189                        self.mask(value);
190                    }
191                }
192            }
193            Value::Array(items) => items.iter_mut().for_each(|item| self.mask(item)),
194            _ => {}
195        }
196    }
197}
198
199impl Redactor for RedactKeys {
200    fn redact(&self, _field: Field, _effect: &EffectName, value: &mut Value) {
201        self.mask(value);
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use serde_json::json;
208
209    use super::*;
210
211    #[test]
212    fn a_secret_never_serializes_its_value_and_reads_back_redacted() {
213        let secret = Secret::new("tok_live_x".to_string());
214        let stored = serde_json::to_value(&secret).unwrap();
215        assert_eq!(stored, json!(REDACTED));
216        let back: Secret<String> = serde_json::from_value(stored).unwrap();
217        assert!(back.is_redacted());
218        assert_eq!(back.expose(), None);
219        let anything: Secret<u64> = serde_json::from_value(json!({ "x": 1 })).unwrap();
220        assert!(
221            anything.is_redacted(),
222            "any stored shape reads back redacted"
223        );
224    }
225
226    #[test]
227    fn a_closure_is_a_redactor() {
228        let redactor = |field: Field, _: &EffectName, value: &mut Value| {
229            if field == Field::ErrorMessage {
230                *value = json!("hidden");
231            }
232        };
233        let mut message = json!("token sk_live_1 rejected");
234        redactor.redact(
235            Field::ErrorMessage,
236            &EffectName::new("x").unwrap(),
237            &mut message,
238        );
239        assert_eq!(message, json!("hidden"));
240    }
241}