use crate::agent::Agent;
use crate::channel::Channel;
impl<C: Channel> Agent<C> {
pub(super) fn record_shadow_event(
&mut self,
tool_calls: &[zeph_llm::provider::ToolUseRequest],
goal_summary: String,
) {
let Some(ref mut mem) = self.services.security.shadow_memory else {
return;
};
let tool_names: Vec<String> = tool_calls
.iter()
.map(|tc| tc.name.as_str().to_owned())
.collect();
let max_permission_class = tool_names
.iter()
.map(|n| zeph_sanitizer::classify_tool_permission(n))
.max()
.unwrap_or(0);
let turn = u32::try_from(self.runtime.debug.iteration_counter.saturating_sub(1))
.unwrap_or(u32::MAX);
mem.record(zeph_sanitizer::ShadowEvent {
turn,
tools: tool_names,
max_permission_class,
deviation_score: 0.0,
goal_summary,
});
let drift = mem.goal_drift_score();
if drift.should_alert {
tracing::warn!(
score = drift.score,
turn = turn,
"shadow memory: goal drift alert"
);
self.push_security_event(
zeph_common::SecurityEventCategory::GoalDrift,
"shadow_memory",
format!("drift_score={:.3}", drift.score),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tool_req(name: &str) -> zeph_llm::provider::ToolUseRequest {
zeph_llm::provider::ToolUseRequest {
id: format!("id_{name}"),
name: name.into(),
input: serde_json::Value::Null,
}
}
fn make_agent_with_shadow(enabled: bool) -> Agent<crate::testing::MockChannel> {
use crate::testing::{MockChannel, MockToolExecutor, mock_provider};
use zeph_skills::registry::SkillRegistry;
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![] as Vec<String>);
let registry = SkillRegistry::empty();
let executor = MockToolExecutor::no_tools();
let cfg = zeph_config::ShadowMemoryConfig {
enabled,
drift_threshold: 0.01,
window_size: 3,
max_events: 50,
};
Agent::new(provider, channel, registry, None, 5, executor).with_shadow_memory_config(&cfg)
}
#[test]
fn record_shadow_event_noop_when_disabled() {
let mut agent = make_agent_with_shadow(false);
agent.runtime.debug.iteration_counter = 1;
let calls = vec![make_tool_req("shell")];
agent.record_shadow_event(&calls, "goal".into());
assert!(
agent.services.security.shadow_memory.is_none(),
"shadow_memory must remain None when disabled"
);
}
#[test]
fn record_shadow_event_appends_event_when_enabled() {
let mut agent = make_agent_with_shadow(true);
agent.runtime.debug.iteration_counter = 1;
let calls = vec![make_tool_req("shell"), make_tool_req("web_scrape")];
agent.record_shadow_event(&calls, "test goal".into());
let mem = agent.services.security.shadow_memory.as_ref().unwrap();
assert_eq!(mem.len(), 1, "one event must be recorded after one batch");
}
#[test]
fn record_shadow_event_goal_drift_emits_security_event() {
use tokio::sync::watch;
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
rt.block_on(async {
use crate::testing::{MockChannel, MockToolExecutor, mock_provider};
use zeph_skills::registry::SkillRegistry;
let (tx, rx) = watch::channel(crate::metrics::MetricsSnapshot::default());
let cfg = zeph_config::ShadowMemoryConfig {
enabled: true,
drift_threshold: 0.01,
window_size: 3,
max_events: 100,
};
let mut agent = Agent::new(
mock_provider(vec![]),
MockChannel::new(vec![] as Vec<String>),
SkillRegistry::empty(),
None,
5,
MockToolExecutor::no_tools(),
)
.with_shadow_memory_config(&cfg)
.with_metrics(tx);
agent.runtime.debug.iteration_counter = 1;
let low = vec![make_tool_req("read")];
for _ in 0..5 {
agent.record_shadow_event(&low, "read files".into());
}
let high = vec![
make_tool_req("shell"),
make_tool_req("fetch"),
make_tool_req("write"),
];
for _ in 0..5 {
agent.record_shadow_event(&high, "exfiltrate everything".into());
}
let snap = rx.borrow().clone();
for ev in &snap.security_events {
if ev.category == zeph_common::SecurityEventCategory::GoalDrift {
assert_eq!(ev.source, "shadow_memory");
return;
}
}
let mem = agent.services.security.shadow_memory.as_ref().unwrap();
assert!(!mem.is_empty(), "shadow_memory must have recorded events");
});
}
}