Skip to main content

agent_block_types/
obs.rs

1use std::borrow::Cow;
2use std::sync::OnceLock;
3
4use reqwest::Url;
5use uuid::Uuid;
6
7const REDACTED: &str = "[REDACTED]";
8
9/// Returns a process-scoped agent ID that is generated once and reused for the
10/// lifetime of the process.  The semantic scope of `agent_id` is
11/// "one agent-block execution", which is coarser than `run_id` (per-call).
12/// Both currently collapse to the same generated value in single-run
13/// invocations, but the conceptual distinction is preserved so that future
14/// deployments can evolve the two scopes independently (e.g. long-running
15/// daemon vs. per-request).
16fn process_agent_id() -> &'static str {
17    static AGENT_ID: OnceLock<String> = OnceLock::new();
18    AGENT_ID.get_or_init(|| Uuid::new_v4().to_string())
19}
20
21/// Build the observability context tuple `(trace_id, run_id, agent_id, agent_name)`.
22///
23/// Resolution order for `agent_id`:
24/// 1. `AGENT_BLOCK_AGENT_ID` environment variable (non-empty)
25/// 2. `fallback_agent_id` argument (non-None)
26/// 3. Process-scoped auto-generated UUID v4 (generated once per process lifetime).
27///    Scope: one agent-block execution = one `agent_id`.  Conceptually coarser than
28///    `run_id` (per-call), though both may share the same value in simple invocations.
29pub fn obs_context(fallback_agent_id: Option<&str>) -> (String, String, String, String) {
30    let trace_id = std::env::var("AGENT_BLOCK_TRACE_ID").unwrap_or_default();
31    let run_id = std::env::var("AGENT_BLOCK_RUN_ID").unwrap_or_default();
32    let agent_id = std::env::var("AGENT_BLOCK_AGENT_ID")
33        .ok()
34        .filter(|v| !v.is_empty())
35        .or_else(|| fallback_agent_id.map(ToString::to_string))
36        .unwrap_or_else(|| process_agent_id().to_string());
37    let agent_name = std::env::var("AGENT_BLOCK_AGENT_NAME").unwrap_or_default();
38    (trace_id, run_id, agent_id, agent_name)
39}
40
41/// Render a single structured observability log line in the fixed
42/// `key=value` format used by the `ab.obs` logging convention.
43///
44/// The line always begins with `prefix=ab.obs`, followed by `event`,
45/// `component`, and the four context fields carried in `ctx`
46/// (`trace_id`, `run_id`, `agent_id`, `agent_name`), then appends each
47/// caller-supplied `extra` pair in order. Every value is passed through
48/// `kv_escape`, which redacts sensitive keys, sanitizes `url` values, and
49/// JSON-quotes any value containing whitespace or `=` so the line stays
50/// parseable.
51pub fn obs_line(
52    component: &str,
53    event: &str,
54    ctx: &(String, String, String, String),
55    extra: &[(&str, &str)],
56) -> String {
57    let mut parts = vec![
58        "prefix=ab.obs".to_string(),
59        format!("event={}", event),
60        format!("component={}", component),
61        format!("trace_id={}", kv_escape("trace_id", &ctx.0)),
62        format!("run_id={}", kv_escape("run_id", &ctx.1)),
63        format!("agent_id={}", kv_escape("agent_id", &ctx.2)),
64        format!("agent_name={}", kv_escape("agent_name", &ctx.3)),
65    ];
66    for (k, v) in extra {
67        parts.push(format!("{}={}", k, kv_escape(k, v)));
68    }
69    parts.join(" ")
70}
71
72fn kv_escape(key: &str, value: &str) -> String {
73    let safe = sanitize_value(key, value);
74    if safe.is_empty() {
75        "\"\"".to_string()
76    } else if safe.chars().any(|c| c.is_whitespace() || c == '=') {
77        serde_json::Value::String(safe.into_owned()).to_string()
78    } else {
79        safe.into_owned()
80    }
81}
82
83fn sanitize_value<'a>(key: &str, value: &'a str) -> Cow<'a, str> {
84    if is_sensitive_key(key) {
85        return Cow::Borrowed(REDACTED);
86    }
87    if key.eq_ignore_ascii_case("url") {
88        return Cow::Owned(sanitize_url(value));
89    }
90    Cow::Borrowed(value)
91}
92
93fn is_sensitive_key(key: &str) -> bool {
94    let k = key.to_ascii_lowercase();
95    [
96        "authorization",
97        "cookie",
98        "set-cookie",
99        "token",
100        "secret",
101        "password",
102        "passwd",
103        "api_key",
104        "apikey",
105        "access_key",
106        "private_key",
107    ]
108    .iter()
109    .any(|needle| k.contains(needle))
110}
111
112/// Strip credentials and volatile components from a URL so it is safe to log.
113///
114/// Removes any `user:pass@` userinfo, the query string, and the fragment.
115/// Parseable URLs are rebuilt via the `url` crate (username / password cleared,
116/// query / fragment dropped). Unparseable inputs have their `user:pass@`
117/// userinfo and any `?query` / `#fragment` stripped heuristically, then are
118/// truncated to the first 16 characters (suffixed with `...`) so a human can
119/// still recognise the target from logs without leaking secrets.
120pub fn sanitize_url(raw: &str) -> String {
121    match Url::parse(raw) {
122        Ok(mut u) => {
123            let _ = u.set_username("");
124            let _ = u.set_password(None);
125            u.set_query(None);
126            u.set_fragment(None);
127            u.to_string()
128        }
129        Err(_) => {
130            // URL is not parseable: preserve the first 16 characters so a human
131            // can identify the target from logs (e.g. a typo like "htps://...").
132            // Strip both `user:pass@` userinfo AND `?query` / `#fragment` so
133            // credentials or tokens embedded anywhere in the authority / query /
134            // fragment cannot leak even in the truncated form.  Path remains
135            // (truncated) so the host/path prefix is still identifiable.
136            let sanitized = redact_userinfo(raw);
137            let cut_end = sanitized.find(['?', '#']).unwrap_or(sanitized.len());
138            let trimmed = &sanitized[..cut_end];
139            if trimmed.len() <= 16 {
140                trimmed.to_string()
141            } else {
142                format!("{}...", &trimmed[..16])
143            }
144        }
145    }
146}
147
148/// Remove `user:pass@` userinfo from an unparseable URL string.
149///
150/// Uses simple substring heuristics: looks for `://` (scheme separator) then
151/// `@` within the authority. If found, replaces the `user:pass@` span with
152/// an empty string. If not found, returns the input unchanged.
153fn redact_userinfo(raw: &str) -> String {
154    if let Some(scheme_end) = raw.find("://") {
155        let after_scheme = scheme_end + 3;
156        let authority = &raw[after_scheme..];
157        if let Some(at_pos) = authority.find('@') {
158            // Reconstruct: scheme + "://" + everything after "@"
159            let scheme_and_sep = &raw[..after_scheme];
160            let host_and_rest = &authority[at_pos + 1..];
161            return format!("{}{}", scheme_and_sep, host_and_rest);
162        }
163    }
164    raw.to_string()
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn process_agent_id_is_non_empty_and_stable() {
173        // process_agent_id() must return a non-empty value and the same
174        // value on every call (OnceLock semantics within this process).
175        let id1 = process_agent_id();
176        let id2 = process_agent_id();
177        assert!(!id1.is_empty(), "process_agent_id must not be empty");
178        assert_eq!(
179            id1, id2,
180            "process_agent_id must be stable within the process"
181        );
182    }
183
184    #[test]
185    fn obs_context_fallback_agent_id_wins_over_auto() {
186        // When ENV is absent and fallback_agent_id is provided, it takes priority.
187        // This test avoids mutating global ENV to prevent parallelism flakiness.
188        // We temporarily unset via a guard-free approach: only valid if ENV is absent.
189        // Use a distinctive value that cannot collide with a real env setting.
190        let fallback = "test-fallback-agent-xxx";
191        // Ensure ENV is not set to this value (it may be set to something else).
192        // If ENV IS set, skip assertion on fallback path (env wins per spec).
193        if std::env::var("AGENT_BLOCK_AGENT_ID")
194            .unwrap_or_default()
195            .is_empty()
196        {
197            let (_, _, id, _) = obs_context(Some(fallback));
198            assert_eq!(id, fallback);
199        }
200    }
201
202    #[test]
203    fn sanitize_url_strips_credentials_and_query() {
204        let raw = "https://user:pass@example.com/path?q=1&r=2#frag";
205        let got = sanitize_url(raw);
206        assert_eq!(got, "https://example.com/path");
207    }
208
209    #[test]
210    fn sanitize_url_malformed_truncates_to_16_chars() {
211        // Previously returned "[UNPARSEABLE]"; now returns the first 16 chars
212        // of the input so log readers can identify the target.
213        let raw = "not a valid url ://::garbage";
214        let got = sanitize_url(raw);
215        // First 16 chars of "not a valid url " are "not a valid url " — truncated with "..."
216        assert_eq!(got, "not a valid url ...");
217    }
218
219    #[test]
220    fn sanitize_url_empty_string_returns_empty() {
221        let got = sanitize_url("");
222        assert_eq!(got, "");
223    }
224
225    #[test]
226    fn sanitize_url_short_unparseable_returns_as_is() {
227        // A short but unparseable URL (≤16 chars) is returned without truncation.
228        let raw = "htps://x.com";
229        let got = sanitize_url(raw);
230        assert_eq!(got, "htps://x.com");
231    }
232
233    #[test]
234    fn sanitize_url_unparseable_strips_userinfo() {
235        // Even for unparseable URLs, obvious user:pass@ patterns are stripped.
236        let raw = "htps://user:pass@example.com/path";
237        let got = sanitize_url(raw);
238        // After stripping: "htps://example.com/path" → first 16: "htps://example.c" + "..."
239        assert!(
240            !got.contains("pass"),
241            "password must be stripped from unparseable URL: {got}"
242        );
243        assert!(
244            !got.contains("user"),
245            "username must be stripped from unparseable URL: {got}"
246        );
247    }
248
249    #[test]
250    fn sanitize_url_unparseable_strips_query_and_fragment() {
251        // Even for unparseable URLs, secrets embedded in ?query or #fragment
252        // must be stripped before truncation.  Previously the 16-char truncate
253        // could leak a partial token when the secret sat within the first 16
254        // bytes of the input.
255        let raw = "htps://api.x/?token=SUPER_SECRET_VALUE_XYZ";
256        let got = sanitize_url(raw);
257        assert!(
258            !got.contains("SECRET"),
259            "query secret must be stripped: {got}"
260        );
261        assert!(
262            !got.contains("token"),
263            "query key must also be stripped: {got}"
264        );
265
266        let raw2 = "htps://api.x/#token=SECRET";
267        let got2 = sanitize_url(raw2);
268        assert!(
269            !got2.contains("SECRET"),
270            "fragment secret must be stripped: {got2}"
271        );
272    }
273}