Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0119365bd8 | ||
|
|
39066bc614 | ||
|
|
853b23cd14 |
13
CHANGELOG.md
13
CHANGELOG.md
@@ -5,6 +5,19 @@
|
||||
|
||||
---
|
||||
|
||||
## [v0.35] Security hardening
|
||||
*April 5, 2026 | 433 tests*
|
||||
|
||||
### Security fixes
|
||||
- **ENV race condition (HIGH):** Two concurrent sessions could interleave `os.environ` writes, clobbering workspace and session keys. Fixed with a global `_ENV_LOCK` in `streaming.py` that serializes the env save/restore block across all sessions. (#108)
|
||||
- **Predictable signing key (MEDIUM):** Session cookies were signed with `sha256(STATE_DIR)` -- deterministic and forgeable if the install path is known. Now generates a cryptographically random 32-byte key on first startup, persisted to `STATE_DIR/.signing_key` (chmod 600). (#108)
|
||||
- **Upload path traversal (MEDIUM):** Filenames like `..` survived the `[^\w.\-]` sanitization regex because dots are allowed. Fixed by rejecting dot-only filenames and validating the resolved path stays within the workspace sandbox via `safe_resolve_ws()`. (#108)
|
||||
- **Weak password hashing (MEDIUM):** Bare SHA-256 with a predictable salt replaced with PBKDF2-SHA256 at 600k iterations (OWASP recommendation) using the random signing key as salt. No new dependencies (stdlib `hashlib.pbkdf2_hmac`). (#108)
|
||||
|
||||
**Breaking change:** Existing session cookies and password hashes are invalidated on first restart after upgrade. Users with password auth enabled will need to re-set their password.
|
||||
|
||||
---
|
||||
|
||||
## [v0.34.3] Light theme final polish
|
||||
*April 5, 2026 | 433 tests*
|
||||
|
||||
|
||||
15
README.md
15
README.md
@@ -10,7 +10,20 @@ and vanilla JS.
|
||||
Layout: three-panel Claude-style. Left sidebar for sessions and tools,
|
||||
center for chat, right for workspace file browsing.
|
||||
|
||||
<img width="1392" alt="Hermes Web UI — three-panel layout" src="https://github.com/user-attachments/assets/79cd3c0d-3167-42ed-9434-447a742c25c3" />
|
||||
<img alt="Hermes Web UI — three-panel layout" width="1417" height="867" alt="image" src="https://github.com/user-attachments/assets/51adff98-53ee-4800-8508-78b6c34dd3dc" />
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" align="center">
|
||||
<img alt="Light mode with full profile support" src="https://github.com/user-attachments/assets/9b68142f-d974-4493-a8d1-fd73e622c7fd" />
|
||||
<br /><sub>Workspace file browser with inline preview</sub>
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<img alt="Customize your settings, and configure a password" src="https://github.com/user-attachments/assets/941f3156-21e3-41fd-bcc8-f975d5000cb8" />
|
||||
<br /><sub>Session projects, tags, and tool call cards</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
|
||||
11
ROADMAP.md
11
ROADMAP.md
@@ -3,8 +3,8 @@
|
||||
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
|
||||
> Everything you can do from the CLI terminal, you can do from this UI.
|
||||
>
|
||||
> Last updated: v0.33 (April 5, 2026)
|
||||
> Tests: 424 total (424 passing, 0 failures)
|
||||
> Last updated: v0.35 (April 5, 2026)
|
||||
> Tests: 433 total (433 passing, 0 failures)
|
||||
> Source: <repo>/
|
||||
|
||||
---
|
||||
@@ -42,6 +42,11 @@
|
||||
| Sprint 23 | Agentic transparency | Token/cost display, subagent cards, skill picker in cron, skill linked files, workspace tree persistence, timestamp fixes | 424 |
|
||||
| v0.32 | Auto-compaction handling | Compression detection, /compact command, real context window indicator | 424 |
|
||||
| v0.33 | /insights sync | Opt-in state.db sync so `hermes /insights` includes WebUI sessions | 424 |
|
||||
| v0.34 | Sprint 26 — Pluggable themes | Dark, Light, Slate, Solarized, Monokai, Nord; settings unsaved-changes guard; /theme command | 433 |
|
||||
| v0.34.1 | Theme variable polish | 30+ hardcoded dark-navy colors replaced with theme-aware CSS variables | 433 |
|
||||
| v0.34.2 | Theme text colors | 5 new per-theme typography variables (--strong, --em, --code-text, --code-inline-bg, --pre-text) | 433 |
|
||||
| v0.34.3 | Light theme final polish | 46 light-scoped selector overrides for sidebar, roles, chips, interactive elements | 433 |
|
||||
| v0.35 | Security hardening | Env race fix, random signing key, upload path traversal, PBKDF2 password hash | 433 |
|
||||
|
||||
---
|
||||
|
||||
@@ -209,7 +214,7 @@
|
||||
- [x] Background task cancel (activity bar Cancel button)
|
||||
- [ ] Code execution cell (deferred)
|
||||
- [ ] Desktop application (Sprint 25, PLANNED)
|
||||
- [ ] Pluggable UI themes -- light, dark, Solarized, Monokai, Nord (Sprint 26, PLANNED)
|
||||
- [x] Pluggable UI themes -- Dark, Light, Slate, Solarized, Monokai, Nord (Sprint 26, v0.34)
|
||||
- [ ] Extended slash command / skill integration (deferred)
|
||||
- [ ] Virtual scroll for large lists (deferred)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hermes Web UI -- Forward Sprint Plan
|
||||
|
||||
> Current state: v0.34 | 433 tests | Daily driver ready
|
||||
> Current state: v0.35 | 433 tests | Daily driver ready
|
||||
> This document plans the path from here to two targets:
|
||||
>
|
||||
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
|
||||
@@ -893,7 +893,7 @@ genuinely differentiating for an open-source project
|
||||
|
||||
---
|
||||
|
||||
## Sprint 26 -- Pluggable UI Themes (PLANNED)
|
||||
## Sprint 26 -- Pluggable UI Themes (COMPLETED)
|
||||
|
||||
**Theme:** Let users choose how the app looks -- light, dark, and custom color
|
||||
schemes. One-click switching, persistent preference, zero flicker on load.
|
||||
@@ -1156,6 +1156,6 @@ New test cases in `tests/test_sprint26.py`:
|
||||
---
|
||||
|
||||
*Last updated: April 5, 2026*
|
||||
*Current version: v0.34 | 433 tests*
|
||||
*Current version: v0.35 | 433 tests*
|
||||
*Next sprint: Sprint 24 (Web Polish + Bug Fix Pass)*
|
||||
*Horizon sprint: Sprint 26 (Pluggable UI Themes)*
|
||||
*Horizon sprint: Sprint 25 (macOS Desktop Application)*
|
||||
|
||||
14
TESTING.md
14
TESTING.md
@@ -1,14 +1,14 @@
|
||||
# Hermes Web UI: Browser Testing Plan
|
||||
|
||||
> This document is for manual browser testing by you or by a Claude browser agent.
|
||||
> It covers user-facing features of the UI through Sprint 22 (v0.24).
|
||||
> It covers user-facing features of the UI through Sprint 26 (v0.34.3).
|
||||
> Each section is written as a step-by-step test procedure with expected outcomes.
|
||||
> A browser agent (e.g. Claude with Chrome access) can execute this plan directly.
|
||||
>
|
||||
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
|
||||
> Prerequisites: SSH tunnel is active on port 8786. Open http://localhost:8786 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8786/health should return {"status":"ok"}.
|
||||
>
|
||||
> Automated tests: 424 total (424 passing, 0 failures)
|
||||
> Automated tests: 433 total (433 passing, 0 failures)
|
||||
> Run: `pytest tests/ -v --timeout=60`
|
||||
|
||||
---
|
||||
@@ -1708,8 +1708,8 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: Sprint 22 / v0.24, April 3, 2026*
|
||||
*Total automated tests: 415 (392 passing, 23 pre-existing failures)*
|
||||
*Regression gate: tests/test_regressions.py (23 tests)*
|
||||
*Last updated: Sprint 26 / v0.34.3, April 5, 2026*
|
||||
*Total automated tests: 433 (433 passing, 0 failures)*
|
||||
*Regression gate: tests/test_regressions.py*
|
||||
*Run: pytest tests/ -v --timeout=60*
|
||||
*Source: <repo>/*
|
||||
|
||||
31
api/auth.py
31
api/auth.py
@@ -26,14 +26,35 @@ _sessions = {}
|
||||
|
||||
|
||||
def _signing_key():
|
||||
"""Derive a stable signing key from STATE_DIR."""
|
||||
return hashlib.sha256(str(STATE_DIR).encode()).digest()
|
||||
"""Return a random signing key, generating and persisting one on first call."""
|
||||
key_file = STATE_DIR / '.signing_key'
|
||||
if key_file.exists():
|
||||
try:
|
||||
raw = key_file.read_bytes()
|
||||
if len(raw) >= 32:
|
||||
return raw[:32]
|
||||
except Exception:
|
||||
pass
|
||||
# Generate a new random key
|
||||
key = secrets.token_bytes(32)
|
||||
try:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
key_file.write_bytes(key)
|
||||
key_file.chmod(0o600)
|
||||
except Exception:
|
||||
pass # key works for this process even if persist fails
|
||||
return key
|
||||
|
||||
|
||||
def _hash_password(password):
|
||||
"""SHA-256 hash with a salt derived from STATE_DIR."""
|
||||
salt = str(STATE_DIR).encode()
|
||||
return hashlib.sha256(salt + password.encode()).hexdigest()
|
||||
"""PBKDF2-SHA256 with 600k iterations (OWASP recommendation).
|
||||
Salt is the persisted random signing key, which is secret and unique per
|
||||
installation. This keeps the stored hash format a plain hex string
|
||||
(no format change to settings.json) while replacing the predictable
|
||||
STATE_DIR-derived salt from the original implementation."""
|
||||
salt = _signing_key()
|
||||
dk = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 600_000)
|
||||
return dk.hex()
|
||||
|
||||
|
||||
def get_password_hash():
|
||||
|
||||
@@ -17,6 +17,12 @@ from api.config import (
|
||||
resolve_model_provider,
|
||||
)
|
||||
|
||||
# Global lock for os.environ writes. Per-session locks (_agent_lock) prevent
|
||||
# concurrent runs of the SAME session, but two DIFFERENT sessions can still
|
||||
# interleave their os.environ writes. This global lock serializes the env
|
||||
# save/restore around the entire agent run.
|
||||
_ENV_LOCK = threading.Lock()
|
||||
|
||||
# Lazy import to avoid circular deps -- hermes-agent is on sys.path via api/config.py
|
||||
try:
|
||||
from run_agent import AIAgent
|
||||
@@ -101,7 +107,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
HERMES_HOME=_profile_home,
|
||||
)
|
||||
# Still set process-level env as fallback for tools that bypass thread-local
|
||||
with _agent_lock:
|
||||
with _ENV_LOCK:
|
||||
old_cwd = os.environ.get('TERMINAL_CWD')
|
||||
old_exec_ask = os.environ.get('HERMES_EXEC_ASK')
|
||||
old_session_key = os.environ.get('HERMES_SESSION_KEY')
|
||||
|
||||
@@ -70,7 +70,11 @@ def handle_upload(handler):
|
||||
return j(handler, {'error': 'Session not found'}, status=404)
|
||||
workspace = Path(s.workspace)
|
||||
safe_name = _re.sub(r'[^\w.\-]', '_', Path(filename).name)[:200]
|
||||
dest = workspace / safe_name
|
||||
# Reject names that are purely dots (path traversal: ".." survives regex)
|
||||
if not safe_name or safe_name.strip('.') == '':
|
||||
return j(handler, {'error': 'Invalid filename'}, status=400)
|
||||
# Verify the resolved path stays within the workspace
|
||||
dest = safe_resolve_ws(workspace, safe_name)
|
||||
dest.write_bytes(file_bytes)
|
||||
return j(handler, {'filename': safe_name, 'path': str(dest), 'size': dest.stat().st_size})
|
||||
except Exception as e:
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.34.3</div></div></div>
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.35</div></div></div>
|
||||
<div class="sidebar-nav">
|
||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||
|
||||
Reference in New Issue
Block a user