agentox_core/checks/security/
prompt_injection_echo.rs1use crate::checks::runner::{Check, CheckContext};
4use crate::checks::security::constants::{
5 truncate_for_evidence, EVIDENCE_MAX_CHARS, INJECTION_LEAK_MARKERS, INJECTION_PAYLOADS,
6};
7use crate::checks::types::{CheckCategory, CheckResult, Severity};
8use crate::protocol::mcp_types::Content;
9
10pub struct PromptInjectionEchoSafety;
11
12#[async_trait::async_trait]
13impl Check for PromptInjectionEchoSafety {
14 fn id(&self) -> &str {
15 "SEC-001"
16 }
17
18 fn name(&self) -> &str {
19 "Prompt-injection echo safety"
20 }
21
22 fn category(&self) -> CheckCategory {
23 CheckCategory::Security
24 }
25
26 async fn run(&self, ctx: &mut CheckContext) -> Vec<CheckResult> {
27 let desc = "Tool calls should reject or safely handle prompt-injection-like input";
28
29 let tools = match &ctx.tools {
30 Some(tools) => tools.clone(),
31 None => match ctx.session.list_tools().await {
32 Ok(tools) => {
33 ctx.tools = Some(tools.clone());
34 tools
35 }
36 Err(e) => {
37 return vec![CheckResult::fail(
38 self.id(),
39 self.name(),
40 self.category(),
41 Severity::High,
42 desc,
43 format!("Failed to list tools for security probing: {e}"),
44 )];
45 }
46 },
47 };
48
49 if tools.is_empty() {
50 let mut pass = CheckResult::pass(self.id(), self.name(), self.category(), desc);
51 pass.message = "No tools exposed; injection probe is not applicable".to_string();
52 return vec![pass.with_evidence(serde_json::json!({ "tools": 0 }))];
53 }
54
55 let mut findings = Vec::new();
56 let mut benign_echoes = 0usize;
57 for tool in &tools {
58 for payload in INJECTION_PAYLOADS {
59 let args = serde_json::json!({
60 "message": payload,
61 "text": payload,
62 "name": payload
63 });
64
65 match ctx.session.call_tool(&tool.name, args).await {
66 Ok(result) => {
67 let text_out: String = result
68 .content
69 .iter()
70 .filter_map(|c| match c {
71 Content::Text { text } => Some(text.as_str()),
72 _ => None,
73 })
74 .collect::<Vec<_>>()
75 .join(" ");
76 let lowered = text_out.to_ascii_lowercase();
77 let leaked = INJECTION_LEAK_MARKERS
78 .iter()
79 .any(|marker| lowered.contains(marker));
80 if leaked {
81 findings.push(
82 CheckResult::fail(
83 self.id(),
84 self.name(),
85 self.category(),
86 Severity::High,
87 desc,
88 format!(
89 "Tool \"{}\" returned suspicious content for injection probe",
90 tool.name
91 ),
92 )
93 .with_evidence(serde_json::json!({
94 "tool": tool.name,
95 "payload": payload,
96 "output_sample": truncate_for_evidence(&text_out, EVIDENCE_MAX_CHARS),
97 })),
98 );
99 } else if text_out.trim() == *payload {
100 benign_echoes += 1;
102 }
103 }
104 Err(crate::error::SessionError::JsonRpc { .. }) => {
105 }
107 Err(e) => {
108 findings.push(
109 CheckResult::fail(
110 self.id(),
111 self.name(),
112 self.category(),
113 Severity::High,
114 desc,
115 format!(
116 "Tool \"{}\" probe failed with transport/session error: {e}",
117 tool.name
118 ),
119 )
120 .with_evidence(serde_json::json!({
121 "tool": tool.name,
122 "payload": payload
123 })),
124 );
125 }
126 }
127 }
128 }
129
130 if findings.is_empty() {
131 vec![
132 CheckResult::pass(self.id(), self.name(), self.category(), desc).with_evidence(
133 serde_json::json!({
134 "tools_probed": tools.len(),
135 "payloads_per_tool": INJECTION_PAYLOADS.len(),
136 "benign_echo_responses": benign_echoes
137 }),
138 ),
139 ]
140 } else {
141 findings
142 }
143 }
144}