Skip to main content

submilli_engine/runtime/
call_log.rs

1//! Call records: each host call a program made, when it ran, and, for a call that
2//! reaches outside the program, what it sent and what came back.
3//!
4//! The [`DecisionLog`](super::DecisionLog) keeps these beside its decisions. Every gated
5//! host call is a call: it starts at its gate and ends when the host function returns,
6//! so its timing and outcome need nothing from the host function itself. A host function
7//! whose call fetches or sends something also hands the recorder its request and
8//! response ([`record_payload`]): a capped copy, a digest of the full
9//! payload, and its full size.
10//!
11//! What is never kept: a secret's value (a `secrets.get` call keeps its timing only),
12//! and the values of credential-bearing headers, which are masked before the copy and the
13//! digest are taken (see [`mask_headers`]). Matching known secret values inside bodies is
14//! the embedder's job when it stores a record; the runtime does not know them all.
15//!
16//! Hashing a payload walks bytes the program already paid fuel to send or receive, so the
17//! digest adds no unbounded work of its own; like the rest of the recorder it is not
18//! charged to guest fuel (see `DecisionLogConfig::max_line_capture_frames`).
19
20use std::borrow::Cow;
21
22use base64::Engine as _;
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25use sha2::{Digest, Sha256};
26
27use super::StoreData;
28use super::decision::{CallTicket, PackageLine, SourceLine};
29
30/// How a call ended.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum CallOutcome {
34    /// The host function returned a value.
35    Returned,
36    /// The host function failed: a denial, a thrown error, a trap.
37    Failed,
38    /// The run ended while the call was still in progress (a timeout or a cancellation).
39    Unfinished,
40}
41
42impl PayloadRecord {
43    /// This record without its meta, body, and masked names: the digest, the size, and the
44    /// flags.
45    pub(crate) fn digest_only(&self) -> Self {
46        Self {
47            meta: Value::Null,
48            body: None,
49            digest: self.digest.clone(),
50            bytes: self.bytes,
51            truncated: self.truncated,
52            masked_headers: Vec::new(),
53        }
54    }
55}
56
57/// A copy of a payload body, kept as text when it is UTF-8.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "kebab-case", tag = "encoding", content = "data")]
60pub enum BodyCopy {
61    Text(String),
62    Base64(String),
63}
64
65/// One side of a call: what it sent, or what came back.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct PayloadRecord {
68    /// The structured part: a method and URL, a tool, a model, a status. Capped like a
69    /// decision's context, with credential-bearing headers masked.
70    pub meta: Value,
71    /// A copy of the body, capped at `DecisionLogConfig::max_payload_bytes`.
72    pub body: Option<BodyCopy>,
73    /// SHA-256, hex, of the full meta and body (masked headers excluded).
74    pub digest: String,
75    /// The payload's full size, counted before any cap or masking: the body's bytes, or
76    /// the meta's when there is no body.
77    pub bytes: u64,
78    /// The meta or body copy was cut, or dropped for the recorder's byte budget.
79    pub truncated: bool,
80    /// Header names whose values were masked: at most 64, each capped like a meta string.
81    pub masked_headers: Vec<String>,
82}
83
84/// Token counts a model provider reported for one call. Absent when not reported.
85#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
86pub struct ModelUsage {
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub input_tokens: Option<u64>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub output_tokens: Option<u64>,
91}
92
93/// One host call. Times are microseconds measured from the recorder's start.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct CallRecord {
96    /// Run-wide, in call order; the same index the call's decisions carry.
97    pub call_index: u64,
98    pub caller: String,
99    pub capability: String,
100    /// When the call began, at its security gate.
101    pub started_micros: u64,
102    /// `None` while the call runs, and for an [`CallOutcome::Unfinished`] call.
103    pub ended_micros: Option<u64>,
104    pub outcome: Option<CallOutcome>,
105    /// The program line that led to the call, when known.
106    pub line: Option<SourceLine>,
107    /// The line inside a package that made the call, when the program reached it through one.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub package_line: Option<PackageLine>,
110    /// What the call sent, when it reached outside the program.
111    pub request: Option<Box<PayloadRecord>>,
112    /// What came back, or the failure that came instead.
113    pub response: Option<Box<PayloadRecord>>,
114    pub usage: Option<ModelUsage>,
115}
116
117impl CallRecord {
118    /// A copy without the payload bodies and meta, keeping each side's digest, size, and
119    /// flags: what an observer that reports sizes needs, without copying up to
120    /// `max_payload_bytes` per side.
121    pub fn without_bodies(&self) -> Self {
122        let strip = |side: &Option<Box<PayloadRecord>>| {
123            side.as_ref().map(|payload| Box::new(payload.digest_only()))
124        };
125        Self {
126            call_index: self.call_index,
127            caller: self.caller.clone(),
128            capability: self.capability.clone(),
129            started_micros: self.started_micros,
130            ended_micros: self.ended_micros,
131            outcome: self.outcome,
132            line: self.line,
133            package_line: self.package_line.clone(),
134            request: strip(&self.request),
135            response: strip(&self.response),
136            usage: self.usage,
137        }
138    }
139}
140
141/// What a host function hands the recorder for one side of a call.
142pub struct Payload<'a> {
143    pub meta: Value,
144    pub body: Option<Cow<'a, [u8]>>,
145    /// The full size, when it differs from the body handed over: a download written to
146    /// disk, or a body the host function received in another form.
147    pub bytes: Option<u64>,
148    pub masked_headers: Vec<String>,
149}
150
151impl<'a> Payload<'a> {
152    pub fn meta(meta: Value) -> Self {
153        Self {
154            meta,
155            body: None,
156            bytes: None,
157            masked_headers: Vec::new(),
158        }
159    }
160
161    pub fn with_body(mut self, body: &'a [u8]) -> Self {
162        self.body = Some(Cow::Borrowed(body));
163        self
164    }
165
166    pub fn with_owned_body(mut self, body: Vec<u8>) -> Self {
167        self.body = Some(Cow::Owned(body));
168        self
169    }
170
171    pub fn with_size(mut self, bytes: u64) -> Self {
172        self.bytes = Some(bytes);
173        self
174    }
175
176    pub fn with_masked(mut self, masked: Vec<String>) -> Self {
177        self.masked_headers = masked;
178        self
179    }
180}
181
182/// Which side of a call a payload belongs to.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum Side {
185    Request,
186    Response,
187}
188
189/// The value a masked header carries in a record.
190pub const MASKED: &str = "[masked]";
191
192/// Whether a header's value is a credential: `Authorization`, `Proxy-Authorization`,
193/// `Cookie`, `Set-Cookie`, and any name containing `key`, `token`, `secret`, `auth`,
194/// `password`, `passwd`, `signature`, or `credential`.
195pub fn is_sensitive_header(name: &str) -> bool {
196    let name = name.to_ascii_lowercase();
197    matches!(name.as_str(), "cookie" | "set-cookie")
198        || [
199            "key",
200            "token",
201            "secret",
202            "auth",
203            "password",
204            "passwd",
205            "signature",
206            "credential",
207        ]
208        .iter()
209        .any(|word| name.contains(word))
210}
211
212/// Headers as a recordable JSON array of `[name, value]` pairs with credential values
213/// masked, and the names that were masked.
214pub fn mask_headers(headers: &[(String, String)]) -> (Value, Vec<String>) {
215    let mut masked = Vec::new();
216    let pairs = headers
217        .iter()
218        .map(|(name, value)| {
219            if is_sensitive_header(name) {
220                masked.push(name.clone());
221                Value::from(vec![Value::from(name.as_str()), Value::from(MASKED)])
222            } else {
223                Value::from(vec![
224                    Value::from(name.as_str()),
225                    Value::from(value.as_str()),
226                ])
227            }
228        })
229        .collect();
230    (Value::Array(pairs), masked)
231}
232
233/// A URL as a record keeps it: the userinfo and the values of credential-named query
234/// parameters masked, and the fragment dropped. A URL that does not parse keeps nothing
235/// of its authority, path, or query, so a credential in it cannot survive.
236pub fn mask_url(url: &str) -> String {
237    let Ok(parsed) = url::Url::parse(url) else {
238        return unparsable_url(url);
239    };
240    let mut base = parsed.clone();
241    base.set_query(None);
242    base.set_fragment(None);
243    let Some(mut text) = with_masked_userinfo(base) else {
244        return unparsable_url(url);
245    };
246    if let Some(query) = parsed.query() {
247        text.push('?');
248        text.push_str(&mask_query(query));
249    }
250    text
251}
252
253/// `url` as text with its userinfo, if it has any, replaced by [`MASKED`].
254///
255/// The mask is spliced into the text: the `url` crate percent-encodes the brackets of
256/// [`MASKED`] when it is set as a username. `None` when the text is not shaped as the
257/// splice expects.
258fn with_masked_userinfo(mut url: url::Url) -> Option<String> {
259    let has_userinfo = !url.username().is_empty() || url.password().is_some();
260    if !has_userinfo || !url.has_authority() {
261        return Some(url.into());
262    }
263    let at = url.scheme().len().saturating_add("://".len());
264    url.set_username("").ok()?;
265    url.set_password(None).ok()?;
266    let whole = url.as_str();
267    let head = whole.get(..at)?;
268    let tail = whole.get(at..)?;
269    // Userinfo that survived the clearing would leak through the splice.
270    let authority = tail.split('/').next().unwrap_or_default();
271    if authority.contains('@') {
272        return None;
273    }
274    Some(format!("{head}{MASKED}@{tail}"))
275}
276
277/// A query with the values of credential-named parameters masked. Parameters separate at
278/// `&` and at `;`, and each keeps its own separator.
279fn mask_query(query: &str) -> String {
280    query
281        .split_inclusive(['&', ';'])
282        .map(|segment| {
283            let (pair, separator) = match segment.char_indices().last() {
284                Some((at, '&' | ';')) => segment.split_at(at),
285                _ => (segment, ""),
286            };
287            let name = pair.split_once('=').map_or(pair, |(name, _)| name);
288            if is_sensitive_param(name) {
289                format!("{name}={MASKED}{separator}")
290            } else {
291                segment.to_owned()
292            }
293        })
294        .collect()
295}
296
297/// Whether a query parameter's value is a credential, by the rule for header names.
298fn is_sensitive_param(raw_name: &str) -> bool {
299    let name = percent_encoding::percent_decode_str(raw_name).decode_utf8_lossy();
300    is_sensitive_header(&name)
301}
302
303const UNPARSABLE_URL: &str = "[unparsable url]";
304
305/// What a URL that does not parse is recorded as: its scheme, when it plainly has one,
306/// and a placeholder. Everything after the scheme could hold a credential.
307fn unparsable_url(url: &str) -> String {
308    let scheme = url
309        .split_once("://")
310        .map(|(scheme, _)| scheme)
311        .filter(|scheme| {
312            let mut chars = scheme.chars();
313            chars
314                .next()
315                .is_some_and(|first| first.is_ascii_alphabetic())
316                && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
317        });
318    match scheme {
319        Some(scheme) => format!("{scheme}://{UNPARSABLE_URL}"),
320        None => UNPARSABLE_URL.to_owned(),
321    }
322}
323
324/// Most masked header names a payload record keeps.
325const MAX_MASKED_HEADERS: usize = 64;
326
327/// The length of a payload digest as [`payload_cost`] charges it: SHA-256 as hex.
328pub(crate) const DIGEST_HEX_BYTES: u64 = 64;
329
330/// What [`payload_cost`] charges a [`PayloadRecord::digest_only`] record: its digest and a
331/// `null` meta.
332pub(crate) const DIGEST_ONLY_COST: u64 = DIGEST_HEX_BYTES + 2 * "null".len() as u64;
333
334/// Builds a payload record. `max_meta` caps meta strings. The body copy is sized to the
335/// `room` the recorder has left for the whole record and capped at `max_body` raw bytes;
336/// see [`body_allowance`].
337pub(crate) fn capture(
338    payload: &Payload<'_>,
339    max_meta: usize,
340    room: u64,
341    max_body: usize,
342) -> PayloadRecord {
343    let meta_text = serde_json::to_vec(&payload.meta).unwrap_or_default();
344    let body = payload.body.as_deref();
345    let digest = digest_of(&meta_text, body);
346    let (meta, mut truncated) = super::decision::cap_context(&payload.meta, max_meta);
347    if payload.masked_headers.len() > MAX_MASKED_HEADERS {
348        truncated = true;
349    }
350    let masked_headers: Vec<String> = payload
351        .masked_headers
352        .iter()
353        .take(MAX_MASKED_HEADERS)
354        .map(|name| super::decision::cap_text(name, max_meta, &mut truncated))
355        .collect();
356    let full = payload
357        .bytes
358        .unwrap_or_else(|| body.map_or(meta_text.len(), <[u8]>::len) as u64);
359    let body = body.and_then(|body| {
360        // Sized from the capped forms the record will be charged for.
361        let overhead = record_overhead(&meta, &masked_headers);
362        let max_body = body_allowance(body, overhead, room, max_body);
363        if max_body == 0 && !body.is_empty() {
364            // No room for any of it: no copy is made.
365            truncated = true;
366            return None;
367        }
368        let kept = body.get(..max_body.min(body.len())).unwrap_or_default();
369        let cut = kept.len() < body.len();
370        truncated |= cut;
371        Some(copy_body(kept, cut))
372    });
373    PayloadRecord {
374        meta,
375        body,
376        digest,
377        bytes: full,
378        truncated,
379        masked_headers,
380    }
381}
382
383/// SHA-256, hex, of a payload: the meta's JSON text, then the body. The length prefix
384/// marks where the meta ends and the body begins, so two payloads that split the same
385/// bytes differently do not share a digest.
386fn digest_of(meta_text: &[u8], body: Option<&[u8]>) -> String {
387    let mut hasher = Sha256::new();
388    hasher.update((meta_text.len() as u64).to_le_bytes());
389    hasher.update(meta_text);
390    if let Some(body) = body {
391        hasher.update(body);
392    }
393    format!("{:x}", hasher.finalize())
394}
395
396impl Payload<'_> {
397    /// The digest a record of this payload carries ([`PayloadRecord::digest`]), whatever
398    /// the recorder's caps keep of it. Lets a transport compute what the call log
399    /// recorded for a request.
400    pub fn digest(&self) -> String {
401        let meta_text = serde_json::to_vec(&self.meta).unwrap_or_default();
402        digest_of(&meta_text, self.body.as_deref())
403    }
404}
405
406/// How a body copy is kept.
407enum BodyForm {
408    /// As text: the first `valid` bytes.
409    Text {
410        valid: usize,
411    },
412    Base64,
413}
414
415/// The form a copy of `bytes` takes: text when it is UTF-8, else base64. A copy that was
416/// `cut` may end inside a character; the text before it is kept. A body that was not cut
417/// and is not UTF-8 is binary, however it ends. A cut inside the first character has no
418/// text before it, so it is binary too.
419fn body_form(bytes: &[u8], cut: bool) -> BodyForm {
420    match std::str::from_utf8(bytes) {
421        Ok(_) => BodyForm::Text { valid: bytes.len() },
422        Err(error) if cut && error.error_len().is_none() && error.valid_up_to() > 0 => {
423            BodyForm::Text {
424                valid: error.valid_up_to(),
425            }
426        }
427        Err(_) => BodyForm::Base64,
428    }
429}
430
431fn copy_body(bytes: &[u8], cut: bool) -> BodyCopy {
432    match body_form(bytes, cut) {
433        BodyForm::Text { valid } => {
434            let text = bytes.get(..valid).unwrap_or_default();
435            BodyCopy::Text(String::from_utf8_lossy(text).into_owned())
436        }
437        BodyForm::Base64 => {
438            BodyCopy::Base64(base64::engine::general_purpose::STANDARD.encode(bytes))
439        }
440    }
441}
442
443/// Bytes a record holds besides its body and digest, for the recorder's budget: the meta
444/// (parsed JSON costs several times its text, charged generously as decision payloads
445/// are) and the masked names.
446fn record_overhead(meta: &Value, masked: &[String]) -> u64 {
447    let meta = serde_json::to_string(meta).map_or(0, |text| text.len()) as u64;
448    let masked: u64 = masked
449        .iter()
450        .map(|name| name.len().saturating_add(std::mem::size_of::<String>()) as u64)
451        .fold(0, u64::saturating_add);
452    meta.saturating_mul(2).saturating_add(masked)
453}
454
455/// Bytes a payload record holds, for the recorder's budget.
456pub(crate) fn payload_cost(record: &PayloadRecord) -> u64 {
457    let body = match &record.body {
458        Some(BodyCopy::Text(text) | BodyCopy::Base64(text)) => text.len() as u64,
459        None => 0,
460    };
461    record_overhead(&record.meta, &record.masked_headers)
462        .saturating_add(body)
463        .saturating_add(record.digest.len() as u64)
464}
465
466/// How many raw bytes of `body` to copy, given the `room` the recorder has left for the
467/// whole record, the record's `overhead` besides its digest ([`record_overhead`] of the
468/// capped meta and names), and the `max_body` cap on the copy. A cut body costs what it
469/// keeps, so the copy is sized to leave room for the rest of the record and, for a body
470/// kept as base64, its expansion. Zero when nothing fits: the body is not copied at all.
471fn body_allowance(body: &[u8], overhead: u64, room: u64, max_body: usize) -> usize {
472    let available = room
473        .saturating_sub(overhead)
474        .saturating_sub(DIGEST_HEX_BYTES);
475    let available = usize::try_from(available).unwrap_or(usize::MAX);
476    let as_text = available.min(max_body).min(body.len());
477    let probe = body.get(..as_text).unwrap_or_default();
478    if matches!(
479        body_form(probe, as_text < body.len()),
480        BodyForm::Text { .. }
481    ) {
482        return as_text;
483    }
484    // Whole base64 groups: four characters per three bytes, rounded down.
485    (available / 4).saturating_mul(3).min(max_body)
486}
487
488/// Hands one side of the call `ticket` names to the recorder, when one is installed and
489/// would keep it. `payload` runs only then, so building it costs nothing otherwise.
490pub(crate) fn record_payload<'a>(
491    store: &impl wasmtime::AsContext<Data = StoreData>,
492    ticket: Option<CallTicket>,
493    side: Side,
494    payload: impl FnOnce() -> Payload<'a>,
495) {
496    let Some(ticket) = ticket else {
497        return;
498    };
499    if let Some(recorder) = store.as_context().data().security_check.recorder()
500        && recorder.wants_payload(ticket.call_index)
501    {
502        recorder.call_payload(ticket.call_index, side, payload());
503    }
504}
505
506/// Hands the model usage of the call `ticket` names to the recorder.
507pub(crate) fn record_usage(
508    store: &impl wasmtime::AsContext<Data = StoreData>,
509    ticket: Option<CallTicket>,
510    usage: ModelUsage,
511) {
512    let Some(ticket) = ticket else {
513        return;
514    };
515    if let Some(recorder) = store.as_context().data().security_check.recorder() {
516        recorder.call_usage(ticket.call_index, usage);
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    /// What each catalog capability's calls keep beyond their timing and outcome. A
525    /// capability added to the catalog fails [`every_capability_has_a_recording_class`]
526    /// until it is placed here, so a new kind of call cannot skip the call log unnoticed.
527    ///
528    /// A redirect hop (`redirect_guard`) begins its own `http.<verb>` or `http.request` call with no
529    /// payloads: hops are timing-only calls whose decisions carry the hop, so the class
530    /// of `http.*` holds for the request's own call, not for its hops.
531    const REQUEST_AND_RESPONSE: &[&str] = &[
532        "http.get",
533        "http.post",
534        "http.put",
535        "http.patch",
536        "http.delete",
537        "http.head",
538        "http.options",
539        "http.request",
540        "http.download",
541        "llm.call",
542        "embedding.embed",
543        "mcp.<server>",
544        "tools.call",
545        "agent.run",
546        "skill.load",
547    ];
548    /// The request is the decision's context; the response is kept for whole reads and
549    /// session reads.
550    const RESPONSE_ONLY: &[&str] = &["fs.read", "session.read", "session.list"];
551    /// Writes, git, secrets, bound configuration, and the reads whose results are deferred to playback
552    /// (`stat`, `peek`, `list`, and the streaming reads): timing and outcome only. A
553    /// secret's value is never kept. `fs.exists` and `fs.size` gate as `fs.stat` and do
554    /// keep their answer, which no class requires.
555    const TIMING_ONLY: &[&str] = &[
556        "fs.stat",
557        "fs.write",
558        "fs.list",
559        "fs.mkdir",
560        "fs.remove",
561        "fs.move",
562        "fs.copy",
563        "git.init",
564        "git.clone",
565        "git.fetch",
566        "git.commit",
567        "secrets.get",
568        "variables.get",
569        "session.write",
570        "session.remove",
571        "session.finalize",
572    ];
573
574    #[test]
575    fn every_capability_has_a_recording_class() {
576        for group in crate::stdlib::capabilities::all_groups() {
577            for capability in group.capabilities {
578                let classes = [REQUEST_AND_RESPONSE, RESPONSE_ONLY, TIMING_ONLY]
579                    .iter()
580                    .filter(|class| class.contains(&capability.name))
581                    .count();
582                assert_eq!(
583                    classes, 1,
584                    "{} must be in exactly one recording class",
585                    capability.name
586                );
587            }
588        }
589    }
590
591    #[test]
592    fn every_gating_module_ends_the_calls_it_begins() {
593        for group in crate::stdlib::capabilities::all_groups() {
594            // The catalog names `@mcp/<server>` and `@tools` imports; their calls
595            // dispatch through the internal virtual-package modules.
596            let module = match group.module {
597                "@mcp" => crate::runtime::mcp::MCP_MODULE_NAME,
598                "@tools" => crate::runtime::tools::TOOLS_MODULE_NAME,
599                module => module,
600            };
601            assert!(
602                crate::runtime::host::begins_calls(module),
603                "{module} gates capabilities, so its host calls must end their calls"
604            );
605        }
606    }
607
608    /// Modules that gate without being a catalog group: `submilli:code` reads gate as
609    /// `fs.read`, and `submilli:security` gates a package's own `security.check`.
610    #[test]
611    fn gating_modules_outside_the_catalog_end_their_calls_too() {
612        for module in [
613            crate::stdlib::code::MODULE_NAME,
614            crate::stdlib::security::MODULE_NAME,
615        ] {
616            assert!(
617                crate::runtime::host::begins_calls(module),
618                "{module} gates, so its host calls must end their calls"
619            );
620        }
621        assert!(!crate::runtime::host::begins_calls("submilli:test"));
622    }
623
624    /// Every source file that gates a capability or begins a call, with the module whose
625    /// host functions run it. `None` marks the shared helpers: they run inside whichever
626    /// module calls them.
627    const CALLING_FILES: &[(&str, Option<&str>)] = &[
628        (
629            "src/stdlib/agents/mod.rs",
630            Some(crate::stdlib::agents::MODULE_NAME),
631        ),
632        (
633            "src/stdlib/skills/mod.rs",
634            Some(crate::stdlib::skills::MODULE_NAME),
635        ),
636        (
637            "src/stdlib/code/mod.rs",
638            Some(crate::stdlib::code::MODULE_NAME),
639        ),
640        (
641            "src/stdlib/code/update.rs",
642            Some(crate::stdlib::code::MODULE_NAME),
643        ),
644        (
645            "src/stdlib/embedding/mod.rs",
646            Some(crate::stdlib::embedding::MODULE_NAME),
647        ),
648        (
649            "src/stdlib/fs/install.rs",
650            Some(crate::stdlib::fs::MODULE_NAME),
651        ),
652        ("src/stdlib/fs/mod.rs", Some(crate::stdlib::fs::MODULE_NAME)),
653        (
654            "src/stdlib/fs/summary.rs",
655            Some(crate::stdlib::fs::MODULE_NAME),
656        ),
657        (
658            "src/stdlib/git/mod.rs",
659            Some(crate::stdlib::git::MODULE_NAME),
660        ),
661        (
662            "src/stdlib/http/mod.rs",
663            Some(crate::stdlib::http::MODULE_NAME),
664        ),
665        (
666            "src/stdlib/http/redirect_guard.rs",
667            Some(crate::stdlib::http::MODULE_NAME),
668        ),
669        (
670            "src/stdlib/llm/mod.rs",
671            Some(crate::stdlib::llm::MODULE_NAME),
672        ),
673        (
674            "src/stdlib/secrets.rs",
675            Some(crate::stdlib::secrets::MODULE_NAME),
676        ),
677        (
678            "src/stdlib/variables.rs",
679            Some(crate::stdlib::variables::MODULE_NAME),
680        ),
681        (
682            "src/stdlib/security.rs",
683            Some(crate::stdlib::security::MODULE_NAME),
684        ),
685        (
686            "src/stdlib/session/mod.rs",
687            Some(crate::stdlib::session::MODULE_NAME),
688        ),
689        (
690            "src/stdlib/session/finalizers.rs",
691            Some(crate::stdlib::session::MODULE_NAME),
692        ),
693        (
694            "src/runtime/mcp.rs",
695            Some(crate::runtime::mcp::MCP_MODULE_NAME),
696        ),
697        (
698            "src/runtime/tools.rs",
699            Some(crate::runtime::tools::TOOLS_MODULE_NAME),
700        ),
701        ("src/stdlib/shared.rs", None),
702    ];
703
704    fn rust_files(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
705        let Ok(entries) = std::fs::read_dir(dir) else {
706            return;
707        };
708        for entry in entries.flatten() {
709            let path = entry.path();
710            if path.is_dir() {
711                rust_files(&path, files);
712            } else if path.extension().is_some_and(|ext| ext == "rs") {
713                files.push(path);
714            }
715        }
716    }
717
718    /// A file that begins calls but whose module is not in `CALL_MODULES` would leave
719    /// its calls open until the run ends. The scan finds such a file even when it is
720    /// new; the table must then name it and its module.
721    #[test]
722    fn every_file_that_begins_calls_belongs_to_a_call_module() {
723        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
724        let mut files = Vec::new();
725        rust_files(&root.join("src/stdlib"), &mut files);
726        files.push(root.join("src/runtime/mcp.rs"));
727        files.push(root.join("src/runtime/tools.rs"));
728        let needles = [
729            "check_security_call(",
730            "check_security(",
731            "begin_call(",
732            "begin_recorded_call(",
733            "audit_entry_denial(",
734            "audit_denial_in(",
735            "authorize_capability(",
736            "check_and_audit(",
737        ];
738        let mut found = Vec::new();
739        for path in files {
740            let text = std::fs::read_to_string(&path).unwrap_or_default();
741            if needles.iter().any(|needle| text.contains(needle)) {
742                let relative = path
743                    .strip_prefix(root)
744                    .unwrap_or(&path)
745                    .to_string_lossy()
746                    .replace('\\', "/");
747                found.push(relative);
748            }
749        }
750        found.sort();
751        let mut table: Vec<&str> = CALLING_FILES.iter().map(|(file, _)| *file).collect();
752        table.sort_unstable();
753        assert_eq!(
754            found, table,
755            "a file that gates or begins calls must be listed in CALLING_FILES with its module"
756        );
757        for (file, module) in CALLING_FILES {
758            if let Some(module) = module {
759                assert!(
760                    crate::runtime::host::begins_calls(module),
761                    "{file} gates through {module}, which is missing from CALL_MODULES"
762                );
763            }
764        }
765    }
766
767    #[test]
768    fn url_credentials_are_masked_before_recording() {
769        let masked =
770            mask_url("https://user:pw@api.test:8443/v1/x?q=1&api_key=abc&Token=t&flag#frag");
771        assert_eq!(
772            masked,
773            "https://[masked]@api.test:8443/v1/x?q=1&api_key=[masked]&Token=[masked]&flag"
774        );
775        let plain = mask_url("https://api.test/a?page=2");
776        assert_eq!(plain, "https://api.test/a?page=2");
777        let encoded = mask_url("https://api.test/?access%5Ftoken=abc&x=1");
778        assert!(!encoded.contains("abc"), "{encoded}");
779        let signed =
780            mask_url("https://s3.test/o?X-Amz-Signature=sig&X-Amz-Credential=id&password=pw");
781        assert_eq!(
782            signed,
783            "https://s3.test/o?X-Amz-Signature=[masked]&X-Amz-Credential=[masked]&password=[masked]"
784        );
785        let only_user = mask_url("https://tok@api.test/");
786        assert_eq!(only_user, "https://[masked]@api.test/");
787    }
788
789    #[test]
790    fn an_unparsable_url_keeps_only_its_scheme() {
791        assert_eq!(
792            mask_url("http://u:p@exa mple.test/p?token=abc"),
793            "http://[unparsable url]"
794        );
795        // An invalid port: a fallback that kept the text before the last `@` would leak.
796        assert_eq!(
797            mask_url("https://user:abc/def@host/"),
798            "https://[unparsable url]"
799        );
800        assert_eq!(mask_url("notaurl?api_key=SECRET://x"), "[unparsable url]");
801        assert_eq!(mask_url("not a url token=abc"), "[unparsable url]");
802        assert_eq!(mask_url(""), "[unparsable url]");
803    }
804
805    #[test]
806    fn query_parameters_separate_at_semicolons_too() {
807        assert_eq!(
808            mask_url("https://h.test/?q=1;token=SECRET&x=2;api_key=K"),
809            "https://h.test/?q=1;token=[masked]&x=2;api_key=[masked]"
810        );
811    }
812
813    #[test]
814    fn masked_header_names_are_capped_in_number_and_length() {
815        let names: Vec<String> = (0..200).map(|index| format!("x-token-{index}")).collect();
816        let long = "k".repeat(10_000);
817        let payload = Payload::meta(Value::Null)
818            .with_masked(names.iter().cloned().chain([long.clone()]).collect());
819        let record = capture(&payload, 32, u64::MAX, 16);
820        assert_eq!(record.masked_headers.len(), MAX_MASKED_HEADERS);
821        assert!(record.truncated);
822        let long_record = capture(
823            &Payload::meta(Value::Null).with_masked(vec![long]),
824            32,
825            u64::MAX,
826            16,
827        );
828        assert!(long_record.masked_headers[0].len() < 200);
829        assert!(
830            payload_cost(&record)
831                >= record.masked_headers.iter().map(String::len).sum::<usize>() as u64,
832            "the names are charged"
833        );
834    }
835
836    #[test]
837    fn a_body_that_ends_mid_character_is_binary_unless_it_was_cut() {
838        let payload = Payload::meta(Value::Null).with_body(b"a\xC3");
839        let whole = capture(&payload, 16, u64::MAX, 16);
840        assert!(matches!(whole.body, Some(BodyCopy::Base64(_))));
841        assert!(!whole.truncated);
842        let longer = Payload::meta(Value::Null).with_body(b"a\xC3b");
843        let cut = capture(&longer, 16, u64::MAX, 2);
844        assert!(cut.truncated);
845        assert_eq!(cut.body, Some(BodyCopy::Text("a".into())));
846    }
847
848    #[test]
849    fn credential_headers_are_masked_by_name() {
850        let headers = [
851            ("Authorization", "Bearer t"),
852            ("x-api-key", "k"),
853            ("X-OAuth-Scopes", "repo"),
854            ("cookie", "c"),
855            ("Accept", "application/json"),
856        ]
857        .map(|(name, value)| (name.to_owned(), value.to_owned()));
858        let (value, masked) = mask_headers(&headers);
859        assert_eq!(
860            masked,
861            ["Authorization", "x-api-key", "X-OAuth-Scopes", "cookie"]
862        );
863        let text = value.to_string();
864        assert!(!text.contains("Bearer t") && !text.contains("\"k\"") && !text.contains("repo"));
865        assert!(text.contains("application/json"));
866    }
867
868    #[test]
869    fn a_capped_body_keeps_the_full_size_and_digest() {
870        let body = vec![b'a'; 100];
871        let payload =
872            Payload::meta(serde_json::json!({ "url": "https://x.test" })).with_body(&body);
873        let record = capture(&payload, 1024, u64::MAX, 10);
874        assert!(record.truncated);
875        assert_eq!(record.body, Some(BodyCopy::Text("a".repeat(10))));
876        let whole = capture(&payload, 1024, u64::MAX, 1000);
877        assert_eq!(
878            record.digest, whole.digest,
879            "the digest covers the full body"
880        );
881        assert_eq!(record.bytes, 100);
882        assert_eq!(whole.bytes, 100);
883    }
884
885    #[test]
886    fn binary_bodies_are_kept_as_base64() {
887        let payload = Payload::meta(Value::Null).with_body(&[0xff, 0x00, 0xfe]);
888        assert!(matches!(
889            capture(&payload, 16, u64::MAX, 16).body,
890            Some(BodyCopy::Base64(_))
891        ));
892    }
893
894    /// Whatever the room, a body the allowance lets through is charged within it: no copy
895    /// is made only to be dropped for the budget.
896    fn assert_a_kept_body_fits_its_room(payload: &Payload<'_>, max_meta: usize, rooms: u64) {
897        let mut kept_somewhere = false;
898        for room in 0..rooms {
899            let record = capture(payload, max_meta, room, usize::MAX);
900            if record.body.is_some() {
901                kept_somewhere = true;
902                assert!(
903                    payload_cost(&record) <= room,
904                    "room {room}: the record costs {}",
905                    payload_cost(&record)
906                );
907            }
908        }
909        assert!(
910            kept_somewhere,
911            "the sweep must reach rooms that keep a body"
912        );
913    }
914
915    #[test]
916    fn a_whole_body_ending_mid_character_is_sized_as_base64() {
917        let payload = Payload::meta(Value::Null).with_body(b"abcdefgh\xC3");
918        assert_a_kept_body_fits_its_room(&payload, 16, 200);
919    }
920
921    #[test]
922    fn a_cut_inside_the_first_character_is_sized_as_base64() {
923        let payload = Payload::meta(Value::Null).with_body("éééééééé".as_bytes());
924        assert_a_kept_body_fits_its_room(&payload, 16, 200);
925    }
926
927    #[test]
928    fn masked_names_are_sized_as_capped_not_as_given() {
929        let payload = Payload::meta(Value::Null)
930            .with_body(&[b'x'; 200])
931            .with_masked(vec!["k".repeat(10_000), "x-token".into()]);
932        assert_a_kept_body_fits_its_room(&payload, 32, 500);
933    }
934
935    #[test]
936    fn a_meta_string_just_over_its_cap_is_sized_as_capped() {
937        let payload =
938            Payload::meta(serde_json::json!({ "u": "a".repeat(33) })).with_body(&[b'x'; 200]);
939        assert_a_kept_body_fits_its_room(&payload, 32, 500);
940    }
941
942    #[test]
943    fn the_raw_body_cap_is_not_shrunk_by_the_base64_expansion() {
944        let body = vec![0xffu8; 900 * 1024];
945        let payload = Payload::meta(Value::Null).with_body(&body);
946        let record = capture(&payload, 16, 8 * 1024 * 1024, 1024 * 1024);
947        assert!(!record.truncated);
948        let Some(BodyCopy::Base64(text)) = &record.body else {
949            panic!("a binary body is kept as base64: {:?}", record.body);
950        };
951        let kept = base64::engine::general_purpose::STANDARD.decode(text);
952        assert_eq!(kept.map(|bytes| bytes.len()).ok(), Some(body.len()));
953        let cut = capture(&payload, 16, 8 * 1024 * 1024, 1024);
954        assert!(cut.truncated, "the cap still bounds the raw copy");
955    }
956
957    #[test]
958    fn a_digest_only_record_costs_what_the_constant_says() {
959        let record = capture(
960            &Payload::meta(serde_json::json!({ "a": 1 })).with_body(b"body"),
961            16,
962            u64::MAX,
963            16,
964        );
965        let bare = record.digest_only();
966        assert!(bare.body.is_none() && bare.masked_headers.is_empty());
967        assert_eq!(payload_cost(&bare), DIGEST_ONLY_COST);
968    }
969
970    #[test]
971    fn a_cut_inside_a_character_keeps_the_text_before_it() {
972        let text = "aé".as_bytes();
973        let payload = Payload::meta(Value::Null).with_body(text);
974        assert_eq!(
975            capture(&payload, 16, u64::MAX, 2).body,
976            Some(BodyCopy::Text("a".into()))
977        );
978    }
979}