# NousResearch/hermes-agent · Skills external-dir access and profile-isolation fixes

## Open now

- 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.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #10570. Verify it addresses your case.
- **Recommended:** read PR #10570 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-10570`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member 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.

## Closed / duplicate issues

- #5947: Five HERMES_HOME / profile-isolation leaks: gateway honcho session, hardcoded SKILL paths, install.sh --dir, honcho config fallback, skill_manager_tool external_dirs write-through

## Prior & competing PRs

- **#10570: fix: five HERMES_HOME profile-isolation leaks**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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`
  - fix hunks:
  - `plugins/memory/honcho/client.py`

    ```diff
    @@ -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).
         """
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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"
    ```

    ```diff
    @@ -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 ""
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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)
     
    ```

    ```diff
    @@ -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}
    ```

    ```diff
    @@ -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)
    ```
- **#6338: fix(skills): block mutations in external skill dirs**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#10570`, `#5947`
- **#36820: fix(memory): prefix Honcho gateway session key with ai_peer (#36160)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `d82f9fa7f7197b0a7e5246ca42802f96fbb7b734`

## Fix status

- Merged to main in PR #10570 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-16`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `agent`
