diff --git a/README.md b/README.md index b5ce907..23ddb35 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ For most users, start with these: | `components/hermes/hermes-safe-terminal` | Blocks dangerous terminal commands such as destructive deletes, disk wipes, env dumps, and curl-to-shell. | | `components/hermes/hermes-publish-gate` | Runs a 12-check public-release gate for secrets, PII, AI attribution, artifacts, licenses, and debug code. | | `components/hermes/hermes-workspace-scanner` | Scans an agent workspace for leaked secrets, suspicious files, tracked secret files, and hardcoded user paths. | +| `components/hermes/hermes-mcp-guard` | Reviews Hermes MCP server config for unsafe endpoints, broad tool exposure, literal credentials, and unpinned stdio launchers. | | `components/hermes/hermes-staging-scan` | Moves inbound files through ClamAV-backed scanned/quarantine directories. | | `components/hermes/hermes-secret-store` | Loads secrets from OS keystores instead of plaintext `.env` files. | | `components/hermes/hermes-memory-encrypt` | Encrypts `MEMORY.md` and `USER.md` at rest with HMAC verification. | diff --git a/components/hermes/hermes-mcp-guard/.gitignore b/components/hermes/hermes-mcp-guard/.gitignore new file mode 100644 index 0000000..4bd90bc --- /dev/null +++ b/components/hermes/hermes-mcp-guard/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +*.log +tmp/ diff --git a/components/hermes/hermes-mcp-guard/CHANGELOG.md b/components/hermes/hermes-mcp-guard/CHANGELOG.md new file mode 100644 index 0000000..68b8dc7 --- /dev/null +++ b/components/hermes/hermes-mcp-guard/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Hermes MCP configuration guard. diff --git a/components/hermes/hermes-mcp-guard/CONTRIBUTING.md b/components/hermes/hermes-mcp-guard/CONTRIBUTING.md new file mode 100644 index 0000000..4447b96 --- /dev/null +++ b/components/hermes/hermes-mcp-guard/CONTRIBUTING.md @@ -0,0 +1,10 @@ +# Contributing + +Keep this component conservative and offline. It should inspect local Hermes +configuration without connecting to MCP servers or printing secret values. + +Before opening a PR from the repository root, run: + +```bash +bash scripts/publish-check.sh +``` diff --git a/components/hermes/hermes-mcp-guard/LICENSE b/components/hermes/hermes-mcp-guard/LICENSE new file mode 100644 index 0000000..ba129f4 --- /dev/null +++ b/components/hermes/hermes-mcp-guard/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 RedBeret + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/components/hermes/hermes-mcp-guard/README.md b/components/hermes/hermes-mcp-guard/README.md new file mode 100644 index 0000000..e1b2cf0 --- /dev/null +++ b/components/hermes/hermes-mcp-guard/README.md @@ -0,0 +1,58 @@ +# hermes-mcp-guard + +Offline guard for reviewing Hermes MCP server configuration before an agent +session uses external tools. + +It scans the `mcp_servers:` block in `~/.hermes/config.yaml` and reports: + +- literal API keys or bearer tokens in MCP config +- insecure `http://` MCP endpoints +- remote MCP hosts that are not explicitly allowlisted +- local/private MCP endpoints that deserve review +- stdio commands that shell out through `curl`, `wget`, `bash -c`, or `sh -c` +- `npx`, `uvx`, and `pipx` MCP packages that are not version-pinned +- enabled MCP servers that expose all tools instead of `tools.include` or + `tools.exclude` +- server-initiated sampling with no visible `allowed_models` limit + +The script does not connect to any MCP server and does not print secret values. + +## Usage + +Scan the default Hermes config: + +```bash +bash mcp-guard.sh +``` + +Scan a specific config: + +```bash +bash mcp-guard.sh ~/.hermes/config.yaml +``` + +Treat warnings as failures: + +```bash +bash mcp-guard.sh --strict ~/.hermes/config.yaml +``` + +Allow known remote MCP hosts: + +```bash +MCP_GUARD_ALLOWED_HOSTS_CSV="github.com,mcp.example.com" bash mcp-guard.sh +``` + +## Install + +```bash +bash setup.sh +``` + +This installs `mcp-guard.sh` into `~/.hermes/security/`. + +## Notes + +This guard is deliberately text-based so it can run without extra YAML +dependencies. Review warnings manually; some local MCP servers are legitimate +when they are intentionally scoped and trusted. diff --git a/components/hermes/hermes-mcp-guard/SECURITY.md b/components/hermes/hermes-mcp-guard/SECURITY.md new file mode 100644 index 0000000..756cabb --- /dev/null +++ b/components/hermes/hermes-mcp-guard/SECURITY.md @@ -0,0 +1,6 @@ +# Security + +Report issues through the repository's GitHub security advisory flow. + +This component should never echo raw header values, environment values, or MCP +server credentials. Findings should identify the category and line number only. diff --git a/components/hermes/hermes-mcp-guard/mcp-guard.sh b/components/hermes/hermes-mcp-guard/mcp-guard.sh new file mode 100755 index 0000000..65fabb7 --- /dev/null +++ b/components/hermes/hermes-mcp-guard/mcp-guard.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +# hermes-mcp-guard: offline MCP configuration review for Hermes. + +set -euo pipefail + +STRICT=0 +CONFIG="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --strict) STRICT=1 ;; + -h|--help) + echo "usage: bash mcp-guard.sh [--strict] [config.yaml]" + exit 0 + ;; + *) + if [ -n "$CONFIG" ]; then + echo "usage: bash mcp-guard.sh [--strict] [config.yaml]" >&2 + exit 2 + fi + CONFIG="$1" + ;; + esac + shift +done + +CONFIG="${CONFIG:-${HERMES_CONFIG:-${HERMES_HOME:-$HOME/.hermes}/config.yaml}}" +ALLOWED_HOSTS_CSV="${MCP_GUARD_ALLOWED_HOSTS_CSV:-}" + +PASS=0 +WARN=0 +FAIL=0 + +pass() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } +warn() { printf ' WARN line %s: %s\n' "$1" "$2"; WARN=$((WARN + 1)); } +fail() { printf ' FAIL line %s: %s\n' "$1" "$2"; FAIL=$((FAIL + 1)); } + +is_allowed_host() { + local host="$1" + local item + IFS=',' read -r -a hosts <<< "$ALLOWED_HOSTS_CSV" + for item in "${hosts[@]}"; do + item="$(printf '%s' "$item" | tr '[:upper:]' '[:lower:]' | xargs)" + [ "$host" = "$item" ] && return 0 + done + return 1 +} + +is_private_or_local_host() { + local host="$1" + case "$host" in + localhost|*.localhost|127.*|10.*|192.168.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|169.254.*|::1) + return 0 + ;; + esac + return 1 +} + +extract_host() { + local url="$1" + url="${url#*://}" + url="${url%%/*}" + url="${url%%:*}" + printf '%s' "$url" | tr '[:upper:]' '[:lower:]' +} + +strip_value() { + local value="$1" + value="${value#*:}" + value="${value%%#*}" + value="$(printf '%s' "$value" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//; s/^["'\'']//; s/["'\'']$//')" + printf '%s' "$value" +} + +finish_server() { + local server="$1" + local line="$2" + local enabled="$3" + local has_filter="$4" + local has_sampling="$5" + local has_allowed_models="$6" + + [ -z "$server" ] && return 0 + [ "$enabled" = "false" ] && return 0 + + if [ "$has_filter" -eq 0 ]; then + warn "$line" "MCP server '$server' exposes all tools; prefer tools.include or tools.exclude." + fi + + if [ "$has_sampling" -eq 1 ] && [ "$has_allowed_models" -eq 0 ]; then + warn "$line" "MCP server '$server' enables sampling without an allowed_models limit." + fi +} + +echo "" +echo "Hermes MCP Guard" +echo "Config: $CONFIG" +echo "----------------" + +if [ ! -f "$CONFIG" ]; then + pass "No Hermes config file found." + exit 0 +fi + +MCP_BLOCK="$(awk ' + /^[^[:space:]]/ { + in_mcp = ($1 == "mcp_servers:") + } + in_mcp { + print NR ":" $0 + } +' "$CONFIG")" + +if [ -z "$MCP_BLOCK" ]; then + pass "No mcp_servers block found." + exit 0 +fi + +current_server="" +current_server_line=0 +current_enabled="true" +current_tool_filter=0 +current_sampling=0 +current_allowed_models=0 +in_sampling=0 + +while IFS= read -r record; do + [ -z "$record" ] && continue + line_no="${record%%:*}" + line="${record#*:}" + + if printf '%s\n' "$line" | grep -Eq '^ [A-Za-z0-9_.-]+:[[:space:]]*$'; then + finish_server "$current_server" "$current_server_line" "$current_enabled" "$current_tool_filter" "$current_sampling" "$current_allowed_models" + current_server="$(printf '%s' "$line" | sed -E 's/^ ([A-Za-z0-9_.-]+):[[:space:]]*$/\1/')" + current_server_line="$line_no" + current_enabled="true" + current_tool_filter=0 + current_sampling=0 + current_allowed_models=0 + in_sampling=0 + continue + fi + + if printf '%s\n' "$line" | grep -Eq '^[[:space:]]+enabled:[[:space:]]*false[[:space:]]*$'; then + current_enabled="false" + fi + + if printf '%s\n' "$line" | grep -Eq '^[[:space:]]+tools:[[:space:]]*.*(include|exclude):'; then + current_tool_filter=1 + elif printf '%s\n' "$line" | grep -Eq '^[[:space:]]+(include|exclude):'; then + current_tool_filter=1 + fi + + if printf '%s\n' "$line" | grep -Eq '^[[:space:]]+sampling:[[:space:]]*$'; then + current_sampling=1 + in_sampling=1 + elif [ "$in_sampling" -eq 1 ] && printf '%s\n' "$line" | grep -Eq '^[[:space:]]{4}[A-Za-z0-9_.-]+:'; then + in_sampling=0 + fi + + if [ "$in_sampling" -eq 1 ] && printf '%s\n' "$line" | grep -Eq 'allowed_models:[[:space:]]*\[[^]]+[^[:space:]]\]'; then + current_allowed_models=1 + fi + + if printf '%s\n' "$line" | grep -Eqi '(sk-ant-[A-Za-z0-9_-]{20,}|sk-or-v1-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22,}|glpat-[A-Za-z0-9_-]{20,})'; then + fail "$line_no" "literal credential pattern in MCP config; move it to a keystore or environment interpolation." + fi + + if printf '%s\n' "$line" | grep -Eqi 'Authorization:[[:space:]]*["'\'']?Bearer[[:space:]]+' && ! printf '%s\n' "$line" | grep -q '\${'; then + fail "$line_no" "literal bearer token in MCP header; use Bearer \${ENV_VAR}." + fi + + if printf '%s\n' "$line" | grep -Eqi '[A-Z0-9_]*(API_KEY|TOKEN|SECRET|PASSWORD)[A-Z0-9_]*:[[:space:]]*["'\'']?[^"$\{[:space:]#][^#]{7,}'; then + fail "$line_no" "literal secret-like env value in MCP config; use keystore-backed environment interpolation." + fi + + if printf '%s\n' "$line" | grep -Eqi '^[[:space:]]+url:'; then + url="$(strip_value "$line")" + host="$(extract_host "$url")" + case "$url" in + http://*) + fail "$line_no" "MCP endpoint uses http://; use HTTPS or a trusted local stdio server." + ;; + https://*) + if is_private_or_local_host "$host"; then + warn "$line_no" "MCP endpoint resolves to a local/private-looking host; verify it is intentional." + elif ! is_allowed_host "$host"; then + warn "$line_no" "remote MCP host '$host' is not in MCP_GUARD_ALLOWED_HOSTS_CSV." + fi + ;; + esac + fi + + if printf '%s\n' "$line" | grep -Eqi '^[[:space:]]+command:[[:space:]]*["'\'']?(curl|wget)\b'; then + fail "$line_no" "MCP stdio command starts with a downloader." + fi + + if printf '%s\n' "$line" | grep -Eqi '(curl|wget).*\|[[:space:]]*(ba)?sh|[[:space:]](ba)?sh[[:space:]]+-c|[[:space:]]zsh[[:space:]]+-c'; then + fail "$line_no" "MCP stdio command shells remote or dynamic code." + fi + + if printf '%s\n' "$line" | grep -Eqi '^[[:space:]]+command:[[:space:]]*["'\'']?(npx|uvx|pipx)["'\'']?[[:space:]]*$'; then + warn "$line_no" "MCP stdio launcher should use pinned packages or a reviewed local executable." + fi + + if printf '%s\n' "$line" | grep -Eqi '@[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+["'\'',[:space:]]*$'; then + warn "$line_no" "scoped MCP package appears unpinned; prefer an explicit package version." + fi +done <<< "$MCP_BLOCK" + +finish_server "$current_server" "$current_server_line" "$current_enabled" "$current_tool_filter" "$current_sampling" "$current_allowed_models" + +if [ "$PASS" -eq 0 ] && [ "$WARN" -eq 0 ] && [ "$FAIL" -eq 0 ]; then + pass "MCP config reviewed with no findings." +fi + +echo "----------------" +if [ "$FAIL" -gt 0 ]; then + echo "Result: FAIL ($FAIL fail, $WARN warn, $PASS pass)" + exit 1 +fi + +if [ "$WARN" -gt 0 ]; then + echo "Result: PASS with warnings ($WARN warn, $PASS pass)" + if [ "$STRICT" -eq 1 ]; then + exit 1 + fi + exit 0 +fi + +echo "Result: PASS ($PASS pass)" diff --git a/components/hermes/hermes-mcp-guard/setup.sh b/components/hermes/hermes-mcp-guard/setup.sh new file mode 100755 index 0000000..819204f --- /dev/null +++ b/components/hermes/hermes-mcp-guard/setup.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "hermes-mcp-guard setup" +echo "----------------------" + +mkdir -p "$HERMES_HOME/security" +cp "$SCRIPT_DIR/mcp-guard.sh" "$HERMES_HOME/security/mcp-guard.sh" +chmod +x "$HERMES_HOME/security/mcp-guard.sh" + +echo "Installed mcp-guard.sh to $HERMES_HOME/security/" +echo "" +echo "Usage:" +echo " bash ~/.hermes/security/mcp-guard.sh" +echo " MCP_GUARD_ALLOWED_HOSTS_CSV=\"github.com\" bash ~/.hermes/security/mcp-guard.sh" diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 2d1f786..a5e7302 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -14,6 +14,7 @@ own README and setup script. - Publish gate: release-readiness checks before making a repo public. - Workspace scanner: local security scan for agent workspaces. +- MCP guard: offline review of Hermes MCP server configuration. - Staging scan: ClamAV-backed inbound-file scan and quarantine workflow. ## Secret and State Handling @@ -42,6 +43,7 @@ own README and setup script. 4. Commit guard 5. Workspace scanner 6. Publish gate +7. MCP guard, if MCP servers are enabled Add memory encryption, staging scans, brain backups, and smart launchers only after the core protections are working. diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 8f235d4..fc30629 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -64,4 +64,31 @@ fi grep -q 'SECRETS FOUND' /tmp/ask-scan.out ok "workspace scanner detects secret" +good_mcp=$(mktemp) +cat > "$good_mcp" <<'EOF' +mcp_servers: + reviewed: + url: "https://mcp.example.com/mcp" + headers: + Authorization: "Bearer ${MCP_REVIEWED_API_KEY}" + tools: + include: ["search"] +EOF +MCP_GUARD_ALLOWED_HOSTS_CSV="mcp.example.com" bash components/hermes/hermes-mcp-guard/mcp-guard.sh "$good_mcp" >/tmp/ask-mcp-good.out + +bad_mcp=$(mktemp) +cat > "$bad_mcp" <<'EOF' +mcp_servers: + unsafe: + url: "http://mcp.example.com/mcp" + headers: + Authorization: "Bearer literal-token-value" +EOF +if bash components/hermes/hermes-mcp-guard/mcp-guard.sh "$bad_mcp" >/tmp/ask-mcp-bad.out 2>&1; then + echo "mcp guard missed unsafe config" >&2 + exit 1 +fi +grep -q 'Result: FAIL' /tmp/ask-mcp-bad.out +ok "mcp guard" + printf 'all smoke tests passed\n'