use zeph_sanitizer::{ContentSource, ContentSourceKind, MemorySourceHint};
use super::super::Agent;
use crate::channel::Channel;
#[cfg(feature = "classifiers")]
fn is_policy_blocked_output(body: &str) -> bool {
body.contains("[tool_error]") && body.contains("category: policy_blocked")
}
#[cfg(feature = "classifiers")]
const INTERNAL_TOOLS: &[&str] = &[
"bash",
"shell",
"invoke_skill",
"load_skill",
"memory_save",
"memory_search",
"compress_context",
"request_compaction",
"complete_focus",
"start_focus",
"schedule_periodic",
"schedule_deferred",
"cancel_task",
];
#[cfg(feature = "classifiers")]
fn is_internal_tool(tool_name: &str) -> bool {
!tool_name.contains(':') && INTERNAL_TOOLS.contains(&tool_name)
}
fn split_bash_echo_prefix<'a>(body: &'a str, tool_name: &str) -> (&'a str, &'a str) {
if !matches!(tool_name, "bash" | "shell") || !body.starts_with("$ ") {
return ("", body);
}
match body.find('\n') {
Some(idx) => body.split_at(idx + 1),
None => ("", body),
}
}
fn batch_dispatch_trust_level(
tool_calls: &[zeph_llm::provider::ToolUseRequest],
) -> zeph_sanitizer::ContentTrustLevel {
tool_calls
.iter()
.filter(|tc| tc.name.as_str() != "memory_save")
.map(|tc| build_tool_output_source(tc.name.as_str()).trust_level)
.max()
.unwrap_or(zeph_sanitizer::ContentTrustLevel::Trusted)
}
fn build_tool_output_source(tool_name: &str) -> ContentSource {
if tool_name.contains(':') || tool_name == "mcp" {
ContentSource::new(ContentSourceKind::McpResponse).with_identifier(tool_name)
} else if tool_name == "web-scrape"
|| tool_name == "web_scrape"
|| tool_name == "fetch"
|| tool_name == "web_search"
{
ContentSource::new(ContentSourceKind::WebScrape).with_identifier(tool_name)
} else if tool_name == "memory_search" {
ContentSource::new(ContentSourceKind::MemoryRetrieval)
.with_identifier(tool_name)
.with_memory_hint(MemorySourceHint::ConversationHistory)
} else {
ContentSource::new(ContentSourceKind::ToolResult).with_identifier(tool_name)
}
}
impl<C: Channel> Agent<C> {
pub(super) async fn sanitize_tool_output(
&mut self,
body: &str,
tool_name: &str,
) -> (
String,
bool,
ContentSourceKind,
zeph_sanitizer::ContentTrustLevel,
) {
let source = build_tool_output_source(tool_name);
let kind = source.kind;
let trust_level = source.trust_level;
{
let mut slot = self.services.security.memory_consent_trust.write();
*slot = (*slot).max(trust_level as u8);
}
#[cfg(feature = "classifiers")]
let memory_hint = source.memory_hint;
#[cfg(not(feature = "classifiers"))]
let _ = source.memory_hint;
let (echo_prefix, scrub_target) = split_bash_echo_prefix(body, tool_name);
let scrubbed_remainder = self.scrub_pii_union(scrub_target, tool_name).await;
let body = if echo_prefix.is_empty() {
scrubbed_remainder
} else {
format!("{echo_prefix}{scrubbed_remainder}")
};
let sanitized = self.services.security.sanitizer.sanitize(&body, source);
let has_injection_flags = !sanitized.injection_flags.is_empty();
self.record_injection_flags(&sanitized, tool_name);
if sanitized.was_truncated {
self.update_metrics(|m| m.sanitizer_truncations += 1);
self.push_security_event(
zeph_common::SecurityEventCategory::Truncation,
tool_name,
"Content truncated to max_content_size",
);
}
self.update_metrics(|m| m.sanitizer_runs += 1);
#[cfg(feature = "classifiers")]
if let Some((b, f)) = self
.apply_classifier_verdict(&body, tool_name, memory_hint)
.await
{
return (b, f, kind, trust_level);
}
let is_cross_boundary = self.services.security.is_acp_session
&& self
.runtime
.config
.security
.content_isolation
.mcp_to_acp_boundary
&& kind == ContentSourceKind::McpResponse;
if is_cross_boundary
&& let Some((b, f)) = self
.handle_cross_boundary_quarantine(&sanitized, tool_name, has_injection_flags)
.await
{
return (b, f, kind, trust_level);
}
if !is_cross_boundary
&& let Some((b, f)) = self
.handle_quarantine_summary(&sanitized, tool_name, kind, has_injection_flags)
.await
{
return (b, f, kind, trust_level);
}
let body = sanitized.body;
self.record_nli_verdict(&body, tool_name).await;
let body = self.apply_guardrail_to_tool_output(body, tool_name).await;
(body, has_injection_flags, kind, trust_level)
}
pub(super) fn context_max_trust_level(&self) -> zeph_sanitizer::ContentTrustLevel {
self.msg
.messages
.iter()
.filter_map(|m| m.metadata.trust_level)
.map(zeph_sanitizer::ContentTrustLevel::from_ordinal)
.max()
.unwrap_or(zeph_sanitizer::ContentTrustLevel::Trusted)
}
pub(super) fn ratchet_memory_consent_trust_for_dispatch(
&mut self,
tool_calls: &[zeph_llm::provider::ToolUseRequest],
) {
let effective = batch_dispatch_trust_level(tool_calls).max(self.context_max_trust_level());
let mut slot = self.services.security.memory_consent_trust.write();
*slot = (*slot).max(effective as u8);
}
pub(crate) async fn record_nli_verdict(&mut self, body: &str, tool_name: &str) {
let verdict = match self.services.security.nli_sanitizer.as_ref() {
Some(nli) if nli.is_active() => nli.check(body).await,
_ => None,
};
let Some(verdict) = verdict else {
return;
};
self.update_metrics(|m| m.nli_checks += 1);
if !verdict.flagged {
return;
}
tracing::warn!(
tool = %tool_name,
score = verdict.injection_score,
"NLI entailment check flagged tool output as likely injection"
);
self.update_metrics(|m| m.nli_flags += 1);
self.push_security_event(
zeph_common::SecurityEventCategory::InjectionFlag,
tool_name,
format!(
"NLI entailment score {:.2} exceeded threshold",
verdict.injection_score
),
);
}
fn record_injection_flags(
&mut self,
sanitized: &zeph_sanitizer::SanitizedContent,
tool_name: &str,
) {
if sanitized.injection_flags.is_empty() {
return;
}
tracing::warn!(
tool = %tool_name,
flags = sanitized.injection_flags.len(),
"injection patterns detected in tool output"
);
self.update_metrics(|m| {
let flag_count = sanitized.injection_flags.len() as u64;
m.sanitizer_injection_flags += flag_count;
if sanitized.source.kind == zeph_sanitizer::ContentSourceKind::ToolResult {
m.sanitizer_injection_fp_local += flag_count;
}
});
let detail = sanitized
.injection_flags
.first()
.map_or_else(String::new, |f| {
format!("Detected pattern: {}", f.pattern_name)
});
self.push_security_event(
zeph_common::SecurityEventCategory::InjectionFlag,
tool_name,
detail,
);
let urls = zeph_sanitizer::exfiltration::extract_flagged_urls(&sanitized.body);
self.services.security.flagged_urls.extend(
urls.iter()
.map(|u| zeph_sanitizer::exfiltration::normalize_url_for_matching(u).to_owned()),
);
}
#[cfg(feature = "classifiers")]
async fn apply_classifier_verdict(
&mut self,
body: &str,
tool_name: &str,
memory_hint: Option<zeph_sanitizer::MemorySourceHint>,
) -> Option<(String, bool)> {
let is_utility_gate_synthetic =
body.starts_with("[skipped]") || body.starts_with("[stopped]");
let skip_ml = matches!(
memory_hint,
Some(
zeph_sanitizer::MemorySourceHint::ConversationHistory
| zeph_sanitizer::MemorySourceHint::LlmSummary
)
) || is_policy_blocked_output(body)
|| is_utility_gate_synthetic
|| is_internal_tool(tool_name);
if !skip_ml && self.services.security.sanitizer.has_classifier_backend() {
let ml_verdict = self
.services
.security
.sanitizer
.classify_injection(body)
.await;
match ml_verdict {
zeph_sanitizer::InjectionVerdict::Blocked => {
tracing::warn!(tool = %tool_name, "ML classifier blocked tool output");
self.update_metrics(|m| m.classifier_tool_blocks += 1);
self.push_security_event(
zeph_common::SecurityEventCategory::InjectionBlocked,
tool_name,
"ML classifier blocked tool output",
);
return Some((
"[tool output blocked: injection detected by classifier]".into(),
true,
));
}
zeph_sanitizer::InjectionVerdict::Suspicious => {
tracing::warn!(
tool = %tool_name,
"ML classifier: suspicious tool output"
);
self.update_metrics(|m| m.classifier_tool_suspicious += 1);
}
_ => {}
}
}
None
}
async fn handle_cross_boundary_quarantine(
&mut self,
sanitized: &zeph_sanitizer::SanitizedContent,
tool_name: &str,
has_injection_flags: bool,
) -> Option<(String, bool)> {
let mcp_server_id = tool_name
.split_once(':')
.map(|(server, _)| server.to_owned());
tracing::warn!(
tool = %tool_name,
mcp_server_id = mcp_server_id.as_deref().unwrap_or("unknown"),
"MCP tool result crossing ACP trust boundary"
);
self.push_security_event(
zeph_common::SecurityEventCategory::CrossBoundaryMcpToAcp,
tool_name,
"MCP result force-quarantined for ACP session",
);
if let Some(ref logger) = self.tool_orchestrator.audit_logger {
let entry = zeph_tools::AuditEntry {
source_kind: None,
trust_level: None,
timestamp: zeph_tools::chrono_now(),
tool: tool_name.into(),
command: String::new(),
result: zeph_tools::AuditResult::Success,
duration_ms: 0,
error_category: None,
error_domain: Some("security".to_owned()),
error_phase: None,
claim_source: None,
mcp_server_id,
injection_flagged: has_injection_flags,
embedding_anomalous: false,
cross_boundary_mcp_to_acp: true,
adversarial_policy_decision: None,
exit_code: None,
truncated: false,
caller_id: None,
skill_name: None,
policy_match: None,
correlation_id: None,
vigil_risk: None,
execution_env: None,
resolved_cwd: None,
scope_at_definition: None,
scope_at_dispatch: None,
};
let logger = std::sync::Arc::clone(logger);
self.runtime.lifecycle.supervisor.spawn(
super::super::agent_supervisor::TaskClass::Telemetry,
"audit-log-sanitize",
async move { logger.log(&entry).await },
);
}
if let Some(ref qs) = self.services.security.quarantine_summarizer {
match qs
.extract_facts(sanitized, &self.services.security.sanitizer)
.await
{
Ok((facts, flags)) => {
self.update_metrics(|m| m.quarantine_invocations += 1);
let escaped = zeph_sanitizer::ContentSanitizer::escape_delimiter_tags(&facts);
return Some((
zeph_sanitizer::ContentSanitizer::apply_spotlight(
&escaped,
&sanitized.source,
&flags,
),
has_injection_flags,
));
}
Err(e) => {
let blocked_body = qs
.error_should_block()
.then(|| qs.blocked_fallback(sanitized));
self.update_metrics(|m| m.quarantine_failures += 1);
if let Some(body) = blocked_body {
tracing::warn!(
tool = %tool_name,
error = %e,
"cross-boundary quarantine failed, fail_strategy=closed: blocking content"
);
self.push_security_event(
zeph_common::SecurityEventCategory::Quarantine,
tool_name,
format!("Cross-boundary quarantine failed (fail-closed): {e}"),
);
return Some((body, has_injection_flags));
}
tracing::warn!(
tool = %tool_name,
error = %e,
"cross-boundary quarantine failed, fail_strategy=open: using spotlighted output"
);
}
}
}
None
}
async fn handle_quarantine_summary(
&mut self,
sanitized: &zeph_sanitizer::SanitizedContent,
tool_name: &str,
kind: ContentSourceKind,
has_injection_flags: bool,
) -> Option<(String, bool)> {
if !(self.services.security.sanitizer.is_enabled()
&& self
.services
.security
.quarantine_summarizer
.as_ref()
.is_some_and(|qs| qs.should_quarantine(kind)))
{
return None;
}
let qs = self.services.security.quarantine_summarizer.as_ref()?;
match qs
.extract_facts(sanitized, &self.services.security.sanitizer)
.await
{
Ok((facts, flags)) => {
self.update_metrics(|m| m.quarantine_invocations += 1);
self.push_security_event(
zeph_common::SecurityEventCategory::Quarantine,
tool_name,
"Content quarantined, facts extracted",
);
let escaped = zeph_sanitizer::ContentSanitizer::escape_delimiter_tags(&facts);
Some((
zeph_sanitizer::ContentSanitizer::apply_spotlight(
&escaped,
&sanitized.source,
&flags,
),
has_injection_flags,
))
}
Err(e) => {
let blocked_body = qs
.error_should_block()
.then(|| qs.blocked_fallback(sanitized));
self.update_metrics(|m| m.quarantine_failures += 1);
if let Some(body) = blocked_body {
tracing::warn!(
tool = %tool_name,
error = %e,
"quarantine failed, fail_strategy=closed: blocking content"
);
self.push_security_event(
zeph_common::SecurityEventCategory::Quarantine,
tool_name,
format!("Quarantine failed (fail-closed): {e}"),
);
return Some((body, has_injection_flags));
}
tracing::warn!(
tool = %tool_name,
error = %e,
"quarantine failed, fail_strategy=open: using original sanitized output"
);
self.push_security_event(
zeph_common::SecurityEventCategory::Quarantine,
tool_name,
format!("Quarantine failed: {e}"),
);
None
}
}
}
}
#[cfg(test)]
mod split_bash_echo_prefix_tests {
use super::split_bash_echo_prefix;
#[test]
fn splits_bash_echo_line() {
let body = "$ date +%s.%N\n1783259155.445901000\n";
let (prefix, remainder) = split_bash_echo_prefix(body, "bash");
assert_eq!(prefix, "$ date +%s.%N\n");
assert_eq!(remainder, "1783259155.445901000\n");
}
#[test]
fn splits_shell_echo_line() {
let body = "$ ls -la\ntotal 0\n";
let (prefix, remainder) = split_bash_echo_prefix(body, "shell");
assert_eq!(prefix, "$ ls -la\n");
assert_eq!(remainder, "total 0\n");
}
#[test]
fn non_bash_shell_tool_not_split() {
let body = "$ date +%s.%N\n1783259155.445901000\n";
let (prefix, remainder) = split_bash_echo_prefix(body, "web-scrape");
assert_eq!(prefix, "");
assert_eq!(remainder, body);
}
#[test]
fn body_without_dollar_prefix_not_split() {
let body = "no echo line here\nsome output\n";
let (prefix, remainder) = split_bash_echo_prefix(body, "bash");
assert_eq!(prefix, "");
assert_eq!(remainder, body);
}
#[test]
fn body_without_newline_not_split() {
let body = "$ date +%s.%N";
let (prefix, remainder) = split_bash_echo_prefix(body, "bash");
assert_eq!(prefix, "");
assert_eq!(remainder, body);
}
}
#[cfg(test)]
mod build_tool_output_source_tests {
use super::build_tool_output_source;
use zeph_sanitizer::ContentSourceKind;
#[test]
fn web_search_classified_as_web_scrape_source() {
let source = build_tool_output_source("web_search");
assert_eq!(source.kind, ContentSourceKind::WebScrape);
}
#[test]
fn web_scrape_and_fetch_classified_as_web_scrape_source() {
for name in ["web-scrape", "web_scrape", "fetch"] {
let source = build_tool_output_source(name);
assert_eq!(
source.kind,
ContentSourceKind::WebScrape,
"{name} must classify as WebScrape"
);
}
}
#[test]
fn unrelated_tool_classified_as_tool_result() {
let source = build_tool_output_source("some_other_tool");
assert_eq!(source.kind, ContentSourceKind::ToolResult);
}
}
#[cfg(test)]
mod consent_gate_dispatch_tests {
use super::batch_dispatch_trust_level;
use crate::agent::agent_tests::{
MockChannel, MockToolExecutor, create_test_registry, mock_provider,
};
use zeph_llm::provider::{Message, MessageMetadata, Role, ToolUseRequest};
fn make_agent() -> crate::agent::Agent<MockChannel> {
crate::agent::Agent::new(
mock_provider(vec![]),
MockChannel::new(vec![]),
create_test_registry(),
None,
5,
MockToolExecutor::no_tools(),
)
}
fn tagged_message(trust: Option<u8>) -> Message {
Message {
role: Role::User,
content: String::new(),
parts: vec![],
metadata: MessageMetadata {
trust_level: trust,
..MessageMetadata::default()
},
}
}
fn call(name: &str) -> ToolUseRequest {
ToolUseRequest {
id: format!("id-{name}"),
name: name.to_owned().into(),
input: serde_json::json!({}),
}
}
#[test]
fn context_max_trust_level_defaults_to_trusted_on_empty_context() {
let agent = make_agent();
assert_eq!(
agent.context_max_trust_level(),
zeph_sanitizer::ContentTrustLevel::Trusted
);
}
#[test]
fn context_max_trust_level_ignores_untagged_messages() {
let mut agent = make_agent();
agent.msg.messages.push(tagged_message(None));
assert_eq!(
agent.context_max_trust_level(),
zeph_sanitizer::ContentTrustLevel::Trusted
);
}
#[test]
fn context_max_trust_level_finds_tagged_message_from_prior_turn() {
let mut agent = make_agent();
agent.msg.messages.push(tagged_message(Some(
zeph_sanitizer::ContentTrustLevel::ExternalUntrusted as u8,
)));
assert_eq!(
agent.context_max_trust_level(),
zeph_sanitizer::ContentTrustLevel::ExternalUntrusted
);
}
#[test]
fn context_max_trust_level_takes_max_across_messages() {
let mut agent = make_agent();
agent.msg.messages.push(tagged_message(Some(
zeph_sanitizer::ContentTrustLevel::LocalUntrusted as u8,
)));
agent.msg.messages.push(tagged_message(Some(
zeph_sanitizer::ContentTrustLevel::ExternalUntrusted as u8,
)));
agent.msg.messages.push(tagged_message(None));
assert_eq!(
agent.context_max_trust_level(),
zeph_sanitizer::ContentTrustLevel::ExternalUntrusted
);
}
#[test]
fn batch_dispatch_trust_level_reflects_web_scrape() {
let tool_calls = vec![call("web_scrape")];
assert_eq!(
batch_dispatch_trust_level(&tool_calls),
zeph_sanitizer::ContentTrustLevel::ExternalUntrusted
);
}
#[test]
fn batch_dispatch_trust_level_reflects_memory_search() {
let tool_calls = vec![call("memory_search")];
assert_eq!(
batch_dispatch_trust_level(&tool_calls),
zeph_sanitizer::ContentTrustLevel::ExternalUntrusted
);
}
#[test]
fn batch_dispatch_trust_level_excludes_memory_save_itself() {
let tool_calls = vec![call("memory_save")];
assert_eq!(
batch_dispatch_trust_level(&tool_calls),
zeph_sanitizer::ContentTrustLevel::Trusted
);
}
#[test]
fn batch_dispatch_trust_level_other_tool_alongside_memory_save_still_counts() {
let tool_calls = vec![call("web_scrape"), call("memory_save")];
assert_eq!(
batch_dispatch_trust_level(&tool_calls),
zeph_sanitizer::ContentTrustLevel::ExternalUntrusted
);
}
#[test]
fn ratchet_combines_context_and_batch_trust() {
let mut agent = make_agent();
agent.msg.messages.push(tagged_message(Some(
zeph_sanitizer::ContentTrustLevel::LocalUntrusted as u8,
)));
let tool_calls = vec![call("web_scrape")];
agent.ratchet_memory_consent_trust_for_dispatch(&tool_calls);
assert_eq!(
*agent.services.security.memory_consent_trust.read(),
zeph_sanitizer::ContentTrustLevel::ExternalUntrusted as u8
);
}
#[test]
fn ratchet_leaves_slot_trusted_for_bare_memory_save_in_clean_context() {
let mut agent = make_agent();
let tool_calls = vec![call("memory_save")];
agent.ratchet_memory_consent_trust_for_dispatch(&tool_calls);
assert_eq!(*agent.services.security.memory_consent_trust.read(), 0);
}
}
#[cfg(all(test, feature = "classifiers"))]
mod tests {
use super::is_internal_tool;
#[test]
fn internal_tool_allowlist_covers_all_zeph_tools() {
for name in [
"bash",
"shell",
"invoke_skill",
"load_skill",
"memory_save",
"memory_search",
"compress_context",
"request_compaction",
"complete_focus",
"start_focus",
"schedule_periodic",
"schedule_deferred",
"cancel_task",
] {
assert!(
is_internal_tool(name),
"{name} must be in internal allowlist"
);
}
}
#[test]
fn external_and_mcp_tools_not_in_allowlist() {
for name in [
"web-scrape",
"fetch",
"read_overflow",
"github:list_issues",
"my-server:invoke_skill",
"mcp:invoke_skill",
] {
assert!(
!is_internal_tool(name),
"{name} must NOT be in internal allowlist"
);
}
}
#[test]
fn colon_namespaced_names_always_excluded() {
assert!(!is_internal_tool("server:invoke_skill"));
assert!(!is_internal_tool("attacker:memory_save"));
assert!(!is_internal_tool("x:cancel_task"));
}
}