Skip to main content

lean_ctx/core/
security_events.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Provable security events for the value surface.
3//!
4//! lean-ctx's default-on protections (secret redaction, the shell allowlist,
5//! the path jail, prompt-injection screening) used to act silently: nothing a
6//! user could later point to. This module turns each firing into
7//!
8//! 1. a hash-chained, signed [`crate::core::audit_trail`] entry — the proof, and
9//! 2. a session counter ([`SecurityCounts`]) — what the status line shows.
10//!
11//! Counting is **once per tool result**. Deep code (redaction, allowlist) calls
12//! [`note`], which only lands in the collector that
13//! [`collect`] opens around one MCP tool handler; the dispatch layer then
14//! records the tally for that single result. `note` outside a collector (CLI,
15//! hooks, config loading) is deliberately dropped, so a secret redacted twice
16//! on its way through nested passes, or a re-rendered cache entry outside a
17//! tool call, never inflates the count. Undercounting is acceptable; claiming
18//! more than happened is not. Prompt-injection flags go further: one per
19//! flagged object per process ([`record_injections`]), however often the same
20//! object is delivered again.
21//!
22//! The audit trail's `event_type` enum is **not** extended: an older binary
23//! verifying a trail that contains an unknown variant would report it as
24//! tampered. The kind is committed in the (hashed) `action` field instead.
25
26use std::cell::RefCell;
27use std::sync::Mutex;
28
29use serde::{Deserialize, Serialize};
30
31/// What a protection did. The wording each kind renders with is part of the
32/// honesty contract: injections are *flagged*, never "neutralized".
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum SecurityKind {
35    /// A secret was replaced by `[REDACTED:…]` before the model saw it.
36    SecretRedacted,
37    /// A shell command was refused by the allowlist / dangerous-pattern gate.
38    ShellBlocked,
39    /// A path outside the project jail was refused.
40    PathBlocked,
41    /// Tool output matched prompt-injection patterns and was flagged.
42    InjectionFlagged,
43    /// A checksum-validated PII value was masked by the context gateway.
44    PiiRedacted,
45    /// The context gateway withheld a source entirely.
46    ContentWithheld,
47    /// A source was delivered although a detector did not inspect all of it.
48    CoverageIncomplete,
49}
50
51impl SecurityKind {
52    /// Stable identifier committed into the audit trail's `action` field.
53    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    /// Existing trail event types only: an unknown variant would read as
66    /// tampering to an older verifier, so the kind lives in `action`.
67    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    /// Parses the `action` prefix written by [`record`] back into a kind.
78    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    /// Kinds a signed savings-batch tally carries; server mirrors verify the
84    /// tally field by field and predate the context-gateway kinds.
85    #[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/// Per-kind event counts. Persisted inside `SessionStats`, so every field is
105/// `serde(default)` and old session files load unchanged.
106///
107/// The context-gateway kinds are written only when non-zero: every count
108/// serialized before they existed keeps its exact bytes (signed tallies, see
109/// [`Self::signed_tally_projection`]).
110#[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    /// The counts a signed savings-batch tally carries. Server mirrors verify
132    /// the tally field by field and do not know the gateway kinds yet, so they
133    /// are left out (zero, hence not serialized) instead of breaking the
134    /// signature; the audit trail still proves them locally.
135    #[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    /// Events since `base` (a counter never goes below zero).
191    #[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/// Why the I/O boundary refused a path a call asked for. The trail records
219/// this class only: like every other entry, never the path, a digest of it
220/// or any content.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum RefusalClass {
223    /// A secret-like path (`.env`, key material, credential files): withheld.
224    SecretPath,
225    /// Outside the project root, its trusted roots or the Workspace.
226    OutsideRoot,
227    /// lean-ctx's own runtime context, or a symlink inside the jail.
228    ProtectedPath,
229}
230
231impl RefusalClass {
232    /// Stable identifier committed as `reason=` into the trail's `action`.
233    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    /// The counted kind: a secret path is withheld content; every other class
242    /// is the path jail's existing `path_blocked`.
243    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    /// The class of a path-jail refusal. A path that simply does not exist
251    /// (or a malformed one) is no boundary decision and has none.
252    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/// What one tool call noted: the per-kind counts and the boundary refusals,
270/// which are recorded with their [`RefusalClass`].
271#[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    /// Refusals noted for `class`.
286    #[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
296/// Notes `n` firings of `kind` into the collector of the tool call running on
297/// this thread. A no-op outside [`collect`] — see the module docs for why.
298pub 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
309/// Notes `n` boundary refusals of `class` into the running call's collector,
310/// like [`note`] (a no-op outside one). A call that refuses several paths of
311/// one class gets one trail entry carrying the count.
312pub 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
324/// Runs `f` (one synchronous tool handler) with a fresh collector and returns
325/// what it noted. Nested calls keep the outer collector's tally intact.
326pub fn collect<T>(f: impl FnOnce() -> T) -> (T, SecurityCounts) {
327    let (value, tally) = collect_call(f);
328    (value, tally.counts)
329}
330
331/// [`collect`], including the call's boundary refusals ([`record_call`]).
332pub 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
345/// Counts recorded since the MCP server last drained them into its session.
346static PENDING: Mutex<SecurityCounts> = Mutex::new(SecurityCounts::ZERO);
347
348/// The session suffix of a trail `action`. The session id rides in the hashed
349/// `action` field (the trail's timestamp is not hashed), so per-session
350/// security claims are chain-backed.
351fn session_suffix() -> String {
352    crate::core::value::current_session()
353        .map(|id| format!("|session={id}"))
354        .unwrap_or_default()
355}
356
357/// The audit `action` of `n` refusals: the count first (older readers parse
358/// `<kind>:<n>` up to the session), then the reason class, then the session.
359fn 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
367/// Records `n` boundary refusals of `class` for one tool call: one trail
368/// entry with the reason class (never the path) plus the session counter.
369pub 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
388/// Records one tool call's [`CallTally`]: the counted kinds ([`record`]) and
389/// one entry per refusal class.
390pub 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
397/// Records one tool result's security tally: one audit-trail entry per kind
398/// (the proof) plus the pending session counters (the display).
399pub 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/// One object the prompt-injection heuristic flagged: which pattern families
422/// matched and the object's SHA-256 — never its content.
423#[derive(Debug, Clone, PartialEq, Eq)]
424pub struct InjectionFlag {
425    /// Lowercase hex SHA-256 of the flagged object.
426    pub object_sha256: String,
427    /// [`crate::core::output_sanitizer::InjectionCategory`] bits.
428    pub patterns: u8,
429}
430
431/// Flagged objects remembered per process (a few hundred KiB at most).
432const SEEN_INJECTIONS_CAPACITY: usize = 4096;
433
434/// Digests of flagged objects recorded by this process, bounded LRU.
435static SEEN_INJECTIONS: Mutex<Option<lru::LruCache<String, ()>>> = Mutex::new(None);
436
437/// Whether this process records `object_sha256` for the first time. The
438/// proxy re-admits the unchanged history of every turn and agents re-read the
439/// same files: the object is one finding, not one per delivery. Receipts
440/// still carry the per-delivery signal.
441fn first_sighting(object_sha256: &str) -> bool {
442    let Ok(mut seen) = SEEN_INJECTIONS.lock() else {
443        // Undercounting is acceptable; claiming more than happened is not.
444        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
454/// The audit `action` of one flagged object: the count first (older readers
455/// parse `<kind>:<n>` up to the session), then what matched and a short
456/// object digest prefix, then the session.
457fn 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
466/// Records flagged objects: one audit-trail entry per object this process has
467/// not recorded yet (with the pattern kinds and a digest prefix, never
468/// content) plus the pending session counter. Returns how many were recorded.
469pub 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
499/// Anchors a Context Gateway receipt in the signed, hash-chained audit trail:
500/// the digest in the hashed `action` binds the stored receipt to the chain.
501/// Not a counted kind (`parse_action` ignores it), so nothing is counted twice.
502pub 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
517/// Convenience for a single event recorded outside a handler (dispatch layer).
518pub 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
524/// Takes the counts recorded since the last drain (the MCP server folds them
525/// into the current session's stats).
526pub fn drain_pending() -> SecurityCounts {
527    PENDING
528        .lock()
529        .map(|mut pending| std::mem::take(&mut *pending))
530        .unwrap_or_default()
531}
532
533/// Splits a recorded `action` (`<kind>:<n>[|<detail>…][|session=<id>]`) into
534/// its parts; details (an injection flag's pattern kinds) are skipped.
535pub 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}