1use std::cell::RefCell;
27use std::sync::Mutex;
28
29use serde::{Deserialize, Serialize};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum SecurityKind {
35 SecretRedacted,
37 ShellBlocked,
39 PathBlocked,
41 InjectionFlagged,
43 PiiRedacted,
45 ContentWithheld,
47 CoverageIncomplete,
49}
50
51impl SecurityKind {
52 pub const fn as_str(self) -> &'static str {
54 match self {
55 Self::SecretRedacted => "secret_redacted",
56 Self::ShellBlocked => "shell_blocked",
57 Self::PathBlocked => "path_blocked",
58 Self::InjectionFlagged => "injection_flagged",
59 Self::PiiRedacted => "pii_redacted",
60 Self::ContentWithheld => "content_withheld",
61 Self::CoverageIncomplete => "coverage_incomplete",
62 }
63 }
64
65 fn audit_event_type(self) -> crate::core::audit_trail::AuditEventType {
68 use crate::core::audit_trail::AuditEventType;
69 match self {
70 Self::SecretRedacted | Self::PiiRedacted => AuditEventType::SecretDetected,
71 Self::ShellBlocked | Self::ContentWithheld => AuditEventType::ToolDenied,
72 Self::PathBlocked => AuditEventType::PathJailViolation,
73 Self::InjectionFlagged | Self::CoverageIncomplete => AuditEventType::SecurityViolation,
74 }
75 }
76
77 pub fn from_action(action: &str) -> Option<Self> {
79 let kind = action.split(':').next()?;
80 Self::ALL.into_iter().find(|k| k.as_str() == kind)
81 }
82
83 #[must_use]
86 pub const fn in_signed_tally(self) -> bool {
87 matches!(
88 self,
89 Self::SecretRedacted | Self::ShellBlocked | Self::PathBlocked | Self::InjectionFlagged
90 )
91 }
92
93 const ALL: [Self; 7] = [
94 Self::SecretRedacted,
95 Self::ShellBlocked,
96 Self::PathBlocked,
97 Self::InjectionFlagged,
98 Self::PiiRedacted,
99 Self::ContentWithheld,
100 Self::CoverageIncomplete,
101 ];
102}
103
104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(default)]
112pub struct SecurityCounts {
113 pub secrets_redacted: u64,
114 pub shell_blocked: u64,
115 pub path_blocked: u64,
116 pub injection_flagged: u64,
117 #[serde(skip_serializing_if = "is_zero")]
118 pub pii_redacted: u64,
119 #[serde(skip_serializing_if = "is_zero")]
120 pub content_withheld: u64,
121 #[serde(skip_serializing_if = "is_zero")]
122 pub coverage_incomplete: u64,
123}
124
125#[allow(clippy::trivially_copy_pass_by_ref)]
126fn is_zero(n: &u64) -> bool {
127 *n == 0
128}
129
130impl SecurityCounts {
131 #[must_use]
136 pub fn signed_tally_projection(&self) -> Self {
137 Self {
138 pii_redacted: 0,
139 content_withheld: 0,
140 coverage_incomplete: 0,
141 ..*self
142 }
143 }
144
145 pub const ZERO: Self = Self {
146 secrets_redacted: 0,
147 shell_blocked: 0,
148 path_blocked: 0,
149 injection_flagged: 0,
150 pii_redacted: 0,
151 content_withheld: 0,
152 coverage_incomplete: 0,
153 };
154
155 fn slot(&mut self, kind: SecurityKind) -> &mut u64 {
156 match kind {
157 SecurityKind::SecretRedacted => &mut self.secrets_redacted,
158 SecurityKind::ShellBlocked => &mut self.shell_blocked,
159 SecurityKind::PathBlocked => &mut self.path_blocked,
160 SecurityKind::InjectionFlagged => &mut self.injection_flagged,
161 SecurityKind::PiiRedacted => &mut self.pii_redacted,
162 SecurityKind::ContentWithheld => &mut self.content_withheld,
163 SecurityKind::CoverageIncomplete => &mut self.coverage_incomplete,
164 }
165 }
166
167 fn get(&self, kind: SecurityKind) -> u64 {
168 match kind {
169 SecurityKind::SecretRedacted => self.secrets_redacted,
170 SecurityKind::ShellBlocked => self.shell_blocked,
171 SecurityKind::PathBlocked => self.path_blocked,
172 SecurityKind::InjectionFlagged => self.injection_flagged,
173 SecurityKind::PiiRedacted => self.pii_redacted,
174 SecurityKind::ContentWithheld => self.content_withheld,
175 SecurityKind::CoverageIncomplete => self.coverage_incomplete,
176 }
177 }
178
179 pub fn add(&mut self, kind: SecurityKind, n: u64) {
180 let slot = self.slot(kind);
181 *slot = slot.saturating_add(n);
182 }
183
184 pub fn merge(&mut self, other: &Self) {
185 for kind in SecurityKind::ALL {
186 self.add(kind, other.get(kind));
187 }
188 }
189
190 #[must_use]
192 pub fn since(&self, base: &Self) -> Self {
193 let mut out = Self::ZERO;
194 for kind in SecurityKind::ALL {
195 *out.slot(kind) = self.get(kind).saturating_sub(base.get(kind));
196 }
197 out
198 }
199
200 pub fn total(&self) -> u64 {
201 SecurityKind::ALL
202 .into_iter()
203 .fold(0u64, |sum, kind| sum.saturating_add(self.get(kind)))
204 }
205
206 pub fn is_empty(&self) -> bool {
207 self.total() == 0
208 }
209
210 fn iter(&self) -> impl Iterator<Item = (SecurityKind, u64)> + '_ {
211 SecurityKind::ALL
212 .into_iter()
213 .map(|kind| (kind, self.get(kind)))
214 .filter(|(_, n)| *n > 0)
215 }
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum RefusalClass {
223 SecretPath,
225 OutsideRoot,
227 ProtectedPath,
229}
230
231impl RefusalClass {
232 pub const fn as_str(self) -> &'static str {
234 match self {
235 Self::SecretPath => "secret_path",
236 Self::OutsideRoot => "outside_root",
237 Self::ProtectedPath => "protected_path",
238 }
239 }
240
241 pub const fn kind(self) -> SecurityKind {
244 match self {
245 Self::SecretPath => SecurityKind::ContentWithheld,
246 Self::OutsideRoot | Self::ProtectedPath => SecurityKind::PathBlocked,
247 }
248 }
249
250 pub fn of_jail_error(error: &crate::core::error::PathJailError) -> Option<Self> {
253 use crate::core::error::PathJailError;
254 match error {
255 PathJailError::EscapesRoot { .. }
256 | PathJailError::PostCanonicalizeEscape { .. }
257 | PathJailError::OutsideWorkspace { .. }
258 | PathJailError::WorkspaceUnavailable { .. } => Some(Self::OutsideRoot),
259 PathJailError::ProtectedContext { .. } | PathJailError::Symlink { .. } => {
260 Some(Self::ProtectedPath)
261 }
262 PathJailError::NullByte | PathJailError::NoExistingAncestor { .. } => None,
263 }
264 }
265
266 const ALL: [Self; 3] = [Self::SecretPath, Self::OutsideRoot, Self::ProtectedPath];
267}
268
269#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
272pub struct CallTally {
273 pub counts: SecurityCounts,
274 refusals: [u64; RefusalClass::ALL.len()],
275}
276
277impl CallTally {
278 fn merge(&mut self, other: &Self) {
279 self.counts.merge(&other.counts);
280 for (mine, theirs) in self.refusals.iter_mut().zip(other.refusals) {
281 *mine = mine.saturating_add(theirs);
282 }
283 }
284
285 #[must_use]
287 pub fn refusals(&self, class: RefusalClass) -> u64 {
288 self.refusals[class as usize]
289 }
290}
291
292thread_local! {
293 static COLLECTOR: RefCell<Option<CallTally>> = const { RefCell::new(None) };
294}
295
296pub fn note(kind: SecurityKind, n: usize) {
299 if n == 0 {
300 return;
301 }
302 COLLECTOR.with(|c| {
303 if let Some(tally) = c.borrow_mut().as_mut() {
304 tally.counts.add(kind, n as u64);
305 }
306 });
307}
308
309pub fn note_refusals(class: RefusalClass, n: usize) {
313 if n == 0 {
314 return;
315 }
316 COLLECTOR.with(|c| {
317 if let Some(tally) = c.borrow_mut().as_mut() {
318 let slot = &mut tally.refusals[class as usize];
319 *slot = slot.saturating_add(n as u64);
320 }
321 });
322}
323
324pub fn collect<T>(f: impl FnOnce() -> T) -> (T, SecurityCounts) {
327 let (value, tally) = collect_call(f);
328 (value, tally.counts)
329}
330
331pub fn collect_call<T>(f: impl FnOnce() -> T) -> (T, CallTally) {
333 let outer = COLLECTOR.with(|c| c.borrow_mut().replace(CallTally::default()));
334 let value = f();
335 let tally = COLLECTOR
336 .with(|c| std::mem::replace(&mut *c.borrow_mut(), outer))
337 .unwrap_or_default();
338 if let Some(mut outer) = outer {
339 outer.merge(&tally);
340 COLLECTOR.with(|c| *c.borrow_mut() = Some(outer));
341 }
342 (value, tally)
343}
344
345static PENDING: Mutex<SecurityCounts> = Mutex::new(SecurityCounts::ZERO);
347
348fn session_suffix() -> String {
352 crate::core::value::current_session()
353 .map(|id| format!("|session={id}"))
354 .unwrap_or_default()
355}
356
357fn refusal_action(class: RefusalClass, n: u64, session: &str) -> String {
360 format!(
361 "{}:{n}|reason={}{session}",
362 class.kind().as_str(),
363 class.as_str()
364 )
365}
366
367pub fn record_refusals(tool: &str, agent_id: &str, class: RefusalClass, n: u64) {
370 if n == 0 {
371 return;
372 }
373 let kind = class.kind();
374 crate::core::audit_trail::record(crate::core::audit_trail::AuditEntryData {
375 agent_id: agent_id.to_string(),
376 tool: tool.to_string(),
377 action: Some(refusal_action(class, n, &session_suffix())),
378 input_hash: String::new(),
379 output_tokens: 0,
380 role: crate::core::roles::active_role_name(),
381 event_type: kind.audit_event_type(),
382 });
383 if let Ok(mut pending) = PENDING.lock() {
384 pending.add(kind, n);
385 }
386}
387
388pub fn record_call(tool: &str, agent_id: &str, tally: &CallTally) {
391 record(tool, agent_id, &tally.counts);
392 for class in RefusalClass::ALL {
393 record_refusals(tool, agent_id, class, tally.refusals(class));
394 }
395}
396
397pub fn record(tool: &str, agent_id: &str, counts: &SecurityCounts) {
400 if counts.is_empty() {
401 return;
402 }
403 let role = crate::core::roles::active_role_name();
404 let session = session_suffix();
405 for (kind, n) in counts.iter() {
406 crate::core::audit_trail::record(crate::core::audit_trail::AuditEntryData {
407 agent_id: agent_id.to_string(),
408 tool: tool.to_string(),
409 action: Some(format!("{}:{n}{session}", kind.as_str())),
410 input_hash: String::new(),
411 output_tokens: 0,
412 role: role.clone(),
413 event_type: kind.audit_event_type(),
414 });
415 }
416 if let Ok(mut pending) = PENDING.lock() {
417 pending.merge(counts);
418 }
419}
420
421#[derive(Debug, Clone, PartialEq, Eq)]
424pub struct InjectionFlag {
425 pub object_sha256: String,
427 pub patterns: u8,
429}
430
431const SEEN_INJECTIONS_CAPACITY: usize = 4096;
433
434static SEEN_INJECTIONS: Mutex<Option<lru::LruCache<String, ()>>> = Mutex::new(None);
436
437fn first_sighting(object_sha256: &str) -> bool {
442 let Ok(mut seen) = SEEN_INJECTIONS.lock() else {
443 return false;
445 };
446 let seen = seen.get_or_insert_with(|| {
447 lru::LruCache::new(
448 std::num::NonZeroUsize::new(SEEN_INJECTIONS_CAPACITY).expect("non-zero capacity"),
449 )
450 });
451 seen.put(object_sha256.to_owned(), ()).is_none()
452}
453
454fn injection_action(flag: &InjectionFlag, session: &str) -> String {
458 let prefix: String = flag.object_sha256.chars().take(12).collect();
459 format!(
460 "{}:1|pattern={}|object={prefix}{session}",
461 SecurityKind::InjectionFlagged.as_str(),
462 crate::core::output_sanitizer::InjectionCategory::describe(flag.patterns),
463 )
464}
465
466pub fn record_injections(tool: &str, agent_id: &str, flags: &[InjectionFlag]) -> u64 {
470 let fresh: Vec<&InjectionFlag> = flags
471 .iter()
472 .filter(|flag| first_sighting(&flag.object_sha256))
473 .collect();
474 if fresh.is_empty() {
475 return 0;
476 }
477 let role = crate::core::roles::active_role_name();
478 let session = crate::core::value::current_session()
479 .map(|id| format!("|session={id}"))
480 .unwrap_or_default();
481 for flag in &fresh {
482 crate::core::audit_trail::record(crate::core::audit_trail::AuditEntryData {
483 agent_id: agent_id.to_string(),
484 tool: tool.to_string(),
485 action: Some(injection_action(flag, &session)),
486 input_hash: String::new(),
487 output_tokens: 0,
488 role: role.clone(),
489 event_type: SecurityKind::InjectionFlagged.audit_event_type(),
490 });
491 }
492 let recorded = fresh.len() as u64;
493 if let Ok(mut pending) = PENDING.lock() {
494 pending.add(SecurityKind::InjectionFlagged, recorded);
495 }
496 recorded
497}
498
499pub fn anchor_receipt(tool: &str, agent_id: &str, receipt_sha256_hex: &str) {
503 let session = crate::core::value::current_session()
504 .map(|id| format!("|session={id}"))
505 .unwrap_or_default();
506 crate::core::audit_trail::record(crate::core::audit_trail::AuditEntryData {
507 agent_id: agent_id.to_string(),
508 tool: tool.to_string(),
509 action: Some(format!("gateway_receipt:{receipt_sha256_hex}{session}")),
510 input_hash: String::new(),
511 output_tokens: 0,
512 role: crate::core::roles::active_role_name(),
513 event_type: crate::core::audit_trail::AuditEventType::ToolCall,
514 });
515}
516
517pub fn record_one(tool: &str, agent_id: &str, kind: SecurityKind) {
519 let mut counts = SecurityCounts::default();
520 counts.add(kind, 1);
521 record(tool, agent_id, &counts);
522}
523
524pub fn drain_pending() -> SecurityCounts {
527 PENDING
528 .lock()
529 .map(|mut pending| std::mem::take(&mut *pending))
530 .unwrap_or_default()
531}
532
533pub fn parse_action(action: &str) -> Option<(SecurityKind, u64, Option<&str>)> {
536 let kind = SecurityKind::from_action(action)?;
537 let rest = action.split_once(':').map_or("", |(_, rest)| rest);
538 let (head, session) = match rest.split_once("|session=") {
539 Some((head, session)) => (head, Some(session)),
540 None => (rest, None),
541 };
542 let n = head.split('|').next().unwrap_or(head);
543 Some((kind, n.parse().unwrap_or(1), session))
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 #[test]
551 fn note_outside_collector_is_dropped() {
552 note(SecurityKind::SecretRedacted, 3);
553 let ((), tally) = collect(|| {});
554 assert!(tally.is_empty());
555 }
556
557 #[test]
558 fn collect_counts_only_its_own_handler() {
559 let ((), tally) = collect(|| {
560 note(SecurityKind::SecretRedacted, 2);
561 note(SecurityKind::ShellBlocked, 1);
562 });
563 assert_eq!(tally.secrets_redacted, 2);
564 assert_eq!(tally.shell_blocked, 1);
565 assert_eq!(tally.total(), 3);
566
567 let ((), next) = collect(|| {});
568 assert!(
569 next.is_empty(),
570 "a collector never leaks into the next call"
571 );
572 }
573
574 #[test]
575 fn nested_collect_keeps_outer_total() {
576 let ((), outer) = collect(|| {
577 note(SecurityKind::PathBlocked, 1);
578 let ((), inner) = collect(|| note(SecurityKind::PathBlocked, 1));
579 assert_eq!(inner.path_blocked, 1);
580 });
581 assert_eq!(outer.path_blocked, 2);
582 }
583
584 #[test]
585 fn action_roundtrips_kind() {
586 for kind in [
587 SecurityKind::SecretRedacted,
588 SecurityKind::ShellBlocked,
589 SecurityKind::PathBlocked,
590 SecurityKind::InjectionFlagged,
591 ] {
592 let action = format!("{}:4", kind.as_str());
593 assert_eq!(SecurityKind::from_action(&action), Some(kind));
594 }
595 assert_eq!(SecurityKind::from_action("tool_call"), None);
596 }
597
598 #[test]
599 fn parse_action_reads_count_and_session() {
600 assert_eq!(
601 parse_action("secret_redacted:3|session=abc"),
602 Some((SecurityKind::SecretRedacted, 3, Some("abc")))
603 );
604 assert_eq!(
605 parse_action("shell_blocked:1"),
606 Some((SecurityKind::ShellBlocked, 1, None))
607 );
608 }
609
610 #[test]
611 fn a_flagged_object_is_recorded_once_per_process() {
612 let object = "f".repeat(63) + "1";
613 assert!(first_sighting(&object), "first delivery is recorded");
614 assert!(!first_sighting(&object), "a re-read is not a new finding");
615 assert!(first_sighting(&("f".repeat(63) + "2")));
616 }
617
618 #[test]
619 fn injection_action_explains_without_content_and_parses() {
620 let flag = InjectionFlag {
621 object_sha256: "0123456789abcdef".repeat(4),
622 patterns: crate::core::output_sanitizer::InjectionCategory::RoleMarker.bit(),
623 };
624 let action = injection_action(&flag, "|session=s1");
625 assert_eq!(
626 action,
627 "injection_flagged:1|pattern=role_marker|object=0123456789ab|session=s1"
628 );
629 assert_eq!(
630 parse_action(&action),
631 Some((SecurityKind::InjectionFlagged, 1, Some("s1")))
632 );
633 assert_eq!(
634 parse_action(&injection_action(&flag, "")),
635 Some((SecurityKind::InjectionFlagged, 1, None))
636 );
637 }
638
639 #[test]
640 fn refusal_action_carries_the_class_only_and_parses() {
641 let action = refusal_action(RefusalClass::SecretPath, 1, "|session=s1");
642 assert_eq!(action, "content_withheld:1|reason=secret_path|session=s1");
643 assert_eq!(
644 parse_action(&action),
645 Some((SecurityKind::ContentWithheld, 1, Some("s1")))
646 );
647 let action = refusal_action(RefusalClass::OutsideRoot, 2, "");
648 assert_eq!(action, "path_blocked:2|reason=outside_root");
649 assert_eq!(
650 parse_action(&action),
651 Some((SecurityKind::PathBlocked, 2, None))
652 );
653 }
654
655 #[test]
656 fn refusals_are_collected_per_class_and_kept_apart_from_counts() {
657 let ((), tally) = collect_call(|| {
658 note_refusals(RefusalClass::SecretPath, 2);
659 let ((), inner) = collect_call(|| note_refusals(RefusalClass::OutsideRoot, 1));
660 assert_eq!(inner.refusals(RefusalClass::OutsideRoot), 1);
661 note(SecurityKind::SecretRedacted, 1);
662 });
663 assert_eq!(tally.refusals(RefusalClass::SecretPath), 2);
664 assert_eq!(tally.refusals(RefusalClass::OutsideRoot), 1);
665 assert_eq!(tally.refusals(RefusalClass::ProtectedPath), 0);
666 assert_eq!(
667 tally.counts.total(),
668 1,
669 "a refusal is recorded once, not as a count too"
670 );
671
672 note_refusals(RefusalClass::SecretPath, 1);
673 let ((), next) = collect_call(|| {});
674 assert_eq!(
675 next,
676 CallTally::default(),
677 "outside a collector it is dropped"
678 );
679 }
680
681 #[test]
682 fn a_missing_path_is_no_boundary_refusal() {
683 use crate::core::error::PathJailError;
684 let escape = PathJailError::EscapesRoot {
685 path: "/etc/passwd".into(),
686 root: "/p".into(),
687 hint: String::new(),
688 };
689 assert_eq!(
690 RefusalClass::of_jail_error(&escape),
691 Some(RefusalClass::OutsideRoot)
692 );
693 let symlink = PathJailError::Symlink {
694 path: "/p/l".into(),
695 };
696 assert_eq!(
697 RefusalClass::of_jail_error(&symlink),
698 Some(RefusalClass::ProtectedPath)
699 );
700 let missing = PathJailError::NoExistingAncestor { path: "/x".into() };
701 assert_eq!(RefusalClass::of_jail_error(&missing), None);
702 }
703
704 #[test]
705 fn old_session_json_loads_with_zero_counts() {
706 let counts: SecurityCounts = serde_json::from_str("{}").unwrap();
707 assert!(counts.is_empty());
708 }
709}