NousResearch/hermes-agent

Skills external-dir access and profile-isolation fixes

Cluster 5947 · agent. Data as of

Nothing open on GitHub. Fix merged in #10570 on 16 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #10570. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #10570. That is separate from the fix event.
Still open
Nothing open on GitHub.

These items address profile-isolation leaks related to HERMES_HOME and fix how skill management handles external skill directories, including read/write permissions and hardcoded path issues. A session key prefixing fix for the Honcho gateway is also included.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #10570 fix: five HERMES_HOME profile-isolation leaks

    merged to main

    files: plugins/memory/honcho/client.py, run_agent.py, scripts/install.sh, skills/autonomous-ai-agents/hermes-agent/SKILL.md, skills/github/github-code-review/SKILL.md, skills/github/github-repo-management/references/github-api-cheatsheet.md, skills/productivity/google-workspace/SKILL.md, skills/red-teaming/godmode/SKILL.md, skills/red-teaming/godmode/references/jailbreak-templates.md, skills/red-teaming/godmode/references/refusal-detection.md, skills/red-teaming/godmode/scripts/auto_jailbreak.py, skills/red-teaming/godmode/scripts/godmode_race.py, skills/red-teaming/godmode/scripts/load_godmode.py, skills/red-teaming/godmode/scripts/parseltongue.py, tools/skill_manager_tool.py

    Show the diff
    • 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
       
       
    • run_agent.py
      @@ -954,9 +954,20 @@ def __init__(
                           # message instead of silently routing through OpenRouter.
                           _explicit = (self.provider or "").strip().lower()
                           if _explicit and _explicit not in ("auto", "openrouter", "custom"):
      +                        # Look up the actual env var name from the provider
      +                        # config — some providers use non-standard names
      +                        # (e.g. alibaba → DASHSCOPE_API_KEY, not ALIBABA_API_KEY).
      +                        _env_hint = f"{_explicit.upper()}_API_KEY"
      +                        try:
      +                            from hermes_cli.auth import PROVIDER_REGISTRY
      +                            _pcfg = PROVIDER_REGISTRY.get(_explicit)
      +                            if _pcfg and _pcfg.api_key_env_vars:
      +                                _env_hint = _pcfg.api_key_env_vars[0]
      +                        except Exception:
      +                            pass
                               raise RuntimeError(
                                   f"Provider '{_explicit}' is set in config.yaml but no API key "
      -                            f"was found. Set the {_explicit.upper()}_API_KEY environment "
      +                            f"was found. Set the {_env_hint} environment "
                                   f"variable, or switch to a different provider with `hermes model`."
                               )
                           # Final fallback: try raw OpenRouter key
      @@ -1220,6 +1231,15 @@ def __init__(
                                   "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
    • scripts/install.sh
      @@ -28,7 +28,7 @@ BOLD='\033[1m'
       # Configuration
       REPO_URL_SSH="[redacted:email]: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
                   ;;
    • 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
    • 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
       ```
       
    • 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
    • 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
    • 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",
    • 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'])
    • 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."
    • skills/red-teaming/godmode/scripts/auto_jailbreak.py
      @@ -7,7 +7,7 @@
       
       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
    • skills/red-teaming/godmode/scripts/godmode_race.py
      @@ -7,7 +7,7 @@
       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",
    • skills/red-teaming/godmode/scripts/load_godmode.py
      @@ -3,7 +3,7 @@
       
       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:
    • skills/red-teaming/godmode/scripts/parseltongue.py
      @@ -11,7 +11,7 @@
           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")
       """
       
    • tools/skill_manager_tool.py
      @@ -82,6 +82,18 @@ def _security_scan_skill(skill_dir: Path) -> Optional[str]:
       
       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)

2 PRs closed without merging

From the close comments: done elsewhere 1 · merged via another change 1

  • #6338 fix(skills): block mutations in external skill dirs · merged via another change → #10570, #5947
  • #36820 fix(memory): prefix Honcho gateway session key with ai_peer (#36160) · done elsewhere → d82f9fa
Closed issues · 1
  • Closed#5947Five HERMES_HOME / profile-isolation leaks: gateway honcho session, hardcoded SKILL paths, install.sh --dir, honcho config fallback, skill_manager_tool external_dirs write-through
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#10570
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
16 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.