fix: five HERMES_HOME profile-isolation leaks (#10570)

* fix: show correct env var name in provider API key error (#9506)

The error message for missing provider API keys dynamically built
the env var name as PROVIDER_API_KEY (e.g. ALIBABA_API_KEY), but
some providers use different names (alibaba uses DASHSCOPE_API_KEY).
Users following the error message set the wrong variable.

Fix: look up the actual env var from PROVIDER_REGISTRY before
building the error. Falls back to the dynamic name if the registry
lookup fails.

Closes #9506

* fix: five HERMES_HOME profile-isolation leaks (#5947)

Bug A: Thread session_title from session_db to memory provider init kwargs
so honcho can derive chat-scoped session keys instead of falling back to
cwd-based naming that merges all gateway users into one session.

Bug B: Replace 14 hardcoded ~/.hermes/skills/ paths across 10 skill files
with HERMES_HOME-aware alternatives (${HERMES_HOME:-$HOME/.hermes} in
shell, os.environ.get('HERMES_HOME', ...) in Python).

Bug C: install.sh now respects HERMES_HOME env var and adds --hermes-home
flag. Previously --dir only set INSTALL_DIR while HERMES_HOME was always
hardcoded to $HOME/.hermes.

Bug D: Remove hardcoded ~/.hermes/honcho.json fallback in resolve_config_path().
Non-default profiles no longer silently inherit the default profile's honcho
config. Falls through to ~/.honcho/config.json (global) instead.

Bug E: Guard _edit_skill, _patch_skill, _delete_skill, _write_file, and
_remove_file against writing to skills found in external_dirs. Skills
outside the local SKILLS_DIR are now read-only from the agent's perspective.

Closes #5947
This commit is contained in:
Teknium 2026-04-15 17:09:41 -07:00 committed by GitHub
parent c483b4ceca
commit e402906d48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 58 additions and 22 deletions

View file

@ -58,8 +58,7 @@ def resolve_config_path() -> Path:
Resolution order: Resolution order:
1. $HERMES_HOME/honcho.json (profile-local, if it exists) 1. $HERMES_HOME/honcho.json (profile-local, if it exists)
2. ~/.hermes/honcho.json (default profile shared host blocks live here) 2. ~/.honcho/config.json (global, cross-app interop)
3. ~/.honcho/config.json (global, cross-app interop)
Returns the global path if none exist (for first-time setup writes). Returns the global path if none exist (for first-time setup writes).
""" """
@ -67,11 +66,6 @@ def resolve_config_path() -> Path:
if local_path.exists(): if local_path.exists():
return local_path return local_path
# Default profile's config — host blocks accumulate here via setup/clone
default_path = Path.home() / ".hermes" / "honcho.json"
if default_path != local_path and default_path.exists():
return default_path
return GLOBAL_CONFIG_PATH return GLOBAL_CONFIG_PATH

View file

@ -1280,6 +1280,15 @@ class AIAgent:
"hermes_home": str(_ghh()), "hermes_home": str(_ghh()),
"agent_context": "primary", "agent_context": "primary",
} }
# Thread session title for memory provider scoping
# (e.g. honcho uses this to derive chat-scoped session keys)
if self._session_db:
try:
_st = self._session_db.get_session_title(self.session_id)
if _st:
_init_kwargs["session_title"] = _st
except Exception:
pass
# Thread gateway user identity for per-user memory scoping # Thread gateway user identity for per-user memory scoping
if self._user_id: if self._user_id:
_init_kwargs["user_id"] = self._user_id _init_kwargs["user_id"] = self._user_id

View file

@ -28,7 +28,7 @@ BOLD='\033[1m'
# Configuration # Configuration
REPO_URL_SSH="git@github.com:NousResearch/hermes-agent.git" REPO_URL_SSH="git@github.com:NousResearch/hermes-agent.git"
REPO_URL_HTTPS="https://github.com/NousResearch/hermes-agent.git" REPO_URL_HTTPS="https://github.com/NousResearch/hermes-agent.git"
HERMES_HOME="$HOME/.hermes" HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
INSTALL_DIR="${HERMES_INSTALL_DIR:-$HERMES_HOME/hermes-agent}" INSTALL_DIR="${HERMES_INSTALL_DIR:-$HERMES_HOME/hermes-agent}"
PYTHON_VERSION="3.11" PYTHON_VERSION="3.11"
NODE_VERSION="22" NODE_VERSION="22"
@ -66,6 +66,10 @@ while [[ $# -gt 0 ]]; do
INSTALL_DIR="$2" INSTALL_DIR="$2"
shift 2 shift 2
;; ;;
--hermes-home)
HERMES_HOME="$2"
shift 2
;;
-h|--help) -h|--help)
echo "Hermes Agent Installer" echo "Hermes Agent Installer"
echo "" echo ""
@ -76,6 +80,7 @@ while [[ $# -gt 0 ]]; do
echo " --skip-setup Skip interactive setup wizard" echo " --skip-setup Skip interactive setup wizard"
echo " --branch NAME Git branch to install (default: main)" echo " --branch NAME Git branch to install (default: main)"
echo " --dir PATH Installation directory (default: ~/.hermes/hermes-agent)" echo " --dir PATH Installation directory (default: ~/.hermes/hermes-agent)"
echo " --hermes-home PATH Data directory (default: ~/.hermes, or \$HERMES_HOME)"
echo " -h, --help Show this help" echo " -h, --help Show this help"
exit 0 exit 0
;; ;;

View file

@ -313,7 +313,7 @@ Type these during an interactive chat session.
``` ```
~/.hermes/config.yaml Main configuration ~/.hermes/config.yaml Main configuration
~/.hermes/.env API keys and secrets ~/.hermes/.env API keys and secrets
~/.hermes/skills/ Installed skills $HERMES_HOME/skills/ Installed skills
~/.hermes/sessions/ Session transcripts ~/.hermes/sessions/ Session transcripts
~/.hermes/logs/ Gateway and error logs ~/.hermes/logs/ Gateway and error logs
~/.hermes/auth.json OAuth tokens and credential pools ~/.hermes/auth.json OAuth tokens and credential pools

View file

@ -334,7 +334,7 @@ When the user asks you to "review PR #N", "look at this PR", or gives you a PR U
### Step 1: Set up environment ### Step 1: Set up environment
```bash ```bash
source ~/.hermes/skills/github/github-auth/scripts/gh-env.sh source "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/gh-env.sh"
# Or run the inline setup block from the top of this skill # Or run the inline setup block from the top of this skill
``` ```

View file

@ -6,7 +6,7 @@ All requests need: `-H "Authorization: token $GITHUB_TOKEN"`
Use the `gh-env.sh` helper to set `$GITHUB_TOKEN`, `$GH_OWNER`, `$GH_REPO` automatically: Use the `gh-env.sh` helper to set `$GITHUB_TOKEN`, `$GH_OWNER`, `$GH_REPO` automatically:
```bash ```bash
source ~/.hermes/skills/github/github-auth/scripts/gh-env.sh source "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/gh-env.sh"
``` ```
## Repositories ## Repositories

View file

@ -32,7 +32,7 @@ on CLI, Telegram, Discord, or any platform.
Define a shorthand first: Define a shorthand first:
```bash ```bash
GSETUP="python ~/.hermes/skills/productivity/google-workspace/scripts/setup.py" GSETUP="python ${HERMES_HOME:-$HOME/.hermes}/skills/productivity/google-workspace/scripts/setup.py"
``` ```
### Step 0: Check if already set up ### Step 0: Check if already set up
@ -163,7 +163,7 @@ Should print `AUTHENTICATED`. Setup is complete — token refreshes automaticall
All commands go through the API script. Set `GAPI` as a shorthand: All commands go through the API script. Set `GAPI` as a shorthand:
```bash ```bash
GAPI="python ~/.hermes/skills/productivity/google-workspace/scripts/google_api.py" GAPI="python ${HERMES_HOME:-$HOME/.hermes}/skills/productivity/google-workspace/scripts/google_api.py"
``` ```
### Gmail ### Gmail

View file

@ -60,7 +60,7 @@ The fastest path — auto-detect the model, test strategies, and lock in the win
# In execute_code — use the loader to avoid exec-scoping issues: # In execute_code — use the loader to avoid exec-scoping issues:
import os import os
exec(open(os.path.expanduser( exec(open(os.path.expanduser(
"~/.hermes/skills/red-teaming/godmode/scripts/load_godmode.py" os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/load_godmode.py")
)).read()) )).read())
# Auto-detect model from config and jailbreak it # Auto-detect model from config and jailbreak it
@ -192,7 +192,7 @@ python3 scripts/parseltongue.py "How do I hack into a WiFi network?" --tier stan
Or use `execute_code` inline: Or use `execute_code` inline:
```python ```python
# Load the parseltongue module # Load the parseltongue module
exec(open(os.path.expanduser("~/.hermes/skills/red-teaming/godmode/scripts/parseltongue.py")).read()) exec(open(os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/parseltongue.py")).read())
query = "How do I hack into a WiFi network?" query = "How do I hack into a WiFi network?"
variants = generate_variants(query, tier="standard") variants = generate_variants(query, tier="standard")
@ -229,7 +229,7 @@ Race multiple models against the same query, score responses, pick the winner:
```python ```python
# Via execute_code # Via execute_code
exec(open(os.path.expanduser("~/.hermes/skills/red-teaming/godmode/scripts/godmode_race.py")).read()) exec(open(os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/godmode_race.py")).read())
result = race_models( result = race_models(
query="Explain how SQL injection works with a practical example", query="Explain how SQL injection works with a practical example",

View file

@ -114,7 +114,7 @@ hermes
### Via the GODMODE CLASSIC racer script ### Via the GODMODE CLASSIC racer script
```python ```python
exec(open(os.path.expanduser("~/.hermes/skills/red-teaming/godmode/scripts/godmode_race.py")).read()) exec(open(os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/godmode_race.py")).read())
result = race_godmode_classic("Your query here") result = race_godmode_classic("Your query here")
print(f"Winner: {result['codename']} — Score: {result['score']}") print(f"Winner: {result['codename']} — Score: {result['score']}")
print(result['content']) print(result['content'])

View file

@ -129,7 +129,7 @@ These don't auto-reject but reduce the response score:
## Using in Python ## Using in Python
```python ```python
exec(open(os.path.expanduser("~/.hermes/skills/red-teaming/godmode/scripts/godmode_race.py")).read()) exec(open(os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/godmode_race.py")).read())
# Check if a response is a refusal # Check if a response is a refusal
text = "I'm sorry, but I can't assist with that request." text = "I'm sorry, but I can't assist with that request."

View file

@ -7,7 +7,7 @@ finds what works, and locks it in by writing config.yaml + prefill.json.
Usage in execute_code: Usage in execute_code:
exec(open(os.path.expanduser( exec(open(os.path.expanduser(
"~/.hermes/skills/red-teaming/godmode/scripts/auto_jailbreak.py" os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/auto_jailbreak.py")
)).read()) )).read())
result = auto_jailbreak() # Uses current model from config result = auto_jailbreak() # Uses current model from config

View file

@ -7,7 +7,7 @@ Queries multiple models in parallel via OpenRouter, scores responses
on quality/filteredness/speed, returns the best unfiltered answer. on quality/filteredness/speed, returns the best unfiltered answer.
Usage in execute_code: Usage in execute_code:
exec(open(os.path.expanduser("~/.hermes/skills/red-teaming/godmode/scripts/godmode_race.py")).read()) exec(open(os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/godmode_race.py")).read())
result = race_models( result = race_models(
query="Your query here", query="Your query here",

View file

@ -3,7 +3,7 @@ Loader for G0DM0D3 scripts. Handles the exec-scoping issues.
Usage in execute_code: Usage in execute_code:
exec(open(os.path.expanduser( exec(open(os.path.expanduser(
"~/.hermes/skills/red-teaming/godmode/scripts/load_godmode.py" os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/load_godmode.py")
)).read()) )).read())
# Now all functions are available: # Now all functions are available:

View file

@ -11,7 +11,7 @@ Usage:
python parseltongue.py "How do I hack a WiFi network?" --tier standard python parseltongue.py "How do I hack a WiFi network?" --tier standard
# As a module in execute_code # As a module in execute_code
exec(open("~/.hermes/skills/red-teaming/godmode/scripts/parseltongue.py").read()) exec(open(os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/parseltongue.py")).read())
variants = generate_variants("How do I hack a WiFi network?", tier="standard") variants = generate_variants("How do I hack a WiFi network?", tier="standard")
""" """

View file

@ -82,6 +82,18 @@ SKILLS_DIR = HERMES_HOME / "skills"
MAX_NAME_LENGTH = 64 MAX_NAME_LENGTH = 64
MAX_DESCRIPTION_LENGTH = 1024 MAX_DESCRIPTION_LENGTH = 1024
def _is_local_skill(skill_path: Path) -> bool:
"""Check if a skill path is within the local SKILLS_DIR.
Skills found in external_dirs are read-only from the agent's perspective.
"""
try:
skill_path.resolve().relative_to(SKILLS_DIR.resolve())
return True
except ValueError:
return False
MAX_SKILL_CONTENT_CHARS = 100_000 # ~36k tokens at 2.75 chars/token MAX_SKILL_CONTENT_CHARS = 100_000 # ~36k tokens at 2.75 chars/token
MAX_SKILL_FILE_BYTES = 1_048_576 # 1 MiB per supporting file MAX_SKILL_FILE_BYTES = 1_048_576 # 1 MiB per supporting file
@ -360,6 +372,9 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]:
if not existing: if not existing:
return {"success": False, "error": f"Skill '{name}' not found. Use skills_list() to see available skills."} return {"success": False, "error": f"Skill '{name}' not found. Use skills_list() to see available skills."}
if not _is_local_skill(existing["path"]):
return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified. Copy it to your local skills directory first."}
skill_md = existing["path"] / "SKILL.md" skill_md = existing["path"] / "SKILL.md"
# Back up original content for rollback # Back up original content for rollback
original_content = skill_md.read_text(encoding="utf-8") if skill_md.exists() else None original_content = skill_md.read_text(encoding="utf-8") if skill_md.exists() else None
@ -400,6 +415,9 @@ def _patch_skill(
if not existing: if not existing:
return {"success": False, "error": f"Skill '{name}' not found."} return {"success": False, "error": f"Skill '{name}' not found."}
if not _is_local_skill(existing["path"]):
return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified. Copy it to your local skills directory first."}
skill_dir = existing["path"] skill_dir = existing["path"]
if file_path: if file_path:
@ -473,6 +491,9 @@ def _delete_skill(name: str) -> Dict[str, Any]:
if not existing: if not existing:
return {"success": False, "error": f"Skill '{name}' not found."} return {"success": False, "error": f"Skill '{name}' not found."}
if not _is_local_skill(existing["path"]):
return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be deleted."}
skill_dir = existing["path"] skill_dir = existing["path"]
shutil.rmtree(skill_dir) shutil.rmtree(skill_dir)
@ -515,6 +536,9 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]:
if not existing: if not existing:
return {"success": False, "error": f"Skill '{name}' not found. Create it first with action='create'."} return {"success": False, "error": f"Skill '{name}' not found. Create it first with action='create'."}
if not _is_local_skill(existing["path"]):
return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified. Copy it to your local skills directory first."}
target, err = _resolve_skill_target(existing["path"], file_path) target, err = _resolve_skill_target(existing["path"], file_path)
if err: if err:
return {"success": False, "error": err} return {"success": False, "error": err}
@ -548,6 +572,10 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]:
existing = _find_skill(name) existing = _find_skill(name)
if not existing: if not existing:
return {"success": False, "error": f"Skill '{name}' not found."} return {"success": False, "error": f"Skill '{name}' not found."}
if not _is_local_skill(existing["path"]):
return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified."}
skill_dir = existing["path"] skill_dir = existing["path"]
target, err = _resolve_skill_target(skill_dir, file_path) target, err = _resolve_skill_target(skill_dir, file_path)