Skip to main content

agentshield/adapter/
hermes.rs

1//! Hermes Agent adapter.
2//!
3//! Detects Hermes Agent client configuration and skill trees, then loads:
4//! - `config.yaml` / `.hermes/config.yaml` / profile configs with `mcp_servers`
5//! - `.hermes.md` project context
6//! - `skills/`, `optional-skills/`, and `optional-mcps/` artifacts
7
8use 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
21/// Hermes Agent client adapter.
22///
23/// Detection intentionally requires Hermes-specific artifacts. Generic context
24/// files such as `AGENTS.md` and `CLAUDE.md` are not enough to avoid treating
25/// ordinary coding-agent projects as Hermes projects.
26pub 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}