1use std::path::{Path, PathBuf};
9
10use crate::analysis::cross_file::apply_cross_file_sanitization;
11use crate::config::ScanPathFilter;
12use crate::error::Result;
13use crate::ir::execution_surface::{
14 CommandInvocation, EnvAccess, ExecutionSurface, NetworkOperation,
15};
16use crate::ir::taint_builder::build_data_surface;
17use crate::ir::tool_surface::ToolSurface;
18use crate::ir::*;
19use crate::parser;
20
21pub struct HermesAgentAdapter;
27
28impl super::Adapter for HermesAgentAdapter {
29 fn framework(&self) -> Framework {
30 Framework::HermesAgent
31 }
32
33 fn detect(&self, root: &Path) -> bool {
34 root.join(".hermes.md").exists()
35 || looks_like_hermes_config(&root.join("config.yaml"))
36 || looks_like_hermes_config(&root.join(".hermes").join("config.yaml"))
37 || has_profile_config(root)
38 || has_hermes_skill_tree(root)
39 || has_optional_mcp_catalog(root)
40 }
41
42 fn load(&self, root: &Path, ignore_tests: bool) -> Result<Vec<ScanTarget>> {
43 let filter = ScanPathFilter::for_ignore_tests(ignore_tests);
44 self.load_with_filter(root, &filter)
45 }
46
47 fn load_with_filter(&self, root: &Path, filter: &ScanPathFilter) -> Result<Vec<ScanTarget>> {
48 let name = root
49 .file_name()
50 .map(|n| n.to_string_lossy().to_string())
51 .unwrap_or_else(|| "hermes-agent".into());
52
53 let mut tools: Vec<ToolSurface> = Vec::new();
54 let mut execution = ExecutionSurface::default();
55 let mut source_files: Vec<SourceFile> = Vec::new();
56
57 collect_hermes_source_files(root, filter, &mut source_files)?;
58
59 for sf in &source_files {
60 if is_yaml_file(&sf.path) {
61 parse_mcp_servers_from_yaml(&sf.content, &sf.path, &mut tools, &mut execution);
62 }
63 }
64
65 let mut parsed_files: Vec<(PathBuf, parser::ParsedFile)> = Vec::new();
66 for sf in &source_files {
67 if let Some(parser) = parser::parser_for_language(sf.language) {
68 if let Ok(parsed) = parser.parse_file(&sf.path, &sf.content) {
69 parsed_files.push((sf.path.clone(), parsed));
70 }
71 }
72 }
73
74 apply_cross_file_sanitization(&mut parsed_files);
75
76 for (_, parsed) in parsed_files {
77 execution.commands.extend(parsed.commands);
78 execution.file_operations.extend(parsed.file_operations);
79 execution
80 .network_operations
81 .extend(parsed.network_operations);
82 execution.env_accesses.extend(parsed.env_accesses);
83 execution.dynamic_exec.extend(parsed.dynamic_exec);
84 }
85
86 let dependencies = super::mcp::parse_dependencies(root, filter);
87 let provenance = super::mcp::parse_provenance(root, filter);
88 let data = build_data_surface(&tools, &execution);
89
90 Ok(vec![ScanTarget {
91 name,
92 framework: Framework::HermesAgent,
93 root_path: root.to_path_buf(),
94 tools,
95 execution,
96 data,
97 dependencies,
98 provenance,
99 source_files,
100 }])
101 }
102}
103
104fn looks_like_hermes_config(path: &Path) -> bool {
105 let Ok(content) = std::fs::read_to_string(path) else {
106 return false;
107 };
108
109 content.contains("mcp_servers:")
110 || content.contains("skills:")
111 || content.contains("terminal:")
112 || content.contains("gateway:")
113 || content.contains("sessions:")
114 || content.contains("model:")
115}
116
117fn has_profile_config(root: &Path) -> bool {
118 let profiles_dir = root.join("profiles");
119 let Ok(entries) = std::fs::read_dir(profiles_dir) else {
120 return false;
121 };
122
123 entries
124 .flatten()
125 .any(|entry| looks_like_hermes_config(&entry.path().join("config.yaml")))
126}
127
128fn has_hermes_skill_tree(root: &Path) -> bool {
129 has_skill_md_under(&root.join("skills")) || has_skill_md_under(&root.join("optional-skills"))
130}
131
132fn has_optional_mcp_catalog(root: &Path) -> bool {
133 let catalog_dir = root.join("optional-mcps");
134 let Ok(entries) = std::fs::read_dir(catalog_dir) else {
135 return false;
136 };
137
138 entries
139 .flatten()
140 .any(|entry| entry.path().join("manifest.yaml").exists())
141}
142
143fn has_skill_md_under(dir: &Path) -> bool {
144 let Ok(entries) = std::fs::read_dir(dir) else {
145 return false;
146 };
147
148 entries.flatten().any(|entry| {
149 let path = entry.path();
150 path.join("SKILL.md").exists() || has_skill_md_under(&path)
151 })
152}
153
154fn collect_hermes_source_files(
155 root: &Path,
156 filter: &ScanPathFilter,
157 source_files: &mut Vec<SourceFile>,
158) -> Result<()> {
159 for path in [
160 root.join("config.yaml"),
161 root.join(".hermes").join("config.yaml"),
162 root.join(".hermes.md"),
163 root.join("SOUL.md"),
164 ] {
165 push_source_file_if_allowed(root, &path, filter, source_files)?;
166 }
167
168 collect_profile_configs(root, filter, source_files)?;
169
170 for dir in [
171 root.join("skills"),
172 root.join("optional-skills"),
173 root.join("optional-mcps"),
174 ] {
175 collect_artifact_tree(root, &dir, filter, source_files)?;
176 }
177
178 Ok(())
179}
180
181fn collect_profile_configs(
182 root: &Path,
183 filter: &ScanPathFilter,
184 source_files: &mut Vec<SourceFile>,
185) -> Result<()> {
186 let profiles_dir = root.join("profiles");
187 let Ok(entries) = std::fs::read_dir(profiles_dir) else {
188 return Ok(());
189 };
190
191 for entry in entries.flatten() {
192 push_source_file_if_allowed(
193 root,
194 &entry.path().join("config.yaml"),
195 filter,
196 source_files,
197 )?;
198 }
199
200 Ok(())
201}
202
203fn collect_artifact_tree(
204 root: &Path,
205 dir: &Path,
206 filter: &ScanPathFilter,
207 source_files: &mut Vec<SourceFile>,
208) -> Result<()> {
209 if !dir.exists() {
210 return Ok(());
211 }
212
213 let walker = ignore::WalkBuilder::new(dir)
214 .hidden(true)
215 .git_ignore(true)
216 .max_depth(Some(6))
217 .build();
218
219 for entry in walker.flatten() {
220 let path = entry.path();
221 if !path.is_file() {
222 continue;
223 }
224
225 if filter.ignore_tests() && super::mcp::is_test_file(path) {
226 continue;
227 }
228
229 if !filter.allows_path(root, path) {
230 continue;
231 }
232
233 let Some(file_name) = path.file_name().map(|n| n.to_string_lossy()) else {
234 continue;
235 };
236
237 let language = language_for_path(path);
238 let is_relevant = file_name == "SKILL.md"
239 || file_name == "manifest.yaml"
240 || matches!(
241 language,
242 Language::Python
243 | Language::Shell
244 | Language::JavaScript
245 | Language::TypeScript
246 | Language::Json
247 | Language::Yaml
248 | Language::Markdown
249 );
250
251 if is_relevant {
252 push_source_file(path, source_files)?;
253 }
254 }
255
256 Ok(())
257}
258
259fn push_source_file_if_allowed(
260 root: &Path,
261 path: &Path,
262 filter: &ScanPathFilter,
263 source_files: &mut Vec<SourceFile>,
264) -> Result<()> {
265 if filter.allows_path(root, path) {
266 push_source_file(path, source_files)?;
267 }
268 Ok(())
269}
270
271fn push_source_file(path: &Path, source_files: &mut Vec<SourceFile>) -> Result<()> {
272 if !path.exists() || !path.is_file() {
273 return Ok(());
274 }
275
276 let metadata = std::fs::metadata(path)?;
277 if metadata.len() > 1_048_576 {
278 return Ok(());
279 }
280
281 if let Ok(content) = std::fs::read_to_string(path) {
282 let hash = format!(
283 "{:x}",
284 sha2::Digest::finalize(sha2::Sha256::new().chain_update(content.as_bytes()))
285 );
286 source_files.push(SourceFile {
287 path: path.to_path_buf(),
288 language: language_for_path(path),
289 size_bytes: metadata.len(),
290 content_hash: hash,
291 content,
292 });
293 }
294
295 Ok(())
296}
297
298fn language_for_path(path: &Path) -> Language {
299 let Some(file_name) = path.file_name().map(|n| n.to_string_lossy()) else {
300 return Language::Unknown;
301 };
302
303 if file_name == ".hermes.md" || file_name == "SKILL.md" || file_name == "SOUL.md" {
304 return Language::Markdown;
305 }
306
307 let ext = path
308 .extension()
309 .map(|e| e.to_string_lossy().to_string())
310 .unwrap_or_default();
311 Language::from_extension(&ext)
312}
313
314fn is_yaml_file(path: &Path) -> bool {
315 matches!(language_for_path(path), Language::Yaml)
316}
317
318#[derive(Debug, Default)]
319struct HermesMcpServer {
320 name: String,
321 command: Option<String>,
322 args: Vec<String>,
323 url: Option<String>,
324 env_vars: Vec<String>,
325 headers: Vec<String>,
326 enabled: bool,
327 line: usize,
328}
329
330fn parse_mcp_servers_from_yaml(
331 content: &str,
332 path: &Path,
333 tools: &mut Vec<ToolSurface>,
334 execution: &mut ExecutionSurface,
335) {
336 let servers = parse_mcp_server_entries(content);
337
338 for server in servers.into_iter().filter(|server| server.enabled) {
339 let location = SourceLocation {
340 file: path.to_path_buf(),
341 line: server.line,
342 column: 0,
343 end_line: None,
344 end_column: None,
345 };
346
347 tools.push(ToolSurface {
348 name: server.name.clone(),
349 description: Some(format!(
350 "MCP server '{}' configured in Hermes Agent",
351 server.name
352 )),
353 input_schema: Some(serde_json::json!({
354 "type": "object",
355 "properties": {}
356 })),
357 output_schema: None,
358 declared_permissions: vec![],
359 defined_at: Some(location.clone()),
360 });
361
362 if let Some(command) = server.command {
363 let full_command = if server.args.is_empty() {
364 command.clone()
365 } else {
366 format!("{} {}", command, server.args.join(" "))
367 };
368 execution.commands.push(CommandInvocation {
369 function: command,
370 command_arg: ArgumentSource::Literal(full_command),
371 location: location.clone(),
372 });
373 }
374
375 if let Some(url) = server.url {
376 execution.network_operations.push(NetworkOperation {
377 function: "hermes.mcp.http".into(),
378 url_arg: ArgumentSource::Literal(url),
379 method: None,
380 sends_data: true,
381 location: location.clone(),
382 });
383 }
384
385 for var_name in server.env_vars {
386 execution.env_accesses.push(EnvAccess {
387 is_sensitive: looks_sensitive(&var_name),
388 var_name: ArgumentSource::Literal(var_name),
389 location: location.clone(),
390 });
391 }
392
393 for header_name in server.headers {
394 execution.env_accesses.push(EnvAccess {
395 is_sensitive: looks_sensitive(&header_name),
396 var_name: ArgumentSource::Literal(format!("header:{header_name}")),
397 location: location.clone(),
398 });
399 }
400 }
401}
402
403fn parse_mcp_server_entries(content: &str) -> Vec<HermesMcpServer> {
404 let mut servers = Vec::new();
405 let mut in_mcp_servers = false;
406 let mut mcp_indent = 0usize;
407 let mut current: Option<HermesMcpServer> = None;
408 let mut current_indent = 0usize;
409 let mut section: Option<&str> = None;
410
411 for (line_index, raw_line) in content.lines().enumerate() {
412 let line_no = line_index + 1;
413 let trimmed = raw_line.trim();
414 if trimmed.is_empty() || trimmed.starts_with('#') {
415 continue;
416 }
417
418 let indent = raw_line.len() - raw_line.trim_start().len();
419 if trimmed == "mcp_servers:" {
420 in_mcp_servers = true;
421 mcp_indent = indent;
422 continue;
423 }
424
425 if !in_mcp_servers {
426 continue;
427 }
428
429 if indent <= mcp_indent {
430 break;
431 }
432
433 if indent == mcp_indent + 2 && trimmed.ends_with(':') && !trimmed.contains(' ') {
434 if let Some(server) = current.take() {
435 servers.push(server);
436 }
437 let name = trimmed.trim_end_matches(':').to_string();
438 current = Some(HermesMcpServer {
439 name,
440 enabled: true,
441 line: line_no,
442 ..Default::default()
443 });
444 current_indent = indent;
445 section = None;
446 continue;
447 }
448
449 let Some(server) = current.as_mut() else {
450 continue;
451 };
452
453 if indent <= current_indent {
454 section = None;
455 continue;
456 }
457
458 if trimmed == "env:" || trimmed == "headers:" || trimmed == "args:" {
459 section = Some(trimmed.trim_end_matches(':'));
460 continue;
461 }
462
463 if let Some(value) = trimmed.strip_prefix("command:") {
464 server.command = Some(clean_scalar(value));
465 section = None;
466 continue;
467 }
468
469 if let Some(value) = trimmed.strip_prefix("url:") {
470 server.url = Some(clean_scalar(value));
471 section = None;
472 continue;
473 }
474
475 if let Some(value) = trimmed.strip_prefix("enabled:") {
476 server.enabled = clean_scalar(value) != "false";
477 section = None;
478 continue;
479 }
480
481 if let Some(value) = trimmed.strip_prefix("args:") {
482 server.args.extend(parse_inline_list(value));
483 section = Some("args");
484 continue;
485 }
486
487 match section {
488 Some("env") => {
489 if let Some((key, _)) = trimmed.split_once(':') {
490 server.env_vars.push(clean_scalar(key));
491 }
492 }
493 Some("headers") => {
494 if let Some((key, _)) = trimmed.split_once(':') {
495 server.headers.push(clean_scalar(key));
496 }
497 }
498 Some("args") => {
499 if let Some(arg) = trimmed.strip_prefix('-') {
500 server.args.push(clean_scalar(arg));
501 }
502 }
503 _ => {}
504 }
505 }
506
507 if let Some(server) = current {
508 servers.push(server);
509 }
510
511 servers
512}
513
514fn parse_inline_list(value: &str) -> Vec<String> {
515 let value = value.trim();
516 if !value.starts_with('[') || !value.ends_with(']') {
517 return Vec::new();
518 }
519
520 value
521 .trim_start_matches('[')
522 .trim_end_matches(']')
523 .split(',')
524 .map(clean_scalar)
525 .filter(|item| !item.is_empty())
526 .collect()
527}
528
529fn clean_scalar(value: &str) -> String {
530 value
531 .trim()
532 .trim_matches('"')
533 .trim_matches('\'')
534 .to_string()
535}
536
537fn looks_sensitive(name: &str) -> bool {
538 let upper = name.to_uppercase();
539 upper.contains("KEY")
540 || upper.contains("SECRET")
541 || upper.contains("TOKEN")
542 || upper.contains("PASSWORD")
543 || upper.contains("CREDENTIAL")
544 || upper.contains("AUTH")
545 || upper.starts_with("AWS_")
546 || upper.starts_with("GH_")
547 || upper.starts_with("GITHUB_")
548}
549
550use sha2::Digest;
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use crate::adapter::Adapter;
556
557 fn fixture_dir() -> PathBuf {
558 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hermes_agent")
559 }
560
561 #[test]
562 fn test_detect_hermes_agent() {
563 let adapter = HermesAgentAdapter;
564 assert!(adapter.detect(&fixture_dir()));
565 }
566
567 #[test]
568 fn test_detect_non_hermes_project() {
569 let adapter = HermesAgentAdapter;
570 let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
571 .join("tests/fixtures/mcp_servers/safe_calculator");
572 assert!(!adapter.detect(&dir));
573 }
574
575 #[test]
576 fn test_load_hermes_framework() {
577 let adapter = HermesAgentAdapter;
578 let targets = adapter.load(&fixture_dir(), false).unwrap();
579 assert_eq!(targets.len(), 1);
580 assert_eq!(targets[0].framework, Framework::HermesAgent);
581 }
582
583 #[test]
584 fn test_load_hermes_mcp_servers() {
585 let adapter = HermesAgentAdapter;
586 let targets = adapter.load(&fixture_dir(), false).unwrap();
587 let target = &targets[0];
588
589 let tool_names: Vec<&str> = target.tools.iter().map(|tool| tool.name.as_str()).collect();
590 assert!(tool_names.contains(&"filesystem"));
591 assert!(tool_names.contains(&"company_api"));
592 assert!(!tool_names.contains(&"legacy"));
593
594 assert!(target
595 .execution
596 .commands
597 .iter()
598 .any(|command| command.function == "npx"));
599 assert!(target
600 .execution
601 .network_operations
602 .iter()
603 .any(|network| matches!(&network.url_arg, ArgumentSource::Literal(url) if url == "https://mcp.internal.example.com")));
604 }
605
606 #[test]
607 fn test_load_hermes_sensitive_env_and_headers() {
608 let adapter = HermesAgentAdapter;
609 let targets = adapter.load(&fixture_dir(), false).unwrap();
610 let target = &targets[0];
611
612 assert!(target.execution.env_accesses.iter().any(|env| {
613 env.is_sensitive
614 && matches!(&env.var_name, ArgumentSource::Literal(name) if name == "GITHUB_PERSONAL_ACCESS_TOKEN")
615 }));
616 assert!(target.execution.env_accesses.iter().any(|env| {
617 env.is_sensitive
618 && matches!(&env.var_name, ArgumentSource::Literal(name) if name == "header:Authorization")
619 }));
620 }
621}