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

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