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