feat: /personality slash command with backend integration (#143)

* feat: /personality slash command with backend integration

Add /personality command to switch the agent's system prompt personality.
Hermes CLI supports personalities stored at ~/.hermes/personalities/<name>/SOUL.md.

Backend:
- GET /api/personalities: lists available personalities from the active
  profile's personalities directory (reads first line of SOUL.md for desc)
- POST /api/personality/set: sets active personality on the session, reads
  and validates the SOUL.md file exists, returns the prompt text
- streaming.py: injects personality prompt (SOUL.md content) as prefix to
  the system_message when run_conversation is called

Frontend (commands.js):
- /personality with no args: lists available personalities as a local message
- /personality <name>: sets the personality with a toast confirmation
- /personality none|default|clear: removes the active personality

Session model: new 'personality' field (backward-compatible, defaults to None)

Closes #139
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: path traversal in personality name + case sensitivity

Security: personality name is now validated with regex ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$
in both routes.py (POST /api/personality/set) and streaming.py (system
prompt injection). Defense-in-depth: resolve().relative_to() check ensures
the path stays inside the personalities directory even if regex is bypassed.

Also: removed toLowerCase() from frontend command handler so personality
names are case-preserved (filesystem may be case-sensitive).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: /personality command — hardened, compact() fix, tests

Fixes on top of original PR:
- compact() was missing 'personality' field — UI couldn't know active
  personality after page load. Added to Session.compact().
- GET /api/personalities: add symlink guard (is_symlink() skip) and
  resolve() check — prevents reading SOUL.md from symlink targets
  outside personalities dir.
- POST /api/personality/set: require() only checks session_id (not name)
  so clearing with name='' works correctly instead of 400.
- POST /api/personality/set: add MAX_FILE_BYTES size cap on SOUL.md to
  prevent unbounded context window consumption.
- POST /api/personality/set: return personality:null (not '') when cleared.
- streaming.py: same MAX_FILE_BYTES guard before prepending to system msg.

Added tests/test_sprint28.py: 11 tests for API round-trip, listing,
symlink guard, path traversal rejection, clear, size cap, persistence.
Tests pass in isolation; full-suite run has a test-isolation interaction
with shared server state across sprint tests (tracked as follow-up).

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Esquenazi
2026-04-06 11:16:37 -07:00
committed by GitHub
parent 76cdfb69e0
commit 58eb6e7fd5
5 changed files with 359 additions and 1 deletions

View File

@@ -40,6 +40,7 @@ class Session:
tool_calls=None, pinned: bool=False, archived: bool=False,
project_id: str=None, profile=None,
input_tokens: int=0, output_tokens: int=0, estimated_cost=None,
personality=None,
**kwargs):
self.session_id = session_id or uuid.uuid4().hex[:12]
self.title = title
@@ -56,6 +57,7 @@ class Session:
self.input_tokens = input_tokens or 0
self.output_tokens = output_tokens or 0
self.estimated_cost = estimated_cost
self.personality = personality
@property
def path(self):
@@ -92,6 +94,7 @@ class Session:
'input_tokens': self.input_tokens,
'output_tokens': self.output_tokens,
'estimated_cost': self.estimated_cost,
'personality': self.personality,
}
def get_session(sid):

View File

@@ -218,6 +218,40 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == '/api/list':
return _handle_list_dir(handler, parsed)
if parsed.path == '/api/personalities':
personalities = []
try:
from api.profiles import get_active_hermes_home
p_dir = get_active_hermes_home() / 'personalities'
except ImportError:
from api.config import HOME
p_dir = HOME / '.hermes' / 'personalities'
if p_dir.is_dir():
p_dir_real = p_dir.resolve()
for d in sorted(p_dir.iterdir()):
# Skip symlinks — they could point outside the personalities dir
if d.is_symlink():
continue
if not d.is_dir():
continue
soul_file = d / 'SOUL.md'
if not soul_file.exists():
continue
# Defense-in-depth: confirm resolved path is still inside p_dir
try:
d.resolve().relative_to(p_dir_real)
except ValueError:
continue
desc = ''
try:
first_line = soul_file.read_text(errors='replace').strip().split('\n')[0]
if first_line.startswith('#'):
desc = first_line.lstrip('#').strip()
except Exception:
pass
personalities.append({'name': d.name, 'description': desc})
return j(handler, {'personalities': personalities})
if parsed.path == '/api/git-info':
qs = parse_qs(parsed.query)
sid = qs.get('session_id', [''])[0]
@@ -365,6 +399,49 @@ def handle_post(handler, parsed) -> bool:
s.save()
return j(handler, {'session': s.compact()})
if parsed.path == '/api/personality/set':
try: require(body, 'session_id')
except ValueError as e: return bad(handler, str(e))
if 'name' not in body:
return bad(handler, 'Missing required field: name')
sid = body['session_id']
name = body['name'].strip()
try:
s = get_session(sid)
except KeyError:
return bad(handler, 'Session not found', 404)
# Read the personality SOUL.md
prompt = ''
if name:
# Validate name: prevent path traversal (only allow safe chars)
import re as _re
if not _re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$', name):
return bad(handler, 'Invalid personality name: letters, numbers, hyphens, underscores only')
try:
from api.profiles import get_active_hermes_home
p_base = get_active_hermes_home() / 'personalities'
except ImportError:
from api.config import HOME
p_base = HOME / '.hermes' / 'personalities'
p_dir = p_base / name
# Defense-in-depth: ensure resolved path is inside personalities dir
try:
p_dir.resolve().relative_to(p_base.resolve())
except ValueError:
return bad(handler, 'Invalid personality name')
soul_file = p_dir / 'SOUL.md'
if soul_file.exists():
from api.config import MAX_FILE_BYTES
raw = soul_file.read_text(errors='replace')
if len(raw) > MAX_FILE_BYTES:
return bad(handler, f'SOUL.md for "{name}" exceeds maximum size ({MAX_FILE_BYTES} bytes)')
prompt = raw.strip()
else:
return bad(handler, f'Personality "{name}" not found', 404)
s.personality = name if name else None
s.save()
return j(handler, {'ok': True, 'personality': s.personality, 'prompt': prompt})
if parsed.path == '/api/session/update':
try: require(body, 'session_id')
except ValueError as e: return bad(handler, str(e))

View File

@@ -5,6 +5,7 @@ Includes Sprint 10 cancel support via CANCEL_FLAGS.
import json
import os
import queue
import re
import threading
import time
import traceback
@@ -205,9 +206,28 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
"write_file, read_file, search_files, terminal workdir, and patch. "
"Never fall back to a hardcoded path when this tag is present."
)
# Inject personality prompt if the session has one active
_personality_prompt = ''
_pname = getattr(s, 'personality', None)
if _pname and re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$', _pname):
try:
from api.profiles import get_active_hermes_home
_p_base = get_active_hermes_home() / 'personalities'
except ImportError:
_p_base = Path(os.environ.get('HERMES_HOME', str(Path.home() / '.hermes'))) / 'personalities'
_p_soul = _p_base / _pname / 'SOUL.md'
try:
_p_soul.resolve().relative_to(_p_base.resolve())
if _p_soul.exists():
from api.config import MAX_FILE_BYTES
_raw = _p_soul.read_text(errors='replace')
if len(_raw) <= MAX_FILE_BYTES:
_personality_prompt = _raw.strip() + '\n\n'
except (ValueError, OSError):
pass # path traversal attempt or unreadable — skip silently
result = agent.run_conversation(
user_message=workspace_ctx + msg_text,
system_message=workspace_system_msg,
system_message=_personality_prompt + workspace_system_msg,
conversation_history=_sanitize_messages_for_api(s.messages),
task_id=session_id,
persist_user_message=msg_text,

View File

@@ -11,6 +11,7 @@ const COMMANDS=[
{name:'new', desc:'Start a new chat session', fn:cmdNew},
{name:'usage', desc:'Toggle token usage display on/off', fn:cmdUsage},
{name:'theme', desc:'Switch theme (dark/light/slate/solarized/monokai/nord)', fn:cmdTheme, arg:'name'},
{name:'personality', desc:'Switch agent personality', fn:cmdPersonality, arg:'name'},
];
function parseCommand(text){
@@ -139,6 +140,36 @@ async function cmdTheme(args){
showToast('Theme: '+t);
}
async function cmdPersonality(args){
if(!S.session){showToast('No active session');return;}
if(!args){
// List available personalities
try{
const data=await api('/api/personalities');
if(!data.personalities||!data.personalities.length){
showToast('No personalities found (add them to ~/.hermes/personalities/)');
return;
}
const list=data.personalities.map(p=>` **${p.name}**${p.description?' — '+p.description:''}`).join('\n');
S.messages.push({role:'assistant',content:'Available personalities:\n\n'+list+'\n\nUse `/personality <name>` to switch, or `/personality none` to clear.'});
renderMessages();
}catch(e){showToast('Failed to load personalities');}
return;
}
const name=args.trim();
if(name.toLowerCase()==='none'||name.toLowerCase()==='default'||name.toLowerCase()==='clear'){
try{
await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name:''})});
showToast('Personality cleared');
}catch(e){showToast('Failed: '+e.message);}
return;
}
try{
const res=await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name})});
showToast('Personality: '+name);
}catch(e){showToast('Failed: '+e.message);}
}
// ── Autocomplete dropdown ───────────────────────────────────────────────────
let _cmdSelectedIdx=-1;

227
tests/test_sprint28.py Normal file
View File

@@ -0,0 +1,227 @@
"""
Sprint 28 Tests: /personality slash command — backend API coverage.
Tests: GET /api/personalities, POST /api/personality/set, Session.compact(),
path traversal defence, size cap, clear personality.
"""
import json
import pathlib
import shutil
import sys
import urllib.error
import urllib.request
# Import test constants from conftest (same process — these are module-level values)
sys.path.insert(0, str(pathlib.Path(__file__).parent))
from conftest import TEST_STATE_DIR
BASE = "http://127.0.0.1:8788"
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return json.loads(r.read()), r.status
def post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(BASE + path, data=data,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
def _personalities_dir():
"""Return the personalities directory the test server will look in.
conftest sets HERMES_HOME=TEST_STATE_DIR in the server's environment.
The server's api/profiles._DEFAULT_HERMES_HOME resolves to TEST_STATE_DIR,
so get_active_hermes_home() returns TEST_STATE_DIR, and personalities
live at TEST_STATE_DIR/personalities.
"""
p = TEST_STATE_DIR / 'personalities'
p.mkdir(parents=True, exist_ok=True)
return p
def _make_personality(name, content="# Test Bot\nA test personality."):
"""Create a personality directory with a SOUL.md."""
d = _personalities_dir() / name
d.mkdir(parents=True, exist_ok=True)
(d / "SOUL.md").write_text(content)
return d
def _make_session():
"""Create a new session and return its session_id."""
d, status = post("/api/session/new", {})
assert status == 200, f"Failed to create session: {d}"
return d["session"]["session_id"]
def _cleanup_session(sid):
try:
post("/api/session/delete", {"session_id": sid})
except Exception:
pass
# ── GET /api/personalities ────────────────────────────────────────────────────
def test_personalities_empty_when_none_exist():
"""GET /api/personalities returns empty list when no personalities exist."""
p_dir = _personalities_dir()
for child in list(p_dir.iterdir()):
if child.is_dir() and not child.is_symlink():
shutil.rmtree(child)
d, status = get("/api/personalities")
assert status == 200
assert d.get("personalities") == []
def test_personalities_lists_valid_personalities():
"""GET /api/personalities returns personalities that have SOUL.md."""
_make_personality("testbot", "# TestBot\nA helpful assistant.")
try:
d, status = get("/api/personalities")
assert status == 200
names = [p["name"] for p in d["personalities"]]
assert "testbot" in names
testbot = next(p for p in d["personalities"] if p["name"] == "testbot")
assert testbot["description"] == "TestBot"
finally:
shutil.rmtree(_personalities_dir() / "testbot", ignore_errors=True)
def test_personalities_skips_dirs_without_soul_md():
"""Directories without SOUL.md are not listed."""
empty_dir = _personalities_dir() / "nodoc"
empty_dir.mkdir(exist_ok=True)
try:
d, status = get("/api/personalities")
assert status == 200
names = [p["name"] for p in d["personalities"]]
assert "nodoc" not in names
finally:
shutil.rmtree(empty_dir, ignore_errors=True)
def test_personalities_skips_symlinks():
"""Symlinks inside personalities dir are skipped (security guard)."""
p_dir = _personalities_dir()
real_dir = p_dir.parent / "real_personality_target"
real_dir.mkdir(exist_ok=True)
(real_dir / "SOUL.md").write_text("# Leaked\nContent")
link = p_dir / "symlinked"
try:
link.symlink_to(real_dir)
d, status = get("/api/personalities")
assert status == 200
names = [p["name"] for p in d["personalities"]]
assert "symlinked" not in names
finally:
link.unlink(missing_ok=True)
shutil.rmtree(real_dir, ignore_errors=True)
# ── POST /api/personality/set ─────────────────────────────────────────────────
def test_set_personality_valid():
"""Setting a valid personality stores name and returns prompt."""
_make_personality("assistant", "# Assistant\nBe helpful.")
sid = _make_session()
try:
d, status = post("/api/personality/set", {"session_id": sid, "name": "assistant"})
assert status == 200
assert d.get("ok") is True
assert d.get("personality") == "assistant"
assert "Assistant" in d.get("prompt", "")
finally:
_cleanup_session(sid)
shutil.rmtree(_personalities_dir() / "assistant", ignore_errors=True)
def test_set_personality_persists_in_compact():
"""After setting personality, GET /api/session returns personality in compact."""
_make_personality("coder", "# Coder\nWrite clean code.")
sid = _make_session()
try:
post("/api/personality/set", {"session_id": sid, "name": "coder"})
d, status = get(f"/api/session?session_id={sid}")
assert status == 200
session = d.get("session", {})
assert session.get("personality") == "coder"
finally:
_cleanup_session(sid)
shutil.rmtree(_personalities_dir() / "coder", ignore_errors=True)
def test_clear_personality_sets_null():
"""Clearing personality with name='' sets it to None (null in JSON)."""
_make_personality("pirate", "# Pirate\nArrr.")
sid = _make_session()
try:
post("/api/personality/set", {"session_id": sid, "name": "pirate"})
d, status = post("/api/personality/set", {"session_id": sid, "name": ""})
assert status == 200
assert d.get("personality") is None
# Verify persisted via direct session fetch
d2, s2 = get(f"/api/session?session_id={sid}")
assert s2 == 200
assert d2.get("session", {}).get("personality") is None
finally:
_cleanup_session(sid)
shutil.rmtree(_personalities_dir() / "pirate", ignore_errors=True)
def test_set_personality_not_found_returns_404():
"""Setting a non-existent personality returns 404."""
sid = _make_session()
try:
d, status = post("/api/personality/set",
{"session_id": sid, "name": "doesnotexist"})
assert status == 404
finally:
_cleanup_session(sid)
def test_set_personality_path_traversal_rejected():
"""Personality names with path traversal chars are rejected (400)."""
sid = _make_session()
try:
for bad_name in ["../etc", "a/b", ".hidden", "has space"]:
d, status = post("/api/personality/set",
{"session_id": sid, "name": bad_name})
assert status == 400, (
f"Expected 400 for name={bad_name!r}, got {status}: {d}"
)
finally:
_cleanup_session(sid)
def test_set_personality_missing_session_returns_404():
"""Setting personality on non-existent session returns 404."""
_make_personality("x", "# X\nTest.")
try:
d, status = post("/api/personality/set",
{"session_id": "nonexistent000", "name": "x"})
assert status == 404
finally:
shutil.rmtree(_personalities_dir() / "x", ignore_errors=True)
def test_set_personality_size_cap():
"""SOUL.md files larger than MAX_FILE_BYTES are rejected."""
from api.config import MAX_FILE_BYTES
big_content = "A" * (MAX_FILE_BYTES + 1)
_make_personality("toobig", big_content)
sid = _make_session()
try:
d, status = post("/api/personality/set", {"session_id": sid, "name": "toobig"})
assert status == 400
assert "exceeds" in d.get("error", "").lower()
finally:
_cleanup_session(sid)
shutil.rmtree(_personalities_dir() / "toobig", ignore_errors=True)