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