Skip to main content

vs_daemon/
redact.rs

1//! Mask sensitive arguments before they reach the audit log.
2//!
3//! The default redaction list mirrors what shows up in agent
4//! workflows: passwords, tokens, API keys, secrets. The match is
5//! case-insensitive on the *flag name* (e.g. `--token=…`) and on
6//! the immediate-prior arg (e.g. `vs_act 2 fill PASSWORD_VALUE`
7//! redacts the value because the previous arg is `fill` of a
8//! password-like target — but for v1 we only redact based on flag
9//! names; positional secrets are the agent's responsibility unless
10//! they pass `--unsafe-log` (M5).
11//!
12//! For now: any flag whose name matches the regex `(?i)password|
13//! token|secret|key|auth` has its value replaced with `***`.
14
15const SENSITIVE: &[&str] = &["password", "token", "secret", "key", "auth"];
16
17/// Render the request args for the audit log, redacting sensitive
18/// flag values. Returns a single string — the wire-form of args
19/// minus the primitive name — suitable for `args_redacted`.
20#[must_use]
21pub fn redact_args(args: &[String], flags: &[(String, Option<String>)]) -> String {
22    let mut parts: Vec<String> = Vec::with_capacity(args.len() + flags.len());
23    for a in args {
24        parts.push(a.clone());
25    }
26    for (name, value) in flags {
27        match value {
28            Some(v) if is_sensitive(name) => parts.push(format!("--{name}=***")),
29            Some(v) => parts.push(format!("--{name}={v}")),
30            None => parts.push(format!("--{name}")),
31        }
32    }
33    parts.join(" ")
34}
35
36fn is_sensitive(name: &str) -> bool {
37    let lower = name.to_lowercase();
38    SENSITIVE.iter().any(|n| lower.contains(n))
39}
40
41/// Redact a single free-form string (used for `vs_inspect eval`
42/// expressions in `args_redacted`). Replaces inline `bearer ...` /
43/// `token = ...` style secrets with `***`. Matching is intentionally
44/// loose so casually-pasted credentials don't survive the audit log.
45#[must_use]
46pub fn redact_string(s: &str) -> String {
47    let mut out = String::with_capacity(s.len());
48    let lower = s.to_ascii_lowercase();
49    let bytes = s.as_bytes();
50    let mut i = 0;
51    while i < bytes.len() {
52        // Look for the start of one of the keywords.
53        let rest = &lower[i..];
54        let mut hit = None;
55        for kw in [
56            "bearer ",
57            "authorization:",
58            "x-api-key:",
59            "secret",
60            "password",
61            "token",
62        ] {
63            if rest.starts_with(kw) {
64                hit = Some(kw.len());
65                break;
66            }
67        }
68        if let Some(kw_len) = hit {
69            out.push_str(&s[i..i + kw_len]);
70            // Skip until the next quote/semicolon/whitespace boundary.
71            let mut j = i + kw_len;
72            while j < bytes.len() && !matches!(bytes[j], b'"' | b'\'' | b';' | b'\n' | b'}' | b')')
73            {
74                j += 1;
75            }
76            if j > i + kw_len {
77                out.push_str("***");
78            }
79            i = j;
80            continue;
81        }
82        out.push(s.as_bytes()[i] as char);
83        i += 1;
84    }
85    out
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn no_flags_is_just_args() {
94        assert_eq!(redact_args(&["a".into(), "b".into()], &[]), "a b");
95    }
96
97    #[test]
98    fn bare_flag_kept() {
99        assert_eq!(
100            redact_args(&[], &[("full-page".into(), None)]),
101            "--full-page"
102        );
103    }
104
105    #[test]
106    fn token_flag_redacted() {
107        assert_eq!(
108            redact_args(&[], &[("token".into(), Some("abcdef0123456789".into()))],),
109            "--token=***",
110        );
111    }
112
113    #[test]
114    fn password_flag_redacted_case_insensitively() {
115        assert_eq!(
116            redact_args(&[], &[("Password".into(), Some("hunter2".into()))]),
117            "--Password=***",
118        );
119    }
120
121    #[test]
122    fn key_inside_name_triggers_redaction() {
123        assert_eq!(
124            redact_args(&[], &[("api-key".into(), Some("xxx".into()))]),
125            "--api-key=***",
126        );
127    }
128
129    #[test]
130    fn unrelated_flag_kept() {
131        assert_eq!(
132            redact_args(&[], &[("viewport".into(), Some("mobile".into()))]),
133            "--viewport=mobile",
134        );
135    }
136}