use parking_lot::RwLock;
use std::collections::HashSet;
use std::sync::{
Arc,
atomic::{AtomicU32, Ordering},
};
use tokio::sync::Mutex;
use tokio::task::JoinSet;
use serde_json::Value as JsonValue;
use tracing::{Instrument as _, info_span};
use zeph_db::{DbPool, sql};
use zeph_llm::LlmProvider;
use zeph_llm::any::AnyProvider;
use zeph_llm::provider::{Message, Role};
use zeph_common::SessionId;
use crate::agent::error::AgentError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ToolRiskCategory {
Shell,
FileWrite,
ExfilCapable,
McpUnclassified,
Low,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ProbeVerdict {
Allow,
Deny {
reason: String,
},
Skip,
}
#[derive(Debug, Clone)]
pub struct SentinelEvent {
pub id: i64,
pub session_id: SessionId,
pub turn_number: u64,
pub event_type: String,
pub tool_id: Option<String>,
pub risk_signal: Option<String>,
pub risk_level: String,
pub probe_verdict: Option<String>,
pub context_summary: Option<String>,
pub created_at: i64,
}
pub trait SafetyProbe: Send + Sync {
fn evaluate<'a>(
&'a self,
tool_id: &'a str,
tool_args: &'a JsonValue,
trajectory: &'a [SentinelEvent],
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ProbeVerdict> + Send + 'a>>;
}
pub struct LlmSafetyProbe {
provider: Arc<AnyProvider>,
timeout_ms: u64,
deny_on_timeout: bool,
}
impl LlmSafetyProbe {
#[must_use]
pub fn new(provider: Arc<AnyProvider>, timeout_ms: u64, deny_on_timeout: bool) -> Self {
Self {
provider,
timeout_ms,
deny_on_timeout,
}
}
fn build_prompt(
tool_id: &str,
tool_args: &JsonValue,
trajectory: &[SentinelEvent],
) -> Vec<Message> {
let context = if trajectory.is_empty() {
"No prior events in this session.".to_owned()
} else {
trajectory
.iter()
.map(|e| {
format!(
"[turn {}] {} | tool={} | {}",
e.turn_number,
e.event_type,
e.tool_id.as_deref().unwrap_or("-"),
e.context_summary.as_deref().unwrap_or(""),
)
})
.collect::<Vec<_>>()
.join("\n")
};
let args_preview = serde_json::to_string(tool_args)
.unwrap_or_default()
.chars()
.take(500)
.collect::<String>();
let system = "You are a safety evaluator for an AI agent. \
Evaluate whether the proposed tool call is safe to execute given the session context. \
Reply ONLY with a JSON object: {\"verdict\": \"allow\"} or {\"verdict\": \"deny\", \"reason\": \"...\"}. \
Deny only if there is clear evidence of harmful intent or a dangerous pattern. \
When uncertain, allow.";
let user =
format!("Tool: {tool_id}\nArgs: {args_preview}\n\nRecent session events:\n{context}");
vec![
Message::from_legacy(Role::System, system),
Message::from_legacy(Role::User, user),
]
}
fn parse_verdict(response: &str) -> ProbeVerdict {
let start = response.find('{');
let end = response.rfind('}');
if let (Some(s), Some(e)) = (start, end)
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(&response[s..=e])
{
match v.get("verdict").and_then(|x| x.as_str()) {
Some("allow") => return ProbeVerdict::Allow,
Some("deny") => {
let reason = v
.get("reason")
.and_then(|r| r.as_str())
.unwrap_or("safety probe denied this tool call")
.to_owned();
return ProbeVerdict::Deny { reason };
}
_ => {}
}
}
tracing::warn!(
raw = %response,
"ShadowSentinel: probe response could not be parsed, defaulting to Allow"
);
ProbeVerdict::Allow
}
}
impl SafetyProbe for LlmSafetyProbe {
fn evaluate<'a>(
&'a self,
tool_id: &'a str,
tool_args: &'a JsonValue,
trajectory: &'a [SentinelEvent],
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ProbeVerdict> + Send + 'a>> {
let span = info_span!("security.shadow.probe", tool_id = %tool_id);
Box::pin(
async move {
let messages = Self::build_prompt(tool_id, tool_args, trajectory);
let timeout = std::time::Duration::from_millis(self.timeout_ms);
match tokio::time::timeout(timeout, self.provider.chat(&messages)).await {
Ok(Ok(response)) => Self::parse_verdict(&response),
Ok(Err(e)) => {
tracing::warn!(error = %e, "ShadowSentinel: probe LLM error");
if self.deny_on_timeout {
ProbeVerdict::Deny {
reason: format!("probe LLM error: {e}"),
}
} else {
ProbeVerdict::Allow
}
}
Err(_) => {
tracing::warn!(
timeout_ms = self.timeout_ms,
"ShadowSentinel: probe timed out"
);
if self.deny_on_timeout {
ProbeVerdict::Deny {
reason: "safety probe timed out".to_owned(),
}
} else {
ProbeVerdict::Allow
}
}
}
}
.instrument(span),
)
}
}
#[derive(Clone)]
pub struct ShadowEventStore {
pool: DbPool,
}
impl ShadowEventStore {
#[must_use]
pub fn new(pool: DbPool) -> Self {
Self { pool }
}
#[tracing::instrument(name = "security.shadow.record", skip_all, fields(event_type = %event.event_type))]
pub async fn record(&self, event: &SentinelEvent) -> Result<(), AgentError> {
zeph_db::query(sql!(
"INSERT INTO safety_shadow_events \
(session_id, turn_number, event_type, tool_id, risk_signal, risk_level, \
probe_verdict, context_summary, created_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
))
.bind(event.session_id.as_str())
.bind(i64::try_from(event.turn_number).unwrap_or(i64::MAX))
.bind(&event.event_type)
.bind(&event.tool_id)
.bind(&event.risk_signal)
.bind(&event.risk_level)
.bind(&event.probe_verdict)
.bind(&event.context_summary)
.bind(event.created_at)
.execute(&self.pool)
.await
.map_err(|e| AgentError::Db(e.into()))?;
Ok(())
}
#[tracing::instrument(name = "security.shadow.get_trajectory", skip(self), fields(session_id = %session_id))]
pub async fn get_trajectory(
&self,
session_id: &str,
limit: usize,
) -> Result<Vec<SentinelEvent>, AgentError> {
let rows = zeph_db::query_as::<_, ShadowEventRow>(sql!(
"SELECT id, session_id, turn_number, event_type, tool_id, risk_signal, \
risk_level, probe_verdict, context_summary, created_at \
FROM safety_shadow_events \
WHERE session_id = ? \
ORDER BY created_at DESC \
LIMIT ?"
))
.bind(session_id)
.bind(i64::try_from(limit).unwrap_or(i64::MAX))
.fetch_all(&self.pool)
.await
.map_err(|e| AgentError::Db(e.into()))?;
let mut events: Vec<SentinelEvent> = rows.into_iter().map(SentinelEvent::from).collect();
events.reverse();
Ok(events)
}
#[tracing::instrument(name = "security.shadow.get_tool_history", skip(self), fields(tool_id = %tool_id))]
pub async fn get_tool_history(
&self,
tool_id: &str,
exclude_session_id: &str,
limit: usize,
) -> Result<Vec<SentinelEvent>, AgentError> {
let rows = zeph_db::query_as::<_, ShadowEventRow>(sql!(
"SELECT id, session_id, turn_number, event_type, tool_id, risk_signal, \
risk_level, probe_verdict, context_summary, created_at \
FROM safety_shadow_events \
WHERE tool_id = ? AND session_id != ? \
ORDER BY created_at DESC \
LIMIT ?"
))
.bind(tool_id)
.bind(exclude_session_id)
.bind(i64::try_from(limit).unwrap_or(i64::MAX))
.fetch_all(&self.pool)
.await
.map_err(|e| AgentError::Db(e.into()))?;
Ok(rows.into_iter().map(SentinelEvent::from).collect())
}
}
#[derive(sqlx::FromRow)]
struct ShadowEventRow {
id: i64,
session_id: String,
turn_number: i64,
event_type: String,
tool_id: Option<String>,
risk_signal: Option<String>,
risk_level: String,
probe_verdict: Option<String>,
context_summary: Option<String>,
created_at: i64,
}
impl From<ShadowEventRow> for SentinelEvent {
fn from(r: ShadowEventRow) -> Self {
Self {
id: r.id,
session_id: SessionId::new(r.session_id),
turn_number: u64::try_from(r.turn_number).unwrap_or(0),
event_type: r.event_type,
tool_id: r.tool_id,
risk_signal: r.risk_signal,
risk_level: r.risk_level,
probe_verdict: r.probe_verdict,
context_summary: r.context_summary,
created_at: r.created_at,
}
}
}
const MAX_PENDING_WRITES: usize = 32;
pub struct ShadowSentinel {
store: ShadowEventStore,
probe: Box<dyn SafetyProbe>,
config: zeph_config::ShadowSentinelConfig,
probes_this_turn: AtomicU32,
exfil_probes_this_turn: AtomicU32,
session_id: SessionId,
pending_writes: Mutex<JoinSet<()>>,
mcp_tool_ids: Arc<RwLock<HashSet<String>>>,
}
impl ShadowSentinel {
#[must_use]
pub fn new(
store: ShadowEventStore,
probe: Box<dyn SafetyProbe>,
config: zeph_config::ShadowSentinelConfig,
session_id: impl Into<SessionId>,
) -> Self {
Self {
store,
probe,
config,
probes_this_turn: AtomicU32::new(0),
exfil_probes_this_turn: AtomicU32::new(0),
session_id: session_id.into(),
pending_writes: Mutex::new(JoinSet::new()),
mcp_tool_ids: Arc::new(RwLock::new(HashSet::new())),
}
}
#[must_use]
pub fn mcp_tool_ids_handle(&self) -> Arc<RwLock<HashSet<String>>> {
Arc::clone(&self.mcp_tool_ids)
}
fn is_mcp_tool(&self, tool_id: &str) -> bool {
self.mcp_tool_ids.read().contains(tool_id)
}
#[must_use]
pub fn classify_tool(&self, qualified_tool_id: &str) -> ToolRiskCategory {
if qualified_tool_id == "builtin:shell"
|| qualified_tool_id == "builtin:bash"
|| qualified_tool_id.starts_with("builtin:shell")
|| qualified_tool_id == "bash"
|| qualified_tool_id == "shell"
|| qualified_tool_id == "sh"
{
return ToolRiskCategory::Shell;
}
if qualified_tool_id == "builtin:write"
|| qualified_tool_id == "builtin:edit"
|| qualified_tool_id == "builtin:delete"
|| qualified_tool_id == "write"
|| qualified_tool_id == "edit"
|| qualified_tool_id == "delete"
{
return ToolRiskCategory::FileWrite;
}
for pattern in &self.config.probe_patterns {
if glob_matches(pattern, qualified_tool_id) {
if pattern.contains("shell") || pattern.contains("exec") {
return ToolRiskCategory::Shell;
}
if pattern.contains("write")
|| pattern.contains("edit")
|| pattern.contains("delete")
|| pattern.contains("file")
{
if self.is_mcp_tool(qualified_tool_id) {
return ToolRiskCategory::ExfilCapable;
}
return ToolRiskCategory::FileWrite;
}
return ToolRiskCategory::ExfilCapable;
}
}
if self.is_mcp_tool(qualified_tool_id) {
return ToolRiskCategory::McpUnclassified;
}
ToolRiskCategory::Low
}
fn probe_budget_exhausted(&self, category: ToolRiskCategory) -> bool {
let max_probes = u32::try_from(self.config.max_probes_per_turn).unwrap_or(u32::MAX);
if category == ToolRiskCategory::ExfilCapable {
let exfil_max = max_probes.saturating_mul(2);
let count = self.exfil_probes_this_turn.fetch_add(1, Ordering::Relaxed);
if count >= exfil_max {
self.exfil_probes_this_turn.fetch_sub(1, Ordering::Relaxed);
tracing::debug!(
max = exfil_max,
"ShadowSentinel: ExfilCapable probe budget exhausted for this turn, skipping"
);
return true;
}
return false;
}
let count = self.probes_this_turn.fetch_add(1, Ordering::Relaxed);
let effective_max = if category == ToolRiskCategory::McpUnclassified {
max_probes.saturating_sub(1)
} else {
max_probes
};
if count >= effective_max {
self.probes_this_turn.fetch_sub(1, Ordering::Relaxed);
tracing::debug!(
max = self.config.max_probes_per_turn,
?category,
"ShadowSentinel: probe budget exhausted for this turn, skipping"
);
return true;
}
false
}
async fn load_probe_context(&self, qualified_tool_id: &str) -> Vec<SentinelEvent> {
let db_timeout_ms = self.config.probe_timeout_ms.min(2000);
let db_timeout = std::time::Duration::from_millis(db_timeout_ms);
let mut trajectory: Vec<SentinelEvent> = match tokio::time::timeout(
db_timeout,
self.store
.get_trajectory(&self.session_id, self.config.max_context_events),
)
.await
{
Ok(Ok(t)) => t
.into_iter()
.filter(|e| e.event_type != "probe_result")
.collect(),
Ok(Err(e)) => {
tracing::warn!(error = %e, "ShadowSentinel: failed to load trajectory, proceeding without context");
vec![]
}
Err(_) => {
tracing::warn!(
timeout_ms = db_timeout_ms,
"ShadowSentinel: trajectory load timed out, proceeding without context"
);
vec![]
}
};
let cross_session_budget = self.config.max_context_events / 2;
let session_budget = self.config.max_context_events - cross_session_budget;
if trajectory.len() > session_budget {
let excess = trajectory.len() - session_budget;
trajectory.drain(0..excess);
}
match tokio::time::timeout(
db_timeout,
self.store.get_tool_history(
qualified_tool_id,
self.session_id.as_str(),
self.config.max_context_events,
),
)
.await
{
Ok(Ok(history)) => {
let mut cross_session: Vec<SentinelEvent> = history
.into_iter()
.filter(|e| e.event_type != "probe_result")
.rev()
.collect();
if cross_session.len() > cross_session_budget {
let excess = cross_session.len() - cross_session_budget;
cross_session.drain(0..excess);
}
trajectory.splice(0..0, cross_session);
}
Ok(Err(e)) => {
tracing::warn!(error = %e, "ShadowSentinel: failed to load cross-session tool history, proceeding without it");
}
Err(_) => {
tracing::warn!(
timeout_ms = db_timeout_ms,
"ShadowSentinel: cross-session tool history load timed out, proceeding without it"
);
}
}
trajectory
}
#[tracing::instrument(name = "security.shadow.check", skip(self, tool_args), fields(tool_id = %qualified_tool_id))]
pub async fn check_tool_call(
&self,
qualified_tool_id: &str,
tool_args: &JsonValue,
turn_number: u64,
current_risk_level: &str,
) -> ProbeVerdict {
if !self.config.enabled {
return ProbeVerdict::Skip;
}
let category = self.classify_tool(qualified_tool_id);
if category == ToolRiskCategory::Low {
return ProbeVerdict::Skip;
}
if self.probe_budget_exhausted(category) {
return ProbeVerdict::Skip;
}
let trajectory = self.load_probe_context(qualified_tool_id).await;
let verdict = self
.probe
.evaluate(qualified_tool_id, tool_args, &trajectory)
.await;
let probe_verdict_str = match &verdict {
ProbeVerdict::Allow => "allow",
ProbeVerdict::Deny { .. } => "deny",
ProbeVerdict::Skip => "skip",
};
let summary = match &verdict {
ProbeVerdict::Deny { reason } => {
format!("probe denied: {}", &reason[..reason.len().min(120)])
}
ProbeVerdict::Allow => format!("probe allowed {qualified_tool_id}"),
ProbeVerdict::Skip => format!("probe skipped {qualified_tool_id}"),
};
let event = SentinelEvent {
id: 0,
session_id: self.session_id.clone(),
turn_number,
event_type: "probe_result".to_owned(),
tool_id: Some(qualified_tool_id.to_owned()),
risk_signal: None,
risk_level: current_risk_level.to_owned(),
probe_verdict: Some(probe_verdict_str.to_owned()),
context_summary: Some(summary),
created_at: unix_now(),
};
self.persist_event(event, "probe result").await;
verdict
}
pub async fn record_tool_event(
&self,
qualified_tool_id: &str,
turn_number: u64,
risk_level: &str,
context_summary: &str,
) {
if !self.config.enabled {
return;
}
let event = SentinelEvent {
id: 0,
session_id: self.session_id.clone(),
turn_number,
event_type: "tool_call".to_owned(),
tool_id: Some(qualified_tool_id.to_owned()),
risk_signal: None,
risk_level: risk_level.to_owned(),
probe_verdict: None,
context_summary: Some(context_summary.chars().take(250).collect()),
created_at: unix_now(),
};
self.persist_event(event, "tool event").await;
}
pub async fn drain_pending(&self) {
let mut set = {
let mut guard = self.pending_writes.lock().await;
std::mem::take(&mut *guard)
};
while set.join_next().await.is_some() {}
}
async fn persist_event(&self, event: SentinelEvent, warn_context: &'static str) {
let store = self.store.clone();
self.spawn_persist(async move {
if let Err(e) = store.record(&event).await {
tracing::warn!(error = %e, "ShadowSentinel: failed to persist {warn_context}");
}
})
.await;
}
async fn spawn_persist<F>(&self, fut: F)
where
F: std::future::Future<Output = ()> + Send + 'static,
{
let mut set = self.pending_writes.lock().await;
while set.try_join_next().is_some() {}
if set.len() < MAX_PENDING_WRITES {
set.spawn(fut);
} else {
tracing::debug!(
max = MAX_PENDING_WRITES,
"ShadowSentinel: pending_writes at capacity, skipping persist"
);
}
}
pub fn advance_turn(&self) {
self.probes_this_turn.store(0, Ordering::Release);
self.exfil_probes_this_turn.store(0, Ordering::Release);
}
}
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0)
}
fn glob_matches(pattern: &str, value: &str) -> bool {
if pattern == "*" {
return true;
}
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return pattern == value;
}
let mut remaining = value;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if i == 0 {
if !remaining.starts_with(part) {
return false;
}
remaining = &remaining[part.len()..];
} else if i == parts.len() - 1 {
return remaining.ends_with(part);
} else if let Some(pos) = remaining.find(part) {
remaining = &remaining[pos + part.len()..];
} else {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn classify_builtin_shell_is_shell_risk() {
let config = zeph_config::ShadowSentinelConfig::default();
let sentinel = make_test_sentinel(config).await;
assert_eq!(
sentinel.classify_tool("builtin:shell"),
ToolRiskCategory::Shell
);
assert_eq!(
sentinel.classify_tool("builtin:bash"),
ToolRiskCategory::Shell
);
}
#[tokio::test]
async fn classify_builtin_write_is_file_write_risk() {
let config = zeph_config::ShadowSentinelConfig::default();
let sentinel = make_test_sentinel(config).await;
assert_eq!(
sentinel.classify_tool("builtin:write"),
ToolRiskCategory::FileWrite
);
assert_eq!(
sentinel.classify_tool("builtin:edit"),
ToolRiskCategory::FileWrite
);
}
#[tokio::test]
async fn classify_low_risk_returns_low() {
let config = zeph_config::ShadowSentinelConfig::default();
let sentinel = make_test_sentinel(config).await;
assert_eq!(
sentinel.classify_tool("builtin:read"),
ToolRiskCategory::Low
);
assert_eq!(
sentinel.classify_tool("builtin:search"),
ToolRiskCategory::Low
);
}
#[tokio::test]
async fn classify_mcp_tool_with_no_keyword_match_is_mcp_unclassified() {
let config = zeph_config::ShadowSentinelConfig::default();
let sentinel = make_test_sentinel(config).await;
sentinel
.mcp_tool_ids_handle()
.write()
.insert("some-server_frobnicate".to_owned());
assert_eq!(
sentinel.classify_tool("some-server_frobnicate"),
ToolRiskCategory::McpUnclassified
);
}
#[tokio::test]
async fn classify_non_mcp_tool_with_no_keyword_match_stays_low() {
let config = zeph_config::ShadowSentinelConfig::default();
let sentinel = make_test_sentinel(config).await;
assert_eq!(
sentinel.classify_tool("some-server_frobnicate"),
ToolRiskCategory::Low
);
}
#[tokio::test]
async fn classify_bare_shell_names_are_shell_risk() {
let config = zeph_config::ShadowSentinelConfig::default();
let sentinel = make_test_sentinel(config).await;
assert_eq!(sentinel.classify_tool("bash"), ToolRiskCategory::Shell);
assert_eq!(sentinel.classify_tool("shell"), ToolRiskCategory::Shell);
assert_eq!(sentinel.classify_tool("sh"), ToolRiskCategory::Shell);
}
#[tokio::test]
async fn classify_bare_file_write_names_are_file_write_risk() {
let config = zeph_config::ShadowSentinelConfig::default();
let sentinel = make_test_sentinel(config).await;
assert_eq!(sentinel.classify_tool("write"), ToolRiskCategory::FileWrite);
assert_eq!(sentinel.classify_tool("edit"), ToolRiskCategory::FileWrite);
assert_eq!(
sentinel.classify_tool("delete"),
ToolRiskCategory::FileWrite
);
}
#[tokio::test]
async fn classify_mcp_tool_write_pattern_escalates_to_exfil_capable() {
let config = zeph_config::ShadowSentinelConfig {
probe_patterns: vec!["*edit*".to_owned()],
..zeph_config::ShadowSentinelConfig::default()
};
let sentinel = make_test_sentinel(config).await;
assert_eq!(
sentinel.classify_tool("github_edit_file"),
ToolRiskCategory::FileWrite
);
sentinel
.mcp_tool_ids_handle()
.write()
.insert("github_edit_file".to_owned());
assert_eq!(
sentinel.classify_tool("github_edit_file"),
ToolRiskCategory::ExfilCapable
);
}
#[tokio::test]
async fn classify_mcp_tool_write_under_default_config_escalates_to_exfil_capable() {
let config = zeph_config::ShadowSentinelConfig::default();
let sentinel = make_test_sentinel(config).await;
sentinel
.mcp_tool_ids_handle()
.write()
.insert("fs-test_write_file".to_owned());
assert_eq!(
sentinel.classify_tool("fs-test_write_file"),
ToolRiskCategory::ExfilCapable
);
}
#[tokio::test]
async fn advance_turn_resets_counter() {
let config = zeph_config::ShadowSentinelConfig::default();
let sentinel = make_test_sentinel(config).await;
sentinel.probes_this_turn.store(3, Ordering::Relaxed);
sentinel.advance_turn();
assert_eq!(sentinel.probes_this_turn.load(Ordering::Relaxed), 0);
}
#[test]
fn glob_matches_star_wildcard() {
assert!(glob_matches("mcp:*/file_*", "mcp:myserver/file_read"));
assert!(glob_matches("mcp:*/file_*", "mcp:other/file_write"));
assert!(!glob_matches("mcp:*/file_*", "builtin:shell"));
}
#[test]
fn glob_matches_exact() {
assert!(glob_matches("builtin:shell", "builtin:shell"));
assert!(!glob_matches("builtin:shell", "builtin:write"));
}
#[test]
fn parse_verdict_allow() {
let v = LlmSafetyProbe::parse_verdict(r#"{"verdict": "allow"}"#);
assert_eq!(v, ProbeVerdict::Allow);
}
#[test]
fn parse_verdict_deny_with_reason() {
let v =
LlmSafetyProbe::parse_verdict(r#"{"verdict": "deny", "reason": "suspicious pattern"}"#);
assert_eq!(
v,
ProbeVerdict::Deny {
reason: "suspicious pattern".to_owned()
}
);
}
#[test]
fn parse_verdict_unparseable_allows() {
let v = LlmSafetyProbe::parse_verdict("I think this is fine");
assert_eq!(v, ProbeVerdict::Allow);
}
#[tokio::test]
async fn check_tool_call_skips_after_budget_exhausted() {
let config = zeph_config::ShadowSentinelConfig {
enabled: true,
max_probes_per_turn: 2,
..zeph_config::ShadowSentinelConfig::default()
};
let sentinel = make_test_sentinel(config).await;
let args = serde_json::Value::Object(serde_json::Map::new());
let v1 = sentinel
.check_tool_call("builtin:shell", &args, 1, "calm")
.await;
let v2 = sentinel
.check_tool_call("builtin:shell", &args, 1, "calm")
.await;
assert_ne!(v1, ProbeVerdict::Skip, "first call within budget");
assert_ne!(v2, ProbeVerdict::Skip, "second call within budget");
let v3 = sentinel
.check_tool_call("builtin:shell", &args, 1, "calm")
.await;
assert_eq!(
v3,
ProbeVerdict::Skip,
"third call must be skipped (budget exhausted)"
);
}
#[tokio::test]
async fn check_tool_call_exfil_capable_bypasses_shared_budget_exhaustion() {
let config = zeph_config::ShadowSentinelConfig {
enabled: true,
max_probes_per_turn: 1,
probe_patterns: vec!["*edit*".to_owned()],
..zeph_config::ShadowSentinelConfig::default()
};
let sentinel = make_test_sentinel(config).await;
sentinel
.mcp_tool_ids_handle()
.write()
.insert("server_edit_file".to_owned());
assert_eq!(
sentinel.classify_tool("server_edit_file"),
ToolRiskCategory::ExfilCapable
);
let args = serde_json::Value::Object(serde_json::Map::new());
let v1 = sentinel
.check_tool_call("builtin:shell", &args, 1, "calm")
.await;
assert_ne!(v1, ProbeVerdict::Skip, "first Shell call within budget");
let v2 = sentinel
.check_tool_call("builtin:shell", &args, 1, "calm")
.await;
assert_eq!(
v2,
ProbeVerdict::Skip,
"second Shell call must be skipped — budget exhausted"
);
let v3 = sentinel
.check_tool_call("server_edit_file", &args, 1, "calm")
.await;
assert_ne!(
v3,
ProbeVerdict::Skip,
"ExfilCapable must not be starved by the shared per-turn budget"
);
}
#[tokio::test]
async fn check_tool_call_exfil_capable_has_finite_cap() {
let config = zeph_config::ShadowSentinelConfig {
enabled: true,
max_probes_per_turn: 1,
probe_patterns: vec!["*edit*".to_owned()],
..zeph_config::ShadowSentinelConfig::default()
};
let sentinel = make_test_sentinel(config).await;
sentinel
.mcp_tool_ids_handle()
.write()
.insert("server_edit_file".to_owned());
let args = serde_json::Value::Object(serde_json::Map::new());
let v1 = sentinel
.check_tool_call("server_edit_file", &args, 1, "calm")
.await;
let v2 = sentinel
.check_tool_call("server_edit_file", &args, 1, "calm")
.await;
assert_ne!(
v1,
ProbeVerdict::Skip,
"first ExfilCapable call within its own budget"
);
assert_ne!(
v2,
ProbeVerdict::Skip,
"second ExfilCapable call within its own budget"
);
let v3 = sentinel
.check_tool_call("server_edit_file", &args, 1, "calm")
.await;
assert_eq!(
v3,
ProbeVerdict::Skip,
"ExfilCapable's own budget must still be finite (2 * max_probes_per_turn)"
);
}
#[tokio::test]
async fn check_tool_call_mcp_unclassified_reserves_budget_slot() {
let config = zeph_config::ShadowSentinelConfig {
enabled: true,
max_probes_per_turn: 2,
..zeph_config::ShadowSentinelConfig::default()
};
let sentinel = make_test_sentinel(config).await;
sentinel
.mcp_tool_ids_handle()
.write()
.insert("some-server_frobnicate".to_owned());
assert_eq!(
sentinel.classify_tool("some-server_frobnicate"),
ToolRiskCategory::McpUnclassified
);
let args = serde_json::Value::Object(serde_json::Map::new());
let v1 = sentinel
.check_tool_call("some-server_frobnicate", &args, 1, "calm")
.await;
assert_ne!(
v1,
ProbeVerdict::Skip,
"first McpUnclassified call within reserved share"
);
let v2 = sentinel
.check_tool_call("some-server_frobnicate", &args, 1, "calm")
.await;
assert_eq!(
v2,
ProbeVerdict::Skip,
"second McpUnclassified call must be skipped — reserved share exhausted"
);
let v3 = sentinel
.check_tool_call("builtin:shell", &args, 1, "calm")
.await;
assert_ne!(
v3,
ProbeVerdict::Skip,
"Shell call must still probe using the slot reserved for non-McpUnclassified categories"
);
}
#[tokio::test]
async fn check_tool_call_mcp_unclassified_fully_reserved_out_at_budget_one() {
let config = zeph_config::ShadowSentinelConfig {
enabled: true,
max_probes_per_turn: 1,
..zeph_config::ShadowSentinelConfig::default()
};
let sentinel = make_test_sentinel(config).await;
sentinel
.mcp_tool_ids_handle()
.write()
.insert("some-server_frobnicate".to_owned());
let args = serde_json::Value::Object(serde_json::Map::new());
let v1 = sentinel
.check_tool_call("some-server_frobnicate", &args, 1, "calm")
.await;
assert_eq!(
v1,
ProbeVerdict::Skip,
"McpUnclassified must get zero share when max_probes_per_turn == 1"
);
let v2 = sentinel
.check_tool_call("builtin:shell", &args, 1, "calm")
.await;
assert_ne!(
v2,
ProbeVerdict::Skip,
"Shell must not be starved by a prior McpUnclassified attempt at max_probes_per_turn == 1"
);
}
#[tokio::test]
async fn check_tool_call_all_categories_skip_at_budget_zero() {
let config = zeph_config::ShadowSentinelConfig {
enabled: true,
max_probes_per_turn: 0,
probe_patterns: vec!["*edit*".to_owned()],
..zeph_config::ShadowSentinelConfig::default()
};
let sentinel = make_test_sentinel(config).await;
sentinel
.mcp_tool_ids_handle()
.write()
.insert("server_edit_file".to_owned());
sentinel
.mcp_tool_ids_handle()
.write()
.insert("some-server_frobnicate".to_owned());
assert_eq!(
sentinel.classify_tool("server_edit_file"),
ToolRiskCategory::ExfilCapable
);
assert_eq!(
sentinel.classify_tool("some-server_frobnicate"),
ToolRiskCategory::McpUnclassified
);
let args = serde_json::Value::Object(serde_json::Map::new());
assert_eq!(
sentinel
.check_tool_call("builtin:shell", &args, 1, "calm")
.await,
ProbeVerdict::Skip,
"Shell must skip when max_probes_per_turn == 0"
);
assert_eq!(
sentinel
.check_tool_call("some-server_frobnicate", &args, 1, "calm")
.await,
ProbeVerdict::Skip,
"McpUnclassified must skip when max_probes_per_turn == 0"
);
assert_eq!(
sentinel
.check_tool_call("server_edit_file", &args, 1, "calm")
.await,
ProbeVerdict::Skip,
"ExfilCapable's independent budget (2 * 0 == 0) must also skip, not run unbounded"
);
}
#[tokio::test]
async fn check_tool_call_returns_skip_when_disabled() {
let config = zeph_config::ShadowSentinelConfig {
enabled: false,
..zeph_config::ShadowSentinelConfig::default()
};
let sentinel = make_test_sentinel(config).await;
let args = serde_json::Value::Object(serde_json::Map::new());
let verdict = sentinel
.check_tool_call("builtin:shell", &args, 1, "calm")
.await;
assert_eq!(
verdict,
ProbeVerdict::Skip,
"disabled sentinel must always return Skip without calling the probe"
);
}
#[tokio::test]
async fn drain_pending_awaits_all_tasks() {
use std::sync::atomic::{AtomicU32, Ordering};
let config = zeph_config::ShadowSentinelConfig::default();
let sentinel = make_test_sentinel(config).await;
let counter = Arc::new(AtomicU32::new(0));
for _ in 0..5 {
let c = Arc::clone(&counter);
sentinel
.spawn_persist(async move {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
c.fetch_add(1, Ordering::Relaxed);
})
.await;
}
sentinel.drain_pending().await;
assert_eq!(
counter.load(Ordering::Relaxed),
5,
"drain_pending must join all 5 tasks before returning"
);
}
#[tokio::test]
async fn spawn_persist_beyond_capacity_does_not_panic() {
use std::sync::atomic::{AtomicU32, Ordering};
let config = zeph_config::ShadowSentinelConfig::default();
let sentinel = make_test_sentinel(config).await;
let counter = Arc::new(AtomicU32::new(0));
for _ in 0..(MAX_PENDING_WRITES * 2) {
let c = Arc::clone(&counter);
sentinel
.spawn_persist(async move {
c.fetch_add(1, Ordering::Relaxed);
})
.await;
}
sentinel.drain_pending().await;
let ran = counter.load(Ordering::Relaxed);
assert!(
ran >= u32::try_from(MAX_PENDING_WRITES).unwrap(),
"at least MAX_PENDING_WRITES tasks must complete; ran={ran}"
);
}
async fn make_test_sentinel(config: zeph_config::ShadowSentinelConfig) -> ShadowSentinel {
struct NoopProbe;
impl SafetyProbe for NoopProbe {
fn evaluate<'a>(
&'a self,
_: &'a str,
_: &'a JsonValue,
_: &'a [SentinelEvent],
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ProbeVerdict> + Send + 'a>>
{
Box::pin(async { ProbeVerdict::Allow })
}
}
let pool = test_pool().await;
let store = ShadowEventStore::new(pool);
ShadowSentinel::new(store, Box::new(NoopProbe), config, "test-session")
}
async fn test_pool() -> DbPool {
zeph_db::DbConfig {
url: ":memory:".to_owned(),
..Default::default()
}
.connect()
.await
.expect("connect + migrate in-memory sqlite pool")
}
fn make_event(
session_id: &str,
turn_number: u64,
tool_id: &str,
summary: &str,
) -> SentinelEvent {
SentinelEvent {
id: 0,
session_id: SessionId::new(session_id),
turn_number,
event_type: "tool_call".to_owned(),
tool_id: Some(tool_id.to_owned()),
risk_signal: None,
risk_level: "elevated".to_owned(),
probe_verdict: None,
context_summary: Some(summary.to_owned()),
created_at: unix_now(),
}
}
#[tokio::test]
async fn get_tool_history_returns_events_across_sessions() {
let store = ShadowEventStore::new(test_pool().await);
store
.record(&make_event(
"session-a",
1,
"builtin:shell",
"session-a ran a command",
))
.await
.expect("record session-a event");
store
.record(&make_event(
"session-b",
1,
"builtin:shell",
"session-b ran a command",
))
.await
.expect("record session-b event");
store
.record(&make_event(
"session-a",
2,
"builtin:write",
"unrelated tool",
))
.await
.expect("record unrelated-tool event");
let history = store
.get_tool_history("builtin:shell", "unrelated-session", 10)
.await
.expect("get_tool_history");
assert_eq!(
history.len(),
2,
"must return events from both non-excluded sessions for the queried tool_id, \
excluding other tools"
);
assert!(history.iter().any(|e| e.session_id.as_str() == "session-a"));
assert!(history.iter().any(|e| e.session_id.as_str() == "session-b"));
let history_excluding_a = store
.get_tool_history("builtin:shell", "session-a", 10)
.await
.expect("get_tool_history");
assert_eq!(
history_excluding_a.len(),
1,
"exclude_session_id must be applied in SQL, not just usable for client-side \
filtering afterward"
);
assert!(
history_excluding_a
.iter()
.all(|e| e.session_id.as_str() != "session-a")
);
}
#[tokio::test]
async fn check_tool_call_incorporates_cross_session_tool_history() {
struct CapturingProbe {
captured: Arc<Mutex<Vec<SentinelEvent>>>,
}
impl SafetyProbe for CapturingProbe {
fn evaluate<'a>(
&'a self,
_tool_id: &'a str,
_tool_args: &'a JsonValue,
trajectory: &'a [SentinelEvent],
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ProbeVerdict> + Send + 'a>>
{
let captured = Arc::clone(&self.captured);
let trajectory = trajectory.to_vec();
Box::pin(async move {
*captured.lock().await = trajectory;
ProbeVerdict::Allow
})
}
}
let store = ShadowEventStore::new(test_pool().await);
let other_session = "other-session";
store
.record(&make_event(
other_session,
1,
"builtin:shell",
"other session ran rm -rf",
))
.await
.expect("record cross-session event");
let captured: Arc<Mutex<Vec<SentinelEvent>>> = Arc::new(Mutex::new(Vec::new()));
let config = zeph_config::ShadowSentinelConfig {
enabled: true,
..zeph_config::ShadowSentinelConfig::default()
};
let sentinel = ShadowSentinel::new(
store,
Box::new(CapturingProbe {
captured: Arc::clone(&captured),
}),
config,
"current-session",
);
let args = serde_json::Value::Object(serde_json::Map::new());
sentinel
.check_tool_call("builtin:shell", &args, 1, "calm")
.await;
let seen = captured.lock().await;
assert!(
seen.iter().any(|e| e.session_id.as_str() == other_session
&& e.context_summary.as_deref() == Some("other session ran rm -rf")),
"probe context must include the cross-session tool history event, got: {seen:?}"
);
}
async fn capture_check_tool_call_trajectory(
store: ShadowEventStore,
config: zeph_config::ShadowSentinelConfig,
session_id: &str,
tool_id: &str,
) -> Vec<SentinelEvent> {
struct CapturingProbe {
captured: Arc<Mutex<Vec<SentinelEvent>>>,
}
impl SafetyProbe for CapturingProbe {
fn evaluate<'a>(
&'a self,
_tool_id: &'a str,
_tool_args: &'a JsonValue,
trajectory: &'a [SentinelEvent],
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ProbeVerdict> + Send + 'a>>
{
let captured = Arc::clone(&self.captured);
let trajectory = trajectory.to_vec();
Box::pin(async move {
*captured.lock().await = trajectory;
ProbeVerdict::Allow
})
}
}
let captured: Arc<Mutex<Vec<SentinelEvent>>> = Arc::new(Mutex::new(Vec::new()));
let sentinel = ShadowSentinel::new(
store,
Box::new(CapturingProbe {
captured: Arc::clone(&captured),
}),
config,
session_id,
);
let args = serde_json::Value::Object(serde_json::Map::new());
sentinel.check_tool_call(tool_id, &args, 1, "calm").await;
captured.lock().await.clone()
}
async fn seed_events(
store: &ShadowEventStore,
session_id: &str,
tool_id: &str,
summary_prefix: &str,
base: i64,
count: u32,
) {
for i in 0..count {
let mut event = make_event(
session_id,
u64::from(i),
tool_id,
&format!("{summary_prefix}-{i}"),
);
event.created_at = base + i64::from(i);
store.record(&event).await.expect("record seeded event");
}
}
#[tokio::test]
async fn check_tool_call_cap_reserves_cross_session_budget_when_session_heavy() {
let store = ShadowEventStore::new(test_pool().await);
let base = unix_now();
seed_events(
&store,
"current-session",
"builtin:shell",
"session",
base,
4,
)
.await;
seed_events(&store, "other-session", "builtin:shell", "cross", base, 3).await;
let config = zeph_config::ShadowSentinelConfig {
enabled: true,
max_context_events: 4,
..zeph_config::ShadowSentinelConfig::default()
};
let trajectory =
capture_check_tool_call_trajectory(store, config, "current-session", "builtin:shell")
.await;
assert_eq!(
trajectory.len(),
4,
"total must be capped at max_context_events"
);
let cross_session_count = trajectory
.iter()
.filter(|e| e.session_id.as_str() == "other-session")
.count();
assert_eq!(
cross_session_count, 2,
"cross-session budget is max_context_events/2 = 2, and must survive even \
though the session's own trajectory alone fills the whole budget; \
got trajectory: {trajectory:?}"
);
}
#[tokio::test]
async fn check_tool_call_cap_cross_session_heavy_case() {
let store = ShadowEventStore::new(test_pool().await);
let base = unix_now();
seed_events(
&store,
"current-session",
"builtin:shell",
"session",
base,
1,
)
.await;
seed_events(&store, "other-session", "builtin:shell", "cross", base, 4).await;
let config = zeph_config::ShadowSentinelConfig {
enabled: true,
max_context_events: 4,
..zeph_config::ShadowSentinelConfig::default()
};
let trajectory =
capture_check_tool_call_trajectory(store, config, "current-session", "builtin:shell")
.await;
let session_count = trajectory
.iter()
.filter(|e| e.session_id.as_str() == "current-session")
.count();
let cross_session_count = trajectory.len() - session_count;
assert_eq!(
session_count, 1,
"session's own (light) trajectory must not be trimmed"
);
assert_eq!(
cross_session_count, 2,
"cross-session budget is max_context_events/2 = 2"
);
}
#[tokio::test]
async fn check_tool_call_cap_boundary_at_exact_limit() {
let store = ShadowEventStore::new(test_pool().await);
let base = unix_now();
seed_events(
&store,
"current-session",
"builtin:shell",
"session",
base,
2,
)
.await;
seed_events(&store, "other-session", "builtin:shell", "cross", base, 2).await;
let config = zeph_config::ShadowSentinelConfig {
enabled: true,
max_context_events: 4,
..zeph_config::ShadowSentinelConfig::default()
};
let trajectory =
capture_check_tool_call_trajectory(store, config, "current-session", "builtin:shell")
.await;
assert_eq!(
trajectory.len(),
4,
"exactly at the limit: nothing should be dropped"
);
}
#[tokio::test]
async fn check_tool_call_cap_boundary_at_limit_plus_one() {
let store = ShadowEventStore::new(test_pool().await);
let base = unix_now();
seed_events(
&store,
"current-session",
"builtin:shell",
"session",
base,
2,
)
.await;
seed_events(&store, "other-session", "builtin:shell", "cross", base, 3).await;
let config = zeph_config::ShadowSentinelConfig {
enabled: true,
max_context_events: 4,
..zeph_config::ShadowSentinelConfig::default()
};
let trajectory =
capture_check_tool_call_trajectory(store, config, "current-session", "builtin:shell")
.await;
assert_eq!(
trajectory.len(),
4,
"limit+1 overall: exactly one event must be dropped"
);
let cross_summaries: Vec<&str> = trajectory
.iter()
.filter(|e| e.session_id.as_str() == "other-session")
.filter_map(|e| e.context_summary.as_deref())
.collect();
assert_eq!(
cross_summaries,
vec!["cross-1", "cross-2"],
"the oldest cross-session event (cross-0) must be the one dropped, \
got: {cross_summaries:?}"
);
}
#[tokio::test]
async fn check_tool_call_excludes_current_session_from_cross_session_merge() {
let store = ShadowEventStore::new(test_pool().await);
let base = unix_now();
seed_events(&store, "current-session", "builtin:shell", "own", base, 2).await;
let config = zeph_config::ShadowSentinelConfig {
enabled: true,
max_context_events: 10,
..zeph_config::ShadowSentinelConfig::default()
};
let trajectory =
capture_check_tool_call_trajectory(store, config, "current-session", "builtin:shell")
.await;
assert_eq!(
trajectory.len(),
2,
"current session's own events must appear exactly once, not duplicated via \
the cross-session merge; got: {trajectory:?}"
);
}
#[tokio::test]
async fn check_tool_call_excludes_probe_result_events_from_cross_session_merge() {
let store = ShadowEventStore::new(test_pool().await);
let base = unix_now();
let mut event = make_event("other-session", 1, "builtin:shell", "probe verdict leaked");
event.event_type = "probe_result".to_owned();
event.created_at = base;
store
.record(&event)
.await
.expect("record probe_result event");
let config = zeph_config::ShadowSentinelConfig {
enabled: true,
max_context_events: 10,
..zeph_config::ShadowSentinelConfig::default()
};
let trajectory =
capture_check_tool_call_trajectory(store, config, "current-session", "builtin:shell")
.await;
assert!(
trajectory.is_empty(),
"probe_result events from other sessions must never appear in the \
cross-session merge (LLM isolation invariant), got: {trajectory:?}"
);
}
#[tokio::test]
async fn check_tool_call_falls_open_when_both_db_reads_stall() {
use tracing_subscriber::layer::SubscriberExt as _;
let pool = test_pool().await;
let raw_pool = pool.clone();
let store = ShadowEventStore::new(pool);
let base = unix_now();
seed_events(&store, "current-session", "builtin:shell", "own", base, 2).await;
seed_events(&store, "other-session", "builtin:shell", "cross", base, 2).await;
let messages: Arc<std::sync::Mutex<Vec<String>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let layer = MessageCaptureLayer {
messages: messages.clone(),
};
let subscriber = tracing_subscriber::registry().with(layer);
let _guard = tracing::subscriber::set_default(subscriber);
let config = zeph_config::ShadowSentinelConfig {
enabled: true,
probe_timeout_ms: 50,
..zeph_config::ShadowSentinelConfig::default()
};
let tx = zeph_db::begin_write(&raw_pool)
.await
.expect("hold sole in-memory sqlite connection");
let trajectory =
capture_check_tool_call_trajectory(store, config, "current-session", "builtin:shell")
.await;
drop(tx);
assert!(
trajectory.is_empty(),
"trajectory passed to the probe must be empty when both get_trajectory and \
get_tool_history time out, despite real seeded data existing; got: {trajectory:?}"
);
let captured_logs = messages.lock().unwrap();
assert!(
captured_logs
.iter()
.any(|m| m.contains("trajectory load timed out")),
"expected a warn log for the timed-out get_trajectory read, got: {captured_logs:?}"
);
assert!(
captured_logs
.iter()
.any(|m| m.contains("cross-session tool history load timed out")),
"expected a warn log for the timed-out get_tool_history read, got: {captured_logs:?}"
);
}
#[tokio::test]
async fn record_tool_event_persists_event_normal_path() {
let config = zeph_config::ShadowSentinelConfig {
enabled: true,
..zeph_config::ShadowSentinelConfig::default()
};
let sentinel = make_test_sentinel(config).await;
sentinel
.record_tool_event("builtin:shell", 3, "elevated", "ran `ls -la`")
.await;
sentinel.drain_pending().await;
let events = sentinel
.store
.get_trajectory("test-session", 10)
.await
.expect("get_trajectory");
assert_eq!(events.len(), 1, "expected exactly one persisted event");
assert_eq!(events[0].event_type, "tool_call");
assert_eq!(events[0].tool_id.as_deref(), Some("builtin:shell"));
assert_eq!(events[0].turn_number, 3);
assert_eq!(events[0].risk_level, "elevated");
assert_eq!(events[0].context_summary.as_deref(), Some("ran `ls -la`"));
}
#[tokio::test]
async fn record_tool_event_disabled_does_not_persist() {
let config = zeph_config::ShadowSentinelConfig {
enabled: false,
..zeph_config::ShadowSentinelConfig::default()
};
let sentinel = make_test_sentinel(config).await;
sentinel
.record_tool_event("builtin:shell", 1, "elevated", "should be skipped")
.await;
sentinel.drain_pending().await;
let events = sentinel
.store
.get_trajectory("test-session", 10)
.await
.expect("get_trajectory");
assert!(
events.is_empty(),
"record_tool_event must be a no-op when the sentinel is disabled"
);
}
struct MessageCaptureLayer {
messages: Arc<std::sync::Mutex<Vec<String>>>,
}
struct MessageVisitor(String);
impl tracing::field::Visit for MessageVisitor {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.0 = format!("{value:?}");
}
}
}
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for MessageCaptureLayer {
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
let mut visitor = MessageVisitor(String::new());
event.record(&mut visitor);
self.messages.lock().unwrap().push(visitor.0);
}
}
#[tokio::test]
async fn record_tool_event_persist_failure_logs_warn_with_tool_event_context() {
use tracing_subscriber::layer::SubscriberExt as _;
struct NoopProbe;
impl SafetyProbe for NoopProbe {
fn evaluate<'a>(
&'a self,
_: &'a str,
_: &'a JsonValue,
_: &'a [SentinelEvent],
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ProbeVerdict> + Send + 'a>>
{
Box::pin(async { ProbeVerdict::Allow })
}
}
let pool = test_pool().await;
zeph_db::query(zeph_db::sql!("DROP TABLE safety_shadow_events"))
.execute(&pool)
.await
.expect("drop safety_shadow_events table");
let store = ShadowEventStore::new(pool);
let config = zeph_config::ShadowSentinelConfig {
enabled: true,
..zeph_config::ShadowSentinelConfig::default()
};
let sentinel = ShadowSentinel::new(store, Box::new(NoopProbe), config, "test-session");
let messages: Arc<std::sync::Mutex<Vec<String>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let layer = MessageCaptureLayer {
messages: messages.clone(),
};
let subscriber = tracing_subscriber::registry().with(layer);
let _guard = tracing::subscriber::set_default(subscriber);
sentinel
.record_tool_event("builtin:shell", 1, "elevated", "ran a command")
.await;
sentinel.drain_pending().await;
let captured = messages.lock().unwrap();
assert!(
captured
.iter()
.any(|m| m.contains("failed to persist tool event")),
"expected a warn log with 'failed to persist tool event' context, got: {captured:?}"
);
}
}