mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
c483b4ceca
commit
e402906d48
15 changed files with 58 additions and 22 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
;;
|
;;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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'])
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue