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>` 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.download",
540        "llm.call",
541        "embedding.embed",
542        "mcp.<server>",
543        "tools.call",
544        "agent.run",
545        "skill.load",
546    ];
547    /// The request is the decision's context; the response is kept for whole reads and
548    /// session reads.
549    const RESPONSE_ONLY: &[&str] = &["fs.read", "session.read", "session.list"];
550    /// Writes, git, secrets, bound configuration, and the reads whose results are deferred to playback
551    /// (`stat`, `peek`, `list`, and the streaming reads): timing and outcome only. A
552    /// secret's value is never kept. `fs.exists` and `fs.size` gate as `fs.stat` and do
553    /// keep their answer, which no class requires.
554    const TIMING_ONLY: &[&str] = &[
555        "fs.stat",
556        "fs.write",
557        "fs.list",
558        "fs.mkdir",
559        "fs.remove",
560        "fs.move",
561        "fs.copy",
562        "git.init",
563        "git.clone",
564        "git.fetch",
565        "git.commit",
566        "secrets.get",
567        "variables.get",
568        "session.write",
569        "session.remove",
570        "session.finalize",
571    ];
572
573    #[test]
574    fn every_capability_has_a_recording_class() {
575        for group in crate::stdlib::capabilities::all_groups() {
576            for capability in group.capabilities {
577                let classes = [REQUEST_AND_RESPONSE, RESPONSE_ONLY, TIMING_ONLY]
578                    .iter()
579                    .filter(|class| class.contains(&capability.name))
580                    .count();
581                assert_eq!(
582                    classes, 1,
583                    "{} must be in exactly one recording class",
584                    capability.name
585                );
586            }
587        }
588    }
589
590    #[test]
591    fn every_gating_module_ends_the_calls_it_begins() {
592        for group in crate::stdlib::capabilities::all_groups() {
593            // The catalog names `@mcp/<server>` and `@tools` imports; their calls
594            // dispatch through the internal virtual-package modules.
595            let module = match group.module {
596                "@mcp" => crate::runtime::mcp::MCP_MODULE_NAME,
597                "@tools" => crate::runtime::tools::TOOLS_MODULE_NAME,
598                module => module,
599            };
600            assert!(
601                crate::runtime::host::begins_calls(module),
602                "{module} gates capabilities, so its host calls must end their calls"
603            );
604        }
605    }
606
607    /// Modules that gate without being a catalog group: `submilli:code` reads gate as
608    /// `fs.read`, and `submilli:security` gates a package's own `security.check`.
609    #[test]
610    fn gating_modules_outside_the_catalog_end_their_calls_too() {
611        for module in [
612            crate::stdlib::code::MODULE_NAME,
613            crate::stdlib::security::MODULE_NAME,
614        ] {
615            assert!(
616                crate::runtime::host::begins_calls(module),
617                "{module} gates, so its host calls must end their calls"
618            );
619        }
620        assert!(!crate::runtime::host::begins_calls("submilli:test"));
621    }
622
623    /// Every source file that gates a capability or begins a call, with the module whose
624    /// host functions run it. `None` marks the shared helpers: they run inside whichever
625    /// module calls them.
626    const CALLING_FILES: &[(&str, Option<&str>)] = &[
627        (
628            "src/stdlib/agents/mod.rs",
629            Some(crate::stdlib::agents::MODULE_NAME),
630        ),
631        (
632            "src/stdlib/skills/mod.rs",
633            Some(crate::stdlib::skills::MODULE_NAME),
634        ),
635        (
636            "src/stdlib/code/mod.rs",
637            Some(crate::stdlib::code::MODULE_NAME),
638        ),
639        (
640            "src/stdlib/embedding/mod.rs",
641            Some(crate::stdlib::embedding::MODULE_NAME),
642        ),
643        ("src/stdlib/fs/mod.rs", Some(crate::stdlib::fs::MODULE_NAME)),
644        (
645            "src/stdlib/git/mod.rs",
646            Some(crate::stdlib::git::MODULE_NAME),
647        ),
648        (
649            "src/stdlib/http/mod.rs",
650            Some(crate::stdlib::http::MODULE_NAME),
651        ),
652        (
653            "src/stdlib/http/redirect_guard.rs",
654            Some(crate::stdlib::http::MODULE_NAME),
655        ),
656        (
657            "src/stdlib/llm/mod.rs",
658            Some(crate::stdlib::llm::MODULE_NAME),
659        ),
660        (
661            "src/stdlib/secrets.rs",
662            Some(crate::stdlib::secrets::MODULE_NAME),
663        ),
664        (
665            "src/stdlib/variables.rs",
666            Some(crate::stdlib::variables::MODULE_NAME),
667        ),
668        (
669            "src/stdlib/security.rs",
670            Some(crate::stdlib::security::MODULE_NAME),
671        ),
672        (
673            "src/stdlib/session/mod.rs",
674            Some(crate::stdlib::session::MODULE_NAME),
675        ),
676        (
677            "src/stdlib/session/finalizers.rs",
678            Some(crate::stdlib::session::MODULE_NAME),
679        ),
680        (
681            "src/runtime/mcp.rs",
682            Some(crate::runtime::mcp::MCP_MODULE_NAME),
683        ),
684        (
685            "src/runtime/tools.rs",
686            Some(crate::runtime::tools::TOOLS_MODULE_NAME),
687        ),
688        ("src/stdlib/shared.rs", None),
689    ];
690
691    fn rust_files(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
692        let Ok(entries) = std::fs::read_dir(dir) else {
693            return;
694        };
695        for entry in entries.flatten() {
696            let path = entry.path();
697            if path.is_dir() {
698                rust_files(&path, files);
699            } else if path.extension().is_some_and(|ext| ext == "rs") {
700                files.push(path);
701            }
702        }
703    }
704
705    /// A file that begins calls but whose module is not in `CALL_MODULES` would leave
706    /// its calls open until the run ends. The scan finds such a file even when it is
707    /// new; the table must then name it and its module.
708    #[test]
709    fn every_file_that_begins_calls_belongs_to_a_call_module() {
710        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
711        let mut files = Vec::new();
712        rust_files(&root.join("src/stdlib"), &mut files);
713        files.push(root.join("src/runtime/mcp.rs"));
714        files.push(root.join("src/runtime/tools.rs"));
715        let needles = [
716            "check_security_call(",
717            "check_security(",
718            "begin_call(",
719            "begin_recorded_call(",
720            "audit_entry_denial(",
721            "audit_denial_in(",
722            "authorize_capability(",
723            "check_and_audit(",
724        ];
725        let mut found = Vec::new();
726        for path in files {
727            let text = std::fs::read_to_string(&path).unwrap_or_default();
728            if needles.iter().any(|needle| text.contains(needle)) {
729                let relative = path
730                    .strip_prefix(root)
731                    .unwrap_or(&path)
732                    .to_string_lossy()
733                    .replace('\\', "/");
734                found.push(relative);
735            }
736        }
737        found.sort();
738        let mut table: Vec<&str> = CALLING_FILES.iter().map(|(file, _)| *file).collect();
739        table.sort_unstable();
740        assert_eq!(
741            found, table,
742            "a file that gates or begins calls must be listed in CALLING_FILES with its module"
743        );
744        for (file, module) in CALLING_FILES {
745            if let Some(module) = module {
746                assert!(
747                    crate::runtime::host::begins_calls(module),
748                    "{file} gates through {module}, which is missing from CALL_MODULES"
749                );
750            }
751        }
752    }
753
754    #[test]
755    fn url_credentials_are_masked_before_recording() {
756        let masked =
757            mask_url("https://user:pw@api.test:8443/v1/x?q=1&api_key=abc&Token=t&flag#frag");
758        assert_eq!(
759            masked,
760            "https://[masked]@api.test:8443/v1/x?q=1&api_key=[masked]&Token=[masked]&flag"
761        );
762        let plain = mask_url("https://api.test/a?page=2");
763        assert_eq!(plain, "https://api.test/a?page=2");
764        let encoded = mask_url("https://api.test/?access%5Ftoken=abc&x=1");
765        assert!(!encoded.contains("abc"), "{encoded}");
766        let signed =
767            mask_url("https://s3.test/o?X-Amz-Signature=sig&X-Amz-Credential=id&password=pw");
768        assert_eq!(
769            signed,
770            "https://s3.test/o?X-Amz-Signature=[masked]&X-Amz-Credential=[masked]&password=[masked]"
771        );
772        let only_user = mask_url("https://tok@api.test/");
773        assert_eq!(only_user, "https://[masked]@api.test/");
774    }
775
776    #[test]
777    fn an_unparsable_url_keeps_only_its_scheme() {
778        assert_eq!(
779            mask_url("http://u:p@exa mple.test/p?token=abc"),
780            "http://[unparsable url]"
781        );
782        // An invalid port: a fallback that kept the text before the last `@` would leak.
783        assert_eq!(
784            mask_url("https://user:abc/def@host/"),
785            "https://[unparsable url]"
786        );
787        assert_eq!(mask_url("notaurl?api_key=SECRET://x"), "[unparsable url]");
788        assert_eq!(mask_url("not a url token=abc"), "[unparsable url]");
789        assert_eq!(mask_url(""), "[unparsable url]");
790    }
791
792    #[test]
793    fn query_parameters_separate_at_semicolons_too() {
794        assert_eq!(
795            mask_url("https://h.test/?q=1;token=SECRET&x=2;api_key=K"),
796            "https://h.test/?q=1;token=[masked]&x=2;api_key=[masked]"
797        );
798    }
799
800    #[test]
801    fn masked_header_names_are_capped_in_number_and_length() {
802        let names: Vec<String> = (0..200).map(|index| format!("x-token-{index}")).collect();
803        let long = "k".repeat(10_000);
804        let payload = Payload::meta(Value::Null)
805            .with_masked(names.iter().cloned().chain([long.clone()]).collect());
806        let record = capture(&payload, 32, u64::MAX, 16);
807        assert_eq!(record.masked_headers.len(), MAX_MASKED_HEADERS);
808        assert!(record.truncated);
809        let long_record = capture(
810            &Payload::meta(Value::Null).with_masked(vec![long]),
811            32,
812            u64::MAX,
813            16,
814        );
815        assert!(long_record.masked_headers[0].len() < 200);
816        assert!(
817            payload_cost(&record)
818                >= record.masked_headers.iter().map(String::len).sum::<usize>() as u64,
819            "the names are charged"
820        );
821    }
822
823    #[test]
824    fn a_body_that_ends_mid_character_is_binary_unless_it_was_cut() {
825        let payload = Payload::meta(Value::Null).with_body(b"a\xC3");
826        let whole = capture(&payload, 16, u64::MAX, 16);
827        assert!(matches!(whole.body, Some(BodyCopy::Base64(_))));
828        assert!(!whole.truncated);
829        let longer = Payload::meta(Value::Null).with_body(b"a\xC3b");
830        let cut = capture(&longer, 16, u64::MAX, 2);
831        assert!(cut.truncated);
832        assert_eq!(cut.body, Some(BodyCopy::Text("a".into())));
833    }
834
835    #[test]
836    fn credential_headers_are_masked_by_name() {
837        let headers = [
838            ("Authorization", "Bearer t"),
839            ("x-api-key", "k"),
840            ("X-OAuth-Scopes", "repo"),
841            ("cookie", "c"),
842            ("Accept", "application/json"),
843        ]
844        .map(|(name, value)| (name.to_owned(), value.to_owned()));
845        let (value, masked) = mask_headers(&headers);
846        assert_eq!(
847            masked,
848            ["Authorization", "x-api-key", "X-OAuth-Scopes", "cookie"]
849        );
850        let text = value.to_string();
851        assert!(!text.contains("Bearer t") && !text.contains("\"k\"") && !text.contains("repo"));
852        assert!(text.contains("application/json"));
853    }
854
855    #[test]
856    fn a_capped_body_keeps_the_full_size_and_digest() {
857        let body = vec![b'a'; 100];
858        let payload =
859            Payload::meta(serde_json::json!({ "url": "https://x.test" })).with_body(&body);
860        let record = capture(&payload, 1024, u64::MAX, 10);
861        assert!(record.truncated);
862        assert_eq!(record.body, Some(BodyCopy::Text("a".repeat(10))));
863        let whole = capture(&payload, 1024, u64::MAX, 1000);
864        assert_eq!(
865            record.digest, whole.digest,
866            "the digest covers the full body"
867        );
868        assert_eq!(record.bytes, 100);
869        assert_eq!(whole.bytes, 100);
870    }
871
872    #[test]
873    fn binary_bodies_are_kept_as_base64() {
874        let payload = Payload::meta(Value::Null).with_body(&[0xff, 0x00, 0xfe]);
875        assert!(matches!(
876            capture(&payload, 16, u64::MAX, 16).body,
877            Some(BodyCopy::Base64(_))
878        ));
879    }
880
881    /// Whatever the room, a body the allowance lets through is charged within it: no copy
882    /// is made only to be dropped for the budget.
883    fn assert_a_kept_body_fits_its_room(payload: &Payload<'_>, max_meta: usize, rooms: u64) {
884        let mut kept_somewhere = false;
885        for room in 0..rooms {
886            let record = capture(payload, max_meta, room, usize::MAX);
887            if record.body.is_some() {
888                kept_somewhere = true;
889                assert!(
890                    payload_cost(&record) <= room,
891                    "room {room}: the record costs {}",
892                    payload_cost(&record)
893                );
894            }
895        }
896        assert!(
897            kept_somewhere,
898            "the sweep must reach rooms that keep a body"
899        );
900    }
901
902    #[test]
903    fn a_whole_body_ending_mid_character_is_sized_as_base64() {
904        let payload = Payload::meta(Value::Null).with_body(b"abcdefgh\xC3");
905        assert_a_kept_body_fits_its_room(&payload, 16, 200);
906    }
907
908    #[test]
909    fn a_cut_inside_the_first_character_is_sized_as_base64() {
910        let payload = Payload::meta(Value::Null).with_body("éééééééé".as_bytes());
911        assert_a_kept_body_fits_its_room(&payload, 16, 200);
912    }
913
914    #[test]
915    fn masked_names_are_sized_as_capped_not_as_given() {
916        let payload = Payload::meta(Value::Null)
917            .with_body(&[b'x'; 200])
918            .with_masked(vec!["k".repeat(10_000), "x-token".into()]);
919        assert_a_kept_body_fits_its_room(&payload, 32, 500);
920    }
921
922    #[test]
923    fn a_meta_string_just_over_its_cap_is_sized_as_capped() {
924        let payload =
925            Payload::meta(serde_json::json!({ "u": "a".repeat(33) })).with_body(&[b'x'; 200]);
926        assert_a_kept_body_fits_its_room(&payload, 32, 500);
927    }
928
929    #[test]
930    fn the_raw_body_cap_is_not_shrunk_by_the_base64_expansion() {
931        let body = vec![0xffu8; 900 * 1024];
932        let payload = Payload::meta(Value::Null).with_body(&body);
933        let record = capture(&payload, 16, 8 * 1024 * 1024, 1024 * 1024);
934        assert!(!record.truncated);
935        let Some(BodyCopy::Base64(text)) = &record.body else {
936            panic!("a binary body is kept as base64: {:?}", record.body);
937        };
938        let kept = base64::engine::general_purpose::STANDARD.decode(text);
939        assert_eq!(kept.map(|bytes| bytes.len()).ok(), Some(body.len()));
940        let cut = capture(&payload, 16, 8 * 1024 * 1024, 1024);
941        assert!(cut.truncated, "the cap still bounds the raw copy");
942    }
943
944    #[test]
945    fn a_digest_only_record_costs_what_the_constant_says() {
946        let record = capture(
947            &Payload::meta(serde_json::json!({ "a": 1 })).with_body(b"body"),
948            16,
949            u64::MAX,
950            16,
951        );
952        let bare = record.digest_only();
953        assert!(bare.body.is_none() && bare.masked_headers.is_empty());
954        assert_eq!(payload_cost(&bare), DIGEST_ONLY_COST);
955    }
956
957    #[test]
958    fn a_cut_inside_a_character_keeps_the_text_before_it() {
959        let text = "aé".as_bytes();
960        let payload = Payload::meta(Value::Null).with_body(text);
961        assert_eq!(
962            capture(&payload, 16, u64::MAX, 2).body,
963            Some(BodyCopy::Text("a".into()))
964        );
965    }
966}