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/embedding/mod.rs",
642            Some(crate::stdlib::embedding::MODULE_NAME),
643        ),
644        (
645            "src/stdlib/fs/install.rs",
646            Some(crate::stdlib::fs::MODULE_NAME),
647        ),
648        ("src/stdlib/fs/mod.rs", Some(crate::stdlib::fs::MODULE_NAME)),
649        (
650            "src/stdlib/fs/summary.rs",
651            Some(crate::stdlib::fs::MODULE_NAME),
652        ),
653        (
654            "src/stdlib/git/mod.rs",
655            Some(crate::stdlib::git::MODULE_NAME),
656        ),
657        (
658            "src/stdlib/http/mod.rs",
659            Some(crate::stdlib::http::MODULE_NAME),
660        ),
661        (
662            "src/stdlib/http/redirect_guard.rs",
663            Some(crate::stdlib::http::MODULE_NAME),
664        ),
665        (
666            "src/stdlib/llm/mod.rs",
667            Some(crate::stdlib::llm::MODULE_NAME),
668        ),
669        (
670            "src/stdlib/secrets.rs",
671            Some(crate::stdlib::secrets::MODULE_NAME),
672        ),
673        (
674            "src/stdlib/variables.rs",
675            Some(crate::stdlib::variables::MODULE_NAME),
676        ),
677        (
678            "src/stdlib/security.rs",
679            Some(crate::stdlib::security::MODULE_NAME),
680        ),
681        (
682            "src/stdlib/session/mod.rs",
683            Some(crate::stdlib::session::MODULE_NAME),
684        ),
685        (
686            "src/stdlib/session/finalizers.rs",
687            Some(crate::stdlib::session::MODULE_NAME),
688        ),
689        (
690            "src/runtime/mcp.rs",
691            Some(crate::runtime::mcp::MCP_MODULE_NAME),
692        ),
693        (
694            "src/runtime/tools.rs",
695            Some(crate::runtime::tools::TOOLS_MODULE_NAME),
696        ),
697        ("src/stdlib/shared.rs", None),
698    ];
699
700    fn rust_files(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
701        let Ok(entries) = std::fs::read_dir(dir) else {
702            return;
703        };
704        for entry in entries.flatten() {
705            let path = entry.path();
706            if path.is_dir() {
707                rust_files(&path, files);
708            } else if path.extension().is_some_and(|ext| ext == "rs") {
709                files.push(path);
710            }
711        }
712    }
713
714    /// A file that begins calls but whose module is not in `CALL_MODULES` would leave
715    /// its calls open until the run ends. The scan finds such a file even when it is
716    /// new; the table must then name it and its module.
717    #[test]
718    fn every_file_that_begins_calls_belongs_to_a_call_module() {
719        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
720        let mut files = Vec::new();
721        rust_files(&root.join("src/stdlib"), &mut files);
722        files.push(root.join("src/runtime/mcp.rs"));
723        files.push(root.join("src/runtime/tools.rs"));
724        let needles = [
725            "check_security_call(",
726            "check_security(",
727            "begin_call(",
728            "begin_recorded_call(",
729            "audit_entry_denial(",
730            "audit_denial_in(",
731            "authorize_capability(",
732            "check_and_audit(",
733        ];
734        let mut found = Vec::new();
735        for path in files {
736            let text = std::fs::read_to_string(&path).unwrap_or_default();
737            if needles.iter().any(|needle| text.contains(needle)) {
738                let relative = path
739                    .strip_prefix(root)
740                    .unwrap_or(&path)
741                    .to_string_lossy()
742                    .replace('\\', "/");
743                found.push(relative);
744            }
745        }
746        found.sort();
747        let mut table: Vec<&str> = CALLING_FILES.iter().map(|(file, _)| *file).collect();
748        table.sort_unstable();
749        assert_eq!(
750            found, table,
751            "a file that gates or begins calls must be listed in CALLING_FILES with its module"
752        );
753        for (file, module) in CALLING_FILES {
754            if let Some(module) = module {
755                assert!(
756                    crate::runtime::host::begins_calls(module),
757                    "{file} gates through {module}, which is missing from CALL_MODULES"
758                );
759            }
760        }
761    }
762
763    #[test]
764    fn url_credentials_are_masked_before_recording() {
765        let masked =
766            mask_url("https://user:pw@api.test:8443/v1/x?q=1&api_key=abc&Token=t&flag#frag");
767        assert_eq!(
768            masked,
769            "https://[masked]@api.test:8443/v1/x?q=1&api_key=[masked]&Token=[masked]&flag"
770        );
771        let plain = mask_url("https://api.test/a?page=2");
772        assert_eq!(plain, "https://api.test/a?page=2");
773        let encoded = mask_url("https://api.test/?access%5Ftoken=abc&x=1");
774        assert!(!encoded.contains("abc"), "{encoded}");
775        let signed =
776            mask_url("https://s3.test/o?X-Amz-Signature=sig&X-Amz-Credential=id&password=pw");
777        assert_eq!(
778            signed,
779            "https://s3.test/o?X-Amz-Signature=[masked]&X-Amz-Credential=[masked]&password=[masked]"
780        );
781        let only_user = mask_url("https://tok@api.test/");
782        assert_eq!(only_user, "https://[masked]@api.test/");
783    }
784
785    #[test]
786    fn an_unparsable_url_keeps_only_its_scheme() {
787        assert_eq!(
788            mask_url("http://u:p@exa mple.test/p?token=abc"),
789            "http://[unparsable url]"
790        );
791        // An invalid port: a fallback that kept the text before the last `@` would leak.
792        assert_eq!(
793            mask_url("https://user:abc/def@host/"),
794            "https://[unparsable url]"
795        );
796        assert_eq!(mask_url("notaurl?api_key=SECRET://x"), "[unparsable url]");
797        assert_eq!(mask_url("not a url token=abc"), "[unparsable url]");
798        assert_eq!(mask_url(""), "[unparsable url]");
799    }
800
801    #[test]
802    fn query_parameters_separate_at_semicolons_too() {
803        assert_eq!(
804            mask_url("https://h.test/?q=1;token=SECRET&x=2;api_key=K"),
805            "https://h.test/?q=1;token=[masked]&x=2;api_key=[masked]"
806        );
807    }
808
809    #[test]
810    fn masked_header_names_are_capped_in_number_and_length() {
811        let names: Vec<String> = (0..200).map(|index| format!("x-token-{index}")).collect();
812        let long = "k".repeat(10_000);
813        let payload = Payload::meta(Value::Null)
814            .with_masked(names.iter().cloned().chain([long.clone()]).collect());
815        let record = capture(&payload, 32, u64::MAX, 16);
816        assert_eq!(record.masked_headers.len(), MAX_MASKED_HEADERS);
817        assert!(record.truncated);
818        let long_record = capture(
819            &Payload::meta(Value::Null).with_masked(vec![long]),
820            32,
821            u64::MAX,
822            16,
823        );
824        assert!(long_record.masked_headers[0].len() < 200);
825        assert!(
826            payload_cost(&record)
827                >= record.masked_headers.iter().map(String::len).sum::<usize>() as u64,
828            "the names are charged"
829        );
830    }
831
832    #[test]
833    fn a_body_that_ends_mid_character_is_binary_unless_it_was_cut() {
834        let payload = Payload::meta(Value::Null).with_body(b"a\xC3");
835        let whole = capture(&payload, 16, u64::MAX, 16);
836        assert!(matches!(whole.body, Some(BodyCopy::Base64(_))));
837        assert!(!whole.truncated);
838        let longer = Payload::meta(Value::Null).with_body(b"a\xC3b");
839        let cut = capture(&longer, 16, u64::MAX, 2);
840        assert!(cut.truncated);
841        assert_eq!(cut.body, Some(BodyCopy::Text("a".into())));
842    }
843
844    #[test]
845    fn credential_headers_are_masked_by_name() {
846        let headers = [
847            ("Authorization", "Bearer t"),
848            ("x-api-key", "k"),
849            ("X-OAuth-Scopes", "repo"),
850            ("cookie", "c"),
851            ("Accept", "application/json"),
852        ]
853        .map(|(name, value)| (name.to_owned(), value.to_owned()));
854        let (value, masked) = mask_headers(&headers);
855        assert_eq!(
856            masked,
857            ["Authorization", "x-api-key", "X-OAuth-Scopes", "cookie"]
858        );
859        let text = value.to_string();
860        assert!(!text.contains("Bearer t") && !text.contains("\"k\"") && !text.contains("repo"));
861        assert!(text.contains("application/json"));
862    }
863
864    #[test]
865    fn a_capped_body_keeps_the_full_size_and_digest() {
866        let body = vec![b'a'; 100];
867        let payload =
868            Payload::meta(serde_json::json!({ "url": "https://x.test" })).with_body(&body);
869        let record = capture(&payload, 1024, u64::MAX, 10);
870        assert!(record.truncated);
871        assert_eq!(record.body, Some(BodyCopy::Text("a".repeat(10))));
872        let whole = capture(&payload, 1024, u64::MAX, 1000);
873        assert_eq!(
874            record.digest, whole.digest,
875            "the digest covers the full body"
876        );
877        assert_eq!(record.bytes, 100);
878        assert_eq!(whole.bytes, 100);
879    }
880
881    #[test]
882    fn binary_bodies_are_kept_as_base64() {
883        let payload = Payload::meta(Value::Null).with_body(&[0xff, 0x00, 0xfe]);
884        assert!(matches!(
885            capture(&payload, 16, u64::MAX, 16).body,
886            Some(BodyCopy::Base64(_))
887        ));
888    }
889
890    /// Whatever the room, a body the allowance lets through is charged within it: no copy
891    /// is made only to be dropped for the budget.
892    fn assert_a_kept_body_fits_its_room(payload: &Payload<'_>, max_meta: usize, rooms: u64) {
893        let mut kept_somewhere = false;
894        for room in 0..rooms {
895            let record = capture(payload, max_meta, room, usize::MAX);
896            if record.body.is_some() {
897                kept_somewhere = true;
898                assert!(
899                    payload_cost(&record) <= room,
900                    "room {room}: the record costs {}",
901                    payload_cost(&record)
902                );
903            }
904        }
905        assert!(
906            kept_somewhere,
907            "the sweep must reach rooms that keep a body"
908        );
909    }
910
911    #[test]
912    fn a_whole_body_ending_mid_character_is_sized_as_base64() {
913        let payload = Payload::meta(Value::Null).with_body(b"abcdefgh\xC3");
914        assert_a_kept_body_fits_its_room(&payload, 16, 200);
915    }
916
917    #[test]
918    fn a_cut_inside_the_first_character_is_sized_as_base64() {
919        let payload = Payload::meta(Value::Null).with_body("éééééééé".as_bytes());
920        assert_a_kept_body_fits_its_room(&payload, 16, 200);
921    }
922
923    #[test]
924    fn masked_names_are_sized_as_capped_not_as_given() {
925        let payload = Payload::meta(Value::Null)
926            .with_body(&[b'x'; 200])
927            .with_masked(vec!["k".repeat(10_000), "x-token".into()]);
928        assert_a_kept_body_fits_its_room(&payload, 32, 500);
929    }
930
931    #[test]
932    fn a_meta_string_just_over_its_cap_is_sized_as_capped() {
933        let payload =
934            Payload::meta(serde_json::json!({ "u": "a".repeat(33) })).with_body(&[b'x'; 200]);
935        assert_a_kept_body_fits_its_room(&payload, 32, 500);
936    }
937
938    #[test]
939    fn the_raw_body_cap_is_not_shrunk_by_the_base64_expansion() {
940        let body = vec![0xffu8; 900 * 1024];
941        let payload = Payload::meta(Value::Null).with_body(&body);
942        let record = capture(&payload, 16, 8 * 1024 * 1024, 1024 * 1024);
943        assert!(!record.truncated);
944        let Some(BodyCopy::Base64(text)) = &record.body else {
945            panic!("a binary body is kept as base64: {:?}", record.body);
946        };
947        let kept = base64::engine::general_purpose::STANDARD.decode(text);
948        assert_eq!(kept.map(|bytes| bytes.len()).ok(), Some(body.len()));
949        let cut = capture(&payload, 16, 8 * 1024 * 1024, 1024);
950        assert!(cut.truncated, "the cap still bounds the raw copy");
951    }
952
953    #[test]
954    fn a_digest_only_record_costs_what_the_constant_says() {
955        let record = capture(
956            &Payload::meta(serde_json::json!({ "a": 1 })).with_body(b"body"),
957            16,
958            u64::MAX,
959            16,
960        );
961        let bare = record.digest_only();
962        assert!(bare.body.is_none() && bare.masked_headers.is_empty());
963        assert_eq!(payload_cost(&bare), DIGEST_ONLY_COST);
964    }
965
966    #[test]
967    fn a_cut_inside_a_character_keeps_the_text_before_it() {
968        let text = "aé".as_bytes();
969        let payload = Payload::meta(Value::Null).with_body(text);
970        assert_eq!(
971            capture(&payload, 16, u64::MAX, 2).body,
972            Some(BodyCopy::Text("a".into()))
973        );
974    }
975}