use std::cell::RefCell;
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecurityKind {
SecretRedacted,
ShellBlocked,
PathBlocked,
InjectionFlagged,
PiiRedacted,
ContentWithheld,
CoverageIncomplete,
}
impl SecurityKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::SecretRedacted => "secret_redacted",
Self::ShellBlocked => "shell_blocked",
Self::PathBlocked => "path_blocked",
Self::InjectionFlagged => "injection_flagged",
Self::PiiRedacted => "pii_redacted",
Self::ContentWithheld => "content_withheld",
Self::CoverageIncomplete => "coverage_incomplete",
}
}
fn audit_event_type(self) -> crate::core::audit_trail::AuditEventType {
use crate::core::audit_trail::AuditEventType;
match self {
Self::SecretRedacted | Self::PiiRedacted => AuditEventType::SecretDetected,
Self::ShellBlocked | Self::ContentWithheld => AuditEventType::ToolDenied,
Self::PathBlocked => AuditEventType::PathJailViolation,
Self::InjectionFlagged | Self::CoverageIncomplete => AuditEventType::SecurityViolation,
}
}
pub fn from_action(action: &str) -> Option<Self> {
let kind = action.split(':').next()?;
Self::ALL.into_iter().find(|k| k.as_str() == kind)
}
#[must_use]
pub const fn in_signed_tally(self) -> bool {
matches!(
self,
Self::SecretRedacted | Self::ShellBlocked | Self::PathBlocked | Self::InjectionFlagged
)
}
const ALL: [Self; 7] = [
Self::SecretRedacted,
Self::ShellBlocked,
Self::PathBlocked,
Self::InjectionFlagged,
Self::PiiRedacted,
Self::ContentWithheld,
Self::CoverageIncomplete,
];
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SecurityCounts {
pub secrets_redacted: u64,
pub shell_blocked: u64,
pub path_blocked: u64,
pub injection_flagged: u64,
#[serde(skip_serializing_if = "is_zero")]
pub pii_redacted: u64,
#[serde(skip_serializing_if = "is_zero")]
pub content_withheld: u64,
#[serde(skip_serializing_if = "is_zero")]
pub coverage_incomplete: u64,
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_zero(n: &u64) -> bool {
*n == 0
}
impl SecurityCounts {
#[must_use]
pub fn signed_tally_projection(&self) -> Self {
Self {
pii_redacted: 0,
content_withheld: 0,
coverage_incomplete: 0,
..*self
}
}
pub const ZERO: Self = Self {
secrets_redacted: 0,
shell_blocked: 0,
path_blocked: 0,
injection_flagged: 0,
pii_redacted: 0,
content_withheld: 0,
coverage_incomplete: 0,
};
fn slot(&mut self, kind: SecurityKind) -> &mut u64 {
match kind {
SecurityKind::SecretRedacted => &mut self.secrets_redacted,
SecurityKind::ShellBlocked => &mut self.shell_blocked,
SecurityKind::PathBlocked => &mut self.path_blocked,
SecurityKind::InjectionFlagged => &mut self.injection_flagged,
SecurityKind::PiiRedacted => &mut self.pii_redacted,
SecurityKind::ContentWithheld => &mut self.content_withheld,
SecurityKind::CoverageIncomplete => &mut self.coverage_incomplete,
}
}
fn get(&self, kind: SecurityKind) -> u64 {
match kind {
SecurityKind::SecretRedacted => self.secrets_redacted,
SecurityKind::ShellBlocked => self.shell_blocked,
SecurityKind::PathBlocked => self.path_blocked,
SecurityKind::InjectionFlagged => self.injection_flagged,
SecurityKind::PiiRedacted => self.pii_redacted,
SecurityKind::ContentWithheld => self.content_withheld,
SecurityKind::CoverageIncomplete => self.coverage_incomplete,
}
}
pub fn add(&mut self, kind: SecurityKind, n: u64) {
let slot = self.slot(kind);
*slot = slot.saturating_add(n);
}
pub fn merge(&mut self, other: &Self) {
for kind in SecurityKind::ALL {
self.add(kind, other.get(kind));
}
}
#[must_use]
pub fn since(&self, base: &Self) -> Self {
let mut out = Self::ZERO;
for kind in SecurityKind::ALL {
*out.slot(kind) = self.get(kind).saturating_sub(base.get(kind));
}
out
}
pub fn total(&self) -> u64 {
SecurityKind::ALL
.into_iter()
.fold(0u64, |sum, kind| sum.saturating_add(self.get(kind)))
}
pub fn is_empty(&self) -> bool {
self.total() == 0
}
fn iter(&self) -> impl Iterator<Item = (SecurityKind, u64)> + '_ {
SecurityKind::ALL
.into_iter()
.map(|kind| (kind, self.get(kind)))
.filter(|(_, n)| *n > 0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefusalClass {
SecretPath,
OutsideRoot,
ProtectedPath,
}
impl RefusalClass {
pub const fn as_str(self) -> &'static str {
match self {
Self::SecretPath => "secret_path",
Self::OutsideRoot => "outside_root",
Self::ProtectedPath => "protected_path",
}
}
pub const fn kind(self) -> SecurityKind {
match self {
Self::SecretPath => SecurityKind::ContentWithheld,
Self::OutsideRoot | Self::ProtectedPath => SecurityKind::PathBlocked,
}
}
pub fn of_jail_error(error: &crate::core::error::PathJailError) -> Option<Self> {
use crate::core::error::PathJailError;
match error {
PathJailError::EscapesRoot { .. }
| PathJailError::PostCanonicalizeEscape { .. }
| PathJailError::OutsideWorkspace { .. }
| PathJailError::WorkspaceUnavailable { .. } => Some(Self::OutsideRoot),
PathJailError::ProtectedContext { .. } | PathJailError::Symlink { .. } => {
Some(Self::ProtectedPath)
}
PathJailError::NullByte | PathJailError::NoExistingAncestor { .. } => None,
}
}
const ALL: [Self; 3] = [Self::SecretPath, Self::OutsideRoot, Self::ProtectedPath];
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CallTally {
pub counts: SecurityCounts,
refusals: [u64; RefusalClass::ALL.len()],
}
impl CallTally {
fn merge(&mut self, other: &Self) {
self.counts.merge(&other.counts);
for (mine, theirs) in self.refusals.iter_mut().zip(other.refusals) {
*mine = mine.saturating_add(theirs);
}
}
#[must_use]
pub fn refusals(&self, class: RefusalClass) -> u64 {
self.refusals[class as usize]
}
}
thread_local! {
static COLLECTOR: RefCell<Option<CallTally>> = const { RefCell::new(None) };
}
pub fn note(kind: SecurityKind, n: usize) {
if n == 0 {
return;
}
COLLECTOR.with(|c| {
if let Some(tally) = c.borrow_mut().as_mut() {
tally.counts.add(kind, n as u64);
}
});
}
pub fn note_refusals(class: RefusalClass, n: usize) {
if n == 0 {
return;
}
COLLECTOR.with(|c| {
if let Some(tally) = c.borrow_mut().as_mut() {
let slot = &mut tally.refusals[class as usize];
*slot = slot.saturating_add(n as u64);
}
});
}
pub fn collect<T>(f: impl FnOnce() -> T) -> (T, SecurityCounts) {
let (value, tally) = collect_call(f);
(value, tally.counts)
}
pub fn collect_call<T>(f: impl FnOnce() -> T) -> (T, CallTally) {
let outer = COLLECTOR.with(|c| c.borrow_mut().replace(CallTally::default()));
let value = f();
let tally = COLLECTOR
.with(|c| std::mem::replace(&mut *c.borrow_mut(), outer))
.unwrap_or_default();
if let Some(mut outer) = outer {
outer.merge(&tally);
COLLECTOR.with(|c| *c.borrow_mut() = Some(outer));
}
(value, tally)
}
static PENDING: Mutex<SecurityCounts> = Mutex::new(SecurityCounts::ZERO);
fn session_suffix() -> String {
crate::core::value::current_session()
.map(|id| format!("|session={id}"))
.unwrap_or_default()
}
fn refusal_action(class: RefusalClass, n: u64, session: &str) -> String {
format!(
"{}:{n}|reason={}{session}",
class.kind().as_str(),
class.as_str()
)
}
pub fn record_refusals(tool: &str, agent_id: &str, class: RefusalClass, n: u64) {
if n == 0 {
return;
}
let kind = class.kind();
crate::core::audit_trail::record(crate::core::audit_trail::AuditEntryData {
agent_id: agent_id.to_string(),
tool: tool.to_string(),
action: Some(refusal_action(class, n, &session_suffix())),
input_hash: String::new(),
output_tokens: 0,
role: crate::core::roles::active_role_name(),
event_type: kind.audit_event_type(),
});
if let Ok(mut pending) = PENDING.lock() {
pending.add(kind, n);
}
}
pub fn record_call(tool: &str, agent_id: &str, tally: &CallTally) {
record(tool, agent_id, &tally.counts);
for class in RefusalClass::ALL {
record_refusals(tool, agent_id, class, tally.refusals(class));
}
}
pub fn record(tool: &str, agent_id: &str, counts: &SecurityCounts) {
if counts.is_empty() {
return;
}
let role = crate::core::roles::active_role_name();
let session = session_suffix();
for (kind, n) in counts.iter() {
crate::core::audit_trail::record(crate::core::audit_trail::AuditEntryData {
agent_id: agent_id.to_string(),
tool: tool.to_string(),
action: Some(format!("{}:{n}{session}", kind.as_str())),
input_hash: String::new(),
output_tokens: 0,
role: role.clone(),
event_type: kind.audit_event_type(),
});
}
if let Ok(mut pending) = PENDING.lock() {
pending.merge(counts);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InjectionFlag {
pub object_sha256: String,
pub patterns: u8,
}
const SEEN_INJECTIONS_CAPACITY: usize = 4096;
static SEEN_INJECTIONS: Mutex<Option<lru::LruCache<String, ()>>> = Mutex::new(None);
fn first_sighting(object_sha256: &str) -> bool {
let Ok(mut seen) = SEEN_INJECTIONS.lock() else {
return false;
};
let seen = seen.get_or_insert_with(|| {
lru::LruCache::new(
std::num::NonZeroUsize::new(SEEN_INJECTIONS_CAPACITY).expect("non-zero capacity"),
)
});
seen.put(object_sha256.to_owned(), ()).is_none()
}
fn injection_action(flag: &InjectionFlag, session: &str) -> String {
let prefix: String = flag.object_sha256.chars().take(12).collect();
format!(
"{}:1|pattern={}|object={prefix}{session}",
SecurityKind::InjectionFlagged.as_str(),
crate::core::output_sanitizer::InjectionCategory::describe(flag.patterns),
)
}
pub fn record_injections(tool: &str, agent_id: &str, flags: &[InjectionFlag]) -> u64 {
let fresh: Vec<&InjectionFlag> = flags
.iter()
.filter(|flag| first_sighting(&flag.object_sha256))
.collect();
if fresh.is_empty() {
return 0;
}
let role = crate::core::roles::active_role_name();
let session = crate::core::value::current_session()
.map(|id| format!("|session={id}"))
.unwrap_or_default();
for flag in &fresh {
crate::core::audit_trail::record(crate::core::audit_trail::AuditEntryData {
agent_id: agent_id.to_string(),
tool: tool.to_string(),
action: Some(injection_action(flag, &session)),
input_hash: String::new(),
output_tokens: 0,
role: role.clone(),
event_type: SecurityKind::InjectionFlagged.audit_event_type(),
});
}
let recorded = fresh.len() as u64;
if let Ok(mut pending) = PENDING.lock() {
pending.add(SecurityKind::InjectionFlagged, recorded);
}
recorded
}
pub fn anchor_receipt(tool: &str, agent_id: &str, receipt_sha256_hex: &str) {
let session = crate::core::value::current_session()
.map(|id| format!("|session={id}"))
.unwrap_or_default();
crate::core::audit_trail::record(crate::core::audit_trail::AuditEntryData {
agent_id: agent_id.to_string(),
tool: tool.to_string(),
action: Some(format!("gateway_receipt:{receipt_sha256_hex}{session}")),
input_hash: String::new(),
output_tokens: 0,
role: crate::core::roles::active_role_name(),
event_type: crate::core::audit_trail::AuditEventType::ToolCall,
});
}
pub fn record_one(tool: &str, agent_id: &str, kind: SecurityKind) {
let mut counts = SecurityCounts::default();
counts.add(kind, 1);
record(tool, agent_id, &counts);
}
pub fn drain_pending() -> SecurityCounts {
PENDING
.lock()
.map(|mut pending| std::mem::take(&mut *pending))
.unwrap_or_default()
}
pub fn parse_action(action: &str) -> Option<(SecurityKind, u64, Option<&str>)> {
let kind = SecurityKind::from_action(action)?;
let rest = action.split_once(':').map_or("", |(_, rest)| rest);
let (head, session) = match rest.split_once("|session=") {
Some((head, session)) => (head, Some(session)),
None => (rest, None),
};
let n = head.split('|').next().unwrap_or(head);
Some((kind, n.parse().unwrap_or(1), session))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn note_outside_collector_is_dropped() {
note(SecurityKind::SecretRedacted, 3);
let ((), tally) = collect(|| {});
assert!(tally.is_empty());
}
#[test]
fn collect_counts_only_its_own_handler() {
let ((), tally) = collect(|| {
note(SecurityKind::SecretRedacted, 2);
note(SecurityKind::ShellBlocked, 1);
});
assert_eq!(tally.secrets_redacted, 2);
assert_eq!(tally.shell_blocked, 1);
assert_eq!(tally.total(), 3);
let ((), next) = collect(|| {});
assert!(
next.is_empty(),
"a collector never leaks into the next call"
);
}
#[test]
fn nested_collect_keeps_outer_total() {
let ((), outer) = collect(|| {
note(SecurityKind::PathBlocked, 1);
let ((), inner) = collect(|| note(SecurityKind::PathBlocked, 1));
assert_eq!(inner.path_blocked, 1);
});
assert_eq!(outer.path_blocked, 2);
}
#[test]
fn action_roundtrips_kind() {
for kind in [
SecurityKind::SecretRedacted,
SecurityKind::ShellBlocked,
SecurityKind::PathBlocked,
SecurityKind::InjectionFlagged,
] {
let action = format!("{}:4", kind.as_str());
assert_eq!(SecurityKind::from_action(&action), Some(kind));
}
assert_eq!(SecurityKind::from_action("tool_call"), None);
}
#[test]
fn parse_action_reads_count_and_session() {
assert_eq!(
parse_action("secret_redacted:3|session=abc"),
Some((SecurityKind::SecretRedacted, 3, Some("abc")))
);
assert_eq!(
parse_action("shell_blocked:1"),
Some((SecurityKind::ShellBlocked, 1, None))
);
}
#[test]
fn a_flagged_object_is_recorded_once_per_process() {
let object = "f".repeat(63) + "1";
assert!(first_sighting(&object), "first delivery is recorded");
assert!(!first_sighting(&object), "a re-read is not a new finding");
assert!(first_sighting(&("f".repeat(63) + "2")));
}
#[test]
fn injection_action_explains_without_content_and_parses() {
let flag = InjectionFlag {
object_sha256: "0123456789abcdef".repeat(4),
patterns: crate::core::output_sanitizer::InjectionCategory::RoleMarker.bit(),
};
let action = injection_action(&flag, "|session=s1");
assert_eq!(
action,
"injection_flagged:1|pattern=role_marker|object=0123456789ab|session=s1"
);
assert_eq!(
parse_action(&action),
Some((SecurityKind::InjectionFlagged, 1, Some("s1")))
);
assert_eq!(
parse_action(&injection_action(&flag, "")),
Some((SecurityKind::InjectionFlagged, 1, None))
);
}
#[test]
fn refusal_action_carries_the_class_only_and_parses() {
let action = refusal_action(RefusalClass::SecretPath, 1, "|session=s1");
assert_eq!(action, "content_withheld:1|reason=secret_path|session=s1");
assert_eq!(
parse_action(&action),
Some((SecurityKind::ContentWithheld, 1, Some("s1")))
);
let action = refusal_action(RefusalClass::OutsideRoot, 2, "");
assert_eq!(action, "path_blocked:2|reason=outside_root");
assert_eq!(
parse_action(&action),
Some((SecurityKind::PathBlocked, 2, None))
);
}
#[test]
fn refusals_are_collected_per_class_and_kept_apart_from_counts() {
let ((), tally) = collect_call(|| {
note_refusals(RefusalClass::SecretPath, 2);
let ((), inner) = collect_call(|| note_refusals(RefusalClass::OutsideRoot, 1));
assert_eq!(inner.refusals(RefusalClass::OutsideRoot), 1);
note(SecurityKind::SecretRedacted, 1);
});
assert_eq!(tally.refusals(RefusalClass::SecretPath), 2);
assert_eq!(tally.refusals(RefusalClass::OutsideRoot), 1);
assert_eq!(tally.refusals(RefusalClass::ProtectedPath), 0);
assert_eq!(
tally.counts.total(),
1,
"a refusal is recorded once, not as a count too"
);
note_refusals(RefusalClass::SecretPath, 1);
let ((), next) = collect_call(|| {});
assert_eq!(
next,
CallTally::default(),
"outside a collector it is dropped"
);
}
#[test]
fn a_missing_path_is_no_boundary_refusal() {
use crate::core::error::PathJailError;
let escape = PathJailError::EscapesRoot {
path: "/etc/passwd".into(),
root: "/p".into(),
hint: String::new(),
};
assert_eq!(
RefusalClass::of_jail_error(&escape),
Some(RefusalClass::OutsideRoot)
);
let symlink = PathJailError::Symlink {
path: "/p/l".into(),
};
assert_eq!(
RefusalClass::of_jail_error(&symlink),
Some(RefusalClass::ProtectedPath)
);
let missing = PathJailError::NoExistingAncestor { path: "/x".into() };
assert_eq!(RefusalClass::of_jail_error(&missing), None);
}
#[test]
fn old_session_json_loads_with_zero_counts() {
let counts: SecurityCounts = serde_json::from_str("{}").unwrap();
assert!(counts.is_empty());
}
}