From e402906d48e91d50631aba11aa029d08f6ea9556 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:09:41 -0700 Subject: [PATCH] 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 --- plugins/memory/honcho/client.py | 8 +----- run_agent.py | 9 ++++++ scripts/install.sh | 7 ++++- .../hermes-agent/SKILL.md | 2 +- skills/github/github-code-review/SKILL.md | 2 +- .../references/github-api-cheatsheet.md | 2 +- skills/productivity/google-workspace/SKILL.md | 4 +-- skills/red-teaming/godmode/SKILL.md | 6 ++-- .../godmode/references/jailbreak-templates.md | 2 +- .../godmode/references/refusal-detection.md | 2 +- .../godmode/scripts/auto_jailbreak.py | 2 +- .../godmode/scripts/godmode_race.py | 2 +- .../godmode/scripts/load_godmode.py | 2 +- .../godmode/scripts/parseltongue.py | 2 +- tools/skill_manager_tool.py | 28 +++++++++++++++++++ 15 files changed, 58 insertions(+), 22 deletions(-) diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 3c779f64f..22cd393a2 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -58,8 +58,7 @@ def resolve_config_path() -> Path: Resolution order: 1. $HERMES_HOME/honcho.json (profile-local, if it exists) - 2. ~/.hermes/honcho.json (default profile — shared host blocks live here) - 3. ~/.honcho/config.json (global, cross-app interop) + 2. ~/.honcho/config.json (global, cross-app interop) 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(): 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 diff --git a/run_agent.py b/run_agent.py index f199d806d..d332fb6eb 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1280,6 +1280,15 @@ class AIAgent: "hermes_home": str(_ghh()), "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 if self._user_id: _init_kwargs["user_id"] = self._user_id diff --git a/scripts/install.sh b/scripts/install.sh index aa6f4f79b..2b943797a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -28,7 +28,7 @@ BOLD='\033[1m' # Configuration REPO_URL_SSH="git@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}" PYTHON_VERSION="3.11" NODE_VERSION="22" @@ -66,6 +66,10 @@ while [[ $# -gt 0 ]]; do INSTALL_DIR="$2" shift 2 ;; + --hermes-home) + HERMES_HOME="$2" + shift 2 + ;; -h|--help) echo "Hermes Agent Installer" echo "" @@ -76,6 +80,7 @@ while [[ $# -gt 0 ]]; do echo " --skip-setup Skip interactive setup wizard" echo " --branch NAME Git branch to install (default: main)" 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" exit 0 ;; diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index 77e1b1d18..bea9d0a5a 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -313,7 +313,7 @@ Type these during an interactive chat session. ``` ~/.hermes/config.yaml Main configuration ~/.hermes/.env API keys and secrets -~/.hermes/skills/ Installed skills +$HERMES_HOME/skills/ Installed skills ~/.hermes/sessions/ Session transcripts ~/.hermes/logs/ Gateway and error logs ~/.hermes/auth.json OAuth tokens and credential pools diff --git a/skills/github/github-code-review/SKILL.md b/skills/github/github-code-review/SKILL.md index 52d8e4a07..8041fbb6e 100644 --- a/skills/github/github-code-review/SKILL.md +++ b/skills/github/github-code-review/SKILL.md @@ -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 ```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 ``` diff --git a/skills/github/github-repo-management/references/github-api-cheatsheet.md b/skills/github/github-repo-management/references/github-api-cheatsheet.md index ab7e1d19d..501a81af1 100644 --- a/skills/github/github-repo-management/references/github-api-cheatsheet.md +++ b/skills/github/github-repo-management/references/github-api-cheatsheet.md @@ -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: ```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 diff --git a/skills/productivity/google-workspace/SKILL.md b/skills/productivity/google-workspace/SKILL.md index fb9f00be2..ebde7d0e8 100644 --- a/skills/productivity/google-workspace/SKILL.md +++ b/skills/productivity/google-workspace/SKILL.md @@ -32,7 +32,7 @@ on CLI, Telegram, Discord, or any platform. Define a shorthand first: ```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 @@ -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: ```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 diff --git a/skills/red-teaming/godmode/SKILL.md b/skills/red-teaming/godmode/SKILL.md index 47d1268aa..fa248c021 100644 --- a/skills/red-teaming/godmode/SKILL.md +++ b/skills/red-teaming/godmode/SKILL.md @@ -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: import os 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()) # 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: ```python # 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?" variants = generate_variants(query, tier="standard") @@ -229,7 +229,7 @@ Race multiple models against the same query, score responses, pick the winner: ```python # 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( query="Explain how SQL injection works with a practical example", diff --git a/skills/red-teaming/godmode/references/jailbreak-templates.md b/skills/red-teaming/godmode/references/jailbreak-templates.md index 3eb5e869e..c7b901986 100644 --- a/skills/red-teaming/godmode/references/jailbreak-templates.md +++ b/skills/red-teaming/godmode/references/jailbreak-templates.md @@ -114,7 +114,7 @@ hermes ### Via the GODMODE CLASSIC racer script ```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") print(f"Winner: {result['codename']} — Score: {result['score']}") print(result['content']) diff --git a/skills/red-teaming/godmode/references/refusal-detection.md b/skills/red-teaming/godmode/references/refusal-detection.md index 0b359e4b4..5fb3414c5 100644 --- a/skills/red-teaming/godmode/references/refusal-detection.md +++ b/skills/red-teaming/godmode/references/refusal-detection.md @@ -129,7 +129,7 @@ These don't auto-reject but reduce the response score: ## Using in 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 text = "I'm sorry, but I can't assist with that request." diff --git a/skills/red-teaming/godmode/scripts/auto_jailbreak.py b/skills/red-teaming/godmode/scripts/auto_jailbreak.py index 0b17de509..e6efced48 100644 --- a/skills/red-teaming/godmode/scripts/auto_jailbreak.py +++ b/skills/red-teaming/godmode/scripts/auto_jailbreak.py @@ -7,7 +7,7 @@ finds what works, and locks it in by writing config.yaml + prefill.json. Usage in execute_code: 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()) result = auto_jailbreak() # Uses current model from config diff --git a/skills/red-teaming/godmode/scripts/godmode_race.py b/skills/red-teaming/godmode/scripts/godmode_race.py index ccd021392..dbc451030 100644 --- a/skills/red-teaming/godmode/scripts/godmode_race.py +++ b/skills/red-teaming/godmode/scripts/godmode_race.py @@ -7,7 +7,7 @@ Queries multiple models in parallel via OpenRouter, scores responses on quality/filteredness/speed, returns the best unfiltered answer. 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( query="Your query here", diff --git a/skills/red-teaming/godmode/scripts/load_godmode.py b/skills/red-teaming/godmode/scripts/load_godmode.py index f8bf31acf..71cb2f224 100644 --- a/skills/red-teaming/godmode/scripts/load_godmode.py +++ b/skills/red-teaming/godmode/scripts/load_godmode.py @@ -3,7 +3,7 @@ Loader for G0DM0D3 scripts. Handles the exec-scoping issues. Usage in execute_code: 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()) # Now all functions are available: diff --git a/skills/red-teaming/godmode/scripts/parseltongue.py b/skills/red-teaming/godmode/scripts/parseltongue.py index ba891c6ac..0b24f1550 100644 --- a/skills/red-teaming/godmode/scripts/parseltongue.py +++ b/skills/red-teaming/godmode/scripts/parseltongue.py @@ -11,7 +11,7 @@ Usage: python parseltongue.py "How do I hack a WiFi network?" --tier standard # 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") """ diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index a3e585a58..33d3976ea 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -82,6 +82,18 @@ SKILLS_DIR = HERMES_HOME / "skills" MAX_NAME_LENGTH = 64 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_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: 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" # Back up original content for rollback 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: 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"] if file_path: @@ -473,6 +491,9 @@ def _delete_skill(name: str) -> Dict[str, Any]: if not existing: 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"] 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: 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) if 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) if not existing: 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"] target, err = _resolve_skill_target(skill_dir, file_path)