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::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
20/// Hermes Agent client adapter.
21///
22/// Detection intentionally requires Hermes-specific artifacts. Generic context
23/// files such as `AGENTS.md` and `CLAUDE.md` are not enough to avoid treating
24/// ordinary coding-agent projects as Hermes projects.
25pub 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}