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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user