2026-03-31 03:40:19 +00:00
|
|
|
|
"""
|
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
|
|
|
|
Hermes Web UI -- File upload: multipart parser and upload handler.
|
2026-03-31 03:40:19 +00:00
|
|
|
|
"""
|
2026-04-28 15:18:51 +00:00
|
|
|
|
import mimetypes
|
2026-05-15 18:52:23 +00:00
|
|
|
|
import os
|
2026-03-31 03:40:19 +00:00
|
|
|
|
import re as _re
|
|
|
|
|
|
import email.parser
|
v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)
* fix: restore mobile chat scrolling and drawer close (#397)
- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites
Original PR by @Jordan-SkyLF
* fix: preserve imported session timestamps (#395)
- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block
Original PR by @Jordan-SkyLF
* fix(profiles): block path traversal in profile switch and delete flows (#399)
Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.
- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes
Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)
* feat: add desktop microphone transcription fallback (#396)
Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).
- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks
Original PR by @Jordan-SkyLF
* docs: v0.50.25 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 05:11:45 +00:00
|
|
|
|
import tempfile
|
2026-03-31 03:40:19 +00:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
2026-05-15 18:52:23 +00:00
|
|
|
|
from api.config import MAX_UPLOAD_BYTES, STATE_DIR
|
2026-03-31 03:40:19 +00:00
|
|
|
|
from api.helpers import j, bad
|
|
|
|
|
|
from api.models import get_session
|
2026-06-29 08:24:15 +00:00
|
|
|
|
from api.profiles import _profiles_match, get_active_profile_name as _get_active_profile_name
|
2026-06-05 19:58:11 +00:00
|
|
|
|
from api.workspace import (
|
|
|
|
|
|
safe_resolve_ws,
|
|
|
|
|
|
resolve_trusted_workspace,
|
|
|
|
|
|
open_anchored_create_fd,
|
|
|
|
|
|
make_anchored_dir,
|
|
|
|
|
|
rmtree_anchored,
|
|
|
|
|
|
unlink_anchored,
|
|
|
|
|
|
)
|
2026-03-31 03:40:19 +00:00
|
|
|
|
|
2026-06-02 00:41:11 +00:00
|
|
|
|
|
|
|
|
|
|
def _max_extracted_bytes() -> int:
|
|
|
|
|
|
"""Total-extracted-bytes cap for archive uploads (zip/tar-bomb guard).
|
|
|
|
|
|
|
|
|
|
|
|
Independently tunable from the upload size cap via
|
|
|
|
|
|
HERMES_WEBUI_MAX_EXTRACTED_MB; defaults to 10x the upload cap. Read at call
|
|
|
|
|
|
time (not import) so the value reflects the running process's environment
|
|
|
|
|
|
and is exercisable by tests against the out-of-process test server.
|
|
|
|
|
|
"""
|
|
|
|
|
|
raw = os.getenv("HERMES_WEBUI_MAX_EXTRACTED_MB", "").strip()
|
|
|
|
|
|
if raw:
|
|
|
|
|
|
try:
|
|
|
|
|
|
mb = float(raw)
|
|
|
|
|
|
if mb > 0:
|
|
|
|
|
|
return int(mb * 1024 * 1024)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return 10 * MAX_UPLOAD_BYTES
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Back-compat module constant (some call sites / tests reference it). The
|
|
|
|
|
|
# authoritative value is _max_extracted_bytes(), read at extraction time.
|
2026-05-15 02:20:05 +00:00
|
|
|
|
_MAX_EXTRACTED_BYTES = 10 * MAX_UPLOAD_BYTES
|
|
|
|
|
|
|
2026-03-31 03:40:19 +00:00
|
|
|
|
|
2026-04-05 06:30:20 +00:00
|
|
|
|
def parse_multipart(rfile, content_type, content_length) -> tuple:
|
2026-03-31 03:40:19 +00:00
|
|
|
|
import re as _re, email.parser as _ep
|
2026-06-02 02:27:21 +00:00
|
|
|
|
# Imported locally (not just module-level) so the function stays
|
|
|
|
|
|
# self-contained — some tests exec() this function's source in an isolated
|
|
|
|
|
|
# namespace, and a bare module global would NameError there.
|
|
|
|
|
|
try:
|
|
|
|
|
|
from api.config import MAX_UPLOAD_BYTES as _MAX_UPLOAD_BYTES
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
2026-03-31 03:40:19 +00:00
|
|
|
|
m = _re.search(r'boundary=([^;\s]+)', content_type)
|
|
|
|
|
|
if not m:
|
|
|
|
|
|
raise ValueError('No boundary in Content-Type')
|
|
|
|
|
|
boundary = m.group(1).strip('"').encode()
|
2026-06-02 02:20:05 +00:00
|
|
|
|
# Centralized length guard for ALL upload callers: a missing/garbage or
|
|
|
|
|
|
# NEGATIVE Content-Length must never reach rfile.read(<0), which reads the
|
|
|
|
|
|
# stream unbounded (read(-1) == read-to-EOF) and bypasses the per-handler
|
|
|
|
|
|
# size cap. Reject anything not in [0, MAX_UPLOAD_BYTES].
|
|
|
|
|
|
try:
|
|
|
|
|
|
length = int(content_length)
|
|
|
|
|
|
except (TypeError, ValueError):
|
2026-06-02 02:20:43 +00:00
|
|
|
|
raise ValueError('Invalid Content-Length') from None
|
2026-06-02 02:20:05 +00:00
|
|
|
|
if length < 0:
|
|
|
|
|
|
raise ValueError('Invalid Content-Length (negative)')
|
2026-06-02 02:27:21 +00:00
|
|
|
|
if length > _MAX_UPLOAD_BYTES:
|
|
|
|
|
|
raise ValueError(f'Upload too large (max {_MAX_UPLOAD_BYTES} bytes)')
|
2026-06-02 02:20:05 +00:00
|
|
|
|
raw = rfile.read(length)
|
2026-03-31 03:40:19 +00:00
|
|
|
|
fields = {}
|
|
|
|
|
|
files = {}
|
|
|
|
|
|
delimiter = b'--' + boundary
|
|
|
|
|
|
end_marker = b'--' + boundary + b'--'
|
|
|
|
|
|
parts = raw.split(delimiter)
|
|
|
|
|
|
for part in parts[1:]:
|
|
|
|
|
|
stripped = part.lstrip(b'\r\n')
|
|
|
|
|
|
if stripped.startswith(b'--'):
|
|
|
|
|
|
break
|
|
|
|
|
|
sep = b'\r\n\r\n' if b'\r\n\r\n' in part else b'\n\n'
|
|
|
|
|
|
if sep not in part:
|
|
|
|
|
|
continue
|
|
|
|
|
|
header_raw, body = part.split(sep, 1)
|
|
|
|
|
|
if body.endswith(b'\r\n'):
|
|
|
|
|
|
body = body[:-2]
|
|
|
|
|
|
elif body.endswith(b'\n'):
|
|
|
|
|
|
body = body[:-1]
|
|
|
|
|
|
header_text = header_raw.lstrip(b'\r\n').decode('utf-8', errors='replace')
|
|
|
|
|
|
msg = _ep.HeaderParser().parsestr(header_text)
|
|
|
|
|
|
disp = msg.get('Content-Disposition', '')
|
|
|
|
|
|
name_m = _re.search(r'name="([^"]*)"', disp)
|
|
|
|
|
|
file_m = _re.search(r'filename="([^"]*)"', disp)
|
|
|
|
|
|
if not name_m:
|
|
|
|
|
|
continue
|
|
|
|
|
|
name = name_m.group(1)
|
|
|
|
|
|
if file_m:
|
|
|
|
|
|
files[name] = (file_m.group(1), body)
|
|
|
|
|
|
else:
|
|
|
|
|
|
fields[name] = body.decode('utf-8', errors='replace')
|
|
|
|
|
|
return fields, files
|
|
|
|
|
|
|
|
|
|
|
|
|
v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)
* fix: restore mobile chat scrolling and drawer close (#397)
- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites
Original PR by @Jordan-SkyLF
* fix: preserve imported session timestamps (#395)
- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block
Original PR by @Jordan-SkyLF
* fix(profiles): block path traversal in profile switch and delete flows (#399)
Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.
- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes
Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)
* feat: add desktop microphone transcription fallback (#396)
Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).
- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks
Original PR by @Jordan-SkyLF
* docs: v0.50.25 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 05:11:45 +00:00
|
|
|
|
def _sanitize_upload_name(filename: str) -> str:
|
|
|
|
|
|
safe_name = _re.sub(r'[^\w.\-]', '_', Path(filename).name)[:200]
|
|
|
|
|
|
if not safe_name or safe_name.strip('.') == '':
|
|
|
|
|
|
raise ValueError('Invalid filename')
|
|
|
|
|
|
return safe_name
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-15 18:52:23 +00:00
|
|
|
|
def _attachment_root() -> Path:
|
|
|
|
|
|
"""Return the configured upload inbox root.
|
|
|
|
|
|
|
|
|
|
|
|
Plain chat attachments are transient context for the agent, not project
|
|
|
|
|
|
source files. Keep them out of the active workspace by default while still
|
|
|
|
|
|
allowing operators to move the inbox with HERMES_WEBUI_ATTACHMENT_DIR.
|
|
|
|
|
|
"""
|
|
|
|
|
|
override = os.getenv('HERMES_WEBUI_ATTACHMENT_DIR', '').strip()
|
|
|
|
|
|
if override:
|
|
|
|
|
|
return Path(override).expanduser().resolve()
|
|
|
|
|
|
return (STATE_DIR / 'attachments').resolve()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _upload_destination(session_id: str, safe_name: str) -> Path:
|
2026-05-15 20:30:46 +00:00
|
|
|
|
dest_dir = _session_attachment_dir(session_id)
|
2026-05-15 18:52:23 +00:00
|
|
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
dest = (dest_dir / safe_name).resolve()
|
|
|
|
|
|
if not dest.is_relative_to(dest_dir):
|
|
|
|
|
|
raise ValueError('Invalid upload destination')
|
2026-05-25 17:06:45 +00:00
|
|
|
|
if dest.exists():
|
|
|
|
|
|
stem = dest.stem
|
|
|
|
|
|
suffix = dest.suffix
|
|
|
|
|
|
for idx in range(1, 1000):
|
|
|
|
|
|
candidate = (dest_dir / f'{stem}-{idx}{suffix}').resolve()
|
|
|
|
|
|
if not candidate.is_relative_to(dest_dir):
|
|
|
|
|
|
raise ValueError('Invalid upload destination')
|
|
|
|
|
|
if not candidate.exists():
|
|
|
|
|
|
return candidate
|
|
|
|
|
|
raise ValueError('Too many uploads with the same filename')
|
2026-05-15 18:52:23 +00:00
|
|
|
|
return dest
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-15 20:30:46 +00:00
|
|
|
|
def _session_attachment_dir(session_id: str, *, root: Path | None = None) -> Path:
|
|
|
|
|
|
root = (root or _attachment_root()).resolve()
|
|
|
|
|
|
dest_dir = (root / _re.sub(r'[^\w.\-]', '_', str(session_id or 'session'))[:120]).resolve()
|
|
|
|
|
|
if not dest_dir.is_relative_to(root):
|
|
|
|
|
|
raise ValueError('Invalid attachment directory')
|
|
|
|
|
|
return dest_dir
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 08:24:15 +00:00
|
|
|
|
def _session_visible_to_active_profile(session) -> bool:
|
|
|
|
|
|
"""Return whether an upload target session belongs to the active profile."""
|
|
|
|
|
|
session_profile = getattr(session, 'profile', None)
|
|
|
|
|
|
if not isinstance(session_profile, str):
|
|
|
|
|
|
session_profile = None
|
|
|
|
|
|
return _profiles_match(session_profile, _get_active_profile_name())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _reject_invisible_session(handler, session) -> bool:
|
|
|
|
|
|
if _session_visible_to_active_profile(session):
|
|
|
|
|
|
return False
|
|
|
|
|
|
j(handler, {'error': 'Session not found'}, status=404)
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-31 03:40:19 +00:00
|
|
|
|
def handle_upload(handler):
|
v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)
* fix: restore mobile chat scrolling and drawer close (#397)
- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites
Original PR by @Jordan-SkyLF
* fix: preserve imported session timestamps (#395)
- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block
Original PR by @Jordan-SkyLF
* fix(profiles): block path traversal in profile switch and delete flows (#399)
Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.
- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes
Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)
* feat: add desktop microphone transcription fallback (#396)
Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).
- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks
Original PR by @Jordan-SkyLF
* docs: v0.50.25 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 05:11:45 +00:00
|
|
|
|
import traceback as _tb
|
2026-03-31 03:40:19 +00:00
|
|
|
|
try:
|
|
|
|
|
|
content_type = handler.headers.get('Content-Type', '')
|
|
|
|
|
|
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
|
|
|
|
|
if content_length > MAX_UPLOAD_BYTES:
|
|
|
|
|
|
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
|
|
|
|
|
|
fields, files = parse_multipart(handler.rfile, content_type, content_length)
|
|
|
|
|
|
session_id = fields.get('session_id', '')
|
|
|
|
|
|
if 'file' not in files:
|
|
|
|
|
|
return j(handler, {'error': 'No file field in request'}, status=400)
|
|
|
|
|
|
filename, file_bytes = files['file']
|
|
|
|
|
|
if not filename:
|
|
|
|
|
|
return j(handler, {'error': 'No filename in upload'}, status=400)
|
|
|
|
|
|
try:
|
|
|
|
|
|
s = get_session(session_id)
|
|
|
|
|
|
except KeyError:
|
|
|
|
|
|
return j(handler, {'error': 'Session not found'}, status=404)
|
2026-06-29 08:24:15 +00:00
|
|
|
|
if _reject_invisible_session(handler, s):
|
|
|
|
|
|
return True
|
v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)
* fix: restore mobile chat scrolling and drawer close (#397)
- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites
Original PR by @Jordan-SkyLF
* fix: preserve imported session timestamps (#395)
- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block
Original PR by @Jordan-SkyLF
* fix(profiles): block path traversal in profile switch and delete flows (#399)
Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.
- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes
Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)
* feat: add desktop microphone transcription fallback (#396)
Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).
- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks
Original PR by @Jordan-SkyLF
* docs: v0.50.25 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 05:11:45 +00:00
|
|
|
|
safe_name = _sanitize_upload_name(filename)
|
2026-05-15 18:52:23 +00:00
|
|
|
|
dest = _upload_destination(session_id, safe_name)
|
2026-03-31 03:40:19 +00:00
|
|
|
|
dest.write_bytes(file_bytes)
|
2026-04-28 15:18:51 +00:00
|
|
|
|
mime = mimetypes.guess_type(safe_name)[0] or 'application/octet-stream'
|
|
|
|
|
|
return j(handler, {
|
2026-05-28 12:33:50 +00:00
|
|
|
|
'filename': dest.name,
|
2026-04-28 15:18:51 +00:00
|
|
|
|
'path': str(dest),
|
|
|
|
|
|
'size': dest.stat().st_size,
|
|
|
|
|
|
'mime': mime,
|
|
|
|
|
|
'is_image': mime.startswith('image/'),
|
|
|
|
|
|
})
|
v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)
* fix: restore mobile chat scrolling and drawer close (#397)
- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites
Original PR by @Jordan-SkyLF
* fix: preserve imported session timestamps (#395)
- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block
Original PR by @Jordan-SkyLF
* fix(profiles): block path traversal in profile switch and delete flows (#399)
Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.
- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes
Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)
* feat: add desktop microphone transcription fallback (#396)
Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).
- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks
Original PR by @Jordan-SkyLF
* docs: v0.50.25 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 05:11:45 +00:00
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
return j(handler, {'error': str(e)}, status=400)
|
|
|
|
|
|
except Exception:
|
2026-04-03 13:41:32 +00:00
|
|
|
|
print('[webui] upload error: ' + _tb.format_exc(), flush=True)
|
|
|
|
|
|
return j(handler, {'error': 'Upload failed'}, status=500)
|
v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)
* fix: restore mobile chat scrolling and drawer close (#397)
- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites
Original PR by @Jordan-SkyLF
* fix: preserve imported session timestamps (#395)
- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block
Original PR by @Jordan-SkyLF
* fix(profiles): block path traversal in profile switch and delete flows (#399)
Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.
- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes
Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)
* feat: add desktop microphone transcription fallback (#396)
Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).
- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks
Original PR by @Jordan-SkyLF
* docs: v0.50.25 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 05:11:45 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-28 11:28:28 +00:00
|
|
|
|
def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
|
|
|
|
|
|
"""Extract a zip or tar archive into the workspace.
|
|
|
|
|
|
|
|
|
|
|
|
Returns a dict with ``extracted`` (int), ``files`` (list[str]).
|
|
|
|
|
|
Raises ValueError on zip-slip or unsupported format.
|
|
|
|
|
|
"""
|
2026-04-28 13:11:55 +00:00
|
|
|
|
import zipfile, tarfile, io, os, shutil
|
2026-04-28 11:28:28 +00:00
|
|
|
|
|
2026-06-02 00:41:11 +00:00
|
|
|
|
cap = _max_extracted_bytes()
|
2026-04-28 11:28:28 +00:00
|
|
|
|
name = Path(filename).name
|
|
|
|
|
|
stem = Path(filename).stem # strip .zip / .tar.gz etc.
|
|
|
|
|
|
|
|
|
|
|
|
if name.lower().endswith(('.zip',)):
|
|
|
|
|
|
_mode = 'zip'
|
|
|
|
|
|
elif name.lower().endswith(('.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz')):
|
|
|
|
|
|
_mode = 'tar'
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise ValueError(f'Unsupported archive format: {filename}')
|
|
|
|
|
|
|
|
|
|
|
|
# Determine destination directory — use archive stem as folder name
|
|
|
|
|
|
dest_dir = safe_resolve_ws(workspace, stem)
|
2026-06-02 00:52:50 +00:00
|
|
|
|
# Avoid overwriting existing files by appending a suffix (bounded — astronomically
|
|
|
|
|
|
# unlikely to collide, but never spin forever).
|
2026-04-28 11:28:28 +00:00
|
|
|
|
if dest_dir.exists():
|
|
|
|
|
|
import string, random
|
2026-06-02 00:52:50 +00:00
|
|
|
|
for _ in range(1000):
|
|
|
|
|
|
if not dest_dir.exists():
|
|
|
|
|
|
break
|
2026-04-28 11:28:28 +00:00
|
|
|
|
suffix = ''.join(random.choices(string.digits, k=3))
|
2026-06-02 00:52:50 +00:00
|
|
|
|
dest_dir = safe_resolve_ws(workspace, stem).with_name(stem + '_' + suffix)
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise ValueError('Could not allocate a unique extraction directory')
|
Release v0.51.221 — Release GO (stage-p3e — block all workspace symlink escapes + portable TOCTOU hardening [security]) (#3398) (#3451)
* [security] fix(workspace): block all symlink escapes from the selected workspace (#3398, @Hinotoi-agent)
Previously safe_resolve_ws allowed a symlink placed inside a workspace to resolve
to an external host path as long as it wasn't a system dir (/etc, /proc, etc).
But the workspace file API is reachable by LLM agent tool calls (read_file_content),
so an in-workspace symlink to ~/.ssh, ~/.hermes/auth.json (credentials), etc. was a
real read path. Now ANY symlink escape is blocked: safe_resolve_ws resolves and
requires the result stay under the workspace root; list_dir hides escaping symlinks
(they could never be opened anyway); internal symlinks resolving back under the
workspace still work. Updated the upload symlink-target test to accept the new
400 'Path traversal blocked' rejection (was 403) — the invariant (nothing lands
outside the workspace) is unchanged.
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
* docs(changelog): v0.51.221 release header for #3398 symlink-escape security fix
* [security] harden workspace file API against symlink-swap TOCTOU via portable anchored openat-walk (#3398 follow-up)
Codex review of #3398 flagged that safe_resolve_ws() validates a path but
list_dir/read_file_content/upload/extraction then re-open by pathname, leaving a
TOCTOU window: a symlink swapped in AFTER the check could still escape. (This
race pre-existed #3398; closing it here so the containment is complete.)
A first attempt used /proc/self/fd for the post-open containment check, but that
BRICKS workspace browsing on macOS/Windows (no /proc → every read/list rejected).
This version is portable:
- open_anchored_fd(): opens the (already symlink-resolved) target
component-by-component from the workspace root via openat (dir_fd) + O_NOFOLLOW.
Every component must be a real non-symlink entry, so a component swapped to a
symlink mid-flight is refused. No /proc dependency. Used by read_file_content
(read from the fd) and list_dir (enumerate via os.scandir(fd), per-entry
fstatat/readlinkat).
- open_anchored_create_fd(): same anchored walk for writes, creating missing
intermediate dirs with mkdir(dir_fd=) and the leaf with O_CREAT|O_EXCL|
O_NOFOLLOW. Used by the workspace upload write AND archive (zip+tar) member
writes, anchored against the TRUE workspace root (not the mutable extraction
dest_dir, closing Codex's root-swap finding). fd-leak-safe on rejection.
- Portability: gated on os.supports_dir_fd; platforms without it (Windows, where
symlink creation needs admin) fall back to a plain O_NOFOLLOW open/exclusive
create — no new race protection but no regression vs the prior path-based code.
Legit in-workspace symlinks still resolve and read/list fine (safe_resolve_ws
collapses them to a real in-workspace path, which the anchored walk then opens).
Verified: the swap-race leaks external content against the old path-based read
and is blocked here; macOS-class symlinked-root workspaces work; no fd leak over
300 rejected creates. Adds TOCTOU + anchored-create regression tests.
* [security] close 3 more #3398 TOCTOU gaps from Codex r3: root-swap, pre-create mkdir, Windows list_dir fallback
Codex round-3 review found three residual issues in the anchored openat-walk:
1. (CORE) The workspace ROOT itself could be swapped to a symlink after
resolve() but before the root os.open() — add _O_NOFOLLOW to the root open in
open_anchored_fd() and open_anchored_create_fd() (and make_anchored_dir()), so
a raced root symlink is refused. Verified: root-swap race now blocked.
2. (SILENT) Upload/extraction still did pathname Path.mkdir() AFTER the
containment check, so a raced symlink component could make the server create
dirs outside the workspace before the anchored file create rejected. Removed
the redundant member_path.parent.mkdir() calls (open_anchored_create_fd
already creates intermediates via anchored mkdirat) and replaced the two
base-dir mkdirs (upload target dir + archive extraction root) with a new
make_anchored_dir() that walks from the true workspace root via
openat+O_NOFOLLOW + mkdir(dir_fd=).
3. (CORE) list_dir() unconditionally used os.scandir(fd)/os.stat(dir_fd=)/
os.readlink(dir_fd=), which would brick workspace browsing on platforms
without os.supports_dir_fd (Windows). Split list_dir() into a _DIR_FD_OK
anchored branch and a path-based fallback branch (prior behaviour) sharing one
_process() entry builder. open_anchored_create_fd()'s Windows fallback now also
creates parent dirs.
Adds regression tests: no-dir_fd fallback (list+read+create+symlink filtering)
and the root-swap race. All prior TOCTOU + anchored-create tests still green.
* fix(workspace): portable symlink-loop filtering in list_dir via follow-stat ELOOP
CI on Python 3.13 caught test_mutual_symlink_loop_filtered failing: a mutual
symlink loop (a->b->a) was NOT filtered from the listing. Root cause: the new
readlink-based cycle detection relied on (target_resolved / raw_link).resolve()
RAISING on a loop, but Path.resolve() loop handling differs by Python version
(3.11 raises RuntimeError, 3.13 can return a path), so the loop slipped through
on 3.13.
Fix: compute a version-independent 'reachable' flag per symlink via
os.stat(..., follow_symlinks=True) — the syscall reliably returns ELOOP for
mutual/self loops and ENOENT for broken targets on every platform/version. A
symlink whose follow-stat raises can never be opened, so list_dir filters it.
Applied in both the dir_fd-anchored branch (fd-relative stat) and the Windows
path-based fallback branch. Mutual loop now filtered on all versions.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
2026-06-02 23:56:30 +00:00
|
|
|
|
# #3398: create the extraction root race-safely under the true workspace root.
|
|
|
|
|
|
make_anchored_dir(workspace, dest_dir)
|
2026-04-28 11:28:28 +00:00
|
|
|
|
|
2026-06-02 00:52:50 +00:00
|
|
|
|
# Member-count cap: a tiny archive with millions of (possibly empty) members
|
|
|
|
|
|
# slips under the byte cap but can exhaust inodes / file descriptors. Bound it.
|
|
|
|
|
|
_MAX_ARCHIVE_MEMBERS = 10000
|
|
|
|
|
|
|
2026-04-28 11:28:28 +00:00
|
|
|
|
extracted_files = []
|
2026-04-28 13:11:55 +00:00
|
|
|
|
total_extracted = 0
|
2026-04-28 11:28:28 +00:00
|
|
|
|
|
2026-04-28 13:11:55 +00:00
|
|
|
|
try:
|
|
|
|
|
|
if _mode == 'zip':
|
|
|
|
|
|
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
|
|
|
|
|
for member in zf.infolist():
|
|
|
|
|
|
# Skip directories
|
|
|
|
|
|
if member.is_dir():
|
|
|
|
|
|
continue
|
2026-06-02 00:52:50 +00:00
|
|
|
|
if len(extracted_files) >= _MAX_ARCHIVE_MEMBERS:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f'Archive has too many files (> {_MAX_ARCHIVE_MEMBERS}). '
|
|
|
|
|
|
f'Possible archive bomb.'
|
|
|
|
|
|
)
|
2026-04-28 13:11:55 +00:00
|
|
|
|
# Zip-slip protection
|
|
|
|
|
|
member_path = (dest_dir / member.filename).resolve()
|
2026-04-29 05:06:34 +00:00
|
|
|
|
if not member_path.is_relative_to(dest_dir.resolve()):
|
2026-04-28 13:11:55 +00:00
|
|
|
|
raise ValueError(f'Zip-slip blocked: {member.filename}')
|
2026-04-29 05:06:34 +00:00
|
|
|
|
# Zip-bomb protection: track actual extracted bytes (not declared file_size)
|
2026-06-02 00:41:11 +00:00
|
|
|
|
if total_extracted > cap:
|
2026-04-28 13:11:55 +00:00
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f'Extraction too large ({total_extracted // (1024*1024)} MB > '
|
2026-06-02 00:41:11 +00:00
|
|
|
|
f'{cap // (1024*1024)} MB limit). '
|
2026-04-28 13:11:55 +00:00
|
|
|
|
f'Possible zip bomb.'
|
|
|
|
|
|
)
|
Release v0.51.221 — Release GO (stage-p3e — block all workspace symlink escapes + portable TOCTOU hardening [security]) (#3398) (#3451)
* [security] fix(workspace): block all symlink escapes from the selected workspace (#3398, @Hinotoi-agent)
Previously safe_resolve_ws allowed a symlink placed inside a workspace to resolve
to an external host path as long as it wasn't a system dir (/etc, /proc, etc).
But the workspace file API is reachable by LLM agent tool calls (read_file_content),
so an in-workspace symlink to ~/.ssh, ~/.hermes/auth.json (credentials), etc. was a
real read path. Now ANY symlink escape is blocked: safe_resolve_ws resolves and
requires the result stay under the workspace root; list_dir hides escaping symlinks
(they could never be opened anyway); internal symlinks resolving back under the
workspace still work. Updated the upload symlink-target test to accept the new
400 'Path traversal blocked' rejection (was 403) — the invariant (nothing lands
outside the workspace) is unchanged.
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
* docs(changelog): v0.51.221 release header for #3398 symlink-escape security fix
* [security] harden workspace file API against symlink-swap TOCTOU via portable anchored openat-walk (#3398 follow-up)
Codex review of #3398 flagged that safe_resolve_ws() validates a path but
list_dir/read_file_content/upload/extraction then re-open by pathname, leaving a
TOCTOU window: a symlink swapped in AFTER the check could still escape. (This
race pre-existed #3398; closing it here so the containment is complete.)
A first attempt used /proc/self/fd for the post-open containment check, but that
BRICKS workspace browsing on macOS/Windows (no /proc → every read/list rejected).
This version is portable:
- open_anchored_fd(): opens the (already symlink-resolved) target
component-by-component from the workspace root via openat (dir_fd) + O_NOFOLLOW.
Every component must be a real non-symlink entry, so a component swapped to a
symlink mid-flight is refused. No /proc dependency. Used by read_file_content
(read from the fd) and list_dir (enumerate via os.scandir(fd), per-entry
fstatat/readlinkat).
- open_anchored_create_fd(): same anchored walk for writes, creating missing
intermediate dirs with mkdir(dir_fd=) and the leaf with O_CREAT|O_EXCL|
O_NOFOLLOW. Used by the workspace upload write AND archive (zip+tar) member
writes, anchored against the TRUE workspace root (not the mutable extraction
dest_dir, closing Codex's root-swap finding). fd-leak-safe on rejection.
- Portability: gated on os.supports_dir_fd; platforms without it (Windows, where
symlink creation needs admin) fall back to a plain O_NOFOLLOW open/exclusive
create — no new race protection but no regression vs the prior path-based code.
Legit in-workspace symlinks still resolve and read/list fine (safe_resolve_ws
collapses them to a real in-workspace path, which the anchored walk then opens).
Verified: the swap-race leaks external content against the old path-based read
and is blocked here; macOS-class symlinked-root workspaces work; no fd leak over
300 rejected creates. Adds TOCTOU + anchored-create regression tests.
* [security] close 3 more #3398 TOCTOU gaps from Codex r3: root-swap, pre-create mkdir, Windows list_dir fallback
Codex round-3 review found three residual issues in the anchored openat-walk:
1. (CORE) The workspace ROOT itself could be swapped to a symlink after
resolve() but before the root os.open() — add _O_NOFOLLOW to the root open in
open_anchored_fd() and open_anchored_create_fd() (and make_anchored_dir()), so
a raced root symlink is refused. Verified: root-swap race now blocked.
2. (SILENT) Upload/extraction still did pathname Path.mkdir() AFTER the
containment check, so a raced symlink component could make the server create
dirs outside the workspace before the anchored file create rejected. Removed
the redundant member_path.parent.mkdir() calls (open_anchored_create_fd
already creates intermediates via anchored mkdirat) and replaced the two
base-dir mkdirs (upload target dir + archive extraction root) with a new
make_anchored_dir() that walks from the true workspace root via
openat+O_NOFOLLOW + mkdir(dir_fd=).
3. (CORE) list_dir() unconditionally used os.scandir(fd)/os.stat(dir_fd=)/
os.readlink(dir_fd=), which would brick workspace browsing on platforms
without os.supports_dir_fd (Windows). Split list_dir() into a _DIR_FD_OK
anchored branch and a path-based fallback branch (prior behaviour) sharing one
_process() entry builder. open_anchored_create_fd()'s Windows fallback now also
creates parent dirs.
Adds regression tests: no-dir_fd fallback (list+read+create+symlink filtering)
and the root-swap race. All prior TOCTOU + anchored-create tests still green.
* fix(workspace): portable symlink-loop filtering in list_dir via follow-stat ELOOP
CI on Python 3.13 caught test_mutual_symlink_loop_filtered failing: a mutual
symlink loop (a->b->a) was NOT filtered from the listing. Root cause: the new
readlink-based cycle detection relied on (target_resolved / raw_link).resolve()
RAISING on a loop, but Path.resolve() loop handling differs by Python version
(3.11 raises RuntimeError, 3.13 can return a path), so the loop slipped through
on 3.13.
Fix: compute a version-independent 'reachable' flag per symlink via
os.stat(..., follow_symlinks=True) — the syscall reliably returns ELOOP for
mutual/self loops and ENOENT for broken targets on every platform/version. A
symlink whose follow-stat raises can never be opened, so list_dir filters it.
Applied in both the dir_fd-anchored branch (fd-relative stat) and the Windows
path-based fallback branch. Mutual loop now filtered on all versions.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
2026-06-02 23:56:30 +00:00
|
|
|
|
# #3398: open_anchored_create_fd creates intermediate dirs
|
|
|
|
|
|
# race-safely under the true workspace root (anchored mkdirat),
|
|
|
|
|
|
# so no pathname member_path.parent.mkdir() before it (which
|
|
|
|
|
|
# could be redirected outside by a raced symlink component).
|
|
|
|
|
|
_mfd = open_anchored_create_fd(workspace, member_path)
|
|
|
|
|
|
with zf.open(member) as src, os.fdopen(_mfd, 'wb', closefd=True) as dst:
|
2026-04-29 05:06:34 +00:00
|
|
|
|
_chunk_size = 65536
|
|
|
|
|
|
while True:
|
|
|
|
|
|
chunk = src.read(_chunk_size)
|
|
|
|
|
|
if not chunk:
|
|
|
|
|
|
break
|
|
|
|
|
|
total_extracted += len(chunk)
|
2026-06-02 00:41:11 +00:00
|
|
|
|
if total_extracted > cap:
|
2026-04-29 05:06:34 +00:00
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f'Extraction too large (> '
|
2026-06-02 00:41:11 +00:00
|
|
|
|
f'{cap // (1024*1024)} MB limit). '
|
2026-04-29 05:06:34 +00:00
|
|
|
|
f'Possible zip bomb.'
|
|
|
|
|
|
)
|
|
|
|
|
|
dst.write(chunk)
|
2026-04-28 13:11:55 +00:00
|
|
|
|
extracted_files.append(str(member_path.relative_to(workspace.resolve())))
|
|
|
|
|
|
|
|
|
|
|
|
elif _mode == 'tar':
|
|
|
|
|
|
with tarfile.open(fileobj=io.BytesIO(file_bytes)) as tf:
|
|
|
|
|
|
for member in tf.getmembers():
|
|
|
|
|
|
if not member.isfile():
|
|
|
|
|
|
continue
|
2026-06-02 00:52:50 +00:00
|
|
|
|
if len(extracted_files) >= _MAX_ARCHIVE_MEMBERS:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f'Archive has too many files (> {_MAX_ARCHIVE_MEMBERS}). '
|
|
|
|
|
|
f'Possible archive bomb.'
|
|
|
|
|
|
)
|
2026-04-28 13:11:55 +00:00
|
|
|
|
# Tar-slip protection
|
|
|
|
|
|
member_path = (dest_dir / member.name).resolve()
|
2026-04-29 05:06:34 +00:00
|
|
|
|
if not member_path.is_relative_to(dest_dir.resolve()):
|
2026-04-28 13:11:55 +00:00
|
|
|
|
raise ValueError(f'Tar-slip blocked: {member.name}')
|
2026-04-29 05:06:34 +00:00
|
|
|
|
# Tar-bomb protection: track actual extracted bytes (not declared size)
|
2026-06-02 00:41:11 +00:00
|
|
|
|
if total_extracted > cap:
|
2026-04-28 13:11:55 +00:00
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f'Extraction too large ({total_extracted // (1024*1024)} MB > '
|
2026-06-02 00:41:11 +00:00
|
|
|
|
f'{cap // (1024*1024)} MB limit). '
|
2026-04-28 13:11:55 +00:00
|
|
|
|
f'Possible zip bomb.'
|
|
|
|
|
|
)
|
Release v0.51.221 — Release GO (stage-p3e — block all workspace symlink escapes + portable TOCTOU hardening [security]) (#3398) (#3451)
* [security] fix(workspace): block all symlink escapes from the selected workspace (#3398, @Hinotoi-agent)
Previously safe_resolve_ws allowed a symlink placed inside a workspace to resolve
to an external host path as long as it wasn't a system dir (/etc, /proc, etc).
But the workspace file API is reachable by LLM agent tool calls (read_file_content),
so an in-workspace symlink to ~/.ssh, ~/.hermes/auth.json (credentials), etc. was a
real read path. Now ANY symlink escape is blocked: safe_resolve_ws resolves and
requires the result stay under the workspace root; list_dir hides escaping symlinks
(they could never be opened anyway); internal symlinks resolving back under the
workspace still work. Updated the upload symlink-target test to accept the new
400 'Path traversal blocked' rejection (was 403) — the invariant (nothing lands
outside the workspace) is unchanged.
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
* docs(changelog): v0.51.221 release header for #3398 symlink-escape security fix
* [security] harden workspace file API against symlink-swap TOCTOU via portable anchored openat-walk (#3398 follow-up)
Codex review of #3398 flagged that safe_resolve_ws() validates a path but
list_dir/read_file_content/upload/extraction then re-open by pathname, leaving a
TOCTOU window: a symlink swapped in AFTER the check could still escape. (This
race pre-existed #3398; closing it here so the containment is complete.)
A first attempt used /proc/self/fd for the post-open containment check, but that
BRICKS workspace browsing on macOS/Windows (no /proc → every read/list rejected).
This version is portable:
- open_anchored_fd(): opens the (already symlink-resolved) target
component-by-component from the workspace root via openat (dir_fd) + O_NOFOLLOW.
Every component must be a real non-symlink entry, so a component swapped to a
symlink mid-flight is refused. No /proc dependency. Used by read_file_content
(read from the fd) and list_dir (enumerate via os.scandir(fd), per-entry
fstatat/readlinkat).
- open_anchored_create_fd(): same anchored walk for writes, creating missing
intermediate dirs with mkdir(dir_fd=) and the leaf with O_CREAT|O_EXCL|
O_NOFOLLOW. Used by the workspace upload write AND archive (zip+tar) member
writes, anchored against the TRUE workspace root (not the mutable extraction
dest_dir, closing Codex's root-swap finding). fd-leak-safe on rejection.
- Portability: gated on os.supports_dir_fd; platforms without it (Windows, where
symlink creation needs admin) fall back to a plain O_NOFOLLOW open/exclusive
create — no new race protection but no regression vs the prior path-based code.
Legit in-workspace symlinks still resolve and read/list fine (safe_resolve_ws
collapses them to a real in-workspace path, which the anchored walk then opens).
Verified: the swap-race leaks external content against the old path-based read
and is blocked here; macOS-class symlinked-root workspaces work; no fd leak over
300 rejected creates. Adds TOCTOU + anchored-create regression tests.
* [security] close 3 more #3398 TOCTOU gaps from Codex r3: root-swap, pre-create mkdir, Windows list_dir fallback
Codex round-3 review found three residual issues in the anchored openat-walk:
1. (CORE) The workspace ROOT itself could be swapped to a symlink after
resolve() but before the root os.open() — add _O_NOFOLLOW to the root open in
open_anchored_fd() and open_anchored_create_fd() (and make_anchored_dir()), so
a raced root symlink is refused. Verified: root-swap race now blocked.
2. (SILENT) Upload/extraction still did pathname Path.mkdir() AFTER the
containment check, so a raced symlink component could make the server create
dirs outside the workspace before the anchored file create rejected. Removed
the redundant member_path.parent.mkdir() calls (open_anchored_create_fd
already creates intermediates via anchored mkdirat) and replaced the two
base-dir mkdirs (upload target dir + archive extraction root) with a new
make_anchored_dir() that walks from the true workspace root via
openat+O_NOFOLLOW + mkdir(dir_fd=).
3. (CORE) list_dir() unconditionally used os.scandir(fd)/os.stat(dir_fd=)/
os.readlink(dir_fd=), which would brick workspace browsing on platforms
without os.supports_dir_fd (Windows). Split list_dir() into a _DIR_FD_OK
anchored branch and a path-based fallback branch (prior behaviour) sharing one
_process() entry builder. open_anchored_create_fd()'s Windows fallback now also
creates parent dirs.
Adds regression tests: no-dir_fd fallback (list+read+create+symlink filtering)
and the root-swap race. All prior TOCTOU + anchored-create tests still green.
* fix(workspace): portable symlink-loop filtering in list_dir via follow-stat ELOOP
CI on Python 3.13 caught test_mutual_symlink_loop_filtered failing: a mutual
symlink loop (a->b->a) was NOT filtered from the listing. Root cause: the new
readlink-based cycle detection relied on (target_resolved / raw_link).resolve()
RAISING on a loop, but Path.resolve() loop handling differs by Python version
(3.11 raises RuntimeError, 3.13 can return a path), so the loop slipped through
on 3.13.
Fix: compute a version-independent 'reachable' flag per symlink via
os.stat(..., follow_symlinks=True) — the syscall reliably returns ELOOP for
mutual/self loops and ENOENT for broken targets on every platform/version. A
symlink whose follow-stat raises can never be opened, so list_dir filters it.
Applied in both the dir_fd-anchored branch (fd-relative stat) and the Windows
path-based fallback branch. Mutual loop now filtered on all versions.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
2026-06-02 23:56:30 +00:00
|
|
|
|
# #3398: anchored member create makes intermediate dirs
|
|
|
|
|
|
# race-safely; no pathname member_path.parent.mkdir() first.
|
2026-04-29 05:06:34 +00:00
|
|
|
|
src_obj = tf.extractfile(member)
|
|
|
|
|
|
if src_obj:
|
Release v0.51.221 — Release GO (stage-p3e — block all workspace symlink escapes + portable TOCTOU hardening [security]) (#3398) (#3451)
* [security] fix(workspace): block all symlink escapes from the selected workspace (#3398, @Hinotoi-agent)
Previously safe_resolve_ws allowed a symlink placed inside a workspace to resolve
to an external host path as long as it wasn't a system dir (/etc, /proc, etc).
But the workspace file API is reachable by LLM agent tool calls (read_file_content),
so an in-workspace symlink to ~/.ssh, ~/.hermes/auth.json (credentials), etc. was a
real read path. Now ANY symlink escape is blocked: safe_resolve_ws resolves and
requires the result stay under the workspace root; list_dir hides escaping symlinks
(they could never be opened anyway); internal symlinks resolving back under the
workspace still work. Updated the upload symlink-target test to accept the new
400 'Path traversal blocked' rejection (was 403) — the invariant (nothing lands
outside the workspace) is unchanged.
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
* docs(changelog): v0.51.221 release header for #3398 symlink-escape security fix
* [security] harden workspace file API against symlink-swap TOCTOU via portable anchored openat-walk (#3398 follow-up)
Codex review of #3398 flagged that safe_resolve_ws() validates a path but
list_dir/read_file_content/upload/extraction then re-open by pathname, leaving a
TOCTOU window: a symlink swapped in AFTER the check could still escape. (This
race pre-existed #3398; closing it here so the containment is complete.)
A first attempt used /proc/self/fd for the post-open containment check, but that
BRICKS workspace browsing on macOS/Windows (no /proc → every read/list rejected).
This version is portable:
- open_anchored_fd(): opens the (already symlink-resolved) target
component-by-component from the workspace root via openat (dir_fd) + O_NOFOLLOW.
Every component must be a real non-symlink entry, so a component swapped to a
symlink mid-flight is refused. No /proc dependency. Used by read_file_content
(read from the fd) and list_dir (enumerate via os.scandir(fd), per-entry
fstatat/readlinkat).
- open_anchored_create_fd(): same anchored walk for writes, creating missing
intermediate dirs with mkdir(dir_fd=) and the leaf with O_CREAT|O_EXCL|
O_NOFOLLOW. Used by the workspace upload write AND archive (zip+tar) member
writes, anchored against the TRUE workspace root (not the mutable extraction
dest_dir, closing Codex's root-swap finding). fd-leak-safe on rejection.
- Portability: gated on os.supports_dir_fd; platforms without it (Windows, where
symlink creation needs admin) fall back to a plain O_NOFOLLOW open/exclusive
create — no new race protection but no regression vs the prior path-based code.
Legit in-workspace symlinks still resolve and read/list fine (safe_resolve_ws
collapses them to a real in-workspace path, which the anchored walk then opens).
Verified: the swap-race leaks external content against the old path-based read
and is blocked here; macOS-class symlinked-root workspaces work; no fd leak over
300 rejected creates. Adds TOCTOU + anchored-create regression tests.
* [security] close 3 more #3398 TOCTOU gaps from Codex r3: root-swap, pre-create mkdir, Windows list_dir fallback
Codex round-3 review found three residual issues in the anchored openat-walk:
1. (CORE) The workspace ROOT itself could be swapped to a symlink after
resolve() but before the root os.open() — add _O_NOFOLLOW to the root open in
open_anchored_fd() and open_anchored_create_fd() (and make_anchored_dir()), so
a raced root symlink is refused. Verified: root-swap race now blocked.
2. (SILENT) Upload/extraction still did pathname Path.mkdir() AFTER the
containment check, so a raced symlink component could make the server create
dirs outside the workspace before the anchored file create rejected. Removed
the redundant member_path.parent.mkdir() calls (open_anchored_create_fd
already creates intermediates via anchored mkdirat) and replaced the two
base-dir mkdirs (upload target dir + archive extraction root) with a new
make_anchored_dir() that walks from the true workspace root via
openat+O_NOFOLLOW + mkdir(dir_fd=).
3. (CORE) list_dir() unconditionally used os.scandir(fd)/os.stat(dir_fd=)/
os.readlink(dir_fd=), which would brick workspace browsing on platforms
without os.supports_dir_fd (Windows). Split list_dir() into a _DIR_FD_OK
anchored branch and a path-based fallback branch (prior behaviour) sharing one
_process() entry builder. open_anchored_create_fd()'s Windows fallback now also
creates parent dirs.
Adds regression tests: no-dir_fd fallback (list+read+create+symlink filtering)
and the root-swap race. All prior TOCTOU + anchored-create tests still green.
* fix(workspace): portable symlink-loop filtering in list_dir via follow-stat ELOOP
CI on Python 3.13 caught test_mutual_symlink_loop_filtered failing: a mutual
symlink loop (a->b->a) was NOT filtered from the listing. Root cause: the new
readlink-based cycle detection relied on (target_resolved / raw_link).resolve()
RAISING on a loop, but Path.resolve() loop handling differs by Python version
(3.11 raises RuntimeError, 3.13 can return a path), so the loop slipped through
on 3.13.
Fix: compute a version-independent 'reachable' flag per symlink via
os.stat(..., follow_symlinks=True) — the syscall reliably returns ELOOP for
mutual/self loops and ENOENT for broken targets on every platform/version. A
symlink whose follow-stat raises can never be opened, so list_dir filters it.
Applied in both the dir_fd-anchored branch (fd-relative stat) and the Windows
path-based fallback branch. Mutual loop now filtered on all versions.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
2026-06-02 23:56:30 +00:00
|
|
|
|
# #3398: fd-anchored member create under the TRUE workspace root.
|
|
|
|
|
|
_mfd = open_anchored_create_fd(workspace, member_path)
|
|
|
|
|
|
with src_obj as src, os.fdopen(_mfd, 'wb', closefd=True) as dst:
|
2026-04-29 05:06:34 +00:00
|
|
|
|
_chunk_size = 65536
|
|
|
|
|
|
while True:
|
|
|
|
|
|
chunk = src.read(_chunk_size)
|
|
|
|
|
|
if not chunk:
|
|
|
|
|
|
break
|
|
|
|
|
|
total_extracted += len(chunk)
|
2026-06-02 00:41:11 +00:00
|
|
|
|
if total_extracted > cap:
|
2026-04-29 05:06:34 +00:00
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f'Extraction too large (> '
|
2026-06-02 00:41:11 +00:00
|
|
|
|
f'{cap // (1024*1024)} MB limit). '
|
2026-04-29 05:06:34 +00:00
|
|
|
|
f'Possible zip bomb.'
|
|
|
|
|
|
)
|
|
|
|
|
|
dst.write(chunk)
|
2026-04-28 13:11:55 +00:00
|
|
|
|
extracted_files.append(str(member_path.relative_to(workspace.resolve())))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
# Clean up partially-extracted directory to avoid orphaned folders
|
|
|
|
|
|
try:
|
2026-06-05 19:58:11 +00:00
|
|
|
|
rmtree_anchored(workspace, dest_dir)
|
2026-04-28 13:11:55 +00:00
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
raise
|
2026-04-28 11:28:28 +00:00
|
|
|
|
|
|
|
|
|
|
return {'extracted': len(extracted_files), 'files': extracted_files, 'dest': str(dest_dir)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_upload_extract(handler):
|
|
|
|
|
|
"""Handle archive upload and extraction."""
|
|
|
|
|
|
import traceback as _tb
|
|
|
|
|
|
try:
|
|
|
|
|
|
content_type = handler.headers.get('Content-Type', '')
|
|
|
|
|
|
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
|
|
|
|
|
if content_length > MAX_UPLOAD_BYTES:
|
|
|
|
|
|
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
|
|
|
|
|
|
fields, files = parse_multipart(handler.rfile, content_type, content_length)
|
|
|
|
|
|
session_id = fields.get('session_id', '')
|
|
|
|
|
|
if 'file' not in files:
|
|
|
|
|
|
return j(handler, {'error': 'No file field in request'}, status=400)
|
|
|
|
|
|
filename, file_bytes = files['file']
|
|
|
|
|
|
if not filename:
|
|
|
|
|
|
return j(handler, {'error': 'No filename in upload'}, status=400)
|
|
|
|
|
|
try:
|
|
|
|
|
|
s = get_session(session_id)
|
|
|
|
|
|
except KeyError:
|
|
|
|
|
|
return j(handler, {'error': 'Session not found'}, status=404)
|
2026-06-29 08:24:15 +00:00
|
|
|
|
if _reject_invisible_session(handler, s):
|
|
|
|
|
|
return True
|
2026-05-18 18:22:02 +00:00
|
|
|
|
session_dir = _session_attachment_dir(session_id)
|
|
|
|
|
|
session_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
result = extract_archive(file_bytes, filename, session_dir)
|
2026-04-28 11:28:28 +00:00
|
|
|
|
return j(handler, {'ok': True, **result})
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
return j(handler, {'error': str(e)}, status=400)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
print('[webui] upload extract error: ' + _tb.format_exc(), flush=True)
|
|
|
|
|
|
return j(handler, {'error': 'Archive extraction failed'}, status=500)
|
|
|
|
|
|
|
|
|
|
|
|
|
v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)
* fix: restore mobile chat scrolling and drawer close (#397)
- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites
Original PR by @Jordan-SkyLF
* fix: preserve imported session timestamps (#395)
- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block
Original PR by @Jordan-SkyLF
* fix(profiles): block path traversal in profile switch and delete flows (#399)
Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.
- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes
Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)
* feat: add desktop microphone transcription fallback (#396)
Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).
- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks
Original PR by @Jordan-SkyLF
* docs: v0.50.25 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 05:11:45 +00:00
|
|
|
|
def handle_transcribe(handler):
|
|
|
|
|
|
import traceback as _tb
|
|
|
|
|
|
temp_path = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
content_type = handler.headers.get('Content-Type', '')
|
|
|
|
|
|
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
|
|
|
|
|
if content_length > MAX_UPLOAD_BYTES:
|
|
|
|
|
|
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
|
|
|
|
|
|
fields, files = parse_multipart(handler.rfile, content_type, content_length)
|
|
|
|
|
|
if 'file' not in files:
|
|
|
|
|
|
return j(handler, {'error': 'No file field in request'}, status=400)
|
|
|
|
|
|
filename, file_bytes = files['file']
|
|
|
|
|
|
if not filename:
|
|
|
|
|
|
return j(handler, {'error': 'No filename in upload'}, status=400)
|
|
|
|
|
|
safe_name = _sanitize_upload_name(filename)
|
|
|
|
|
|
suffix = Path(safe_name).suffix or '.webm'
|
|
|
|
|
|
with tempfile.NamedTemporaryFile(prefix='webui-stt-', suffix=suffix, delete=False) as tmp:
|
|
|
|
|
|
temp_path = tmp.name
|
|
|
|
|
|
tmp.write(file_bytes)
|
|
|
|
|
|
try:
|
|
|
|
|
|
from tools.transcription_tools import transcribe_audio
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
return j(handler, {'error': 'Speech-to-text is unavailable on this server'}, status=503)
|
|
|
|
|
|
result = transcribe_audio(temp_path)
|
|
|
|
|
|
if not result.get('success'):
|
|
|
|
|
|
msg = str(result.get('error') or 'Transcription failed')
|
|
|
|
|
|
status = 503 if 'unavailable' in msg.lower() or 'not configured' in msg.lower() else 400
|
|
|
|
|
|
return j(handler, {'error': msg}, status=status)
|
|
|
|
|
|
transcript = str(result.get('transcript') or '').strip()
|
|
|
|
|
|
return j(handler, {'ok': True, 'transcript': transcript})
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
return j(handler, {'error': str(e)}, status=400)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
print('[webui] transcribe error: ' + _tb.format_exc(), flush=True)
|
|
|
|
|
|
return j(handler, {'error': 'Transcription failed'}, status=500)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
if temp_path:
|
|
|
|
|
|
try:
|
|
|
|
|
|
Path(temp_path).unlink(missing_ok=True)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-06-02 00:26:53 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-08 06:18:51 +00:00
|
|
|
|
def _stt_provider_capability_from_module(stt):
|
|
|
|
|
|
"""Return (available, provider) for a loaded transcription_tools module."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
load_cfg = getattr(stt, "_load_stt_config", None)
|
|
|
|
|
|
stt_config = load_cfg() if callable(load_cfg) else {}
|
|
|
|
|
|
cfg_dict = stt_config if isinstance(stt_config, dict) else {}
|
|
|
|
|
|
is_enabled = getattr(stt, "is_stt_enabled", None)
|
|
|
|
|
|
if callable(is_enabled) and not is_enabled(stt_config):
|
|
|
|
|
|
return False, "none"
|
|
|
|
|
|
|
|
|
|
|
|
# Some tests and future agent releases expose the provider decision as a
|
|
|
|
|
|
# single helper. Use it when the lower-level capability flags are not
|
|
|
|
|
|
# available. The current agent module exposes the flags below, so the
|
|
|
|
|
|
# normal path mirrors _get_provider() without triggering its lazy local
|
|
|
|
|
|
# STT install side effect during a passive web page probe.
|
|
|
|
|
|
has_internal_flags = any(
|
|
|
|
|
|
hasattr(stt, name)
|
|
|
|
|
|
for name in ("_HAS_FASTER_WHISPER", "_HAS_OPENAI", "_HAS_MISTRAL")
|
|
|
|
|
|
)
|
|
|
|
|
|
get_provider = getattr(stt, "_get_provider", None)
|
|
|
|
|
|
if callable(get_provider) and not has_internal_flags:
|
|
|
|
|
|
provider = str(get_provider(stt_config) or "none")
|
|
|
|
|
|
return provider not in ("", "none"), provider or "none"
|
|
|
|
|
|
|
|
|
|
|
|
def env(name):
|
|
|
|
|
|
getter = getattr(stt, "get_env_value", None)
|
|
|
|
|
|
try:
|
|
|
|
|
|
if callable(getter):
|
|
|
|
|
|
return str(getter(name) or "").strip()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
return os.getenv(name, "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
def has_local_command():
|
|
|
|
|
|
helper = getattr(stt, "_has_local_command", None)
|
|
|
|
|
|
try:
|
|
|
|
|
|
return bool(helper()) if callable(helper) else False
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
def has_browser_audio_converter():
|
|
|
|
|
|
helper = getattr(stt, "_find_ffmpeg_binary", None)
|
|
|
|
|
|
try:
|
|
|
|
|
|
return bool(helper()) if callable(helper) else False
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
def has_openai_audio():
|
|
|
|
|
|
helper = getattr(stt, "_has_openai_audio_backend", None)
|
|
|
|
|
|
try:
|
|
|
|
|
|
return bool(helper()) if callable(helper) else False
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
def local_command_available():
|
|
|
|
|
|
# The browser sends WebM/Ogg blobs; the local-command path converts
|
|
|
|
|
|
# non-WAV input through ffmpeg before invoking the command.
|
|
|
|
|
|
return has_local_command() and has_browser_audio_converter()
|
|
|
|
|
|
|
2026-06-16 17:21:11 +00:00
|
|
|
|
def command_provider_available(provider):
|
|
|
|
|
|
resolver = getattr(stt, "_resolve_command_stt_provider_config", None)
|
|
|
|
|
|
try:
|
|
|
|
|
|
return callable(resolver) and resolver(provider, cfg_dict) is not None
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2026-06-08 06:18:51 +00:00
|
|
|
|
def resolve_provider(provider):
|
|
|
|
|
|
if provider == "local":
|
|
|
|
|
|
if bool(getattr(stt, "_HAS_FASTER_WHISPER", False)):
|
|
|
|
|
|
return "local"
|
|
|
|
|
|
if local_command_available():
|
|
|
|
|
|
return "local_command"
|
|
|
|
|
|
return "none"
|
|
|
|
|
|
if provider == "local_command":
|
|
|
|
|
|
if local_command_available():
|
|
|
|
|
|
return "local_command"
|
|
|
|
|
|
if bool(getattr(stt, "_HAS_FASTER_WHISPER", False)):
|
|
|
|
|
|
return "local"
|
|
|
|
|
|
return "none"
|
|
|
|
|
|
if provider == "groq":
|
|
|
|
|
|
return "groq" if bool(getattr(stt, "_HAS_OPENAI", False)) and bool(env("GROQ_API_KEY")) else "none"
|
|
|
|
|
|
if provider == "openai":
|
|
|
|
|
|
return "openai" if bool(getattr(stt, "_HAS_OPENAI", False)) and has_openai_audio() else "none"
|
|
|
|
|
|
if provider == "mistral":
|
|
|
|
|
|
return "mistral" if bool(getattr(stt, "_HAS_MISTRAL", False)) and bool(env("MISTRAL_API_KEY")) else "none"
|
|
|
|
|
|
if provider == "xai":
|
|
|
|
|
|
try:
|
|
|
|
|
|
from tools.xai_http import resolve_xai_http_credentials
|
|
|
|
|
|
|
|
|
|
|
|
return "xai" if resolve_xai_http_credentials().get("api_key") else "none"
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return "none"
|
|
|
|
|
|
if provider == "elevenlabs":
|
|
|
|
|
|
return "elevenlabs" if bool(env("ELEVENLABS_API_KEY")) else "none"
|
2026-06-16 17:21:11 +00:00
|
|
|
|
if command_provider_available(provider):
|
|
|
|
|
|
return provider
|
2026-06-08 06:18:51 +00:00
|
|
|
|
return "none"
|
|
|
|
|
|
|
|
|
|
|
|
explicit = "provider" in cfg_dict
|
|
|
|
|
|
if explicit:
|
|
|
|
|
|
configured = str(cfg_dict.get("provider") or "local")
|
|
|
|
|
|
provider = resolve_provider(configured)
|
|
|
|
|
|
return provider != "none", provider if provider != "none" else configured
|
|
|
|
|
|
|
|
|
|
|
|
for candidate in ("local", "local_command", "groq", "openai", "mistral", "xai", "elevenlabs"):
|
2026-06-16 17:21:11 +00:00
|
|
|
|
# Command (custom) STT providers are intentionally omitted from this
|
|
|
|
|
|
# auto-detect tuple to mirror the agent's _get_provider() (transcription_tools.py),
|
|
|
|
|
|
# which only auto-selects local > groq > openai and never auto-picks a command
|
|
|
|
|
|
# provider. A command-backed STT activates only via an explicit stt.provider.
|
|
|
|
|
|
# Do NOT add command providers here without matching the agent, or the WebUI
|
|
|
|
|
|
# probe will diverge from what the agent actually resolves.
|
2026-06-08 06:18:51 +00:00
|
|
|
|
provider = resolve_provider(candidate)
|
|
|
|
|
|
if provider != "none":
|
|
|
|
|
|
return True, provider
|
|
|
|
|
|
return False, "none"
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return False, "none"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _stt_provider_capability():
|
|
|
|
|
|
"""Return (available, provider) for a cheap server-side STT capability probe."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
import tools.transcription_tools as stt
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
return False, "none"
|
|
|
|
|
|
return _stt_provider_capability_from_module(stt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_transcribe_capability(handler):
|
|
|
|
|
|
available, provider = _stt_provider_capability()
|
|
|
|
|
|
return j(handler, {"ok": True, "available": bool(available), "provider": provider})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 00:26:53 +00:00
|
|
|
|
def handle_workspace_upload(handler):
|
|
|
|
|
|
"""Upload a file into a session's workspace directory.
|
|
|
|
|
|
|
|
|
|
|
|
Form fields:
|
|
|
|
|
|
session_id – target session
|
|
|
|
|
|
path – subdirectory within the workspace (default: '')
|
|
|
|
|
|
File:
|
|
|
|
|
|
file – the uploaded file(s)
|
|
|
|
|
|
"""
|
|
|
|
|
|
import traceback as _tb
|
|
|
|
|
|
try:
|
|
|
|
|
|
content_type = handler.headers.get('Content-Type', '')
|
|
|
|
|
|
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
|
|
|
|
|
if content_length > MAX_UPLOAD_BYTES:
|
|
|
|
|
|
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
|
|
|
|
|
|
|
|
|
|
|
|
fields, files = parse_multipart(handler.rfile, content_type, content_length)
|
|
|
|
|
|
session_id = fields.get('session_id', '')
|
|
|
|
|
|
subpath = fields.get('path', '')
|
|
|
|
|
|
|
|
|
|
|
|
if not session_id:
|
|
|
|
|
|
return j(handler, {'error': 'Missing session_id'}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
if not files:
|
|
|
|
|
|
return j(handler, {'error': 'No file field in request'}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
# Validate session
|
|
|
|
|
|
try:
|
|
|
|
|
|
session = get_session(session_id)
|
|
|
|
|
|
except KeyError:
|
|
|
|
|
|
return j(handler, {'error': 'Session not found'}, status=404)
|
2026-06-29 08:24:15 +00:00
|
|
|
|
if _reject_invisible_session(handler, session):
|
|
|
|
|
|
return True
|
2026-06-02 00:26:53 +00:00
|
|
|
|
|
|
|
|
|
|
# Resolve workspace root from session
|
|
|
|
|
|
workspace = resolve_trusted_workspace(session.workspace)
|
|
|
|
|
|
|
|
|
|
|
|
# Resolve target subdirectory within workspace
|
|
|
|
|
|
target_dir = safe_resolve_ws(workspace, subpath) if subpath else workspace
|
2026-06-02 02:20:05 +00:00
|
|
|
|
# safe_resolve_ws intentionally permits in-workspace symlinks pointing
|
|
|
|
|
|
# outside the root (read trust model). For an UPLOAD target that's not
|
|
|
|
|
|
# acceptable: a planted symlink subpath would let mkdir() + writes create
|
|
|
|
|
|
# files OUTSIDE the workspace. Require the resolved target to be inside
|
2026-06-02 02:28:44 +00:00
|
|
|
|
# the workspace before creating anything. (is_relative_to is True for the
|
|
|
|
|
|
# workspace==target equality case, so the normal subpath='' path passes.)
|
|
|
|
|
|
if not target_dir.resolve().is_relative_to(workspace.resolve()):
|
2026-06-02 02:20:05 +00:00
|
|
|
|
return j(handler, {'error': 'Upload target escapes workspace'}, status=403)
|
Release v0.51.221 — Release GO (stage-p3e — block all workspace symlink escapes + portable TOCTOU hardening [security]) (#3398) (#3451)
* [security] fix(workspace): block all symlink escapes from the selected workspace (#3398, @Hinotoi-agent)
Previously safe_resolve_ws allowed a symlink placed inside a workspace to resolve
to an external host path as long as it wasn't a system dir (/etc, /proc, etc).
But the workspace file API is reachable by LLM agent tool calls (read_file_content),
so an in-workspace symlink to ~/.ssh, ~/.hermes/auth.json (credentials), etc. was a
real read path. Now ANY symlink escape is blocked: safe_resolve_ws resolves and
requires the result stay under the workspace root; list_dir hides escaping symlinks
(they could never be opened anyway); internal symlinks resolving back under the
workspace still work. Updated the upload symlink-target test to accept the new
400 'Path traversal blocked' rejection (was 403) — the invariant (nothing lands
outside the workspace) is unchanged.
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
* docs(changelog): v0.51.221 release header for #3398 symlink-escape security fix
* [security] harden workspace file API against symlink-swap TOCTOU via portable anchored openat-walk (#3398 follow-up)
Codex review of #3398 flagged that safe_resolve_ws() validates a path but
list_dir/read_file_content/upload/extraction then re-open by pathname, leaving a
TOCTOU window: a symlink swapped in AFTER the check could still escape. (This
race pre-existed #3398; closing it here so the containment is complete.)
A first attempt used /proc/self/fd for the post-open containment check, but that
BRICKS workspace browsing on macOS/Windows (no /proc → every read/list rejected).
This version is portable:
- open_anchored_fd(): opens the (already symlink-resolved) target
component-by-component from the workspace root via openat (dir_fd) + O_NOFOLLOW.
Every component must be a real non-symlink entry, so a component swapped to a
symlink mid-flight is refused. No /proc dependency. Used by read_file_content
(read from the fd) and list_dir (enumerate via os.scandir(fd), per-entry
fstatat/readlinkat).
- open_anchored_create_fd(): same anchored walk for writes, creating missing
intermediate dirs with mkdir(dir_fd=) and the leaf with O_CREAT|O_EXCL|
O_NOFOLLOW. Used by the workspace upload write AND archive (zip+tar) member
writes, anchored against the TRUE workspace root (not the mutable extraction
dest_dir, closing Codex's root-swap finding). fd-leak-safe on rejection.
- Portability: gated on os.supports_dir_fd; platforms without it (Windows, where
symlink creation needs admin) fall back to a plain O_NOFOLLOW open/exclusive
create — no new race protection but no regression vs the prior path-based code.
Legit in-workspace symlinks still resolve and read/list fine (safe_resolve_ws
collapses them to a real in-workspace path, which the anchored walk then opens).
Verified: the swap-race leaks external content against the old path-based read
and is blocked here; macOS-class symlinked-root workspaces work; no fd leak over
300 rejected creates. Adds TOCTOU + anchored-create regression tests.
* [security] close 3 more #3398 TOCTOU gaps from Codex r3: root-swap, pre-create mkdir, Windows list_dir fallback
Codex round-3 review found three residual issues in the anchored openat-walk:
1. (CORE) The workspace ROOT itself could be swapped to a symlink after
resolve() but before the root os.open() — add _O_NOFOLLOW to the root open in
open_anchored_fd() and open_anchored_create_fd() (and make_anchored_dir()), so
a raced root symlink is refused. Verified: root-swap race now blocked.
2. (SILENT) Upload/extraction still did pathname Path.mkdir() AFTER the
containment check, so a raced symlink component could make the server create
dirs outside the workspace before the anchored file create rejected. Removed
the redundant member_path.parent.mkdir() calls (open_anchored_create_fd
already creates intermediates via anchored mkdirat) and replaced the two
base-dir mkdirs (upload target dir + archive extraction root) with a new
make_anchored_dir() that walks from the true workspace root via
openat+O_NOFOLLOW + mkdir(dir_fd=).
3. (CORE) list_dir() unconditionally used os.scandir(fd)/os.stat(dir_fd=)/
os.readlink(dir_fd=), which would brick workspace browsing on platforms
without os.supports_dir_fd (Windows). Split list_dir() into a _DIR_FD_OK
anchored branch and a path-based fallback branch (prior behaviour) sharing one
_process() entry builder. open_anchored_create_fd()'s Windows fallback now also
creates parent dirs.
Adds regression tests: no-dir_fd fallback (list+read+create+symlink filtering)
and the root-swap race. All prior TOCTOU + anchored-create tests still green.
* fix(workspace): portable symlink-loop filtering in list_dir via follow-stat ELOOP
CI on Python 3.13 caught test_mutual_symlink_loop_filtered failing: a mutual
symlink loop (a->b->a) was NOT filtered from the listing. Root cause: the new
readlink-based cycle detection relied on (target_resolved / raw_link).resolve()
RAISING on a loop, but Path.resolve() loop handling differs by Python version
(3.11 raises RuntimeError, 3.13 can return a path), so the loop slipped through
on 3.13.
Fix: compute a version-independent 'reachable' flag per symlink via
os.stat(..., follow_symlinks=True) — the syscall reliably returns ELOOP for
mutual/self loops and ENOENT for broken targets on every platform/version. A
symlink whose follow-stat raises can never be opened, so list_dir filters it.
Applied in both the dir_fd-anchored branch (fd-relative stat) and the Windows
path-based fallback branch. Mutual loop now filtered on all versions.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
2026-06-02 23:56:30 +00:00
|
|
|
|
# #3398: create the upload target dir race-safely under the workspace root
|
|
|
|
|
|
# (anchored mkdirat) so a raced symlink subpath can't mkdir outside.
|
|
|
|
|
|
try:
|
|
|
|
|
|
make_anchored_dir(workspace, target_dir)
|
|
|
|
|
|
except (ValueError, OSError):
|
|
|
|
|
|
return j(handler, {'error': 'Upload target escapes workspace'}, status=403)
|
2026-06-02 00:26:53 +00:00
|
|
|
|
|
|
|
|
|
|
results = []
|
2026-06-02 00:41:11 +00:00
|
|
|
|
for _field_name, (filename, file_bytes) in files.items():
|
2026-06-02 00:26:53 +00:00
|
|
|
|
if not filename:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
safe_name = _sanitize_upload_name(filename)
|
|
|
|
|
|
dest = safe_resolve_ws(target_dir, safe_name)
|
|
|
|
|
|
|
|
|
|
|
|
# Path traversal guard (belt-and-suspenders: safe_resolve_ws above is
|
|
|
|
|
|
# the authoritative guard and raises ValueError on traversal; this
|
|
|
|
|
|
# check catches any edge case where the resolved path escapes).
|
|
|
|
|
|
if not dest.resolve().is_relative_to(workspace.resolve()):
|
|
|
|
|
|
return j(handler, {'error': f'Path traversal blocked: {safe_name}'}, status=403)
|
|
|
|
|
|
|
|
|
|
|
|
# Deduplicate: append -1, -2, etc. if file already exists
|
|
|
|
|
|
if dest.exists():
|
|
|
|
|
|
stem = dest.stem
|
|
|
|
|
|
suffix = dest.suffix
|
|
|
|
|
|
for idx in range(1, 1000):
|
|
|
|
|
|
candidate = safe_resolve_ws(target_dir, f'{stem}-{idx}{suffix}')
|
|
|
|
|
|
if not candidate.resolve().is_relative_to(workspace.resolve()):
|
|
|
|
|
|
return j(handler, {'error': 'Path traversal blocked'}, status=403)
|
|
|
|
|
|
if not candidate.exists():
|
|
|
|
|
|
dest = candidate
|
|
|
|
|
|
break
|
|
|
|
|
|
else:
|
|
|
|
|
|
return j(handler, {'error': 'Too many uploads with the same filename'}, status=400)
|
|
|
|
|
|
|
Release v0.51.221 — Release GO (stage-p3e — block all workspace symlink escapes + portable TOCTOU hardening [security]) (#3398) (#3451)
* [security] fix(workspace): block all symlink escapes from the selected workspace (#3398, @Hinotoi-agent)
Previously safe_resolve_ws allowed a symlink placed inside a workspace to resolve
to an external host path as long as it wasn't a system dir (/etc, /proc, etc).
But the workspace file API is reachable by LLM agent tool calls (read_file_content),
so an in-workspace symlink to ~/.ssh, ~/.hermes/auth.json (credentials), etc. was a
real read path. Now ANY symlink escape is blocked: safe_resolve_ws resolves and
requires the result stay under the workspace root; list_dir hides escaping symlinks
(they could never be opened anyway); internal symlinks resolving back under the
workspace still work. Updated the upload symlink-target test to accept the new
400 'Path traversal blocked' rejection (was 403) — the invariant (nothing lands
outside the workspace) is unchanged.
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
* docs(changelog): v0.51.221 release header for #3398 symlink-escape security fix
* [security] harden workspace file API against symlink-swap TOCTOU via portable anchored openat-walk (#3398 follow-up)
Codex review of #3398 flagged that safe_resolve_ws() validates a path but
list_dir/read_file_content/upload/extraction then re-open by pathname, leaving a
TOCTOU window: a symlink swapped in AFTER the check could still escape. (This
race pre-existed #3398; closing it here so the containment is complete.)
A first attempt used /proc/self/fd for the post-open containment check, but that
BRICKS workspace browsing on macOS/Windows (no /proc → every read/list rejected).
This version is portable:
- open_anchored_fd(): opens the (already symlink-resolved) target
component-by-component from the workspace root via openat (dir_fd) + O_NOFOLLOW.
Every component must be a real non-symlink entry, so a component swapped to a
symlink mid-flight is refused. No /proc dependency. Used by read_file_content
(read from the fd) and list_dir (enumerate via os.scandir(fd), per-entry
fstatat/readlinkat).
- open_anchored_create_fd(): same anchored walk for writes, creating missing
intermediate dirs with mkdir(dir_fd=) and the leaf with O_CREAT|O_EXCL|
O_NOFOLLOW. Used by the workspace upload write AND archive (zip+tar) member
writes, anchored against the TRUE workspace root (not the mutable extraction
dest_dir, closing Codex's root-swap finding). fd-leak-safe on rejection.
- Portability: gated on os.supports_dir_fd; platforms without it (Windows, where
symlink creation needs admin) fall back to a plain O_NOFOLLOW open/exclusive
create — no new race protection but no regression vs the prior path-based code.
Legit in-workspace symlinks still resolve and read/list fine (safe_resolve_ws
collapses them to a real in-workspace path, which the anchored walk then opens).
Verified: the swap-race leaks external content against the old path-based read
and is blocked here; macOS-class symlinked-root workspaces work; no fd leak over
300 rejected creates. Adds TOCTOU + anchored-create regression tests.
* [security] close 3 more #3398 TOCTOU gaps from Codex r3: root-swap, pre-create mkdir, Windows list_dir fallback
Codex round-3 review found three residual issues in the anchored openat-walk:
1. (CORE) The workspace ROOT itself could be swapped to a symlink after
resolve() but before the root os.open() — add _O_NOFOLLOW to the root open in
open_anchored_fd() and open_anchored_create_fd() (and make_anchored_dir()), so
a raced root symlink is refused. Verified: root-swap race now blocked.
2. (SILENT) Upload/extraction still did pathname Path.mkdir() AFTER the
containment check, so a raced symlink component could make the server create
dirs outside the workspace before the anchored file create rejected. Removed
the redundant member_path.parent.mkdir() calls (open_anchored_create_fd
already creates intermediates via anchored mkdirat) and replaced the two
base-dir mkdirs (upload target dir + archive extraction root) with a new
make_anchored_dir() that walks from the true workspace root via
openat+O_NOFOLLOW + mkdir(dir_fd=).
3. (CORE) list_dir() unconditionally used os.scandir(fd)/os.stat(dir_fd=)/
os.readlink(dir_fd=), which would brick workspace browsing on platforms
without os.supports_dir_fd (Windows). Split list_dir() into a _DIR_FD_OK
anchored branch and a path-based fallback branch (prior behaviour) sharing one
_process() entry builder. open_anchored_create_fd()'s Windows fallback now also
creates parent dirs.
Adds regression tests: no-dir_fd fallback (list+read+create+symlink filtering)
and the root-swap race. All prior TOCTOU + anchored-create tests still green.
* fix(workspace): portable symlink-loop filtering in list_dir via follow-stat ELOOP
CI on Python 3.13 caught test_mutual_symlink_loop_filtered failing: a mutual
symlink loop (a->b->a) was NOT filtered from the listing. Root cause: the new
readlink-based cycle detection relied on (target_resolved / raw_link).resolve()
RAISING on a loop, but Path.resolve() loop handling differs by Python version
(3.11 raises RuntimeError, 3.13 can return a path), so the loop slipped through
on 3.13.
Fix: compute a version-independent 'reachable' flag per symlink via
os.stat(..., follow_symlinks=True) — the syscall reliably returns ELOOP for
mutual/self loops and ENOENT for broken targets on every platform/version. A
symlink whose follow-stat raises can never be opened, so list_dir filters it.
Applied in both the dir_fd-anchored branch (fd-relative stat) and the Windows
path-based fallback branch. Mutual loop now filtered on all versions.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
2026-06-02 23:56:30 +00:00
|
|
|
|
# #3398 TOCTOU hardening: create the destination via an anchored
|
|
|
|
|
|
# openat-walk from the true workspace root with O_CREAT|O_EXCL|
|
|
|
|
|
|
# O_NOFOLLOW, so a symlink raced into any path component after the
|
|
|
|
|
|
# containment checks above cannot redirect the write outside the
|
|
|
|
|
|
# workspace. The dedup loop guarantees `dest` does not exist.
|
|
|
|
|
|
try:
|
|
|
|
|
|
_wfd = open_anchored_create_fd(workspace, dest.resolve())
|
|
|
|
|
|
except FileExistsError:
|
|
|
|
|
|
return j(handler, {'error': f'Upload destination already exists: {safe_name}'}, status=409)
|
|
|
|
|
|
except (ValueError, OSError):
|
|
|
|
|
|
return j(handler, {'error': f'Path traversal blocked: {safe_name}'}, status=403)
|
|
|
|
|
|
with os.fdopen(_wfd, 'wb', closefd=True) as _wfh:
|
|
|
|
|
|
_wfh.write(file_bytes)
|
2026-06-02 00:26:53 +00:00
|
|
|
|
mime = mimetypes.guess_type(safe_name)[0] or 'application/octet-stream'
|
|
|
|
|
|
|
2026-06-02 02:20:05 +00:00
|
|
|
|
# For archives, optionally extract into the target directory.
|
|
|
|
|
|
# Suffix set MUST match extract_archive()'s supported formats, else
|
|
|
|
|
|
# accepted-but-unlisted archives (.tar/.tbz2/.txz) silently land as
|
|
|
|
|
|
# raw files instead of extracting.
|
|
|
|
|
|
is_archive = safe_name.lower().endswith(('.zip', '.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz'))
|
2026-06-02 00:26:53 +00:00
|
|
|
|
if is_archive:
|
|
|
|
|
|
import zipfile, tarfile, traceback as _extract_tb
|
|
|
|
|
|
try:
|
|
|
|
|
|
extraction = extract_archive(file_bytes, safe_name, target_dir)
|
|
|
|
|
|
# Remove the archive file after successful extraction
|
2026-06-05 19:58:11 +00:00
|
|
|
|
try:
|
|
|
|
|
|
unlink_anchored(workspace, dest.resolve())
|
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
|
pass
|
2026-06-02 00:26:53 +00:00
|
|
|
|
results.append({
|
|
|
|
|
|
'filename': safe_name,
|
|
|
|
|
|
'path': str(extraction.get('dest', target_dir)),
|
|
|
|
|
|
'size': len(file_bytes),
|
|
|
|
|
|
'is_image': False,
|
|
|
|
|
|
'extracted': True,
|
|
|
|
|
|
'extracted_files': extraction.get('files', []),
|
|
|
|
|
|
'extracted_count': extraction.get('extracted', 0),
|
|
|
|
|
|
})
|
|
|
|
|
|
continue
|
|
|
|
|
|
except (zipfile.BadZipFile, tarfile.TarError, ValueError) as e:
|
|
|
|
|
|
# Extraction failed — remove the archive file (no partial
|
|
|
|
|
|
# content left behind) and surface the error to the user.
|
2026-06-05 19:58:11 +00:00
|
|
|
|
try:
|
|
|
|
|
|
unlink_anchored(workspace, dest.resolve())
|
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
|
pass
|
2026-06-02 00:26:53 +00:00
|
|
|
|
print(f'[webui] workspace upload extract error: {e}', flush=True)
|
|
|
|
|
|
results.append({
|
|
|
|
|
|
'filename': safe_name,
|
|
|
|
|
|
'path': str(target_dir),
|
|
|
|
|
|
'size': len(file_bytes),
|
|
|
|
|
|
'mime': mime,
|
|
|
|
|
|
'is_image': False,
|
|
|
|
|
|
'extracted': False,
|
|
|
|
|
|
'extract_error': str(e) or 'Archive extraction failed',
|
|
|
|
|
|
})
|
|
|
|
|
|
continue
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
print('[webui] workspace upload extract error: ' + _extract_tb.format_exc(), flush=True)
|
2026-06-05 19:58:11 +00:00
|
|
|
|
try:
|
|
|
|
|
|
unlink_anchored(workspace, dest.resolve())
|
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
|
pass
|
2026-06-02 00:26:53 +00:00
|
|
|
|
results.append({
|
|
|
|
|
|
'filename': safe_name,
|
|
|
|
|
|
'path': str(target_dir),
|
|
|
|
|
|
'size': len(file_bytes),
|
|
|
|
|
|
'mime': mime,
|
|
|
|
|
|
'is_image': False,
|
|
|
|
|
|
'extracted': False,
|
|
|
|
|
|
'extract_error': 'Archive extraction failed',
|
|
|
|
|
|
})
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
results.append({
|
2026-06-02 00:41:11 +00:00
|
|
|
|
'filename': dest.name,
|
2026-06-02 00:26:53 +00:00
|
|
|
|
'path': str(dest),
|
|
|
|
|
|
'size': dest.stat().st_size,
|
|
|
|
|
|
'mime': mime,
|
|
|
|
|
|
'is_image': mime.startswith('image/'),
|
|
|
|
|
|
'extracted': False,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if len(results) == 1:
|
|
|
|
|
|
return j(handler, results[0])
|
|
|
|
|
|
return j(handler, {'files': results, 'count': len(results)})
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
return j(handler, {'error': str(e)}, status=400)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
print('[webui] workspace upload error: ' + _tb.format_exc(), flush=True)
|
|
|
|
|
|
return j(handler, {'error': 'Upload failed'}, status=500)
|