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}