Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
3 changes: 3 additions & 0 deletions components/hermes/hermes-mcp-guard/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.DS_Store
*.log
tmp/
5 changes: 5 additions & 0 deletions components/hermes/hermes-mcp-guard/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changelog

## 0.1.0

- Initial Hermes MCP configuration guard.
10 changes: 10 additions & 0 deletions components/hermes/hermes-mcp-guard/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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
```
21 changes: 21 additions & 0 deletions components/hermes/hermes-mcp-guard/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 58 additions & 0 deletions components/hermes/hermes-mcp-guard/README.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions components/hermes/hermes-mcp-guard/SECURITY.md
Original file line number Diff line number Diff line change
@@ -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.
231 changes: 231 additions & 0 deletions components/hermes/hermes-mcp-guard/mcp-guard.sh
Original file line number Diff line number Diff line change
@@ -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)"
18 changes: 18 additions & 0 deletions components/hermes/hermes-mcp-guard/setup.sh
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 2 additions & 0 deletions docs/COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Loading