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/api/routes.py b/api/routes.py index 74d15740..fa6c7557 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8083,6 +8083,177 @@ def _handle_media(handler, parsed): target, _INLINE_IMAGE_TYPES, ) + + # ── #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", + "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, + # 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", "cron", "logs", + "checkpoints", "backups", + ) + _state_dir = None + try: + from api.config import STATE_DIR as _STATE_DIR + _state_dir = Path(_STATE_DIR).resolve() + except Exception: + _state_dir = None + _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 + _hermes_roots = [] + for _r in ( + _HERMES_HOME.resolve(), + (_HOME / ".hermes").resolve(), + _base_hermes_home, + _state_dir, + ): + 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). + 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 + # (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 = [] + 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 + # 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 + _aw = Path(get_last_workspace()).resolve() + if _aw.is_dir(): + _active_workspace = _aw + except Exception: + _active_workspace = None + + def _workspace_is_safe_carveout(ws): + if ws is None: + return False + if _equal_ci(ws, _HOME): + return False + for _root in _hermes_roots: + # 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 + 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 _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: + _under_hermes_root = any(_within_ci(target, _root) for _root in _hermes_roots) + _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 ─────────────────────────────────────────────────────── + if not within_allowed and not session_media_allowed: return bad(handler, "Path not in allowed location", 403) diff --git a/static/ui.js b/static/ui.js index 4ca46c0f..a002f18b 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3110,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+'*');
@@ -3449,7 +3460,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 +3479,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_issue1800_file_html_interactions.py b/tests/test_issue1800_file_html_interactions.py index 995c24be..0bee348a 100644 --- a/tests/test_issue1800_file_html_interactions.py +++ b/tests/test_issue1800_file_html_interactions.py @@ -68,10 +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 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 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 621da120..9276d9be 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 @@ -41,6 +42,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 ") @@ -281,6 +291,200 @@ 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_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_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") + # 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") self.assertIn("Accept-Ranges", routes_src) @@ -456,6 +660,79 @@ 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_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") 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 "