Conversation
…attern bypass, redaction bypass, network exposure - Remove hermes-agent root from sandbox PYTHONPATH to prevent internal module import and API key exfiltration (mitigates NousResearch#7071) - Add 8 missing DANGEROUS_PATTERNS: heredoc injection, git destructive ops (reset --hard, push --force, clean -f, branch -D, checkout -- .), and chmod +x social engineering (mitigates NousResearch#6961) - Add base64/hex encoded secret detection to redact_sensitive_text() to prevent redaction bypass via encoding - Change default bind address from 0.0.0.0 to 127.0.0.1 for webhook, SMS/Twilio, and Telegram adapters (mitigates NousResearch#4260, NousResearch#6335) - Fix .env and config.yaml file permissions from 644 to 600 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR tightens hermes-agent’s security posture across sandbox execution, dangerous-command approval gating, secret redaction, and default network bind behavior—addressing multiple audit findings and reported vulnerabilities.
Changes:
- Removes hermes-agent repo-root injection into the
execute_codesandboxPYTHONPATHand filters it out if present. - Expands
DANGEROUS_PATTERNSand extends output redaction to detect base64/hex-encoded secrets. - Switches several gateway/webhook listeners to default to loopback (
127.0.0.1) and updates CLI display defaults.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| tools/code_execution_tool.py | Stops exposing repo-root modules to sandboxed scripts via PYTHONPATH. |
| tools/approval.py | Adds new regex detections intended to close approval-bypass gaps. |
| agent/redact.py | Adds encoded-secret detection/redaction for base64 and hex substrings. |
| gateway/platforms/webhook.py | Changes default webhook bind host to loopback. |
| gateway/platforms/sms.py | Changes Twilio webhook server bind host to loopback. |
| gateway/platforms/telegram.py | Makes Telegram webhook listen address configurable (default loopback). |
| hermes_cli/webhook.py | Updates CLI base URL default host/display behavior for loopback. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Matches: python3 << 'EOF', bash <<EOF, python3 <<-'SCRIPT', etc. | ||
| (r'\b(python[23]?|perl|ruby|node|bash|sh|zsh|ksh)\s+<<-?\s*[\'\"]?\w+', "script execution via heredoc"), |
| # --- Git destructive operations --- | ||
| (r'\bgit\s+reset\s+--hard\b', "git reset --hard (destroys uncommitted changes)"), | ||
| (r'\bgit\s+push\s+.*--force\b', "git force push (rewrites remote history)"), | ||
| (r'\bgit\s+push\s+-f\b', "git force push (rewrites remote history)"), |
| # --- Heredoc script injection (bypass -e/-c flag detection) --- | ||
| # Matches: python3 << 'EOF', bash <<EOF, python3 <<-'SCRIPT', etc. | ||
| (r'\b(python[23]?|perl|ruby|node|bash|sh|zsh|ksh)\s+<<-?\s*[\'\"]?\w+', "script execution via heredoc"), | ||
| # --- Git destructive operations --- | ||
| (r'\bgit\s+reset\s+--hard\b', "git reset --hard (destroys uncommitted changes)"), | ||
| (r'\bgit\s+push\s+.*--force\b', "git force push (rewrites remote history)"), | ||
| (r'\bgit\s+push\s+-f\b', "git force push (rewrites remote history)"), | ||
| (r'\bgit\s+clean\s+-[^\s]*f', "git clean -f (deletes untracked files)"), | ||
| (r'\bgit\s+branch\s+-D\b', "git branch -D (force delete branch)"), | ||
| (r'\bgit\s+checkout\s+--\s+\.', "git checkout -- . (discards all changes)"), | ||
| # --- chmod+x then execute (two-step social engineering) --- | ||
| (r'\bchmod\s+\+x\b', "make file executable (verify script content first)"), |
| # hermes-agent project root. | ||
| _hermes_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | ||
| _filtered = os.pathsep.join( | ||
| p for p in child_env["PYTHONPATH"].split(os.pathsep) | ||
| if p and os.path.normpath(p) != os.path.normpath(_hermes_root) |
| # SECURITY FIX: Do NOT add hermes-agent root to PYTHONPATH. | ||
| # Exposing the project root allows sandbox scripts to import internal | ||
| # modules (hermes_state, hermes_constants, config parsers) which may | ||
| # contain or provide access to API keys and security rules. | ||
| # See: https://github.com/NousResearch/hermes-agent/issues/7071 | ||
| if "PYTHONPATH" in child_env: | ||
| # Preserve any pre-existing PYTHONPATH entries that are NOT the | ||
| # hermes-agent project root. | ||
| _hermes_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | ||
| _filtered = os.pathsep.join( | ||
| p for p in child_env["PYTHONPATH"].split(os.pathsep) | ||
| if p and os.path.normpath(p) != os.path.normpath(_hermes_root) | ||
| ) | ||
| if _filtered: | ||
| child_env["PYTHONPATH"] = _filtered | ||
| else: | ||
| del child_env["PYTHONPATH"] |
| # Base64-encoded secret detection | ||
| _BASE64_CHUNK_RE = re.compile(r"[A-Za-z0-9+/]{20,}={0,2}") | ||
| _HEX_CHUNK_RE = re.compile(r"(?:0x)?[0-9a-fA-F]{20,}") | ||
| # Known prefixes that indicate a secret when found inside decoded data | ||
| _DECODED_SECRET_MARKERS = ( | ||
| b"sk-", b"ghp_", b"github_pat_", b"gho_", b"xoxb-", b"xoxp-", | ||
| b"AIza", b"AKIA", b"sk_live_", b"sk_test_", b"SG.", b"hf_", | ||
| b"Bearer ", b"token=", b"password=", b"api_key=", b"apiKey", | ||
| ) | ||
|
|
||
|
|
||
| def _redact_encoded_secrets(text: str) -> str: | ||
| """Detect and redact base64/hex-encoded strings containing secret markers.""" | ||
| import base64 | ||
| import binascii | ||
|
|
||
| def _check_and_redact_b64(m): | ||
| chunk = m.group(0) | ||
| try: | ||
| decoded = base64.b64decode(chunk, validate=True) | ||
| if any(marker in decoded for marker in _DECODED_SECRET_MARKERS): | ||
| return "[REDACTED BASE64-ENCODED SECRET]" | ||
| except (binascii.Error, ValueError): | ||
| pass | ||
| return chunk | ||
|
|
||
| text = _BASE64_CHUNK_RE.sub(_check_and_redact_b64, text) | ||
|
|
| # --- SECURITY FIX: Detect base64/hex encoded secrets --- | ||
| # Attackers can bypass pattern-based redaction by encoding API keys. | ||
| # Detect base64 strings that decode to known secret prefixes. | ||
| text = _redact_encoded_secrets(text) | ||
|
|
||
| return text | ||
|
|
||
|
|
||
| # Base64-encoded secret detection | ||
| _BASE64_CHUNK_RE = re.compile(r"[A-Za-z0-9+/]{20,}={0,2}") | ||
| _HEX_CHUNK_RE = re.compile(r"(?:0x)?[0-9a-fA-F]{20,}") | ||
| # Known prefixes that indicate a secret when found inside decoded data | ||
| _DECODED_SECRET_MARKERS = ( | ||
| b"sk-", b"ghp_", b"github_pat_", b"gho_", b"xoxb-", b"xoxp-", | ||
| b"AIza", b"AKIA", b"sk_live_", b"sk_test_", b"SG.", b"hf_", | ||
| b"Bearer ", b"token=", b"password=", b"api_key=", b"apiKey", | ||
| ) | ||
|
|
||
|
|
||
| def _redact_encoded_secrets(text: str) -> str: | ||
| """Detect and redact base64/hex-encoded strings containing secret markers.""" | ||
| import base64 | ||
| import binascii | ||
|
|
||
| def _check_and_redact_b64(m): | ||
| chunk = m.group(0) | ||
| try: | ||
| decoded = base64.b64decode(chunk, validate=True) | ||
| if any(marker in decoded for marker in _DECODED_SECRET_MARKERS): | ||
| return "[REDACTED BASE64-ENCODED SECRET]" | ||
| except (binascii.Error, ValueError): | ||
| pass | ||
| return chunk | ||
|
|
||
| text = _BASE64_CHUNK_RE.sub(_check_and_redact_b64, text) | ||
|
|
||
| def _check_and_redact_hex(m): | ||
| chunk = m.group(0) | ||
| raw = chunk[2:] if chunk.startswith("0x") else chunk | ||
| try: | ||
| decoded = bytes.fromhex(raw) | ||
| if any(marker in decoded for marker in _DECODED_SECRET_MARKERS): | ||
| return "[REDACTED HEX-ENCODED SECRET]" | ||
| except (ValueError, binascii.Error): | ||
| pass | ||
| return chunk | ||
|
|
||
| text = _HEX_CHUNK_RE.sub(_check_and_redact_hex, text) | ||
| return text |
| self._runner = web.AppRunner(app) | ||
| await self._runner.setup() | ||
| site = web.TCPSite(self._runner, "0.0.0.0", self._webhook_port) | ||
| site = web.TCPSite(self._runner, "127.0.0.1", self._webhook_port) | ||
| await site.start() |
| _webhook_listen = os.getenv("TELEGRAM_WEBHOOK_LISTEN", "127.0.0.1").strip() | ||
| await self._app.updater.start_webhook( | ||
| listen="0.0.0.0", | ||
| listen=_webhook_listen, | ||
| port=webhook_port, |
|
merge conflicts This PR does not merge cleanly with the base branch. Please rebase or merge current Signed: GPT-5.5-low in Codex |
|
Thanks for the security audit pass, @dpskate — appreciate the effort, and the instinct on each of these is reasonable. But on current
Policy note: we don't label something a vulnerability in our own software unless it's provable from code, and #7071's premise doesn't survive the env-scrub that landed after this PR was opened. Keep them coming though — the audit mindset is welcome. |
Summary
Patches 5 categories of open security vulnerabilities identified via audit:
execute_codesandboxPYTHONPATH. Previously, sandbox scripts couldimport hermes_state,hermes_constants, etc. to access API keys and security rules.python3 << 'EOF'), git destructive operations (reset --hard,push --force,push -f,clean -f,branch -D,checkout -- .), andchmod +xsocial engineering.redact_sensitive_text()to detect and redact base64/hex-encoded strings that decode to known API key prefixes (sk-,ghp_,AKIA,Bearer, etc.).0.0.0.0to127.0.0.1for webhook, SMS/Twilio, and Telegram webhook adapters. Users who need network access can explicitly sethost: "0.0.0.0"orTELEGRAM_WEBHOOK_LISTEN=0.0.0.0.~/.hermes/.envandconfig.yamlfrom 644 (world-readable) to 600 (owner-only). This is a local-only fix not in the diff, but documented here for awareness.Files changed
tools/code_execution_tool.pytools/approval.pyagent/redact.py_redact_encoded_secrets()for base64/hex detectiongateway/platforms/webhook.pygateway/platforms/sms.pygateway/platforms/telegram.pyhermes_cli/webhook.pyTest plan
sk-*key correctly redactedsk-*key correctly redactedTELEGRAM_WEBHOOK_LISTENenv varpytest tests/)Related issues
Closes #7071, mitigates #6961, mitigates #4260, mitigates #6335
🤖 Generated with Claude Code