Skip to main content

harn_vm/stdlib/
token_redaction.rs

1//! `std/oauth/redaction` (OA-06) — OAuth token redaction stdlib.
2//!
3//! Lets scripts register custom redaction patterns alongside the
4//! built-in OAuth token catalog, probe whether a given string would
5//! be scrubbed, and drain the per-thread audit ring for compliance
6//! reporting.
7//!
8//! The actual redaction happens at READ/SERIALIZE time on the four
9//! sinks (transcript events, audit receipts, OTel span attributes,
10//! system reminders) via [`crate::redact::RedactionPolicy`]. Every
11//! redaction synchronously records a [`crate::redact::RedactionEvent`]
12//! in a per-thread ring; this module exposes the ring drain plus an
13//! optional process-wide async forwarder that appends events to the
14//! `audit.token_redaction` event-log topic.
15
16use crate::value::VmDictExt;
17use std::collections::BTreeMap;
18use std::rc::Rc;
19use std::sync::Once;
20
21use serde_json::Value as JsonValue;
22
23use crate::event_log::{active_event_log, EventLog, LogEvent, Topic};
24use crate::redact::{
25    self, custom_pattern_names, default_pattern_names, drain_audit_ring, install_audit_sink,
26    register_custom_pattern, RedactionEvent, TOKEN_REDACTION_AUDIT_TOPIC,
27    TOKEN_REDACTION_DIAGNOSTIC,
28};
29use crate::value::{VmError, VmValue};
30use crate::vm::Vm;
31
32static AUDIT_SINK_INIT: Once = Once::new();
33
34/// Install the process-wide audit forwarder that pushes each
35/// redaction event to:
36///
37///   * the live `events::log_info_meta` sink pipeline (Stderr/Otel),
38///   * the active [`crate::event_log::EventLog`] under
39///     [`TOKEN_REDACTION_AUDIT_TOPIC`], best-effort.
40///
41/// The synchronous per-thread audit ring (see
42/// [`crate::redact::drain_audit_ring`]) is the authoritative
43/// compliance contract; this forwarder is an optional convenience for
44/// streaming consumers.
45///
46/// Safe to call multiple times — only the first call wires the sink.
47/// Per-thread because [`install_audit_sink`] is itself thread-local,
48/// `register_token_redaction_builtins` always re-installs on the
49/// current thread.
50pub fn ensure_audit_sink() {
51    AUDIT_SINK_INIT.call_once(|| {
52        install_audit_sink(Some(Rc::new(|event: &RedactionEvent| {
53            forward_event_to_log(event);
54            forward_event_to_events_sink(event);
55        })));
56    });
57}
58
59fn forward_event_to_log(event: &RedactionEvent) {
60    let Some(log) = active_event_log() else {
61        return;
62    };
63    let topic = match Topic::new(TOKEN_REDACTION_AUDIT_TOPIC) {
64        Ok(topic) => topic,
65        Err(_) => return,
66    };
67    let payload = serde_json::json!({
68        "code": TOKEN_REDACTION_DIAGNOSTIC,
69        "pattern": event.pattern_name,
70        "match_count": event.match_count,
71        "bytes_redacted": event.bytes_redacted,
72    });
73    let log_event = LogEvent::new("token_redaction", payload);
74    let log_ref = log;
75    let topic_clone = topic;
76    let owned = log_event;
77    let task = async move {
78        if let Err(error) = log_ref.append(&topic_clone, owned).await {
79            crate::events::log_warn(
80                "token_redaction.audit",
81                &format!("failed to append token redaction audit event: {error}"),
82            );
83        }
84    };
85    // The append is best-effort. `handle.spawn` requires a multi-
86    // threaded runtime; on a current-thread runtime (the common VM
87    // path) the audit ring already recorded the event and there is
88    // nothing more to do here without panicking on the spawn.
89    if let Ok(handle) = tokio::runtime::Handle::try_current() {
90        let metrics = handle.metrics();
91        if metrics.num_workers() > 1 {
92            handle.spawn(task);
93        }
94    }
95}
96
97fn forward_event_to_events_sink(event: &RedactionEvent) {
98    let metadata: BTreeMap<String, JsonValue> = BTreeMap::from([
99        (
100            "code".to_string(),
101            JsonValue::String(TOKEN_REDACTION_DIAGNOSTIC.to_string()),
102        ),
103        (
104            "pattern".to_string(),
105            JsonValue::String(event.pattern_name.clone()),
106        ),
107        (
108            "match_count".to_string(),
109            JsonValue::Number(event.match_count.into()),
110        ),
111        (
112            "bytes_redacted".to_string(),
113            JsonValue::Number(event.bytes_redacted.into()),
114        ),
115    ]);
116    crate::events::log_info_meta(
117        "token_redaction.audit",
118        "redacted token in persistence sink",
119        metadata,
120    );
121}
122
123pub(crate) fn register_token_redaction_builtins(vm: &mut Vm) {
124    ensure_audit_sink();
125    for def in MODULE_BUILTINS {
126        vm.register_builtin_def(def);
127    }
128}
129
130pub(crate) const MODULE_BUILTINS: &[&crate::stdlib::macros::VmBuiltinDef] = &[
131    &TOKEN_REDACTION_REGISTER_PATTERN_IMPL_DEF,
132    &TOKEN_REDACTION_CLEAR_CUSTOM_PATTERNS_IMPL_DEF,
133    &TOKEN_REDACTION_REDACT_IMPL_DEF,
134    &TOKEN_REDACTION_DEFAULT_PATTERNS_IMPL_DEF,
135    &TOKEN_REDACTION_CUSTOM_PATTERNS_IMPL_DEF,
136    &TOKEN_REDACTION_DRAIN_AUDIT_IMPL_DEF,
137];
138
139#[crate::stdlib::macros::harn_builtin(
140    sig = "__token_redaction_register_pattern(name: string, regex: string) -> nil",
141    category = "token_redaction"
142)]
143fn token_redaction_register_pattern_impl(
144    args: &[VmValue],
145    _out: &mut String,
146) -> Result<VmValue, VmError> {
147    let name = required_string_arg(args, 0, "__token_redaction_register_pattern", "name")?;
148    let regex_source = required_string_arg(args, 1, "__token_redaction_register_pattern", "regex")?;
149    if name.is_empty() {
150        return Err(VmError::Runtime(
151            "__token_redaction_register_pattern: `name` must not be empty".to_string(),
152        ));
153    }
154    register_custom_pattern(&name, &regex_source).map_err(|message| {
155        VmError::Runtime(format!("token_redaction.register_pattern: {message}"))
156    })?;
157    Ok(VmValue::Nil)
158}
159
160#[crate::stdlib::macros::harn_builtin(
161    sig = "__token_redaction_clear_custom_patterns() -> nil",
162    category = "token_redaction"
163)]
164fn token_redaction_clear_custom_patterns_impl(
165    args: &[VmValue],
166    _out: &mut String,
167) -> Result<VmValue, VmError> {
168    if !args.is_empty() {
169        return Err(VmError::Runtime(
170            "__token_redaction_clear_custom_patterns: expected 0 arguments".to_string(),
171        ));
172    }
173    redact::clear_custom_patterns();
174    Ok(VmValue::Nil)
175}
176
177#[crate::stdlib::macros::harn_builtin(
178    sig = "__token_redaction_redact(text: string) -> string",
179    category = "token_redaction"
180)]
181fn token_redaction_redact_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
182    let text = required_string_arg(args, 0, "__token_redaction_redact", "text")?;
183    let policy = redact::current_policy();
184    let redacted = policy.redact_string(&text).into_owned();
185    Ok(VmValue::String(arcstr::ArcStr::from(redacted.as_str())))
186}
187
188#[crate::stdlib::macros::harn_builtin(
189    sig = "__token_redaction_default_patterns() -> list",
190    category = "token_redaction"
191)]
192fn token_redaction_default_patterns_impl(
193    args: &[VmValue],
194    _out: &mut String,
195) -> Result<VmValue, VmError> {
196    if !args.is_empty() {
197        return Err(VmError::Runtime(
198            "__token_redaction_default_patterns: expected 0 arguments".to_string(),
199        ));
200    }
201    let names: Vec<VmValue> = default_pattern_names()
202        .into_iter()
203        .map(|name| VmValue::String(arcstr::ArcStr::from(name)))
204        .collect();
205    Ok(VmValue::List(std::sync::Arc::new(names)))
206}
207
208#[crate::stdlib::macros::harn_builtin(
209    sig = "__token_redaction_custom_patterns() -> list",
210    category = "token_redaction"
211)]
212fn token_redaction_custom_patterns_impl(
213    args: &[VmValue],
214    _out: &mut String,
215) -> Result<VmValue, VmError> {
216    if !args.is_empty() {
217        return Err(VmError::Runtime(
218            "__token_redaction_custom_patterns: expected 0 arguments".to_string(),
219        ));
220    }
221    let names: Vec<VmValue> = custom_pattern_names()
222        .into_iter()
223        .map(|name| VmValue::String(arcstr::ArcStr::from(name.as_str())))
224        .collect();
225    Ok(VmValue::List(std::sync::Arc::new(names)))
226}
227
228#[crate::stdlib::macros::harn_builtin(
229    sig = "__token_redaction_drain_audit() -> list",
230    category = "token_redaction"
231)]
232fn token_redaction_drain_audit_impl(
233    args: &[VmValue],
234    _out: &mut String,
235) -> Result<VmValue, VmError> {
236    if !args.is_empty() {
237        return Err(VmError::Runtime(
238            "__token_redaction_drain_audit: expected 0 arguments".to_string(),
239        ));
240    }
241    let events = drain_audit_ring();
242    let list: Vec<VmValue> = events
243        .into_iter()
244        .map(|event| {
245            let mut entry: crate::value::DictMap = crate::value::DictMap::new();
246            entry.put_str("code", TOKEN_REDACTION_DIAGNOSTIC);
247            entry.put_str("pattern", event.pattern_name.as_str());
248            entry.insert(
249                crate::value::intern_key("match_count"),
250                VmValue::Int(event.match_count as i64),
251            );
252            entry.insert(
253                crate::value::intern_key("bytes_redacted"),
254                VmValue::Int(event.bytes_redacted as i64),
255            );
256            VmValue::dict(entry)
257        })
258        .collect();
259    Ok(VmValue::List(std::sync::Arc::new(list)))
260}
261
262fn required_string_arg(
263    args: &[VmValue],
264    index: usize,
265    fn_name: &str,
266    arg_name: &str,
267) -> Result<String, VmError> {
268    match args.get(index) {
269        Some(VmValue::String(s)) => Ok(s.to_string()),
270        Some(other) => Err(VmError::Runtime(format!(
271            "{fn_name}: `{arg_name}` must be a string, got {}",
272            other.type_name()
273        ))),
274        None => Err(VmError::Runtime(format!(
275            "{fn_name}: `{arg_name}` argument is required"
276        ))),
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn ensure_audit_sink_is_idempotent() {
286        ensure_audit_sink();
287        ensure_audit_sink();
288        // Trigger one redaction to confirm the sink does not panic
289        // when invoked from a non-tokio context.
290        let _ = redact::scan_secret_patterns("AKIAABCDEFGHIJKLMNOP", redact::REDACTED_PLACEHOLDER);
291    }
292}