Skip to main content

submilli_engine/runtime/
decision.rs

1//! Decision records: what an embedder's recorder sees of each capability decision.
2//!
3//! The recorder is a [`SecurityCheck`] decorator ([`DecisionLog::install`]): it forwards
4//! every call to the check it wraps, including `audit`, so the embedder's own audit sees
5//! exactly what it would without a recorder, and taps the same `audit` calls. Recording
6//! never changes a decision, never fails a call, and never charges guest fuel.
7//!
8//! Observation work (the source-line backtrace per call) is never charged to guest fuel;
9//! see [`DecisionLogConfig::max_line_capture_frames`] for why and what bounds it. Lines stop
10//! being captured when the decision cap is reached, when the byte budget cannot fit even a
11//! bare record, after a record has been dropped whole for want of bytes, or when that frame
12//! budget is exceeded. A later, smaller record may still be kept after such a drop, without
13//! a line. Other truncation (a byte-budget payload drop, the pair cap) does not stop them.
14//!
15//! Record buffers are charged against the recorder's own byte budget, separate from the
16//! run's memory limit so that recording never changes a run's memory behavior, and a
17//! per-run decision cap; a run that reaches either is marked truncated rather than refused.
18
19use std::collections::HashMap;
20use std::sync::atomic::{AtomicU64, Ordering};
21use std::sync::{Arc, Mutex, MutexGuard};
22use std::time::Instant;
23
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26
27use super::StoreData;
28use super::call_log::{self, CallOutcome, CallRecord, ModelUsage, Payload, Side};
29use super::security::{AuditDecision, CheckOutcome, SecurityCheck};
30
31/// What the policy decided. `AskHuman` is a denial today (the approval flow is deferred),
32/// kept distinct so a record can say "deferred".
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "kebab-case")]
35pub enum DecisionAction {
36    Allow,
37    Deny,
38    AskHuman,
39}
40
41/// A rule located by caller block and zero-based position.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct RuleCitation {
44    pub caller: String,
45    pub index: usize,
46    pub name: Option<String>,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50#[serde(rename_all = "kebab-case", tag = "kind")]
51pub enum DecisionCause {
52    /// A rule matched.
53    Rule(RuleCitation),
54    /// No rule matched. `caller_block` is whether the caller has any rules.
55    Default { caller_block: bool },
56    /// A runtime invariant refused the call ahead of, or instead of, the policy:
57    /// an unattributable caller, `main` calling a `main_denial` capability, a read-only
58    /// volume, a quota or egress refusal, or a path the policy could not normalize.
59    RuntimeInvariant { reason: String },
60    /// The installed policy does not explain its decisions.
61    Unexplained,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "kebab-case", tag = "kind", content = "variable")]
66pub enum FailureReasonRecord {
67    FieldMissing,
68    VariableNotBound(String),
69    NotSatisfied,
70}
71
72/// One comparison that kept a rule's filter from matching.
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct FailureRecord {
75    pub comparison: String,
76    pub actual: Option<Value>,
77    pub expected: Option<String>,
78    pub reason: FailureReasonRecord,
79    pub negated: bool,
80}
81
82/// A rule that named the capability but whose filter rejected the call.
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub struct NearMissRecord {
85    pub rule: RuleCitation,
86    pub filter: String,
87    pub failures: Vec<FailureRecord>,
88}
89
90/// The policy's reasoning for one decision.
91#[derive(Debug, Clone, PartialEq, Serialize)]
92pub struct DecisionExplanation {
93    pub action: DecisionAction,
94    pub cause: DecisionCause,
95    pub near_misses: Vec<NearMissRecord>,
96}
97
98/// Which seam produced a decision.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
100#[serde(rename_all = "kebab-case", tag = "kind")]
101pub enum EntryPath {
102    /// A gated standard-library operation.
103    #[default]
104    GatedOp,
105    /// A package's `security.check` call, attributed to its consumer.
106    PackageCheck,
107    /// A redirect hop of `parent_call_index`'s request; `index` counts its hops from 0.
108    RedirectHop {
109        parent_call_index: u64,
110        index: u32,
111    },
112    Git,
113    /// A file tool of the server, outside any program run.
114    FileTool,
115}
116
117/// A position in the submitted program.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119pub struct SourceLine {
120    pub line: u32,
121    pub column: Option<u32>,
122}
123
124/// Identity of one host call, assigned when it begins.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
126pub struct CallTicket {
127    /// Run-wide, in call order; counts every call the recorder saw.
128    pub call_index: u64,
129    /// Counts the calls of this (caller, capability) pair in the run, denials included,
130    /// from 1. Zero when the recorder stopped tracking pairs at its cap.
131    pub seq: u64,
132    /// Microseconds since the recorder was installed, from a clock that only moves forward.
133    pub at_micros: u64,
134    pub line: Option<SourceLine>,
135}
136
137/// The call a decision belongs to and how it arrived.
138#[derive(Debug, Clone, Copy, Default)]
139pub struct CallSite {
140    pub ticket: Option<CallTicket>,
141    pub entry: EntryPath,
142}
143
144impl CallSite {
145    pub fn new(ticket: Option<CallTicket>, entry: EntryPath) -> Self {
146        Self { ticket, entry }
147    }
148}
149
150/// The runtime's side of a recorder: identity for each call. Record contents arrive through
151/// [`SecurityCheck::audit`].
152pub trait DecisionRecorder: Send + Sync {
153    /// A new host call. `line` is the program line that led to it, when known.
154    fn begin_call(&self, caller: &str, capability: &str, line: Option<SourceLine>) -> CallTicket;
155    /// Marks the latest call's record as a denial the host function treated as a filter.
156    fn mark_last_filtered(&self);
157    /// Whether the next call's source line would be kept. Callers check it before paying
158    /// for a backtrace; it is false once the decision cap is reached, the byte budget cannot
159    /// fit even a bare record, a record has been dropped whole for want of bytes, or the
160    /// frame budget is exceeded. Other truncation (a payload drop, the pair cap) does not
161    /// turn it off.
162    fn wants_line(&self) -> bool;
163    /// Counts the frames of a backtrace captured for a line. Past the recorder's frame
164    /// budget it marks the run truncated and stops further captures.
165    fn note_line_capture(&self, frames: usize);
166    /// A host function that can begin calls was entered. Returns the marker its
167    /// [`exit_host_call`](Self::exit_host_call) passes back.
168    fn enter_host_call(&self) -> u64;
169    /// That host function returned (`returned`) or failed. Ends every call begun since
170    /// `marker` that is still open; a nested host function has ended its own already.
171    fn exit_host_call(&self, marker: u64, returned: bool);
172    /// Whether a payload for the call `call_index` would be kept: the call is open and the
173    /// payload budget is not spent. Callers check it before building a payload. The
174    /// default asks for every payload.
175    fn wants_payload(&self, _call_index: u64) -> bool {
176        true
177    }
178    /// One side of a call that reached outside the program.
179    fn call_payload(&self, call_index: u64, side: Side, payload: Payload<'_>);
180    /// Token counts a model provider reported for a call.
181    fn call_usage(&self, call_index: u64, usage: ModelUsage);
182}
183
184/// Sees records as they are made, for an embedder that streams a run while it executes.
185///
186/// Called on the thread running the program, after the recorder has released its lock.
187/// It must not block: an implementation that cannot keep up drops what it cannot hold.
188/// What it sees is also in the [`DecisionLogOutput`] the run ends with, unless the
189/// recorder's own caps dropped it there.
190pub trait RecordObserver: Send + Sync {
191    fn call_started(&self, _call: &CallRecord) {}
192    fn decision(&self, _record: &DecisionRecord) {}
193    /// The call as it ended, without its payload contents: each side's meta is `Null`
194    /// and it has no body and no masked header names, but it keeps its size, digest,
195    /// truncation flag, timing, and usage.
196    fn call_finished(&self, _call: &CallRecord) {}
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct DecisionRecord {
201    pub call_index: u64,
202    pub seq: u64,
203    pub at_micros: u64,
204    pub caller: String,
205    pub capability: String,
206    /// Capped; see [`DecisionLogConfig::max_context_value_bytes`].
207    pub context: Value,
208    pub context_truncated: bool,
209    /// Hash of the full context, kept when the payload copy is dropped.
210    pub context_digest: u64,
211    pub allowed: bool,
212    pub action: DecisionAction,
213    pub cause: DecisionCause,
214    pub near_misses: Vec<NearMissRecord>,
215    /// The audit's source: `policy`, `invariant`, `read_only`, `quota`, `egress_guard`.
216    pub source: String,
217    pub rule: Option<usize>,
218    pub reason: Option<String>,
219    pub entry_path: EntryPath,
220    pub line: Option<SourceLine>,
221    /// A denial the host function swallowed to filter a listing.
222    pub filtered: bool,
223    /// The context and near misses were dropped to stay within the recorder's byte budget.
224    pub payload_dropped: bool,
225}
226
227#[derive(Debug, Clone)]
228pub struct DecisionLogConfig {
229    /// Decisions kept per run; later ones are counted and mark the run truncated.
230    pub max_decisions: usize,
231    /// Longest string kept in a recorded context; longer ones end in a marker.
232    pub max_context_value_bytes: usize,
233    /// Bytes the recorder may hold, independent of the run's memory limit. Past it, payloads
234    /// are dropped (verdict and digest kept), then whole records.
235    pub max_recorder_bytes: u64,
236    /// Wasm frames the recorder may walk, summed over the run's source-line captures.
237    ///
238    /// Line capture is observation work, deliberately left uncharged to guest fuel so that
239    /// recording never changes a run: a recorded run spends exactly the fuel an unrecorded
240    /// one does (R19). That deviates from AGENTS.md, "Fuel for host functions", which asks
241    /// host work to be charged; this budget bounds the work instead, together with the
242    /// decision cap. The fuel plan's "Gated functions call `check_security`" bullet
243    /// (`plans/sub-1269-host-fuel-costs.md`) is the written record of the deviation.
244    ///
245    /// The budget is checked before each capture, so it can be exceeded by at most one
246    /// stack: the capture that crosses it keeps its line. Past it no new line is captured
247    /// and the run is marked truncated (a redirect hop or git worker check may still carry
248    /// a line captured earlier).
249    pub max_line_capture_frames: u64,
250    /// Calls kept per run; later ones are counted and mark the run truncated.
251    pub max_calls: usize,
252    /// Longest body copy a call's request or response keeps. Its digest and size always
253    /// describe the full body.
254    pub max_payload_bytes: usize,
255}
256
257impl Default for DecisionLogConfig {
258    fn default() -> Self {
259        Self {
260            max_decisions: 10_000,
261            max_context_value_bytes: 1024,
262            max_recorder_bytes: 16 * 1024 * 1024,
263            max_line_capture_frames: 1_000_000,
264            max_calls: 10_000,
265            max_payload_bytes: 1024 * 1024,
266        }
267    }
268}
269
270/// What a run recorded.
271#[derive(Debug, Clone, Default)]
272pub struct DecisionLogOutput {
273    pub records: Vec<DecisionRecord>,
274    /// Some decision was not kept in full: past the cap, over the byte budget, beyond the
275    /// pairs the recorder tracks, or the line-capture frame budget was exceeded (after which
276    /// no new lines are captured).
277    pub truncated: bool,
278    /// Decisions not kept at all.
279    pub dropped: u64,
280    /// Wasm frames walked by line captures over the run (diagnostic).
281    pub line_frames: u64,
282    /// The run's calls in the order they began.
283    pub calls: Vec<CallRecord>,
284    /// Calls not kept at all, past the cap or the byte budget.
285    pub calls_dropped: u64,
286}
287
288/// Distinct (caller, capability) pairs tracked per run. Capability names come from
289/// `security.check` calls, so the set is not bounded by the catalog.
290const MAX_TRACKED_PAIRS: usize = 4096;
291const PAIR_OVERHEAD_BYTES: u64 = 96;
292const RECORD_BASE_BYTES: u64 = std::mem::size_of::<DecisionRecord>() as u64;
293const CALL_BASE_BYTES: u64 = std::mem::size_of::<CallRecord>() as u64;
294/// The share of the recorder's byte budget, as a divisor, kept for what must survive a
295/// run that moves a lot of data. One rule: payload bodies and metas stop at the reserve;
296/// the digest-only record of a payload whose copy was dropped, and decision records, may
297/// use it.
298const RECORD_RESERVE_DIVISOR: u64 = 4;
299const MAX_CONTEXT_DEPTH: usize = 8;
300const MAX_CONTEXT_ENTRIES: usize = 64;
301
302#[derive(Default)]
303struct PairState {
304    count: u64,
305    last: Option<CallTicket>,
306}
307
308#[derive(Default)]
309struct LogState {
310    records: Vec<DecisionRecord>,
311    pairs: HashMap<(String, String), PairState>,
312    next_call_index: u64,
313    last_call_index: Option<u64>,
314    truncated: bool,
315    dropped: u64,
316    charged: u64,
317    line_frames: u64,
318    /// The frame budget was exceeded: no new lines are captured.
319    lines_exhausted: bool,
320    /// A record was dropped whole for want of bytes: no new lines are captured. A smaller
321    /// later record may still be kept, without a line.
322    bytes_exhausted: bool,
323    calls: Vec<CallRecord>,
324    /// Position in `calls` of each call still running, in call order.
325    open_calls: Vec<(u64, usize)>,
326    calls_dropped: u64,
327}
328
329/// The per-run recorder.
330pub struct DecisionLog {
331    state: Mutex<LogState>,
332    config: DecisionLogConfig,
333    started: Instant,
334    clock: AtomicU64,
335    observer: Option<Arc<dyn RecordObserver>>,
336}
337
338impl DecisionLog {
339    /// Wraps the store's security check in a recording decorator. Returns the log to read
340    /// results from.
341    ///
342    /// Install it outermost: after any embedder wrapper of the check (such as the server's
343    /// audit decorator). Other wrappers do not forward [`SecurityCheck::recorder`], so a
344    /// recorder installed beneath one is invisible to the host functions that begin calls.
345    pub fn install(data: &mut StoreData, config: DecisionLogConfig) -> Arc<Self> {
346        Self::install_observed(data, config, None)
347    }
348
349    /// [`install`](Self::install), with an observer that sees each record as it is made.
350    pub fn install_observed(
351        data: &mut StoreData,
352        config: DecisionLogConfig,
353        observer: Option<Arc<dyn RecordObserver>>,
354    ) -> Arc<Self> {
355        let log = Self::new(config, observer);
356        data.security_check = log.wrap(data.security_check.clone());
357        log
358    }
359
360    /// A log not yet attached to a run; [`wrap`](Self::wrap) a check to record through it.
361    /// The clock starts now.
362    pub fn new(config: DecisionLogConfig, observer: Option<Arc<dyn RecordObserver>>) -> Arc<Self> {
363        Arc::new(Self {
364            state: Mutex::new(LogState::default()),
365            config,
366            started: Instant::now(),
367            clock: AtomicU64::new(0),
368            observer,
369        })
370    }
371
372    /// `inner`, with every decision it audits also recorded here. For a check made outside
373    /// a program run, such as a server's file tool; a run uses [`install`](Self::install).
374    pub fn wrap(self: &Arc<Self>, inner: Arc<dyn SecurityCheck>) -> Arc<dyn SecurityCheck> {
375        Arc::new(RecordingCheck {
376            inner,
377            log: self.clone(),
378        })
379    }
380
381    /// Takes the run's records and releases their byte charge. Call once, when the
382    /// run ends: the truncation flags, drop count, and call numbering describe the
383    /// whole run, so a later call would not describe only its own records.
384    pub fn finish(&self) -> DecisionLogOutput {
385        let mut state = self.lock();
386        let records = std::mem::take(&mut state.records);
387        for (_, position) in std::mem::take(&mut state.open_calls) {
388            if let Some(call) = state.calls.get_mut(position) {
389                call.outcome = Some(CallOutcome::Unfinished);
390            }
391        }
392        let output = DecisionLogOutput {
393            records,
394            truncated: state.truncated,
395            dropped: state.dropped,
396            line_frames: state.line_frames,
397            calls: std::mem::take(&mut state.calls),
398            calls_dropped: state.calls_dropped,
399        };
400        state.charged = 0;
401        state.pairs.clear();
402        output
403    }
404
405    fn lock(&self) -> MutexGuard<'_, LogState> {
406        // A poisoned lock means a recorder call panicked while it may have been updating
407        // this state, so the state could be partly updated. Recovering is acceptable here
408        // (unlike the accepted poisoned-lock panics in AGENTS.md) only because the log is
409        // observation-only: it never feeds a decision, so a skewed record cannot change
410        // what the run is allowed to do.
411        self.state
412            .lock()
413            .unwrap_or_else(std::sync::PoisonError::into_inner)
414    }
415
416    fn now_micros(&self) -> u64 {
417        let elapsed = u64::try_from(self.started.elapsed().as_micros()).unwrap_or(u64::MAX);
418        let previous = self.clock.fetch_max(elapsed, Ordering::Relaxed);
419        previous.max(elapsed)
420    }
421
422    fn charge(&self, state: &mut LogState, bytes: u64) -> bool {
423        self.charge_within(state, bytes, self.config.max_recorder_bytes)
424    }
425
426    /// Charges `bytes` if the total stays within `limit`.
427    fn charge_within(&self, state: &mut LogState, bytes: u64, limit: u64) -> bool {
428        let next = state.charged.saturating_add(bytes);
429        if next > limit {
430            return false;
431        }
432        state.charged = next;
433        true
434    }
435
436    /// The most the recorder's charge may reach for payload bodies and metas: the budget
437    /// less the reserve. Digest-only records and decisions may spend the reserve.
438    fn payload_limit(&self) -> u64 {
439        let max = self.config.max_recorder_bytes;
440        max.saturating_sub(max / RECORD_RESERVE_DIVISOR)
441    }
442
443    /// Whether the decision cap and the byte budget leave room for one more bare record.
444    fn can_keep_another(&self, state: &LogState) -> bool {
445        state.records.len() < self.config.max_decisions
446            && state.charged.saturating_add(RECORD_BASE_BYTES) <= self.config.max_recorder_bytes
447    }
448
449    fn begin(
450        &self,
451        state: &mut LogState,
452        caller: &str,
453        capability: &str,
454        line: Option<SourceLine>,
455    ) -> CallTicket {
456        let call_index = state.next_call_index;
457        state.next_call_index = state.next_call_index.saturating_add(1);
458        state.last_call_index = Some(call_index);
459        let key = (caller.to_owned(), capability.to_owned());
460        if !state.pairs.contains_key(&key) {
461            let cost = (caller.len() + capability.len()) as u64 + PAIR_OVERHEAD_BYTES;
462            if state.pairs.len() >= MAX_TRACKED_PAIRS || !self.charge(state, cost) {
463                state.truncated = true;
464                return CallTicket {
465                    call_index,
466                    seq: 0,
467                    at_micros: self.now_micros(),
468                    line,
469                };
470            }
471        }
472        let pair = state.pairs.entry(key).or_default();
473        pair.count = pair.count.saturating_add(1);
474        let ticket = CallTicket {
475            call_index,
476            seq: pair.count,
477            at_micros: self.now_micros(),
478            line,
479        };
480        pair.last = Some(ticket);
481        ticket
482    }
483
484    /// Stores what the audit saw. Called from the decorator's `audit`.
485    fn record(&self, decision: &AuditDecision<'_>) {
486        let mut state = self.lock();
487        let ticket = match decision.site.ticket {
488            Some(ticket) => ticket,
489            // A decision with no call is its own call, never a follow-up of another one.
490            None => self.begin(&mut state, decision.caller, decision.capability, None),
491        };
492        if state.records.len() >= self.config.max_decisions || state.records.try_reserve(1).is_err()
493        {
494            state.truncated = true;
495            state.dropped = state.dropped.saturating_add(1);
496            return;
497        }
498        let mut record = self.build(decision, ticket);
499        let minimal = RECORD_BASE_BYTES
500            + (record.caller.len()
501                + record.capability.len()
502                + record.source.len()
503                + record.reason.as_ref().map_or(0, String::len)) as u64;
504        let payload = payload_bytes(&record);
505        if !self.charge(&mut state, minimal.saturating_add(payload)) {
506            // Keep the digest and the verdict; drop what carries the bytes.
507            record.context = Value::Null;
508            record.near_misses = Vec::new();
509            record.payload_dropped = true;
510            record.context_truncated = true;
511            state.truncated = true;
512            if !self.charge(&mut state, minimal) {
513                state.bytes_exhausted = true;
514                state.dropped = state.dropped.saturating_add(1);
515                return;
516            }
517        }
518        let observed = self.observer.as_ref().map(|_| record.clone());
519        state.records.push(record);
520        drop(state);
521        if let (Some(observer), Some(record)) = (&self.observer, observed) {
522            observer.decision(&record);
523        }
524    }
525
526    /// Opens the call `ticket` begins. Over the call cap or the byte budget it is
527    /// counted and the run marked truncated.
528    fn open_call(
529        &self,
530        state: &mut LogState,
531        caller: &str,
532        capability: &str,
533        ticket: CallTicket,
534    ) -> Option<CallRecord> {
535        let cost =
536            CALL_BASE_BYTES.saturating_add(caller.len().saturating_add(capability.len()) as u64);
537        if state.calls.len() >= self.config.max_calls
538            || state.calls.try_reserve(1).is_err()
539            || state.open_calls.try_reserve(1).is_err()
540            || !self.charge(state, cost)
541        {
542            state.truncated = true;
543            state.calls_dropped = state.calls_dropped.saturating_add(1);
544            return None;
545        }
546        let call = CallRecord {
547            call_index: ticket.call_index,
548            caller: caller.to_owned(),
549            capability: capability.to_owned(),
550            started_micros: ticket.at_micros,
551            ended_micros: None,
552            outcome: None,
553            line: ticket.line,
554            request: None,
555            response: None,
556            usage: None,
557        };
558        let observed = self.observer.as_ref().map(|_| call.clone());
559        state
560            .open_calls
561            .push((ticket.call_index, state.calls.len()));
562        state.calls.push(call);
563        observed
564    }
565
566    fn call_mut(state: &mut LogState, call_index: u64) -> Option<&mut CallRecord> {
567        let position = state
568            .open_calls
569            .iter()
570            .rev()
571            .find(|(index, _)| *index == call_index)
572            .map(|(_, position)| *position)?;
573        state.calls.get_mut(position)
574    }
575
576    fn build(&self, decision: &AuditDecision<'_>, ticket: CallTicket) -> DecisionRecord {
577        let digest = digest_of(decision.context);
578        let (context, mut context_truncated) = if decision.capability == "secrets.get" {
579            // The key name is the record; nothing else a secret read carries is kept.
580            let name = decision.context.get("name").cloned().unwrap_or(Value::Null);
581            cap_context(
582                &serde_json::json!({ "name": name }),
583                self.config.max_context_value_bytes,
584            )
585        } else {
586            cap_context(decision.context, self.config.max_context_value_bytes)
587        };
588        let (action, cause, near_misses) = match decision.explanation {
589            Some(explanation) => (
590                enforced_action(decision.allowed, Some(explanation.action)),
591                explanation.cause.clone(),
592                self.capped_near_misses(&explanation.near_misses, &mut context_truncated),
593            ),
594            None => (
595                enforced_action(decision.allowed, None),
596                fallback_cause(decision),
597                Vec::new(),
598            ),
599        };
600        DecisionRecord {
601            call_index: ticket.call_index,
602            seq: ticket.seq,
603            at_micros: ticket.at_micros,
604            caller: decision.caller.to_owned(),
605            capability: decision.capability.to_owned(),
606            context,
607            context_truncated,
608            context_digest: digest,
609            allowed: decision.allowed,
610            action,
611            cause,
612            near_misses,
613            source: decision.source.to_owned(),
614            rule: decision.rule,
615            reason: decision.reason.map(str::to_owned),
616            entry_path: decision.site.entry,
617            line: ticket.line,
618            filtered: false,
619            payload_dropped: false,
620        }
621    }
622
623    /// Near misses carry the call's actual values, which are as unbounded as the context.
624    fn capped_near_misses(
625        &self,
626        near_misses: &[NearMissRecord],
627        truncated: &mut bool,
628    ) -> Vec<NearMissRecord> {
629        let max = self.config.max_context_value_bytes;
630        near_misses
631            .iter()
632            .map(|miss| NearMissRecord {
633                rule: miss.rule.clone(),
634                filter: miss.filter.clone(),
635                failures: miss
636                    .failures
637                    .iter()
638                    .map(|failure| FailureRecord {
639                        actual: failure
640                            .actual
641                            .as_ref()
642                            .map(|actual| cap_value(actual, max, 0, truncated)),
643                        ..failure.clone()
644                    })
645                    .collect(),
646            })
647            .collect()
648    }
649}
650
651impl DecisionRecorder for DecisionLog {
652    fn begin_call(&self, caller: &str, capability: &str, line: Option<SourceLine>) -> CallTicket {
653        let mut state = self.lock();
654        let ticket = self.begin(&mut state, caller, capability, line);
655        let opened = self.open_call(&mut state, caller, capability, ticket);
656        drop(state);
657        if let (Some(observer), Some(call)) = (&self.observer, opened) {
658            observer.call_started(&call);
659        }
660        ticket
661    }
662
663    fn enter_host_call(&self) -> u64 {
664        self.lock().next_call_index
665    }
666
667    fn exit_host_call(&self, marker: u64, returned: bool) {
668        let mut state = self.lock();
669        let Some(first) = state
670            .open_calls
671            .iter()
672            .position(|(index, _)| *index >= marker)
673        else {
674            return;
675        };
676        let ended = self.now_micros();
677        let closing: Vec<_> = state.open_calls.drain(first..).collect();
678        let mut finished = Vec::new();
679        for (_, position) in closing {
680            if let Some(call) = state.calls.get_mut(position) {
681                call.ended_micros = Some(ended);
682                call.outcome = Some(if returned {
683                    CallOutcome::Returned
684                } else {
685                    CallOutcome::Failed
686                });
687                if self.observer.is_some() {
688                    // Observers report sizes and timing; the bodies stay in the log.
689                    finished.push(call.without_bodies());
690                }
691            }
692        }
693        drop(state);
694        if let Some(observer) = &self.observer {
695            for call in &finished {
696                observer.call_finished(call);
697            }
698        }
699    }
700
701    fn wants_payload(&self, call_index: u64) -> bool {
702        let mut state = self.lock();
703        // Past the payload limit a record of it, at least its digest, still fits the
704        // budget, so a payload is wanted until even that cannot be charged.
705        Self::call_mut(&mut state, call_index).is_some()
706            && state.charged.saturating_add(call_log::DIGEST_ONLY_COST)
707                <= self.config.max_recorder_bytes
708    }
709
710    fn call_payload(&self, call_index: u64, side: Side, payload: Payload<'_>) {
711        let room = {
712            let mut state = self.lock();
713            // A call that was not kept, or has ended, takes no payload: skip the hashing
714            // and the copy.
715            if Self::call_mut(&mut state, call_index).is_none() {
716                return;
717            }
718            self.payload_limit().saturating_sub(state.charged)
719        };
720        let record = call_log::capture(
721            &payload,
722            self.config.max_context_value_bytes,
723            room,
724            self.config.max_payload_bytes,
725        );
726        let mut state = self.lock();
727        // The lock was released while the payload was hashed, so the call may have ended.
728        if Self::call_mut(&mut state, call_index).is_none() {
729            return;
730        }
731        let cost = call_log::payload_cost(&record);
732        let record = if self.charge_within(&mut state, cost, self.payload_limit()) {
733            record
734        } else {
735            // Only the digest and the size enter the reserve.
736            state.truncated = true;
737            let mut kept = record.digest_only();
738            kept.truncated = true;
739            if !self.charge(&mut state, call_log::payload_cost(&kept)) {
740                return;
741            }
742            kept
743        };
744        let Some(call) = Self::call_mut(&mut state, call_index) else {
745            return;
746        };
747        match side {
748            Side::Request => call.request = Some(Box::new(record)),
749            Side::Response => call.response = Some(Box::new(record)),
750        }
751    }
752
753    fn call_usage(&self, call_index: u64, usage: ModelUsage) {
754        let mut state = self.lock();
755        if let Some(call) = Self::call_mut(&mut state, call_index) {
756            call.usage = Some(usage);
757        }
758    }
759
760    fn wants_line(&self) -> bool {
761        let state = self.lock();
762        !state.lines_exhausted && !state.bytes_exhausted && self.can_keep_another(&state)
763    }
764
765    fn note_line_capture(&self, frames: usize) {
766        let mut state = self.lock();
767        state.line_frames = state.line_frames.saturating_add(frames as u64);
768        if state.line_frames > self.config.max_line_capture_frames {
769            state.lines_exhausted = true;
770            state.truncated = true;
771        }
772    }
773
774    fn mark_last_filtered(&self) {
775        let mut state = self.lock();
776        let Some(last) = state.last_call_index else {
777            return;
778        };
779        if let Some(record) = state
780            .records
781            .iter_mut()
782            .rev()
783            .find(|record| record.call_index == last)
784        {
785            record.filtered = true;
786        }
787    }
788}
789
790/// The action the enforcement path took; the explanation only refines a denial into
791/// `AskHuman`, so a disagreeing explanation cannot change what is recorded as allowed.
792fn enforced_action(allowed: bool, explained: Option<DecisionAction>) -> DecisionAction {
793    match (allowed, explained) {
794        (true, _) => DecisionAction::Allow,
795        (false, Some(DecisionAction::AskHuman)) => DecisionAction::AskHuman,
796        (false, _) => DecisionAction::Deny,
797    }
798}
799
800fn fallback_cause(decision: &AuditDecision<'_>) -> DecisionCause {
801    if decision.source == "policy" {
802        return DecisionCause::Unexplained;
803    }
804    DecisionCause::RuntimeInvariant {
805        reason: decision.reason.unwrap_or_default().to_owned(),
806    }
807}
808
809fn digest_of(context: &Value) -> u64 {
810    use std::hash::{Hash, Hasher};
811    let Ok(text) = serde_json::to_string(context) else {
812        return 0;
813    };
814    // `DefaultHasher::new()` is keyed with fixed constants, so a digest is stable in a run.
815    let mut hasher = std::collections::hash_map::DefaultHasher::new();
816    text.hash(&mut hasher);
817    hasher.finish()
818}
819
820fn payload_bytes(record: &DecisionRecord) -> u64 {
821    let context = serde_json::to_string(&record.context).map_or(0, |text| text.len());
822    let misses = serde_json::to_string(&record.near_misses).map_or(0, |text| text.len());
823    // Parsed JSON costs several times its text; charge generously.
824    (context.saturating_add(misses) as u64).saturating_mul(2)
825}
826
827/// Caps a recorded context: long strings, deep nesting, and wide containers each end in a
828/// marker. Returns whether anything was cut.
829pub(crate) fn cap_context(context: &Value, max_string_bytes: usize) -> (Value, bool) {
830    let mut truncated = false;
831    let capped = cap_value(context, max_string_bytes, 0, &mut truncated);
832    (capped, truncated)
833}
834
835fn cap_value(value: &Value, max_string: usize, depth: usize, truncated: &mut bool) -> Value {
836    match value {
837        Value::String(text) => Value::String(cap_text(text, max_string, truncated)),
838        Value::Array(_) | Value::Object(_) if depth >= MAX_CONTEXT_DEPTH => {
839            *truncated = true;
840            Value::String("…[truncated, nested too deep]".to_owned())
841        }
842        Value::Array(items) => {
843            if items.len() > MAX_CONTEXT_ENTRIES {
844                *truncated = true;
845            }
846            Value::Array(
847                items
848                    .iter()
849                    .take(MAX_CONTEXT_ENTRIES)
850                    .map(|item| cap_value(item, max_string, depth + 1, truncated))
851                    .collect(),
852            )
853        }
854        Value::Object(fields) => {
855            if fields.len() > MAX_CONTEXT_ENTRIES {
856                *truncated = true;
857            }
858            Value::Object(
859                fields
860                    .iter()
861                    .take(MAX_CONTEXT_ENTRIES)
862                    .enumerate()
863                    .map(|(position, (key, item))| {
864                        (
865                            capped_key(key, position, max_string, truncated),
866                            cap_value(item, max_string, depth + 1, truncated),
867                        )
868                    })
869                    .collect(),
870            )
871        }
872        other => other.clone(),
873    }
874}
875
876/// A capped object key. Two long keys can share their kept prefix, so a cut key
877/// also names its position in the object to keep both entries.
878fn capped_key(key: &str, position: usize, max: usize, truncated: &mut bool) -> String {
879    let capped = cap_text(key, max, truncated);
880    if capped.len() == key.len() {
881        return capped;
882    }
883    format!("{capped} #{position}")
884}
885
886/// `text` cut to `max` bytes at a character boundary, ending in a marker when cut.
887pub(crate) fn cap_text(text: &str, max: usize, truncated: &mut bool) -> String {
888    if text.len() <= max {
889        return text.to_owned();
890    }
891    *truncated = true;
892    let mut end = max;
893    while end > 0 && !text.is_char_boundary(end) {
894        end -= 1;
895    }
896    let kept = text.get(..end).unwrap_or_default();
897    format!(
898        "{kept}…[truncated, {} of {} bytes kept]",
899        kept.len(),
900        text.len()
901    )
902}
903
904struct RecordingCheck {
905    inner: Arc<dyn SecurityCheck>,
906    log: Arc<DecisionLog>,
907}
908
909impl SecurityCheck for RecordingCheck {
910    fn audit(&self, decision: AuditDecision<'_>) {
911        self.inner.audit(decision);
912        self.log.record(&decision);
913    }
914
915    fn audit_context<'a>(
916        &self,
917        capability: &str,
918        context: &'a Value,
919        cwd: &str,
920    ) -> std::borrow::Cow<'a, Value> {
921        self.inner.audit_context(capability, context, cwd)
922    }
923
924    fn check_with_cwd(
925        &self,
926        caller: &str,
927        capability: &str,
928        context: &Value,
929        cwd: &str,
930    ) -> CheckOutcome {
931        self.inner.check_with_cwd(caller, capability, context, cwd)
932    }
933
934    fn check(&self, caller: &str, capability: &str, context: &Value) -> CheckOutcome {
935        self.inner.check(caller, capability, context)
936    }
937
938    fn recorder(&self) -> Option<&dyn DecisionRecorder> {
939        Some(self.log.as_ref())
940    }
941
942    fn explain(
943        &self,
944        caller: &str,
945        capability: &str,
946        context: &Value,
947        cwd: &str,
948    ) -> Option<DecisionExplanation> {
949        self.inner.explain(caller, capability, context, cwd)
950    }
951}
952
953#[cfg(test)]
954mod tests {
955    use super::*;
956    use crate::runtime::Vfs;
957
958    fn log_with(config: DecisionLogConfig) -> (StoreData, Arc<DecisionLog>) {
959        let mut data = StoreData::with_vfs_and_cap(Vfs::none(), 1 << 20);
960        let log = DecisionLog::install(&mut data, config);
961        (data, log)
962    }
963
964    fn audit_denied(data: &StoreData, ticket: CallTicket) {
965        let context = serde_json::json!({});
966        data.security_check.audit(
967            AuditDecision::new("main", "fs.read", &context, false, "policy", None, None)
968                .with_site(CallSite::new(Some(ticket), EntryPath::GatedOp)),
969        );
970    }
971
972    #[test]
973    fn a_call_still_open_at_finish_is_unfinished() {
974        let (data, log) = log_with(DecisionLogConfig::default());
975        let recorder = data.security_check.recorder().unwrap();
976        recorder.enter_host_call();
977        recorder.begin_call("main", "http.get", None);
978        let output = log.finish();
979        let [call] = output.calls.as_slice() else {
980            panic!("one call: {output:?}");
981        };
982        assert_eq!(call.outcome, Some(CallOutcome::Unfinished));
983        assert_eq!(call.ended_micros, None);
984    }
985
986    #[test]
987    fn payload_copies_leave_the_decision_reserve_free() {
988        let max = 64 * 1024;
989        let (data, log) = log_with(DecisionLogConfig {
990            max_recorder_bytes: max,
991            ..DecisionLogConfig::default()
992        });
993        let recorder = data.security_check.recorder().unwrap();
994        let body = vec![b'x'; 20 * 1024];
995        for _ in 0..6 {
996            let ticket = recorder.begin_call("main", "http.get", None);
997            recorder.call_payload(
998                ticket.call_index,
999                Side::Response,
1000                Payload::meta(serde_json::Value::Null).with_body(&body),
1001            );
1002        }
1003        assert!(
1004            log.lock().charged <= max - max / RECORD_RESERVE_DIVISOR + 6 * 256,
1005            "bodies stay out of the reserve; only digests may enter it"
1006        );
1007        let output = log.finish();
1008        let responses: Vec<_> = output
1009            .calls
1010            .iter()
1011            .filter_map(|call| call.response.as_deref())
1012            .collect();
1013        assert_eq!(responses.len(), 6, "every payload keeps a record");
1014        assert!(responses.iter().any(|payload| payload.body.is_some()));
1015        let cut = responses
1016            .iter()
1017            .find(|payload| payload.body.is_none())
1018            .expect("a payload past the reserve keeps no body");
1019        assert!(cut.truncated && cut.bytes == body.len() as u64 && !cut.digest.is_empty());
1020        assert!(output.truncated);
1021    }
1022
1023    #[test]
1024    fn digest_only_records_do_not_spend_the_reserve_on_masked_names() {
1025        let max = 64 * 1024;
1026        let (data, log) = log_with(DecisionLogConfig {
1027            max_recorder_bytes: max,
1028            ..DecisionLogConfig::default()
1029        });
1030        let recorder = data.security_check.recorder().unwrap();
1031        let body = vec![b'x'; 20 * 1024];
1032        for _ in 0..6 {
1033            let ticket = recorder.begin_call("main", "http.get", None);
1034            recorder.call_payload(
1035                ticket.call_index,
1036                Side::Response,
1037                Payload::meta(serde_json::Value::Null).with_body(&body),
1038            );
1039        }
1040        let before = log.lock().charged;
1041        let names: Vec<String> = (0..64)
1042            .map(|i| format!("x-token-{i}-{}", "n".repeat(900)))
1043            .collect();
1044        let ticket = recorder.begin_call("main", "http.get", None);
1045        recorder.call_payload(
1046            ticket.call_index,
1047            Side::Response,
1048            Payload::meta(serde_json::Value::Null)
1049                .with_body(&body)
1050                .with_masked(names),
1051        );
1052        let spent = log.lock().charged - before;
1053        assert!(
1054            spent < 512,
1055            "a payload past the limit enters the reserve as a digest and a size: {spent}"
1056        );
1057        audit_denied(&data, recorder.begin_call("main", "fs.read", None));
1058        let output = log.finish();
1059        assert_eq!(output.records.len(), 1, "a later denial is still recorded");
1060        let last = output
1061            .calls
1062            .iter()
1063            .rev()
1064            .find_map(|call| call.response.as_deref());
1065        assert!(last.is_some_and(|payload| payload.masked_headers.is_empty()));
1066    }
1067
1068    #[test]
1069    fn a_body_is_sized_to_the_room_its_record_leaves() {
1070        let max = 16 * 1024;
1071        let (data, log) = log_with(DecisionLogConfig {
1072            max_recorder_bytes: max,
1073            ..DecisionLogConfig::default()
1074        });
1075        let recorder = data.security_check.recorder().unwrap();
1076        let body = vec![b'x'; 64 * 1024];
1077        let meta = serde_json::json!({ "url": "https://x.test/" });
1078        let ticket = recorder.begin_call("main", "http.get", None);
1079        recorder.call_payload(
1080            ticket.call_index,
1081            Side::Response,
1082            Payload::meta(meta.clone()).with_body(&body),
1083        );
1084        let charged = log.lock().charged;
1085        assert!(
1086            charged <= log.payload_limit(),
1087            "the copy fits what it is charged"
1088        );
1089        let output = log.finish();
1090        let payload = output.calls[0]
1091            .response
1092            .as_deref()
1093            .expect("a response record");
1094        let kept = match &payload.body {
1095            Some(call_log::BodyCopy::Text(text)) => text.len(),
1096            other => panic!("a cut text body is kept as text: {other:?}"),
1097        };
1098        assert!(payload.truncated && kept > 0 && kept < body.len());
1099        assert_eq!(payload.bytes, body.len() as u64);
1100        // Binary bodies are charged as base64: a quarter more.
1101        let (data, log) = log_with(DecisionLogConfig {
1102            max_recorder_bytes: max,
1103            ..DecisionLogConfig::default()
1104        });
1105        let recorder = data.security_check.recorder().unwrap();
1106        let binary = vec![0xffu8; 64 * 1024];
1107        let ticket = recorder.begin_call("main", "http.get", None);
1108        recorder.call_payload(
1109            ticket.call_index,
1110            Side::Response,
1111            Payload::meta(meta).with_body(&binary),
1112        );
1113        let output = log.finish();
1114        let payload = output.calls[0]
1115            .response
1116            .as_deref()
1117            .expect("a response record");
1118        let Some(call_log::BodyCopy::Base64(text)) = &payload.body else {
1119            panic!("a binary body is kept as base64: {payload:?}");
1120        };
1121        assert!(payload.truncated);
1122        assert!(text.len() as u64 <= max - max / RECORD_RESERVE_DIVISOR);
1123    }
1124
1125    #[test]
1126    fn a_payload_is_wanted_exactly_while_its_digest_only_record_fits() {
1127        let max = 4096;
1128        let (data, log) = log_with(DecisionLogConfig {
1129            max_recorder_bytes: max,
1130            ..DecisionLogConfig::default()
1131        });
1132        let recorder = data.security_check.recorder().unwrap();
1133        recorder.enter_host_call();
1134        let ticket = recorder.begin_call("main", "http.get", None);
1135        log.lock().charged = max - call_log::DIGEST_ONLY_COST;
1136        assert!(recorder.wants_payload(ticket.call_index));
1137        log.lock().charged = max - call_log::DIGEST_ONLY_COST + 1;
1138        assert!(!recorder.wants_payload(ticket.call_index));
1139    }
1140
1141    #[test]
1142    fn a_body_with_no_room_is_not_copied() {
1143        let (data, log) = log_with(DecisionLogConfig {
1144            max_recorder_bytes: 1024,
1145            ..DecisionLogConfig::default()
1146        });
1147        let recorder = data.security_check.recorder().unwrap();
1148        let ticket = recorder.begin_call("main", "http.get", None);
1149        let meta = serde_json::json!({ "url": "u".repeat(400) });
1150        recorder.call_payload(
1151            ticket.call_index,
1152            Side::Response,
1153            Payload::meta(meta).with_body(&[b'x'; 4096]),
1154        );
1155        let output = log.finish();
1156        let payload = output.calls[0]
1157            .response
1158            .as_deref()
1159            .expect("a response record");
1160        assert!(payload.body.is_none() && payload.truncated);
1161        assert_eq!(payload.bytes, 4096);
1162    }
1163
1164    #[test]
1165    fn a_payload_is_wanted_while_its_call_is_open_and_the_budget_lasts() {
1166        let (data, _log) = log_with(DecisionLogConfig {
1167            max_recorder_bytes: 4096,
1168            ..DecisionLogConfig::default()
1169        });
1170        let recorder = data.security_check.recorder().unwrap();
1171        recorder.enter_host_call();
1172        let ticket = recorder.begin_call("main", "http.get", None);
1173        assert!(recorder.wants_payload(ticket.call_index));
1174        assert!(
1175            !recorder.wants_payload(ticket.call_index + 1),
1176            "no such call"
1177        );
1178        recorder.exit_host_call(0, true);
1179        assert!(!recorder.wants_payload(ticket.call_index), "the call ended");
1180    }
1181
1182    #[test]
1183    fn a_full_log_stops_asking_for_lines() {
1184        let (data, log) = log_with(DecisionLogConfig {
1185            max_decisions: 2,
1186            ..DecisionLogConfig::default()
1187        });
1188        let recorder = data.security_check.recorder().unwrap();
1189        for _ in 0..2 {
1190            assert!(recorder.wants_line());
1191            audit_denied(&data, recorder.begin_call("main", "fs.read", None));
1192        }
1193        assert!(!recorder.wants_line(), "the cap is reached");
1194        audit_denied(&data, recorder.begin_call("main", "fs.read", None));
1195        assert!(!recorder.wants_line(), "still off once the log is full");
1196        assert!(log.finish().truncated);
1197    }
1198
1199    #[test]
1200    fn a_byte_budget_payload_drop_keeps_asking_for_lines() {
1201        let (data, log) = log_with(DecisionLogConfig {
1202            max_recorder_bytes: 4 * RECORD_BASE_BYTES,
1203            ..DecisionLogConfig::default()
1204        });
1205        let recorder = data.security_check.recorder().unwrap();
1206        let context = serde_json::json!({ "path": "p".repeat(900) });
1207        let ticket = recorder.begin_call("main", "fs.read", None);
1208        data.security_check.audit(
1209            AuditDecision::new("main", "fs.read", &context, true, "policy", None, None)
1210                .with_site(CallSite::new(Some(ticket), EntryPath::GatedOp)),
1211        );
1212        assert!(recorder.wants_line(), "a minimal record still fits");
1213        let output = log.finish();
1214        assert!(output.truncated);
1215        assert!(output.records.iter().any(|r| r.payload_dropped));
1216    }
1217
1218    #[test]
1219    fn filling_the_pair_cap_keeps_asking_for_lines() {
1220        let (data, log) = log_with(DecisionLogConfig::default());
1221        let recorder = data.security_check.recorder().unwrap();
1222        for index in 0..=MAX_TRACKED_PAIRS {
1223            recorder.begin_call("main", &format!("cap.{index}"), None);
1224        }
1225        assert!(recorder.wants_line());
1226        assert!(log.finish().truncated, "the pair cap truncates");
1227    }
1228
1229    #[test]
1230    fn lines_stop_once_the_byte_budget_cannot_fit_a_record() {
1231        let (data, log) = log_with(DecisionLogConfig {
1232            max_recorder_bytes: 3 * RECORD_BASE_BYTES,
1233            ..DecisionLogConfig::default()
1234        });
1235        let recorder = data.security_check.recorder().unwrap();
1236        let mut captures = 0;
1237        for _ in 0..20 {
1238            if recorder.wants_line() {
1239                captures += 1;
1240                recorder.note_line_capture(5);
1241            }
1242            audit_denied(&data, recorder.begin_call("main", "fs.read", None));
1243        }
1244        let output = log.finish();
1245        assert!(output.dropped > 0, "records were dropped whole");
1246        assert!(
1247            captures < 20 && output.line_frames == 5 * captures,
1248            "capture stopped: {captures} captures"
1249        );
1250        assert!(
1251            captures <= output.records.len() as u64 + 1,
1252            "no captures beyond the first dropped record: {captures} for {} kept",
1253            output.records.len()
1254        );
1255    }
1256
1257    #[test]
1258    fn a_budget_below_one_bare_record_wants_no_line() {
1259        for max_recorder_bytes in [0, RECORD_BASE_BYTES - 1] {
1260            let (data, _log) = log_with(DecisionLogConfig {
1261                max_recorder_bytes,
1262                ..DecisionLogConfig::default()
1263            });
1264            let recorder = data.security_check.recorder().unwrap();
1265            assert!(!recorder.wants_line(), "budget {max_recorder_bytes}");
1266        }
1267    }
1268
1269    #[test]
1270    fn a_whole_record_drop_stops_lines_though_a_bare_record_would_fit() {
1271        let (data, log) = log_with(DecisionLogConfig {
1272            // After the call's pair is charged, the leftover is a bare record plus 2 bytes:
1273            // less than this record's strings need. No call records take a share.
1274            max_recorder_bytes: PAIR_OVERHEAD_BYTES
1275                + "main".len() as u64
1276                + "fs.read".len() as u64
1277                + RECORD_BASE_BYTES
1278                + 2,
1279            max_calls: 0,
1280            ..DecisionLogConfig::default()
1281        });
1282        let recorder = data.security_check.recorder().unwrap();
1283        assert!(recorder.wants_line(), "a bare record fits before any call");
1284        let ticket = recorder.begin_call("main", "fs.read", None);
1285        assert!(
1286            recorder.wants_line(),
1287            "a bare record still fits after the call"
1288        );
1289        audit_denied(&data, ticket);
1290        assert!(!recorder.wants_line(), "the drop turns lines off");
1291        let output = log.finish();
1292        assert_eq!(output.dropped, 1);
1293        assert!(output.records.is_empty());
1294    }
1295
1296    #[test]
1297    fn the_line_capture_frame_budget_truncates_the_log_once_exceeded() {
1298        let (data, log) = log_with(DecisionLogConfig {
1299            max_line_capture_frames: 10,
1300            ..DecisionLogConfig::default()
1301        });
1302        let recorder = data.security_check.recorder().unwrap();
1303        recorder.note_line_capture(10);
1304        assert!(recorder.wants_line(), "at the budget is still within it");
1305        recorder.note_line_capture(1);
1306        assert!(!recorder.wants_line());
1307        assert!(log.finish().truncated);
1308    }
1309
1310    #[test]
1311    fn a_secret_read_keeps_the_key_name_and_nothing_else() {
1312        let (data, log) = log_with(DecisionLogConfig::default());
1313        let context = serde_json::json!({ "name": "api-key", "value": "s3cret" });
1314        data.security_check.audit(AuditDecision::new(
1315            "main",
1316            "secrets.get",
1317            &context,
1318            false,
1319            "invariant",
1320            None,
1321            Some("refused"),
1322        ));
1323        let output = log.finish();
1324        let [record] = output.records.as_slice() else {
1325            panic!("one record");
1326        };
1327        assert_eq!(record.context, serde_json::json!({ "name": "api-key" }));
1328        assert!(!format!("{record:?}").contains("s3cret"));
1329    }
1330
1331    #[test]
1332    fn an_exhausted_recorder_budget_keeps_the_verdict_and_drops_the_payload() {
1333        // Room for the pair bookkeeping and a bare record, not for a big context.
1334        let (data, log) = log_with(DecisionLogConfig {
1335            max_recorder_bytes: 2048,
1336            ..DecisionLogConfig::default()
1337        });
1338        let context = serde_json::json!({ "path": "p".repeat(900) });
1339        let recorder = data.security_check.recorder().unwrap();
1340        let ticket = recorder.begin_call("main", "fs.read", None);
1341        data.security_check.audit(
1342            AuditDecision::new("main", "fs.read", &context, true, "policy", None, None)
1343                .with_site(CallSite::new(Some(ticket), EntryPath::GatedOp)),
1344        );
1345        let output = log.finish();
1346        assert!(output.truncated);
1347        let [record] = output.records.as_slice() else {
1348            panic!("the verdict is kept: {output:?}");
1349        };
1350        assert!(record.allowed && record.payload_dropped);
1351        assert_eq!(record.context, serde_json::Value::Null);
1352        assert_ne!(record.context_digest, 0, "the digest survives");
1353        assert_eq!(
1354            data.tenant_limits.host_attached_bytes(),
1355            0,
1356            "the run's memory budget is not touched"
1357        );
1358    }
1359
1360    #[test]
1361    fn caps_mark_long_strings_deep_nesting_and_wide_containers() {
1362        let long = serde_json::json!({ "a": "é".repeat(100) });
1363        let (capped, truncated) = cap_context(&long, 11);
1364        assert!(truncated);
1365        let text = capped["a"].as_str().unwrap();
1366        assert!(
1367            text.starts_with("éééé") && text.contains("truncated"),
1368            "{text}"
1369        );
1370
1371        let mut nested = serde_json::json!(1);
1372        for _ in 0..20 {
1373            nested = serde_json::json!([nested]);
1374        }
1375        assert!(cap_context(&nested, 100).1);
1376
1377        let wide = Value::Array((0..200).map(Value::from).collect());
1378        let (capped, truncated) = cap_context(&wide, 100);
1379        assert!(truncated);
1380        assert_eq!(capped.as_array().unwrap().len(), MAX_CONTEXT_ENTRIES);
1381
1382        let small = serde_json::json!({ "host": "x", "n": [1, 2] });
1383        assert_eq!(cap_context(&small, 100), (small.clone(), false));
1384    }
1385
1386    #[test]
1387    fn finish_refunds_the_recorders_budget() {
1388        let (data, log) = log_with(DecisionLogConfig {
1389            max_recorder_bytes: 4096,
1390            ..DecisionLogConfig::default()
1391        });
1392        let context = serde_json::json!({ "path": "p".repeat(300) });
1393        for _ in 0..2 {
1394            data.security_check.audit(AuditDecision::new(
1395                "main", "fs.read", &context, true, "policy", None, None,
1396            ));
1397            let output = log.finish();
1398            assert!(!output.truncated && output.records.len() == 1, "{output:?}");
1399        }
1400        assert_eq!(data.tenant_limits.host_attached_bytes(), 0);
1401    }
1402
1403    #[test]
1404    fn long_keys_sharing_a_prefix_both_survive_capping() {
1405        let long = "k".repeat(100);
1406        let value = serde_json::json!({ format!("{long}a"): 1, format!("{long}b"): 2 });
1407        let mut truncated = false;
1408        let Value::Object(capped) = cap_value(&value, 16, 0, &mut truncated) else {
1409            panic!("expected an object");
1410        };
1411        assert!(truncated);
1412        assert_eq!(capped.len(), 2, "{capped:?}");
1413        let mut values: Vec<_> = capped.values().cloned().collect();
1414        values.sort_by_key(Value::as_i64);
1415        assert_eq!(values, vec![serde_json::json!(1), serde_json::json!(2)]);
1416    }
1417
1418    #[test]
1419    fn near_miss_actual_values_and_keys_are_capped_like_the_context() {
1420        let (data, log) = log_with(DecisionLogConfig {
1421            max_context_value_bytes: 16,
1422            ..DecisionLogConfig::default()
1423        });
1424        let explanation = DecisionExplanation {
1425            action: DecisionAction::Deny,
1426            cause: DecisionCause::Default { caller_block: true },
1427            near_misses: vec![NearMissRecord {
1428                rule: RuleCitation {
1429                    caller: "main".into(),
1430                    index: 0,
1431                    name: None,
1432                },
1433                filter: "f".into(),
1434                failures: vec![FailureRecord {
1435                    comparison: "c".into(),
1436                    actual: Some(serde_json::json!({ "k".repeat(100): "v".repeat(100) })),
1437                    expected: None,
1438                    reason: FailureReasonRecord::NotSatisfied,
1439                    negated: false,
1440                }],
1441            }],
1442        };
1443        let context = serde_json::json!({});
1444        data.security_check.audit(
1445            AuditDecision::new("main", "fs.read", &context, false, "policy", None, None)
1446                .with_explanation(Some(&explanation)),
1447        );
1448        let output = log.finish();
1449        let [record] = output.records.as_slice() else {
1450            panic!("one record");
1451        };
1452        assert!(record.context_truncated);
1453        let actual = record.near_misses[0].failures[0].actual.as_ref().unwrap();
1454        assert!(
1455            serde_json::to_string(actual).unwrap().len() < 150,
1456            "{actual}"
1457        );
1458    }
1459}