From aae3b418f0f9aa52521c67edde2a6130ca8874fe Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Sat, 30 May 2026 20:11:45 -0400 Subject: [PATCH 01/14] fix: render file artifact links as media --- CHANGELOG.md | 8 ++++++++ static/ui.js | 21 ++++++++++++++++++++- tests/test_media_inline.py | 9 +++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9feb92f1..8d7f61e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ ## [Unreleased] +## [v0.51.183] — 2026-05-31 — Release FC (stage-batchC — inline file:// media artifacts + /api/media state-file confinement) + +### Fixed +- Render assistant-emitted `file://` image artifact links inline through the authenticated `/api/media` route, so browser clients can view generated local images instead of seeing unusable server-local file paths. Only bare (line-start or whitespace-delimited) `file://` URLs are rewritten — `[label](file://...)` markdown anchors keep the normal link path (#3219). + +### Security +- `/api/media` now hard-denies WebUI state and secret/config files even when they fall under an allowed root (the WebUI state dir, `settings.json`, `state.db`, `auth.json`, `auth.lock`, `config.yaml`, `.env`, signing/PBKDF2 keys, the `sessions`/`memories`/`profiles` state subdirs). Previously the whole Hermes home was an allowed root, so an authenticated session viewing attacker-influenced agent output that emitted a `file://`/`MEDIA:` link to such a file could fetch it. Hardened the boundary at the route for every entry path (bare `file://`, markdown anchors, and `MEDIA:` tokens), closing #3234. + ## [v0.51.182] — 2026-05-31 — Release FB (stage-batchB2 — headless browser smoke gate + consolidated client-disconnect handling) ### Added diff --git a/static/ui.js b/static/ui.js index 4ca46c0f..94aeabe5 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3014,6 +3014,16 @@ function renderMd(raw){ media_stash.push(raw_ref); return '\x00D'+(media_stash.length-1)+'\x00'; }); + // Some gateway/tool surfaces still emit file:// links for local artifacts + // instead of MEDIA: tokens. Browser clients cannot open the server's + // filesystem directly, so treat bare file:// URLs as media artifacts and + // route them through /api/media. This intentionally runs only for bare URLs + // (line-start or whitespace-delimited), not inside markdown links, so normal + // [label](file://...) anchors continue to use the link path below. + s=s.replace(/(^|\s)(file:\/\/[^\s<>"')\]]+)/g,(_,lead,raw_ref)=>{ + media_stash.push(raw_ref); + return lead+'\x00D'+(media_stash.length-1)+'\x00'; + }); // ── End MEDIA stash ───────────────────────────────────────────────────────── // Pre-pass: decode HTML entities first so markdown processing works correctly. // This prevents double-escaping when LLM outputs entities like < > & @@ -3449,7 +3459,7 @@ function renderMd(raw){ s=s.replace(/\x00E(\d+)\x00/g,(_,i)=>_pre_stash[+i]); // ── Restore MEDIA stash → inline images or download links ───────────────── s=s.replace(/\x00D(\d+)\x00/g,(_,i)=>{ - const ref=media_stash[+i]; + let ref=media_stash[+i]; // Keep this logic self-contained: some tests extract renderMd() alone and // execute it in node, without the top-level helper functions from ui.js. const mediaKindForName=(name='')=>{ @@ -3468,6 +3478,15 @@ function renderMd(raw){ : ``; return `
${tag}
${safeName}
`; }; + if(/^file:\/\//i.test(ref)){ + try{ + const u=new URL(ref); + ref=decodeURIComponent(u.pathname||ref.replace(/^file:\/\//i,'')); + }catch(_){ + try{ref=decodeURIComponent(ref.replace(/^file:\/\//i,''));} + catch(__){ref=ref.replace(/^file:\/\//i,'');} + } + } // HTTP(S) URL if(/^https?:\/\//i.test(ref)){ // Rewrite localhost/127.0.0.1 to the actual server base URL so remote diff --git a/tests/test_media_inline.py b/tests/test_media_inline.py index 621da120..98d195e4 100644 --- a/tests/test_media_inline.py +++ b/tests/test_media_inline.py @@ -41,6 +41,15 @@ class TestMediaRenderMdStash(unittest.TestCase): self.assertIn("MEDIA:", UI_JS, "MEDIA: token regex must be present in renderMd()") + def test_bare_file_urls_are_stashed_as_media_artifacts(self): + self.assertIn("file:// links for local artifacts", UI_JS) + self.assertIn("file:\\/\\/[^\\s<>", UI_JS) + + def test_file_urls_are_rewritten_through_media_endpoint(self): + self.assertIn("new URL(ref)", UI_JS) + self.assertIn("u.pathname", UI_JS) + self.assertIn("api/media?path=", UI_JS) + def test_media_restore_produces_img_tag(self): self.assertIn("msg-media-img", UI_JS, "restore pass must produce ") From 63bb2f98841cb9c352bca5a100d970c10d91df84 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 31 May 2026 02:27:15 +0000 Subject: [PATCH 02/14] fix(security #3234): /api/media hard-denies WebUI state + secret/config files Pre-release dual-gate (Codex + Opus) on #3219 surfaced that /api/media serves files under the allowlisted Hermes home, including settings.json / state.db / auth.json / config.yaml. #3219 makes this materially worse: pre-#3219 a bare file:// URL in agent output rendered as inert text, but #3219 turns it into an auto-loading that fetches on render. Rather than weaken #3219, harden the boundary at the route: hard-deny known secret/config filenames and the WebUI state subdirs (sessions/memories/profiles + STATE_DIR) before the allow/serve decision, covering every entry path (bare file://, markdown anchors, MEDIA: tokens, session-token grants). Adds a live-server regression test. Closes #3234. Co-authored-by: AJV20 <24819659+AJV20@users.noreply.github.com> --- api/routes.py | 32 ++++++++++++++++++++++++++++++ tests/test_media_inline.py | 40 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/api/routes.py b/api/routes.py index 74d15740..ac8c1c11 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8083,6 +8083,38 @@ def _handle_media(handler, parsed): target, _INLINE_IMAGE_TYPES, ) + + # Hard deny WebUI state + secret/config files even when they fall under an + # allowed root (the whole Hermes home is allowed, and the state dir lives + # inside it). Without this, an authenticated session rendering attacker- + # influenced agent output that emits a file:// / MEDIA: link to one of these + # paths could fetch it through /api/media. This guard runs BEFORE the + # allow/serve decision so it covers every entry path (bare file:// URLs, + # markdown anchors, MEDIA: tokens, and session-token grants). See #3234. + _DENY_FILENAMES = { + "settings.json", "state.db", "auth.json", "auth.lock", + "config.yaml", "config.yml", ".env", ".signing_key", + ".pbkdf2_key", ".sessions.json", + } + _state_dir = None + try: + from api.config import STATE_DIR as _STATE_DIR + _state_dir = Path(_STATE_DIR).resolve() + except Exception: + _state_dir = None + _deny_dirs = [d for d in ( + _state_dir, + (_HERMES_HOME / "sessions").resolve(), + (_HERMES_HOME / "memories").resolve(), + (_HERMES_HOME / "profiles").resolve(), + ) if d is not None] + _denied = ( + target.name in _DENY_FILENAMES + or any(_path_is_within_root(target, d) for d in _deny_dirs) + ) + if _denied: + return bad(handler, "Path not in allowed location", 403) + if not within_allowed and not session_media_allowed: return bad(handler, "Path not in allowed location", 403) diff --git a/tests/test_media_inline.py b/tests/test_media_inline.py index 98d195e4..b1f3ae0d 100644 --- a/tests/test_media_inline.py +++ b/tests/test_media_inline.py @@ -465,6 +465,46 @@ class TestMediaEndpointIntegration(unittest.TestCase): ) self.assertIn(status, {403, 404}) + def test_webui_state_secret_files_denied(self): + """#3234: /api/media must hard-deny WebUI state/secret files even though + they live under an allowed root (the whole Hermes home is allowed). + + An authenticated session rendering attacker-influenced agent output that + emits a file:// or MEDIA: link to settings.json / state.db / auth.json + must NOT be able to fetch it through /api/media. + """ + state_dir = pathlib.Path(TEST_STATE_DIR) + state_dir.mkdir(parents=True, exist_ok=True) + # settings.json by name (deny-by-filename) + settings = state_dir / "settings.json" + settings.write_text('{"secret":"value"}', encoding="utf-8") + try: + _, status, _ = self._get( + "/api/media?path=" + urllib.request.quote(str(settings.resolve())) + ) + self.assertEqual( + status, 403, + f"settings.json under the state dir must be denied, got {status}", + ) + finally: + settings.unlink(missing_ok=True) + + # a file inside the sessions/ state subdir (deny-by-dir) + sess_dir = state_dir / "sessions" + sess_dir.mkdir(parents=True, exist_ok=True) + sess_file = sess_dir / "abc123.json" + sess_file.write_text('{"messages":[]}', encoding="utf-8") + try: + _, status, _ = self._get( + "/api/media?path=" + urllib.request.quote(str(sess_file.resolve())) + ) + self.assertEqual( + status, 403, + f"files under the sessions/ state subdir must be denied, got {status}", + ) + finally: + sess_file.unlink(missing_ok=True) + def test_health_check_still_works(self): """Sanity: server is up and /health works.""" body, status, _ = self._get("/health") From c36fda23f772bcff9c4a868f05dc8c45ae9c923f Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 31 May 2026 02:32:16 +0000 Subject: [PATCH 03/14] test: widen _handle_media CSP-slice window to 7000 after #3234 deny-list insertion --- tests/test_issue1800_file_html_interactions.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_issue1800_file_html_interactions.py b/tests/test_issue1800_file_html_interactions.py index 995c24be..00edbef7 100644 --- a/tests/test_issue1800_file_html_interactions.py +++ b/tests/test_issue1800_file_html_interactions.py @@ -68,10 +68,12 @@ def test_html_media_open_full_uses_inline_new_tab_not_download(): def test_media_html_inline_keeps_csp_sandbox(): """api/media may serve HTML inline only behind a CSP sandbox.""" - # Slice widened to 5000 (was 4000) after PR #2044 added MEDIA_ALLOWED_ROOTS - # parsing earlier in _handle_media, which pushed the CSP block past the - # original window. The assertion is structural, not positional. - body = _slice_after(ROUTES_PY, "def _handle_media", 5000) + # Slice widened to 7000 (was 5000) after the #3234 security deny-list block + # was inserted earlier in _handle_media, pushing the CSP block further down + # (the `csp=csp` line now sits ~6050 chars past the def). (Previously widened + # 4000→5000 for PR #2044's MEDIA_ALLOWED_ROOTS parsing.) The assertion is + # structural, not positional. + body = _slice_after(ROUTES_PY, "def _handle_media", 7000) assert 'html_inline_ok = inline_preview and mime == "text/html"' in body assert 'csp = "sandbox allow-scripts" if html_inline_ok else None' in body assert "csp=csp" in body From 68a6099eafdf481d0e7ed75d83d4fec1661390d3 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 31 May 2026 02:39:46 +0000 Subject: [PATCH 04/14] fix(security #3234): harden /api/media deny-list (Codex review) - Add state.db-wal / state.db-shm (SQLite sidecars carry the same data as state.db) - Add google_token.json / google_client_secret.json (OAuth creds) - Scope filename-based denies to files under HERMES_HOME / STATE_DIR so a legitimate workspace or /tmp media artifact named settings.json / config.yaml is not wrongly blocked. Dir-based denies (state subdirs) remain unconditional. --- api/routes.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/api/routes.py b/api/routes.py index ac8c1c11..63d9368d 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8092,24 +8092,34 @@ def _handle_media(handler, parsed): # allow/serve decision so it covers every entry path (bare file:// URLs, # markdown anchors, MEDIA: tokens, and session-token grants). See #3234. _DENY_FILENAMES = { - "settings.json", "state.db", "auth.json", "auth.lock", - "config.yaml", "config.yml", ".env", ".signing_key", - ".pbkdf2_key", ".sessions.json", + "settings.json", "state.db", "state.db-wal", "state.db-shm", + "auth.json", "auth.lock", "config.yaml", "config.yml", ".env", + ".signing_key", ".pbkdf2_key", ".sessions.json", + "google_token.json", "google_client_secret.json", } + # Roots whose contents are sensitive in their entirety. _state_dir = None try: from api.config import STATE_DIR as _STATE_DIR _state_dir = Path(_STATE_DIR).resolve() except Exception: _state_dir = None + _hermes_home_resolved = _HERMES_HOME.resolve() _deny_dirs = [d for d in ( _state_dir, (_HERMES_HOME / "sessions").resolve(), (_HERMES_HOME / "memories").resolve(), (_HERMES_HOME / "profiles").resolve(), ) if d is not None] + # Filename-based denies only apply to files living under a Hermes/WebUI state + # root — so a legitimate workspace or /tmp media artifact that happens to be + # named settings.json / config.yaml is NOT blocked (Codex review #3234). + _under_state_root = ( + _path_is_within_root(target, _hermes_home_resolved) + or (_state_dir is not None and _path_is_within_root(target, _state_dir)) + ) _denied = ( - target.name in _DENY_FILENAMES + (_under_state_root and target.name in _DENY_FILENAMES) or any(_path_is_within_root(target, d) for d in _deny_dirs) ) if _denied: From c514b32f61d599aa621b48596945c9f36786ecaa Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 31 May 2026 02:45:17 +0000 Subject: [PATCH 05/14] fix(security #3234): cover ALL Hermes roots in /api/media deny-list (Codex review #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under a named profile, process HERMES_HOME is ~/.hermes/profiles/ but the allowlist still grants base ~/.hermes — so the prior deny (anchored only on the active-profile root + STATE_DIR) left ~/.hermes/state.db and sibling-profile secrets (~/.hermes/profiles/other/auth.json) reachable. Build deny roots from every Hermes state root the allowlist accepts: active HERMES_HOME, base ~/.hermes, api.profiles._DEFAULT_HERMES_HOME, and STATE_DIR; apply the state-subdir dir-denies under each. Widen the CSP-slice structural test window to match. --- api/routes.py | 43 ++++++++++++++----- .../test_issue1800_file_html_interactions.py | 11 ++--- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/api/routes.py b/api/routes.py index 63d9368d..633050d4 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8097,26 +8097,49 @@ def _handle_media(handler, parsed): ".signing_key", ".pbkdf2_key", ".sessions.json", "google_token.json", "google_client_secret.json", } - # Roots whose contents are sensitive in their entirety. + # Roots whose contents are sensitive in their entirety. Cover EVERY Hermes + # state root that the allowlist accepts — not just the active-profile + # HERMES_HOME. Under a named profile the process HERMES_HOME is + # ~/.hermes/profiles/, but the allowlist (above) also grants the base + # ~/.hermes, so without including the base root an attacker-influenced link + # could still fetch ~/.hermes/state.db or a SIBLING profile's secrets + # (~/.hermes/profiles/other/auth.json). (Codex review #3234.) _state_dir = None try: from api.config import STATE_DIR as _STATE_DIR _state_dir = Path(_STATE_DIR).resolve() except Exception: _state_dir = None - _hermes_home_resolved = _HERMES_HOME.resolve() - _deny_dirs = [d for d in ( + _base_hermes_home = None + try: + from api.profiles import _DEFAULT_HERMES_HOME as _BASE_HH + _base_hermes_home = Path(_BASE_HH).resolve() + except Exception: + _base_hermes_home = None + # All Hermes state roots: active-profile HERMES_HOME, base ~/.hermes, + # api.profiles default home, and the WebUI STATE_DIR. + _hermes_roots = [] + for _r in ( + _HERMES_HOME.resolve(), + (_HOME / ".hermes").resolve(), + _base_hermes_home, _state_dir, - (_HERMES_HOME / "sessions").resolve(), - (_HERMES_HOME / "memories").resolve(), - (_HERMES_HOME / "profiles").resolve(), - ) if d is not None] + ): + if _r is not None and _r not in _hermes_roots: + _hermes_roots.append(_r) + # Dir-based denies: the named state subdirs under every Hermes root, plus + # the STATE_DIR itself (sensitive in its entirety). + _deny_dirs = [] + if _state_dir is not None: + _deny_dirs.append(_state_dir) + for _root in _hermes_roots: + for _sub in ("sessions", "memories", "profiles"): + _deny_dirs.append((_root / _sub).resolve()) # Filename-based denies only apply to files living under a Hermes/WebUI state # root — so a legitimate workspace or /tmp media artifact that happens to be # named settings.json / config.yaml is NOT blocked (Codex review #3234). - _under_state_root = ( - _path_is_within_root(target, _hermes_home_resolved) - or (_state_dir is not None and _path_is_within_root(target, _state_dir)) + _under_state_root = any( + _path_is_within_root(target, _root) for _root in _hermes_roots ) _denied = ( (_under_state_root and target.name in _DENY_FILENAMES) diff --git a/tests/test_issue1800_file_html_interactions.py b/tests/test_issue1800_file_html_interactions.py index 00edbef7..df1190ed 100644 --- a/tests/test_issue1800_file_html_interactions.py +++ b/tests/test_issue1800_file_html_interactions.py @@ -68,12 +68,13 @@ def test_html_media_open_full_uses_inline_new_tab_not_download(): def test_media_html_inline_keeps_csp_sandbox(): """api/media may serve HTML inline only behind a CSP sandbox.""" - # Slice widened to 7000 (was 5000) after the #3234 security deny-list block - # was inserted earlier in _handle_media, pushing the CSP block further down - # (the `csp=csp` line now sits ~6050 chars past the def). (Previously widened + # Slice widened to 9000 (was 5000) after the #3234 security deny-list block + # (multi-profile-aware deny roots) was inserted earlier in _handle_media, + # pushing the CSP block to ~7800 chars past the def. (Previously widened # 4000→5000 for PR #2044's MEDIA_ALLOWED_ROOTS parsing.) The assertion is - # structural, not positional. - body = _slice_after(ROUTES_PY, "def _handle_media", 7000) + # structural, not positional — generous headroom avoids re-breaking on small + # future edits to the early part of _handle_media. + body = _slice_after(ROUTES_PY, "def _handle_media", 9000) assert 'html_inline_ok = inline_preview and mime == "text/html"' in body assert 'csp = "sandbox allow-scripts" if html_inline_ok else None' in body assert "csp=csp" in body From 343bad726394150166dfa29e2658703af115f8cc Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 31 May 2026 02:55:53 +0000 Subject: [PATCH 06/14] fix(security #3234): redesign /api/media deny with active-workspace carve-out (Codex review #3) Codex round-3 found the prior multi-profile hardening OVER-blocked: denying STATE_DIR + base/profiles wholesale 403'd legitimate active-workspace media. Redesign around a single principle: the ACTIVE WORKSPACE is the user's own content (never deny), Hermes INTERNAL STATE lives outside any workspace (deny). If target is inside the active workspace -> allow; else deny known secret/config basenames + internal state subdirs across all Hermes roots. Also folds in Opus defense-in-depth: adds cron/logs/checkpoints/backups subdirs + gateway_state.json/channel_directory.json/jobs.json basenames. Adds an over-block regression test (a /tmp artifact named settings_* still serves 200). --- api/routes.py | 92 +++++++++++++++++++++++--------------- tests/test_media_inline.py | 33 ++++++++++++++ 2 files changed, 90 insertions(+), 35 deletions(-) diff --git a/api/routes.py b/api/routes.py index 633050d4..21db2702 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8084,26 +8084,35 @@ def _handle_media(handler, parsed): _INLINE_IMAGE_TYPES, ) - # Hard deny WebUI state + secret/config files even when they fall under an - # allowed root (the whole Hermes home is allowed, and the state dir lives - # inside it). Without this, an authenticated session rendering attacker- - # influenced agent output that emits a file:// / MEDIA: link to one of these - # paths could fetch it through /api/media. This guard runs BEFORE the - # allow/serve decision so it covers every entry path (bare file:// URLs, - # markdown anchors, MEDIA: tokens, and session-token grants). See #3234. + # ── #3234: hard-deny Hermes's own state + secret/config files ──────────── + # The allowlist above grants the whole Hermes home (and base ~/.hermes), so + # an authenticated session rendering attacker-influenced agent output that + # emits a file:// / MEDIA: link to a state/secret file could fetch it + # through /api/media. This guard runs BEFORE the allow/serve decision so it + # covers every entry path (bare file:// URLs, markdown anchors, MEDIA: + # tokens, and session-token grants). + # + # Model: the ACTIVE WORKSPACE is a legitimate-media carve-out — the user is + # entitled to their own workspace files (that is also how the workspace file + # browser reaches them), even when a workspace happens to live under a + # Hermes root. The deny rules target Hermes's OWN internal state, which lives + # OUTSIDE any workspace. So: if the target is inside the active workspace, it + # is never denied here; otherwise we deny known secret/config basenames and + # the internal state subdirectories across every Hermes root the allowlist + # accepts (active-profile HERMES_HOME, base ~/.hermes, the api.profiles + # default home, and STATE_DIR — which also defends sibling profiles). _DENY_FILENAMES = { "settings.json", "state.db", "state.db-wal", "state.db-shm", "auth.json", "auth.lock", "config.yaml", "config.yml", ".env", ".signing_key", ".pbkdf2_key", ".sessions.json", "google_token.json", "google_client_secret.json", + "gateway_state.json", "channel_directory.json", "jobs.json", } - # Roots whose contents are sensitive in their entirety. Cover EVERY Hermes - # state root that the allowlist accepts — not just the active-profile - # HERMES_HOME. Under a named profile the process HERMES_HOME is - # ~/.hermes/profiles/, but the allowlist (above) also grants the base - # ~/.hermes, so without including the base root an attacker-influenced link - # could still fetch ~/.hermes/state.db or a SIBLING profile's secrets - # (~/.hermes/profiles/other/auth.json). (Codex review #3234.) + # Internal state subdirs that are sensitive in their entirety. + _DENY_SUBDIRS = ( + "sessions", "memories", "profiles", "cron", "logs", + "checkpoints", "backups", + ) _state_dir = None try: from api.config import STATE_DIR as _STATE_DIR @@ -8116,8 +8125,6 @@ def _handle_media(handler, parsed): _base_hermes_home = Path(_BASE_HH).resolve() except Exception: _base_hermes_home = None - # All Hermes state roots: active-profile HERMES_HOME, base ~/.hermes, - # api.profiles default home, and the WebUI STATE_DIR. _hermes_roots = [] for _r in ( _HERMES_HOME.resolve(), @@ -8127,26 +8134,41 @@ def _handle_media(handler, parsed): ): if _r is not None and _r not in _hermes_roots: _hermes_roots.append(_r) - # Dir-based denies: the named state subdirs under every Hermes root, plus - # the STATE_DIR itself (sensitive in its entirety). - _deny_dirs = [] - if _state_dir is not None: - _deny_dirs.append(_state_dir) - for _root in _hermes_roots: - for _sub in ("sessions", "memories", "profiles"): - _deny_dirs.append((_root / _sub).resolve()) - # Filename-based denies only apply to files living under a Hermes/WebUI state - # root — so a legitimate workspace or /tmp media artifact that happens to be - # named settings.json / config.yaml is NOT blocked (Codex review #3234). - _under_state_root = any( - _path_is_within_root(target, _root) for _root in _hermes_roots + + # Active-workspace carve-out: a file inside the active workspace is the + # user's own content, not Hermes internal state — never deny it here. + _active_workspace = None + try: + from api.workspace import get_last_workspace + _aw = Path(get_last_workspace()).resolve() + if _aw.is_dir(): + _active_workspace = _aw + except Exception: + _active_workspace = None + _in_active_workspace = ( + _active_workspace is not None + and _path_is_within_root(target, _active_workspace) ) - _denied = ( - (_under_state_root and target.name in _DENY_FILENAMES) - or any(_path_is_within_root(target, d) for d in _deny_dirs) - ) - if _denied: - return bad(handler, "Path not in allowed location", 403) + + if not _in_active_workspace: + _under_hermes_root = any( + _path_is_within_root(target, _root) for _root in _hermes_roots + ) + # State-subdir deny: any DENY_SUBDIR directly under a Hermes root, and + # the STATE_DIR itself (sensitive in its entirety). + _deny_dirs = [] + if _state_dir is not None: + _deny_dirs.append(_state_dir) + for _root in _hermes_roots: + for _sub in _DENY_SUBDIRS: + _deny_dirs.append((_root / _sub).resolve()) + _denied = ( + (_under_hermes_root and target.name in _DENY_FILENAMES) + or any(_path_is_within_root(target, d) for d in _deny_dirs) + ) + if _denied: + return bad(handler, "Path not in allowed location", 403) + # ── end #3234 deny ─────────────────────────────────────────────────────── if not within_allowed and not session_media_allowed: return bad(handler, "Path not in allowed location", 403) diff --git a/tests/test_media_inline.py b/tests/test_media_inline.py index b1f3ae0d..1c68ccdd 100644 --- a/tests/test_media_inline.py +++ b/tests/test_media_inline.py @@ -505,6 +505,39 @@ class TestMediaEndpointIntegration(unittest.TestCase): finally: sess_file.unlink(missing_ok=True) + def test_deny_list_does_not_overblock_legitimate_media(self): + """#3234 follow-up: the state/secret deny-list must NOT block ordinary + media that merely shares a sensitive basename but lives OUTSIDE any + Hermes state root (e.g. a user artifact in /tmp named settings.json). + + The deny is scoped to files under a Hermes root; a /tmp PNG named + settings.png — or even settings.json — is the user's own content and + must still be served (200), not 403. + """ + png_bytes = ( + b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01' + b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00' + b'\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82' + ) + # A /tmp artifact whose stem collides with a denied basename — must serve + # because /tmp is not a Hermes state root. + with tempfile.NamedTemporaryFile( + suffix=".png", prefix="settings_artifact_", dir="/tmp", delete=False + ) as f: + f.write(png_bytes) + tmp_path = f.name + try: + body, status, headers = self._get( + f"/api/media?path={urllib.request.quote(tmp_path)}" + ) + self.assertEqual( + status, 200, + f"a /tmp PNG outside any Hermes root must serve, got {status}", + ) + self.assertIn("image/png", headers.get("Content-Type", "")) + finally: + pathlib.Path(tmp_path).unlink(missing_ok=True) + def test_health_check_still_works(self): """Sanity: server is up and /health works.""" body, status, _ = self._get("/health") From 26305c2ebb33158665a7f748d9e2b6c8e824f397 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 31 May 2026 03:08:10 +0000 Subject: [PATCH 07/14] fix(security #3234): gate active-workspace carve-out against broad/internal roots (Codex review #4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round-4: the carve-out could re-open the hole if the active workspace is pathologically set to a broad/internal root ($HOME, ~/.hermes, a profile root) — get_last_workspace only checks is_dir(), so workspace=~/.hermes would serve state.db. Gate the carve-out: disable it when the active workspace IS, CONTAINS, or is CONTAINED BY any Hermes root, or is $HOME / a */profiles dir / a named profile root / an internal state subdir. Adds a unit test proving state.db stays 403 when the active workspace is the Hermes home. Widen CSP-slice test window. --- api/routes.py | 26 +++++++++++ .../test_issue1800_file_html_interactions.py | 14 +++--- tests/test_media_inline.py | 46 +++++++++++++++++++ 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/api/routes.py b/api/routes.py index 21db2702..75870e7c 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8137,6 +8137,12 @@ def _handle_media(handler, parsed): # Active-workspace carve-out: a file inside the active workspace is the # user's own content, not Hermes internal state — never deny it here. + # BUT the carve-out is only safe for a genuine PROJECT workspace. If the + # active workspace is pathologically set to a broad/internal root ($HOME, + # any Hermes root, an ancestor of a Hermes root, STATE_DIR, a */profiles + # dir, a named profile root, or an internal state subdir), honoring it would + # re-open the disclosure (e.g. workspace=~/.hermes → state.db served). In + # that case we DISABLE the carve-out so the deny rules below still fire. _active_workspace = None try: from api.workspace import get_last_workspace @@ -8145,8 +8151,28 @@ def _handle_media(handler, parsed): _active_workspace = _aw except Exception: _active_workspace = None + + def _workspace_is_safe_carveout(ws): + if ws is None: + return False + # Never trust a workspace that IS, CONTAINS, or is CONTAINED BY a Hermes + # root, or that is $HOME / a profiles dir / a state subdir. + if ws == _HOME.resolve(): + return False + for _root in _hermes_roots: + # ws == root, ws inside root, or ws an ancestor of root → unsafe. + if ws == _root or _path_is_within_root(ws, _root) or _path_is_within_root(_root, ws): + return False + # A */profiles dir or a named-profile root (…/profiles/). + if ws.name == "profiles" or (ws.parent.name == "profiles"): + return False + if ws.name in _DENY_SUBDIRS: + return False + return True + _in_active_workspace = ( _active_workspace is not None + and _workspace_is_safe_carveout(_active_workspace) and _path_is_within_root(target, _active_workspace) ) diff --git a/tests/test_issue1800_file_html_interactions.py b/tests/test_issue1800_file_html_interactions.py index df1190ed..5c0e810d 100644 --- a/tests/test_issue1800_file_html_interactions.py +++ b/tests/test_issue1800_file_html_interactions.py @@ -68,13 +68,13 @@ def test_html_media_open_full_uses_inline_new_tab_not_download(): def test_media_html_inline_keeps_csp_sandbox(): """api/media may serve HTML inline only behind a CSP sandbox.""" - # Slice widened to 9000 (was 5000) after the #3234 security deny-list block - # (multi-profile-aware deny roots) was inserted earlier in _handle_media, - # pushing the CSP block to ~7800 chars past the def. (Previously widened - # 4000→5000 for PR #2044's MEDIA_ALLOWED_ROOTS parsing.) The assertion is - # structural, not positional — generous headroom avoids re-breaking on small - # future edits to the early part of _handle_media. - body = _slice_after(ROUTES_PY, "def _handle_media", 9000) + # Slice widened to 12000 (was 5000) after the #3234 security work landed a + # multi-profile-aware deny-list + active-workspace carve-out + carve-out + # safety gate earlier in _handle_media, pushing the CSP block to ~9850 chars + # past the def. (Originally widened 4000→5000 for PR #2044's + # MEDIA_ALLOWED_ROOTS parsing.) The assertion is structural, not positional — + # generous headroom avoids re-breaking on small future edits. + body = _slice_after(ROUTES_PY, "def _handle_media", 12000) assert 'html_inline_ok = inline_preview and mime == "text/html"' in body assert 'csp = "sandbox allow-scripts" if html_inline_ok else None' in body assert "csp=csp" in body diff --git a/tests/test_media_inline.py b/tests/test_media_inline.py index 1c68ccdd..b04366df 100644 --- a/tests/test_media_inline.py +++ b/tests/test_media_inline.py @@ -19,6 +19,7 @@ import unittest from types import SimpleNamespace from unittest import mock import urllib.error +import urllib.parse import urllib.request from tests._pytest_port import BASE, TEST_STATE_DIR @@ -290,6 +291,51 @@ class TestMediaEndpointUnit(unittest.TestCase): child.write_bytes(b"png") self.assertTrue(routes._path_is_within_root(child.resolve(), root)) + def test_active_workspace_carveout_gated_against_hermes_roots(self): + """#3234: the active-workspace carve-out must NOT re-open the disclosure + when the active workspace is pathologically set to a broad/internal root + ($HOME, ~/.hermes, a profile root, etc.). A state.db sitting under such a + workspace must still be denied (403), not served. + """ + from api import routes + + class _Handler: + def __init__(self): + self.status = None + self._buf = [] + def send_response(self, code): + self.status = code + def send_header(self, *a, **k): + pass + def end_headers(self): + pass + class _W: + def write(self_inner, b): + pass + wfile = _W() + + with tempfile.TemporaryDirectory() as home: + hermes_home = pathlib.Path(home) / ".hermes" + hermes_home.mkdir(parents=True) + secret = hermes_home / "state.db" + secret.write_bytes(b"secret-state") + target = secret.resolve() + + handler = _Handler() + parsed = SimpleNamespace( + query=f"path={urllib.parse.quote(str(target))}", path="/api/media" + ) + with mock.patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), \ + mock.patch.object(routes, "get_last_workspace", lambda: str(hermes_home)), \ + mock.patch("api.auth.is_auth_enabled", lambda: False): + routes._handle_media(handler, parsed) + + self.assertEqual( + handler.status, 403, + "state.db must stay denied even when the active workspace IS the " + "Hermes home (carve-out must be gated against internal roots)", + ) + def test_media_endpoints_advertise_byte_range_support(self): routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8") self.assertIn("Accept-Ranges", routes_src) From 5758d4a8272130a24fb3ecb34db0221d29c2503a Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 31 May 2026 03:17:10 +0000 Subject: [PATCH 08/14] fix(security/#3234 + #3219): case-fold deny checks + make bare file:// rewrite code-fence-aware (Codex review #5) 1. api/routes.py: case-fold /api/media deny filename + dir containment checks (os.path.normcase + casefold) so STATE.DB / Sessions/ cannot bypass the state/secret deny on case-insensitive filesystems (macOS/Windows). 2. static/ui.js: move the bare file:// media-stash pass to run AFTER fenced-block and inline-code stashing, so a file:// inside a code block / backtick span stays literal text instead of becoming an auto-loaded . The MEDIA: stash keeps its first-position precedent. Adds behavioral renderer tests (real renderMd via node) for fenced + inline code file:// staying literal, bare file:// becoming media, and anchors keeping the link path. Closes the last Codex review items for #3234. --- api/routes.py | 16 ++++++++++++--- static/ui.js | 21 ++++++++++---------- tests/test_renderer_js_behaviour.py | 30 +++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/api/routes.py b/api/routes.py index 75870e7c..79bcc19f 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8177,8 +8177,18 @@ def _handle_media(handler, parsed): ) if not _in_active_workspace: + # Case-insensitive containment + basename compare so STATE.DB / Sessions/ + # cannot bypass the deny on macOS/Windows filesystems (Codex review #3234). + def _within_ci(child, root): + try: + c = os.path.normcase(str(Path(child).resolve())) + r = os.path.normcase(str(Path(root).resolve())) + return os.path.commonpath([c, r]) == r + except (ValueError, OSError): + return False + _deny_names_ci = {n.casefold() for n in _DENY_FILENAMES} _under_hermes_root = any( - _path_is_within_root(target, _root) for _root in _hermes_roots + _within_ci(target, _root) for _root in _hermes_roots ) # State-subdir deny: any DENY_SUBDIR directly under a Hermes root, and # the STATE_DIR itself (sensitive in its entirety). @@ -8189,8 +8199,8 @@ def _handle_media(handler, parsed): for _sub in _DENY_SUBDIRS: _deny_dirs.append((_root / _sub).resolve()) _denied = ( - (_under_hermes_root and target.name in _DENY_FILENAMES) - or any(_path_is_within_root(target, d) for d in _deny_dirs) + (_under_hermes_root and target.name.casefold() in _deny_names_ci) + or any(_within_ci(target, d) for d in _deny_dirs) ) if _denied: return bad(handler, "Path not in allowed location", 403) diff --git a/static/ui.js b/static/ui.js index 94aeabe5..2fc753ec 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3014,16 +3014,6 @@ function renderMd(raw){ media_stash.push(raw_ref); return '\x00D'+(media_stash.length-1)+'\x00'; }); - // Some gateway/tool surfaces still emit file:// links for local artifacts - // instead of MEDIA: tokens. Browser clients cannot open the server's - // filesystem directly, so treat bare file:// URLs as media artifacts and - // route them through /api/media. This intentionally runs only for bare URLs - // (line-start or whitespace-delimited), not inside markdown links, so normal - // [label](file://...) anchors continue to use the link path below. - s=s.replace(/(^|\s)(file:\/\/[^\s<>"')\]]+)/g,(_,lead,raw_ref)=>{ - media_stash.push(raw_ref); - return lead+'\x00D'+(media_stash.length-1)+'\x00'; - }); // ── End MEDIA stash ───────────────────────────────────────────────────────── // Pre-pass: decode HTML entities first so markdown processing works correctly. // This prevents double-escaping when LLM outputs entities like < > & @@ -3101,6 +3091,17 @@ function renderMd(raw){ return lead+'\x00P'+(_preBlock_stash.length-1)+'\x00'; }); s=s.replace(/`([^`\n]+)`/g,(_,c)=>{fence_stash.push(''+esc(c)+'');return '\x00F'+(fence_stash.length-1)+'\x00';}); + // Bare file:// artifact links → media. Some gateway/tool surfaces emit bare + // file:// links for local artifacts instead of MEDIA: tokens; browser clients + // cannot open the server filesystem directly, so route them through /api/media. + // This runs AFTER fenced-block (\x00P) and inline-code (\x00F) stashing so a + // file:// inside a code block / backtick span stays literal text (#3219/#3234). + // Only bare URLs (line-start or whitespace-delimited) are matched, so normal + // [label](file://...) markdown anchors keep the link path below. + s=s.replace(/(^|\s)(file:\/\/[^\s<>"')\]]+)/g,(_,lead,raw_ref)=>{ + media_stash.push(raw_ref); + return lead+'\x00D'+(media_stash.length-1)+'\x00'; + }); // Math stash: protect $$..$$ and $..$ from markdown processing // Runs AFTER fence_stash so backtick code spans protect their dollar-sign contents const math_stash=[]; diff --git a/tests/test_renderer_js_behaviour.py b/tests/test_renderer_js_behaviour.py index 72a33485..5c92eb7b 100644 --- a/tests/test_renderer_js_behaviour.py +++ b/tests/test_renderer_js_behaviour.py @@ -702,3 +702,33 @@ class TestHeadingLevelsH1ThroughH6: assert "

bold in h4

" in out, ( f"inline markdown inside h4 must still render: {out!r}" ) + + +class TestBareFileUrlMediaRendering: + """#3219/#3234: bare file:// artifact links render as media, but file:// + inside fenced/inline code stays literal (the new pass runs AFTER code-stash).""" + + def test_bare_file_url_becomes_media(self, driver_path): + out = _render(driver_path, "Here is the screenshot file:///tmp/shot.png done") + # Routed through /api/media as an inline image, not left as a raw path. + assert "api/media?path=" in out + assert "msg-media-img" in out or ". + assert "file:///tmp/shot.png" in out + assert "). + assert " Date: Sun, 31 May 2026 03:24:35 +0000 Subject: [PATCH 09/14] fix(security/#3234 + #3219): unify case-folded path checks, fix workspace over-block, protect raw
 (Codex review #6)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

1. routes.py: hoist a single case-folded path helper (_norm/_within_ci/_equal_ci)
   used for ALL deny + carve-out comparisons (consistent macOS/Windows safety).
2. routes.py: split the deny into (a) dir-based denies that ALWAYS fire (even
   inside the active workspace — so a workspace overlapping a state dir cannot
   expose sessions/memories), and (b) filename denies relaxed only by the
   carve-out. Fix the over-block: a workspace that is a proper DESCENDANT of a
   Hermes root (e.g. STATE_DIR/workspace) is a legit project workspace and keeps
   the carve-out; only a root-itself / ancestor / $HOME / profiles / state-subdir
   workspace disables it.
3. ui.js: move the bare file:// media-stash pass after the raw-
 stash too,
   so file:// inside a raw 
 block stays literal (not just fenced/inline code).
---
 api/routes.py | 91 +++++++++++++++++++++++++++------------------------
 static/ui.js  | 22 ++++++-------
 2 files changed, 60 insertions(+), 53 deletions(-)

diff --git a/api/routes.py b/api/routes.py
index 79bcc19f..e024532e 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -8135,14 +8135,42 @@ def _handle_media(handler, parsed):
         if _r is not None and _r not in _hermes_roots:
             _hermes_roots.append(_r)
 
-    # Active-workspace carve-out: a file inside the active workspace is the
-    # user's own content, not Hermes internal state — never deny it here.
-    # BUT the carve-out is only safe for a genuine PROJECT workspace. If the
-    # active workspace is pathologically set to a broad/internal root ($HOME,
-    # any Hermes root, an ancestor of a Hermes root, STATE_DIR, a */profiles
-    # dir, a named profile root, or an internal state subdir), honoring it would
-    # re-open the disclosure (e.g. workspace=~/.hermes → state.db served). In
-    # that case we DISABLE the carve-out so the deny rules below still fire.
+    # Case-insensitive path helpers so STATE.DB / Sessions/ casing variants
+    # cannot bypass the deny on macOS/Windows filesystems (Codex review #3234).
+    def _norm(p):
+        return os.path.normcase(str(Path(p).resolve())).casefold()
+    def _within_ci(child, root):
+        try:
+            c, r = _norm(child), _norm(root)
+            return os.path.commonpath([c, r]) == r
+        except (ValueError, OSError):
+            return False
+    def _equal_ci(a, b):
+        try:
+            return _norm(a) == _norm(b)
+        except (ValueError, OSError):
+            return False
+
+    # State-subdir deny set: each DENY_SUBDIR directly under any Hermes root,
+    # plus STATE_DIR itself (sensitive in its entirety). These ALWAYS apply —
+    # even to a file that lives under the active workspace — so a workspace
+    # pointed at (or overlapping) a state dir cannot expose sessions/memories/etc.
+    _deny_dirs = []
+    if _state_dir is not None:
+        _deny_dirs.append(_state_dir)
+    for _root in _hermes_roots:
+        for _sub in _DENY_SUBDIRS:
+            _deny_dirs.append((_root / _sub).resolve())
+    _deny_names_ci = {n.casefold() for n in _DENY_FILENAMES}
+
+    # Active-workspace carve-out: a file inside a genuine PROJECT workspace is
+    # the user's own content, so the secret/config FILENAME denies are relaxed
+    # for it. The carve-out is DISABLED when the workspace is a broad/internal
+    # location ($HOME, a Hermes root itself, an ANCESTOR of a Hermes root, a
+    # */profiles dir, a named-profile root, or a state subdir) — honoring those
+    # would re-open the disclosure. A workspace that is a proper DESCENDANT of a
+    # Hermes root (e.g. STATE_DIR/workspace) is still a legit project workspace
+    # and keeps the carve-out. The dir-based denies above are NOT relaxed.
     _active_workspace = None
     try:
         from api.workspace import get_last_workspace
@@ -8155,16 +8183,14 @@ def _handle_media(handler, parsed):
     def _workspace_is_safe_carveout(ws):
         if ws is None:
             return False
-        # Never trust a workspace that IS, CONTAINS, or is CONTAINED BY a Hermes
-        # root, or that is $HOME / a profiles dir / a state subdir.
-        if ws == _HOME.resolve():
+        if _equal_ci(ws, _HOME):
             return False
         for _root in _hermes_roots:
-            # ws == root, ws inside root, or ws an ancestor of root → unsafe.
-            if ws == _root or _path_is_within_root(ws, _root) or _path_is_within_root(_root, ws):
+            # ws IS a root, or ws is an ANCESTOR of a root → unsafe. (A proper
+            # descendant of a root is fine — that's a normal project workspace.)
+            if _equal_ci(ws, _root) or _within_ci(_root, ws):
                 return False
-        # A */profiles dir or a named-profile root (…/profiles/).
-        if ws.name == "profiles" or (ws.parent.name == "profiles"):
+        if ws.name == "profiles" or ws.parent.name == "profiles":
             return False
         if ws.name in _DENY_SUBDIRS:
             return False
@@ -8173,36 +8199,17 @@ def _handle_media(handler, parsed):
     _in_active_workspace = (
         _active_workspace is not None
         and _workspace_is_safe_carveout(_active_workspace)
-        and _path_is_within_root(target, _active_workspace)
+        and _within_ci(target, _active_workspace)
     )
 
+    # Dir-based denies always fire (even inside the active workspace).
+    if any(_within_ci(target, d) for d in _deny_dirs):
+        return bad(handler, "Path not in allowed location", 403)
+    # Filename-based denies fire for files under a Hermes root, UNLESS the file
+    # is inside a genuine project workspace (carve-out).
     if not _in_active_workspace:
-        # Case-insensitive containment + basename compare so STATE.DB / Sessions/
-        # cannot bypass the deny on macOS/Windows filesystems (Codex review #3234).
-        def _within_ci(child, root):
-            try:
-                c = os.path.normcase(str(Path(child).resolve()))
-                r = os.path.normcase(str(Path(root).resolve()))
-                return os.path.commonpath([c, r]) == r
-            except (ValueError, OSError):
-                return False
-        _deny_names_ci = {n.casefold() for n in _DENY_FILENAMES}
-        _under_hermes_root = any(
-            _within_ci(target, _root) for _root in _hermes_roots
-        )
-        # State-subdir deny: any DENY_SUBDIR directly under a Hermes root, and
-        # the STATE_DIR itself (sensitive in its entirety).
-        _deny_dirs = []
-        if _state_dir is not None:
-            _deny_dirs.append(_state_dir)
-        for _root in _hermes_roots:
-            for _sub in _DENY_SUBDIRS:
-                _deny_dirs.append((_root / _sub).resolve())
-        _denied = (
-            (_under_hermes_root and target.name.casefold() in _deny_names_ci)
-            or any(_within_ci(target, d) for d in _deny_dirs)
-        )
-        if _denied:
+        _under_hermes_root = any(_within_ci(target, _root) for _root in _hermes_roots)
+        if _under_hermes_root and target.name.casefold() in _deny_names_ci:
             return bad(handler, "Path not in allowed location", 403)
     # ── end #3234 deny ───────────────────────────────────────────────────────
 
diff --git a/static/ui.js b/static/ui.js
index 2fc753ec..a002f18b 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -3091,17 +3091,6 @@ function renderMd(raw){
     return lead+'\x00P'+(_preBlock_stash.length-1)+'\x00';
   });
   s=s.replace(/`([^`\n]+)`/g,(_,c)=>{fence_stash.push(''+esc(c)+'');return '\x00F'+(fence_stash.length-1)+'\x00';});
-  // Bare file:// artifact links → media. Some gateway/tool surfaces emit bare
-  // file:// links for local artifacts instead of MEDIA: tokens; browser clients
-  // cannot open the server filesystem directly, so route them through /api/media.
-  // This runs AFTER fenced-block (\x00P) and inline-code (\x00F) stashing so a
-  // file:// inside a code block / backtick span stays literal text (#3219/#3234).
-  // Only bare URLs (line-start or whitespace-delimited) are matched, so normal
-  // [label](file://...) markdown anchors keep the link path below.
-  s=s.replace(/(^|\s)(file:\/\/[^\s<>"')\]]+)/g,(_,lead,raw_ref)=>{
-    media_stash.push(raw_ref);
-    return lead+'\x00D'+(media_stash.length-1)+'\x00';
-  });
   // Math stash: protect $$..$$ and $..$ from markdown processing
   // Runs AFTER fence_stash so backtick code spans protect their dollar-sign contents
   const math_stash=[];
@@ -3121,6 +3110,17 @@ function renderMd(raw){
   // backticks for multiline code and break subsequent code-box rendering.
   const rawPreStash=[];
   s=s.replace(/(]*>[\s\S]*?<\/pre>)/gi,m=>{rawPreStash.push(m);return `\x00R${rawPreStash.length-1}\x00`;});
+  // Bare file:// artifact links → media. Some gateway/tool surfaces emit bare
+  // file:// links for local artifacts instead of MEDIA: tokens; browser clients
+  // cannot open the server filesystem directly, so route them through /api/media.
+  // Runs AFTER fenced-block (\x00P), inline-code (\x00F), AND raw-
 (\x00R)
+  // stashing so a file:// inside any code/preformatted region stays literal text
+  // (#3219/#3234). Only bare URLs (line-start or whitespace-delimited) match, so
+  // normal [label](file://...) markdown anchors keep the link path below.
+  s=s.replace(/(^|\s)(file:\/\/[^\s<>"')\]]+)/g,(_,lead,raw_ref)=>{
+    media_stash.push(raw_ref);
+    return lead+'\x00D'+(media_stash.length-1)+'\x00';
+  });
   s=s.replace(/([\s\S]*?)<\/strong>/gi,(_,t)=>'**'+t+'**');
   s=s.replace(/([\s\S]*?)<\/b>/gi,(_,t)=>'**'+t+'**');
   s=s.replace(/([\s\S]*?)<\/em>/gi,(_,t)=>'*'+t+'*');

From 124044dc111e0f54fc0e8a501466a684bd58f2b6 Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Sun, 31 May 2026 03:41:21 +0000
Subject: [PATCH 10/14] =?UTF-8?q?fix(security=20#3234):=20do=20not=20deny?=
 =?UTF-8?q?=20STATE=5FDIR=20wholesale=20=E2=80=94=20keep=20STATE=5FDIR/wor?=
 =?UTF-8?q?kspace=20media=20(Codex=20review=20#7)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The default workspace lives at STATE_DIR/workspace, so denying STATE_DIR itself
403'd legitimate workspace media. STATE_DIR is already in _hermes_roots, so its
sensitive subdirs (STATE_DIR/sessions, /memories, /profiles, etc.) are still
covered by the per-root subdir loop; direct sensitive files are still caught by
the filename denies. Drop the wholesale _state_dir deny. Adds a regression test
proving STATE_DIR/workspace/shot.png serves while STATE_DIR/sessions/*.json 403s.
---
 api/routes.py              | 14 ++++----
 tests/test_media_inline.py | 68 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 76 insertions(+), 6 deletions(-)

diff --git a/api/routes.py b/api/routes.py
index e024532e..bc4f6162 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -8151,13 +8151,15 @@ def _handle_media(handler, parsed):
         except (ValueError, OSError):
             return False
 
-    # State-subdir deny set: each DENY_SUBDIR directly under any Hermes root,
-    # plus STATE_DIR itself (sensitive in its entirety). These ALWAYS apply —
-    # even to a file that lives under the active workspace — so a workspace
-    # pointed at (or overlapping) a state dir cannot expose sessions/memories/etc.
+    # State-subdir deny set: each DENY_SUBDIR directly under any Hermes root
+    # (which includes STATE_DIR — so STATE_DIR/sessions, STATE_DIR/memories,
+    # etc. are covered). These ALWAYS apply — even to a file under the active
+    # workspace — so a workspace pointed at (or overlapping) a state dir cannot
+    # expose sessions/memories/profiles/etc. We do NOT deny STATE_DIR itself
+    # wholesale: the default workspace lives at STATE_DIR/workspace, and that is
+    # legitimate user media — direct sensitive files there are still caught by
+    # the filename denies below. (Codex review #3234.)
     _deny_dirs = []
-    if _state_dir is not None:
-        _deny_dirs.append(_state_dir)
     for _root in _hermes_roots:
         for _sub in _DENY_SUBDIRS:
             _deny_dirs.append((_root / _sub).resolve())
diff --git a/tests/test_media_inline.py b/tests/test_media_inline.py
index b04366df..2d4bc1b9 100644
--- a/tests/test_media_inline.py
+++ b/tests/test_media_inline.py
@@ -336,6 +336,74 @@ class TestMediaEndpointUnit(unittest.TestCase):
                 "Hermes home (carve-out must be gated against internal roots)",
             )
 
+    def test_active_workspace_under_state_dir_serves_but_sessions_denied(self):
+        """#3234: a workspace at STATE_DIR/workspace is legitimate user media —
+        STATE_DIR/workspace/shot.png must serve (not 403), while a sibling
+        STATE_DIR/sessions/.json (internal state) must stay denied (403).
+
+        Regression for the over-block where STATE_DIR was denied wholesale.
+        """
+        from api import routes
+
+        class _Handler:
+            def __init__(self):
+                self.status = None
+                self.headers = {}
+            def send_response(self, code):
+                self.status = code
+            def send_header(self, *a, **k):
+                pass
+            def end_headers(self):
+                pass
+            class _W:
+                def write(self_inner, b):
+                    pass
+                def flush(self_inner):
+                    pass
+            wfile = _W()
+
+        png_bytes = (
+            b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
+            b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00'
+            b'\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
+        )
+        with tempfile.TemporaryDirectory() as home:
+            hermes_home = pathlib.Path(home) / ".hermes"
+            state_dir = hermes_home / "webui-state"
+            ws = state_dir / "workspace"
+            sessions = state_dir / "sessions"
+            ws.mkdir(parents=True)
+            sessions.mkdir(parents=True)
+            shot = ws / "shot.png"
+            shot.write_bytes(png_bytes)
+            sess_file = sessions / "abc.json"
+            sess_file.write_text('{"messages":[]}', encoding="utf-8")
+
+            env = {
+                "HERMES_HOME": str(hermes_home),
+                "HERMES_WEBUI_STATE_DIR": str(state_dir),
+            }
+            with mock.patch.dict(os.environ, env), \
+                 mock.patch.object(routes, "get_last_workspace", lambda: str(ws)), \
+                 mock.patch("api.auth.is_auth_enabled", lambda: False), \
+                 mock.patch("api.config.STATE_DIR", state_dir):
+                # workspace media → not blocked by the #3234 deny
+                h1 = _Handler()
+                routes._handle_media(h1, SimpleNamespace(
+                    query=f"path={urllib.parse.quote(str(shot.resolve()))}&inline=1",
+                    path="/api/media"))
+                self.assertNotEqual(
+                    h1.status, 403,
+                    "STATE_DIR/workspace/shot.png must NOT be blocked (legit media)")
+                # sessions state → still denied
+                h2 = _Handler()
+                routes._handle_media(h2, SimpleNamespace(
+                    query=f"path={urllib.parse.quote(str(sess_file.resolve()))}",
+                    path="/api/media"))
+                self.assertEqual(
+                    h2.status, 403,
+                    "STATE_DIR/sessions/abc.json must stay denied (internal state)")
+
     def test_media_endpoints_advertise_byte_range_support(self):
         routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
         self.assertIn("Accept-Ranges", routes_src)

From ce7a2c14dbe34c4c83d4a054787c93fd0f8b92f4 Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Sun, 31 May 2026 03:48:59 +0000
Subject: [PATCH 11/14] fix(security #3234): enumerate named-profile roots,
 drop wholesale profiles deny (Codex review #8)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Denying the whole /profiles tree 403'd legitimate named-profile workspace
media (/profiles/p1/workspace/shot.png). Fix: remove 'profiles' from
_DENY_SUBDIRS and instead enumerate each /profiles/ directory as its
own Hermes root — so each profile's sensitive subdirs (sessions/memories/cron/
logs/checkpoints/backups) + secret filenames are denied, while that profile's
workspace/ is allowed via the carve-out. Adds a regression test: named-profile
workspace media serves, profile + sibling-profile auth.json stay 403.
---
 api/routes.py                                 | 26 ++++++-
 .../test_issue1800_file_html_interactions.py  | 14 ++--
 tests/test_media_inline.py                    | 69 +++++++++++++++++++
 3 files changed, 100 insertions(+), 9 deletions(-)

diff --git a/api/routes.py b/api/routes.py
index bc4f6162..b5cdbda8 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -8108,9 +8108,13 @@ def _handle_media(handler, parsed):
         "google_token.json", "google_client_secret.json",
         "gateway_state.json", "channel_directory.json", "jobs.json",
     }
-    # Internal state subdirs that are sensitive in their entirety.
+    # Internal state subdirs that are sensitive in their entirety. NOTE:
+    # `profiles` is intentionally NOT here — it is a container of profile roots,
+    # each of which has its own legitimate workspace/. We instead enumerate each
+    # named-profile root below and deny ITS state subdirs, so a sibling profile's
+    # secrets are blocked without 403-ing a named-profile workspace. (#3234.)
     _DENY_SUBDIRS = (
-        "sessions", "memories", "profiles", "cron", "logs",
+        "sessions", "memories", "cron", "logs",
         "checkpoints", "backups",
     )
     _state_dir = None
@@ -8134,6 +8138,24 @@ def _handle_media(handler, parsed):
     ):
         if _r is not None and _r not in _hermes_roots:
             _hermes_roots.append(_r)
+    # Enumerate named-profile roots (/profiles/) and treat each as a
+    # Hermes root in its own right, so a sibling/other profile's sensitive subdirs
+    # + secret files are denied — WITHOUT denying the whole `profiles` container
+    # (which would block a legit named-profile workspace at
+    # /profiles//workspace/). (Codex review #3234.)
+    _profile_roots = []
+    for _root in list(_hermes_roots):
+        _profiles_dir = (_root / "profiles")
+        try:
+            if _profiles_dir.is_dir():
+                for _pchild in _profiles_dir.iterdir():
+                    if _pchild.is_dir():
+                        _pr = _pchild.resolve()
+                        if _pr not in _hermes_roots and _pr not in _profile_roots:
+                            _profile_roots.append(_pr)
+        except OSError:
+            pass
+    _hermes_roots.extend(_profile_roots)
 
     # Case-insensitive path helpers so STATE.DB / Sessions/ casing variants
     # cannot bypass the deny on macOS/Windows filesystems (Codex review #3234).
diff --git a/tests/test_issue1800_file_html_interactions.py b/tests/test_issue1800_file_html_interactions.py
index 5c0e810d..0bee348a 100644
--- a/tests/test_issue1800_file_html_interactions.py
+++ b/tests/test_issue1800_file_html_interactions.py
@@ -68,13 +68,13 @@ def test_html_media_open_full_uses_inline_new_tab_not_download():
 
 def test_media_html_inline_keeps_csp_sandbox():
     """api/media may serve HTML inline only behind a CSP sandbox."""
-    # Slice widened to 12000 (was 5000) after the #3234 security work landed a
-    # multi-profile-aware deny-list + active-workspace carve-out + carve-out
-    # safety gate earlier in _handle_media, pushing the CSP block to ~9850 chars
-    # past the def. (Originally widened 4000→5000 for PR #2044's
-    # MEDIA_ALLOWED_ROOTS parsing.) The assertion is structural, not positional —
-    # generous headroom avoids re-breaking on small future edits.
-    body = _slice_after(ROUTES_PY, "def _handle_media", 12000)
+    # Slice widened to 16000 (was 5000) after the #3234 security work landed a
+    # multi-profile-aware deny-list + named-profile-root enumeration +
+    # active-workspace carve-out + safety gate earlier in _handle_media, pushing
+    # the CSP block to ~12100 chars past the def. (Originally widened 4000→5000
+    # for PR #2044's MEDIA_ALLOWED_ROOTS parsing.) The assertion is structural,
+    # not positional — generous headroom avoids re-breaking on small future edits.
+    body = _slice_after(ROUTES_PY, "def _handle_media", 16000)
     assert 'html_inline_ok = inline_preview and mime == "text/html"' in body
     assert 'csp = "sandbox allow-scripts" if html_inline_ok else None' in body
     assert "csp=csp" in body
diff --git a/tests/test_media_inline.py b/tests/test_media_inline.py
index 2d4bc1b9..c3cad01d 100644
--- a/tests/test_media_inline.py
+++ b/tests/test_media_inline.py
@@ -404,6 +404,75 @@ class TestMediaEndpointUnit(unittest.TestCase):
                     h2.status, 403,
                     "STATE_DIR/sessions/abc.json must stay denied (internal state)")
 
+    def test_named_profile_workspace_serves_but_profile_secrets_denied(self):
+        """#3234: a named-profile workspace (/profiles/p1/workspace) is
+        legitimate media and must serve, while that profile's secrets
+        (/profiles/p1/auth.json) and a SIBLING profile's secrets
+        (/profiles/other/auth.json) must stay denied (403).
+
+        Regression for the over-block where the whole `profiles` tree was denied.
+        """
+        from api import routes
+
+        class _Handler:
+            def __init__(self):
+                self.status = None
+                self.headers = {}
+            def send_response(self, code):
+                self.status = code
+            def send_header(self, *a, **k):
+                pass
+            def end_headers(self):
+                pass
+            class _W:
+                def write(self_inner, b):
+                    pass
+                def flush(self_inner):
+                    pass
+            wfile = _W()
+
+        png_bytes = (
+            b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
+            b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00'
+            b'\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
+        )
+        with tempfile.TemporaryDirectory() as home:
+            base = pathlib.Path(home) / ".hermes"
+            p1_ws = base / "profiles" / "p1" / "workspace"
+            p1_ws.mkdir(parents=True)
+            (p1_ws / "shot.png").write_bytes(png_bytes)
+            p1_secret = base / "profiles" / "p1" / "auth.json"
+            p1_secret.write_text("{}", encoding="utf-8")
+            other_secret = base / "profiles" / "other" / "auth.json"
+            other_secret.parent.mkdir(parents=True)
+            other_secret.write_text("{}", encoding="utf-8")
+
+            active = base / "profiles" / "p1"  # active profile HERMES_HOME
+            with mock.patch.dict(os.environ, {"HERMES_HOME": str(active)}), \
+                 mock.patch.object(routes, "get_last_workspace", lambda: str(p1_ws)), \
+                 mock.patch("api.auth.is_auth_enabled", lambda: False), \
+                 mock.patch("api.profiles._DEFAULT_HERMES_HOME", base):
+                # named-profile workspace media → served
+                h1 = _Handler()
+                routes._handle_media(h1, SimpleNamespace(
+                    query=f"path={urllib.parse.quote(str((p1_ws / 'shot.png').resolve()))}&inline=1",
+                    path="/api/media"))
+                self.assertNotEqual(
+                    h1.status, 403,
+                    "named-profile workspace media must NOT be blocked")
+                # this profile's own secret → denied
+                h2 = _Handler()
+                routes._handle_media(h2, SimpleNamespace(
+                    query=f"path={urllib.parse.quote(str(p1_secret.resolve()))}",
+                    path="/api/media"))
+                self.assertEqual(h2.status, 403, "profile auth.json must be denied")
+                # sibling profile's secret → denied
+                h3 = _Handler()
+                routes._handle_media(h3, SimpleNamespace(
+                    query=f"path={urllib.parse.quote(str(other_secret.resolve()))}",
+                    path="/api/media"))
+                self.assertEqual(h3.status, 403, "sibling profile auth.json must be denied")
+
     def test_media_endpoints_advertise_byte_range_support(self):
         routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
         self.assertIn("Accept-Ranges", routes_src)

From d1645ff80336c51a24845b0d6f80059ba2e389b9 Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Sun, 31 May 2026 03:58:19 +0000
Subject: [PATCH 12/14] fix(security #3234): deny passkeys.json +
 .passkey_challenges.json + .login_attempts.json (Codex review #9)

Codex direct probe found three more auth-state basenames under STATE_DIR that
/api/media still served: passkeys.json + .passkey_challenges.json (api/passkeys.py)
and .login_attempts.json (api/auth.py). Add them to _DENY_FILENAMES.
---
 api/routes.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/api/routes.py b/api/routes.py
index b5cdbda8..74f2f894 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -8107,6 +8107,7 @@ def _handle_media(handler, parsed):
         ".signing_key", ".pbkdf2_key", ".sessions.json",
         "google_token.json", "google_client_secret.json",
         "gateway_state.json", "channel_directory.json", "jobs.json",
+        "passkeys.json", ".passkey_challenges.json", ".login_attempts.json",
     }
     # Internal state subdirs that are sensitive in their entirety. NOTE:
     # `profiles` is intentionally NOT here — it is a container of profile roots,

From a62d35a0b3d234b6ba54461716515f00c920fa02 Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Sun, 31 May 2026 04:05:47 +0000
Subject: [PATCH 13/14] fix(security #3234): deny atomic-write temp auth/state
 files under Hermes roots (Codex review #10)

auth.py/passkeys.py write via tmp*..tmp sidecars then rename; deny those
suffixes (.sessions.tmp, .login_attempts.tmp, .passkeys.tmp, .passkey_challenges.tmp)
under Hermes roots so a momentary temp file can't be fetched via /api/media.
---
 api/routes.py | 12 +++++++++++-
 1 file changed, 11 insertions(+), 1 deletion(-)

diff --git a/api/routes.py b/api/routes.py
index 74f2f894..c989eebe 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -8234,7 +8234,17 @@ def _handle_media(handler, parsed):
     # is inside a genuine project workspace (carve-out).
     if not _in_active_workspace:
         _under_hermes_root = any(_within_ci(target, _root) for _root in _hermes_roots)
-        if _under_hermes_root and target.name.casefold() in _deny_names_ci:
+        _name_cf = target.name.casefold()
+        # Exact secret/state basenames, plus atomic-write temp files for those
+        # (api/auth.py and api/passkeys.py write via a `tmp*..tmp` / `tmp*.tmp`
+        # sidecar then rename) — deny those suffixes too so a momentary temp file
+        # cannot be fetched. (Codex review #3234.)
+        _deny_tmp_suffixes = (".sessions.tmp", ".login_attempts.tmp",
+                              ".passkeys.tmp", ".passkey_challenges.tmp")
+        if _under_hermes_root and (
+            _name_cf in _deny_names_ci
+            or _name_cf.endswith(_deny_tmp_suffixes)
+        ):
             return bad(handler, "Path not in allowed location", 403)
     # ── end #3234 deny ───────────────────────────────────────────────────────
 

From d3b69ab98b4fb1ef809d1608991fd1f4dfcc7e2e Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Sun, 31 May 2026 04:11:35 +0000
Subject: [PATCH 14/14] fix(security #3234): deny per-profile webui_state state
 subdirs (Codex review #11)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Per-profile WebUI state lives at /webui_state (api/workspace.py), so
/profiles//webui_state/sessions/*.json was reachable — it is not a
direct child of the profile root, so the prior deny-subdir loop missed it. Add
/webui_state/ to the deny dirs for every Hermes root. Adds a
regression assertion (profile webui_state/sessions/*.json → 403).
---
 api/routes.py              |  6 ++++++
 tests/test_media_inline.py | 12 ++++++++++++
 2 files changed, 18 insertions(+)

diff --git a/api/routes.py b/api/routes.py
index c989eebe..fa6c7557 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -8186,6 +8186,12 @@ def _handle_media(handler, parsed):
     for _root in _hermes_roots:
         for _sub in _DENY_SUBDIRS:
             _deny_dirs.append((_root / _sub).resolve())
+        # Per-profile WebUI state lives at /webui_state (api/workspace.py),
+        # so its state subdirs (/webui_state/sessions, etc.) must be denied
+        # too — they are NOT direct children of . (Codex review #3234.)
+        _ws_state = (_root / "webui_state")
+        for _sub in _DENY_SUBDIRS:
+            _deny_dirs.append((_ws_state / _sub).resolve())
     _deny_names_ci = {n.casefold() for n in _DENY_FILENAMES}
 
     # Active-workspace carve-out: a file inside a genuine PROJECT workspace is
diff --git a/tests/test_media_inline.py b/tests/test_media_inline.py
index c3cad01d..9276d9be 100644
--- a/tests/test_media_inline.py
+++ b/tests/test_media_inline.py
@@ -472,6 +472,18 @@ class TestMediaEndpointUnit(unittest.TestCase):
                     query=f"path={urllib.parse.quote(str(other_secret.resolve()))}",
                     path="/api/media"))
                 self.assertEqual(h3.status, 403, "sibling profile auth.json must be denied")
+                # per-profile webui_state/sessions → denied (not a direct child of root)
+                ws_sess = active / "webui_state" / "sessions"
+                ws_sess.mkdir(parents=True, exist_ok=True)
+                ws_sess_file = ws_sess / "s1.json"
+                ws_sess_file.write_text('{"messages":[]}', encoding="utf-8")
+                h4 = _Handler()
+                routes._handle_media(h4, SimpleNamespace(
+                    query=f"path={urllib.parse.quote(str(ws_sess_file.resolve()))}",
+                    path="/api/media"))
+                self.assertEqual(
+                    h4.status, 403,
+                    "profile webui_state/sessions/*.json must be denied")
 
     def test_media_endpoints_advertise_byte_range_support(self):
         routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")