Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
64339fd
fix(render): match spaced MEDIA paths and strip dotted model-ID prefixes
Jul 29, 2026
e4bbb62
fix(media): resolve spaced MEDIA paths in the allow-list and share in…
Jul 29, 2026
caba3b5
fix(media): one MEDIA grammar across JS+Python; buffer streams to a r…
Jul 30, 2026
5de59e4
fix(media): partition the stream-end flush; share one grammar with TT…
Jul 30, 2026
122b5c9
fix(media): bound the tail buffer by the buffer, not the prose; stop …
Jul 30, 2026
1e267ac
fix(media): partition stream flush, honor code policy, and pin token …
samfoy Aug 5, 2026
9981a44
fix(media): stop spaced refs before dotted prose
samfoy Aug 5, 2026
7bff14a
fix(media): partition oversized stream tails
samfoy Aug 5, 2026
d8b6392
fix(shares): reject http(s) MEDIA refs that hide a local target
Aug 26, 2026
c0c4146
fix(media): make the length cap and the quote rule part of the shared…
Aug 26, 2026
7219307
test(structural): scope JS assertions by brace depth, not fixed windows
Aug 26, 2026
cab0b1c
fix(media): measure the MEDIA token cap in UTF-16 code units
Sep 2, 2026
3d891e3
fix(media): use one inclusive candidate ceiling when streaming
Sep 2, 2026
a929bb4
fix(shares): guard every URL sink in a public snapshot, not only MEDIA:
Sep 2, 2026
8d888f5
fix(shares): classify nested public URLs instead of refusing their sc…
Sep 3, 2026
5e90f1f
fix(shares): re-apply the public-reference guard when loading a snapshot
Sep 3, 2026
7ef58d9
fix(shares): rebuild public rows and classify scheme-relative descend…
Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 202 additions & 0 deletions api/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1208,3 +1208,205 @@ def clear_profile_cookie(handler) -> None:
cookie[cookie_name]['samesite'] = 'Lax'
cookie[cookie_name]['max-age'] = '0'
handler.send_header('Set-Cookie', cookie[cookie_name].OutputString())

# ── MEDIA: token path matching (shared) ──────────────────────────────────────
# A MEDIA path may legitimately contain spaces:
# MEDIA:/home/u/vault/Meeting Notes/2026-07-29 - SDE Focus Group.md
# A ``[^\s)\]]+`` class stops at the first space, which truncates the path.
# Frontend ui.js/messages.js used to do this (the artifact card rendered the
# wrong basename and the tail leaked into the bubble as prose); the same class
# lives in the /api/media allow-list and the public-share inliner, where a
# truncated capture silently fails to match the real on-disk path and the
# artifact becomes unviewable.
#
# Widening cannot be unbounded: greedy space tolerance would swallow trailing
# prose ("MEDIA:/tmp/a.png looks good") and glue an adjacent tag
# ("MEDIA:/a.png MEDIA:/b.png") into one invalid path. The bare form is
# therefore anchored on a file extension and tempered -- it crosses single
# spaces only while still reaching a ``.ext``, never crosses a newline, and
# carries a ``(?!MEDIA:)`` guard on each continuation token so the next token
# is never absorbed. Extension-less paths still match via the no-space
# fallback, so nothing that resolved before stops resolving.
#
# Keep this the single source of truth for MEDIA path shape on the Python side;
# it mirrors ``_mediaPathSrc()`` in static/ui.js.
_MEDIA_TOKEN_BARE = (
r"(?!MEDIA:)[^\s)\]]+?(?:[^\S\n](?!MEDIA:)[^\s)\]]+?)*?\.[A-Za-z0-9]+"
)
# Terminal sentence punctuation belongs to the PROSE, not to the ref:
# `see MEDIA:/tmp/a.png.` is a sentence about a file, not a file named
# `a.png.`. `.`/`!`/`?` only close the token when the NEXT character ends the
# token anyway (whitespace, a closing delimiter, or end of input), so a dot that
# is genuinely inside a name still belongs to the ref — `/tmp/a.tar.gz` and
# `/tmp/v1.2/chart.png` keep matching whole. A real HTTP(S) query keeps its `?`
# and `!` because those are followed by query characters, not by a boundary.
# Mirrors ``_mediaPathSrc()`` in static/ui.js; keep the two in lockstep.
# A `.`/`!`/`?` is sentence punctuation only when a REAL delimiter follows it —
# whitespace or a closing delimiter. End-of-input deliberately does NOT count:
# during streaming the text simply stops mid-token, and treating that as a
# sentence end would capture `/tmp/a` out of `MEDIA:/tmp/a.png` on the last
# chunk, so the streamed and settled renderings of one token would disagree.
_MEDIA_TOKEN_SENTENCE_END = r"[.!?](?=[\s)\]}\"'*_,;:])"
_MEDIA_TOKEN_BOUNDARY = (
r"(?=[\s)\]}\"'*_,;:]|" + _MEDIA_TOKEN_SENTENCE_END + r"|MEDIA:|$)"
)
# Explicit quoted forms. These win before every unquoted alternative so an
# ambiguous path (spaces, a dotted directory before a space, an internal ``)``
# or ``]``) has one unambiguous spelling that both languages agree on. A quoted
# ref may hold any character except its own quote and a newline — a newline
# always ends a MEDIA token. Mirrors the quoted alternatives in
# ``_mediaPathSrc()`` (static/ui.js); keep the two in lockstep.
_MEDIA_TOKEN_QUOTED = r"\"[^\"\n]+\"|'[^'\n]+'"


def unquote_media_ref(ref: str) -> str:
"""Strip one matching pair of surrounding quotes from a MEDIA: capture.

The capture groups in :func:`media_token_pattern` keep the quotes so the
matched span covers the full token (needed to replace it in the source
text). Every consumer that turns a capture into a filesystem path must call
this first, or a quoted ref reaches ``Path()`` with a literal ``"`` in it.

Mirrors ``_unquoteMediaRef()`` in static/ui.js.
"""
value = str(ref or "").strip()
if len(value) >= 2 and value[0] in ("\"", "'") and value[-1] == value[0]:
return value[1:-1]
return value


def is_external_media_url(ref: str) -> bool:
"""True when an (already unquoted) MEDIA ref points at a REMOTE http(s) URL.

Scheme comparison is case-insensitive because URI schemes are
case-insensitive (RFC 3986 §3.1), so ``HTTPS://`` is as external as
``https://``.

Deliberately HTTP(S)-only. ``file://`` is NOT reported here: a public share
must actively reject it (absolute and un-scoped, so it can point anywhere on
the host), and callers use this predicate to decide "leave the token alone",
which for ``file://`` would mean leaking the path into the share instead of
replacing it with a placeholder. Same for ``data:`` — the share boundary
handles those on their own terms.

Mirrors ``_isExternalMediaUrl()`` in static/ui.js.
"""
value = unquote_media_ref(ref)
return bool(_re.match(r"(?i)^https?://", value))


def media_token_pattern(extra_exclude: str = "", exclude_urls: bool = False) -> str:
"""Return the MEDIA: path-capture pattern (one capture group).

``extra_exclude`` adds characters to the excluded set of the unquoted
alternatives (the share inliner also excludes ``>``). ``exclude_urls``
skips ``MEDIA:http(s)://...`` so external images pass through untouched.

The alternatives are ordered, and the order is load-bearing:

1. **Quoted** — the unambiguous spelling; may hold any character.
2. **Spaced run whose FINAL space-separated word carries the extension.**
The continuation is greedy up to the last ``.ext`` on the line, so
``/tmp/v1.2 Reports/chart.png`` resolves whole instead of stopping at
the dotted directory ``/tmp/v1.2``. It is still bounded: each
continuation word must itself be extension-free, which is what stops
``MEDIA:/tmp/a.png looks good`` from absorbing prose and keeps two
adjacent tags separate.
3. **No-space fallback** — legacy shape, any extension or none, so
extension-less paths that resolved before keep resolving.

The returned capture may be quoted; callers must run it through
:func:`unquote_media_ref` before treating it as a path.
"""
# The URL guard is case-insensitive and sits inside an optional quote so a
# QUOTED external URL is skipped too. Spelling it `(?!https?://)` outside the
# capture (the previous shape) let two classes through, both of which the
# share inliner then resolved as LOCAL paths and replaced with the
# missing-media placeholder while the frontend rendered them as remote
# images: `MEDIA:"https://…"` (quote consumed before the guard could see the
# scheme) and `MEDIA:HTTPS://…` (schemes are case-insensitive per RFC 3986).
#
# Callers that need the URL rejected AFTER unquoting should also run
# `is_external_media_url()` on the unquoted capture — the guard here only
# keeps the pattern from matching in the first place.
url_guard = r"(?![\"']?(?i:https?)://)" if exclude_urls else ""
# One path character: no whitespace, and none of the delimiters that close
# a token (plus any caller-specific exclusions).
ch = r"[^\s)\]" + extra_exclude + r"]"
# A whole space-separated word with NO dot in it. Requiring the intermediate
# words to be dot-free is the bound: the run can cross `Reports/` and
# `Notes/` but stops dead at the first word carrying a `.ext`, so trailing
# prose after `a.png` is never absorbed.
#
# Spelled as a dot-free character class rather than a lookbehind to stay
# byte-comparable with the JS half, where `(?<!\.)` is a PARSE-TIME brick on
# engines without regex lookbehind (Safari < 16.4, some embedded WebViews) —
# see tests/test_5552_viewport_anchor_surrogate.py.
word_no_dot = r"(?!MEDIA:)[^\s)\]." + extra_exclude + r"]+"
# Final word carries the extension.
final_with_ext = r"(?!MEDIA:)" + ch + r"+?\.[A-Za-z0-9]+"
# Ambiguous prose shape: the FIRST word is already a complete dot-bearing
# filename, followed by same-line dot-free prose and then another filename:
#
# MEDIA:/tmp/a.png see README.md
#
# The spaced branch used to absorb all of `/tmp/a.png see README.md` as one
# nonexistent ref. Reject that shape so the earlier complete `/tmp/a.png`
# wins and the rest remains prose. The continuation classes deliberately
# exclude `/`: `/tmp/v1.2 Reports/chart.png` therefore does NOT match this
# rejection and retains dotted-directory support. A genuinely ambiguous
# spaced filename can use the already-supported quoted form.
no_slash = r"[^\s)\]" + extra_exclude + r"/]"
word_no_dot_no_slash = (
r"(?!MEDIA:)[^\s)\]." + extra_exclude + r"/]+"
)
final_with_ext_no_slash = (
r"(?!MEDIA:)" + no_slash + r"+?\.[A-Za-z0-9]+"
)
Comment on lines +1544 to +1550

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Slash-bearing prose joins media path

When a complete token is followed by prose ending in a slash-bearing filename, such as MEDIA:/tmp/a.png see /other/README.md, the slash-excluding discriminator fails and the spaced branch captures the entire fragment. The renderer and backend consumers then use /tmp/a.png see /other/README.md as a nonexistent path, losing the attachment and swallowing the trailing prose.

dotted_first_then_dotted_prose = (
r"(?!MEDIA:)" + ch + r"+?\.[A-Za-z0-9]+"
+ r"(?:[^\S\n]" + word_no_dot_no_slash + r")*?"
+ r"[^\S\n]" + final_with_ext_no_slash
+ _MEDIA_TOKEN_BOUNDARY
)
spaced = (
r"(?!(?:" + dotted_first_then_dotted_prose + r"))"
+ r"(?!MEDIA:)" + ch + r"+?"
+ r"(?:[^\S\n]" + word_no_dot + r")*?"
+ r"[^\S\n]" + final_with_ext
+ _MEDIA_TOKEN_BOUNDARY
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
nospace = r"(?!MEDIA:)" + ch + r"+?\.[A-Za-z0-9]+" + _MEDIA_TOKEN_BOUNDARY
# One path character that is NOT terminal sentence punctuation. This is a
# TEMPERED GREEDY run, not a lazy one: it consumes as much as the old greedy
# `ch+` did — so `C:/tmp/live.png` and a URL's own `://` and `MEDIA:`-bearing
# query survive intact — but it refuses a `.`/`!`/`?` that is immediately
# followed by a token boundary, leaving that character to the prose.
#
# A lazy `ch+?` plus a boundary lookahead is WRONG here: `:` closes a token,
# so the lazy form stops at `C` in `C:/tmp/live.png`, and stops at the nested
# `MEDIA:` inside an external URL.
ch_not_sentence_end = r"(?:(?!" + _MEDIA_TOKEN_SENTENCE_END + r")" + ch + r")"
# Extension-less legacy shape: greedy over path characters, but a trailing
# sentence `.`/`!`/`?` is left to the prose instead of being captured.
fallback = ch_not_sentence_end + r"+"
# A real HTTP(S) URL is one tempered-greedy run, tried BEFORE the bare forms
# so `://host/...` (and any nested `MEDIA:` in its path/query) stays inside
# one token. `MEDIA:https://h/a.png?q=1.` keeps the query and leaves the final
# period to the sentence. Not emitted when the caller skips URLs entirely.
external_url = "" if exclude_urls else (
r"(?i:https?)://" + ch_not_sentence_end + r"+"
)
return (
r"MEDIA:" + url_guard + r"("
+ _MEDIA_TOKEN_QUOTED
+ ((r"|" + external_url) if external_url else "")
+ r"|" + spaced
+ r"|" + nospace
+ r"|" + fallback
# Last resort: a token that reaches neither an extension nor a boundary
# (e.g. the final bytes of a still-streaming ref) keeps the historic
# greedy behavior so nothing that resolved before stops resolving.
+ r"|" + ch + r"+"
+ r")"
)
10 changes: 9 additions & 1 deletion api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2848,6 +2848,8 @@ def _rebuild_stale_session_list_cache():
redact_session_data,
_redact_text,
_CLIENT_DISCONNECT_ERRORS,
media_token_pattern,
unquote_media_ref,
)
from api.agent_health import build_agent_health_payload
from api.gateway_chat import gateway_chat_config_status
Expand Down Expand Up @@ -18806,7 +18808,7 @@ def _serve_inline_html_preview(handler, target: Path, cache_control: str, *, csp
return True


_MEDIA_TOKEN_RE = re.compile(r"MEDIA:([^\s\)\]]+)")
_MEDIA_TOKEN_RE = re.compile(media_token_pattern())


def _message_content_text(content) -> str:
Expand Down Expand Up @@ -18852,6 +18854,12 @@ def _session_media_token_allows_path(sid: str, target: Path, allowed_mimes: set[
if "MEDIA:" not in text:
continue
for ref in _MEDIA_TOKEN_RE.findall(text):
# A quoted ref (the unambiguous spelling for spaced paths) is
# captured WITH its quotes so the matched span covers the whole
# token; strip them before touching the filesystem, or the
# allow-list entry would carry a literal quote and never match the
# path the renderer actually requests.
ref = unquote_media_ref(ref)
if "://" in ref:
continue
try:
Expand Down
40 changes: 33 additions & 7 deletions api/shares.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@
from pathlib import Path

from api.config import STATE_DIR
from api.helpers import redact_session_data
from api.helpers import (
redact_session_data,
media_token_pattern,
unquote_media_ref,
is_external_media_url,
)
# _redact_fn_cached is the ALWAYS-ON credential redactor (agent redactor with
# force=True + local fallback regex). Unlike redact_session_data it does NOT
# consult the user-toggleable api_redact_enabled setting — a public share is a
Expand Down Expand Up @@ -115,13 +120,23 @@ def _redact_share_paths(text: str, extra_paths) -> str:
return text


# Regex matching local MEDIA:<path> references — same pattern that
# Regex matching MEDIA:<path> references — same pattern that
# _inlineMediaHtmlForRef() in ui.js handles when rendering messages.
# Excludes MEDIA: followed by http/https URLs so external images pass
# through unchanged. file:// references are NOT matched here — they are
# always rejected at the public-share boundary (absolute, un-scoped).
# Shares additionally exclude '>' so an inlined <img ...> tag terminates the
# path. Shape comes from the shared helper so a spaced path resolves here the
# same way it does in the renderer and the /api/media allow-list.
#
# NOTE: this deliberately does NOT pass exclude_urls=True. That option is a
# negative lookahead at the current start position, so an external URL is
# rejected by NOT MATCHING — and `re.sub` then resumes scanning one character
# later, inside the very token it just refused. For
# `MEDIA:https://cdn.test/img/MEDIA:/etc/passwd.png` the outer token is skipped
# and the nested `MEDIA:` matches as a fresh LOCAL token, so a legitimate
# external URL gets its tail rewritten (and local paths get probed from share
# text). Match the complete canonical token here and classify it in
# _replace_ref(): match -> classify -> decide, which cannot restart mid-token.
_SHARE_MEDIA_RE = re.compile(
r"MEDIA:(?!https?://)([^\s\)\]>]+)"
media_token_pattern(extra_exclude=">")
)

# Max size (in bytes) for files we'll embed as base64 in a share snapshot.
Expand Down Expand Up @@ -290,9 +305,20 @@ def _resolve_against_roots(raw: str) -> Path | None:
return None

def _replace_ref(m: re.Match) -> str:
raw = (m.group(1) or "").strip()
# Quoted refs are captured with their quotes so the replaced span covers
# the whole token; strip them before any path resolution, or a spaced
# path that the renderer displays would be placeholdered here.
raw = unquote_media_ref(m.group(1) or "")
if not raw:
return m.group(0)
# Classification happens HERE, on a token that was actually matched, and
# is the only thing that decides external vs local. The pattern no longer
# rejects external URLs by non-match, so the scanner can never resume
# inside a refused token and rewrite the tail of an external URL that
# itself contains `MEDIA:`. Return the exact original span so an exempt
# external token is preserved byte-for-byte.
if is_external_media_url(raw):
return m.group(0)

# --- Resolve and validate against allowed roots -----------------------
p = _resolve_against_roots(raw)
Expand Down
Loading