1use 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#[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#[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 Rule(RuleCitation),
54 Default { caller_block: bool },
56 RuntimeInvariant { reason: String },
60 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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
100#[serde(rename_all = "kebab-case", tag = "kind")]
101pub enum EntryPath {
102 #[default]
104 GatedOp,
105 PackageCheck,
107 RedirectHop {
109 parent_call_index: u64,
110 index: u32,
111 },
112 Git,
113 FileTool,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119pub struct SourceLine {
120 pub line: u32,
121 pub column: Option<u32>,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
126pub struct CallTicket {
127 pub call_index: u64,
129 pub seq: u64,
132 pub at_micros: u64,
134 pub line: Option<SourceLine>,
135}
136
137#[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
150pub trait DecisionRecorder: Send + Sync {
153 fn begin_call(&self, caller: &str, capability: &str, line: Option<SourceLine>) -> CallTicket;
155 fn mark_last_filtered(&self);
157 fn wants_line(&self) -> bool;
163 fn note_line_capture(&self, frames: usize);
166 fn enter_host_call(&self) -> u64;
169 fn exit_host_call(&self, marker: u64, returned: bool);
172 fn wants_payload(&self, _call_index: u64) -> bool {
176 true
177 }
178 fn call_payload(&self, call_index: u64, side: Side, payload: Payload<'_>);
180 fn call_usage(&self, call_index: u64, usage: ModelUsage);
182}
183
184pub trait RecordObserver: Send + Sync {
191 fn call_started(&self, _call: &CallRecord) {}
192 fn decision(&self, _record: &DecisionRecord) {}
193 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 pub context: Value,
208 pub context_truncated: bool,
209 pub context_digest: u64,
211 pub allowed: bool,
212 pub action: DecisionAction,
213 pub cause: DecisionCause,
214 pub near_misses: Vec<NearMissRecord>,
215 pub source: String,
217 pub rule: Option<usize>,
218 pub reason: Option<String>,
219 pub entry_path: EntryPath,
220 pub line: Option<SourceLine>,
221 pub filtered: bool,
223 pub payload_dropped: bool,
225}
226
227#[derive(Debug, Clone)]
228pub struct DecisionLogConfig {
229 pub max_decisions: usize,
231 pub max_context_value_bytes: usize,
233 pub max_recorder_bytes: u64,
236 pub max_line_capture_frames: u64,
250 pub max_calls: usize,
252 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#[derive(Debug, Clone, Default)]
272pub struct DecisionLogOutput {
273 pub records: Vec<DecisionRecord>,
274 pub truncated: bool,
278 pub dropped: u64,
280 pub line_frames: u64,
282 pub calls: Vec<CallRecord>,
284 pub calls_dropped: u64,
286}
287
288const 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;
294const 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 lines_exhausted: bool,
320 bytes_exhausted: bool,
323 calls: Vec<CallRecord>,
324 open_calls: Vec<(u64, usize)>,
326 calls_dropped: u64,
327}
328
329pub 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 pub fn install(data: &mut StoreData, config: DecisionLogConfig) -> Arc<Self> {
346 Self::install_observed(data, config, None)
347 }
348
349 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 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 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 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 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 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 fn payload_limit(&self) -> u64 {
439 let max = self.config.max_recorder_bytes;
440 max.saturating_sub(max / RECORD_RESERVE_DIVISOR)
441 }
442
443 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 fn record(&self, decision: &AuditDecision<'_>) {
486 let mut state = self.lock();
487 let ticket = match decision.site.ticket {
488 Some(ticket) => ticket,
489 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 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 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 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 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 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 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 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 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 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
790fn 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 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 (context.saturating_add(misses) as u64).saturating_mul(2)
825}
826
827pub(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
876fn 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
886pub(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 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 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 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}