diff --git a/CHANGELOG.md b/CHANGELOG.md index d3dd16e5c..a343221c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.478] — 2026-06-17 — Release QM (read-only LLM Wiki browser in Insights) + +### Added + +- **The Insights panel now has a read-only LLM Wiki browser (#2941).** When the Hermes agent has an LLM Wiki configured (`WIKI_PATH` / `skills.config.wiki.path`), the Insights panel surfaces a knowledge-base observability section — enabled state, entry/page/raw-file counts, last-updated, and last writer — plus a browsable read-only view of the wiki pages. When no wiki directory is configured it shows a clear "Unavailable — set `WIKI_PATH`…" empty state instead of failing. The browser is strictly read-only and auth-gated, and the page-loading path is hardened against directory traversal (realpath + symlink-safe resolution). Thanks @rodboev. + ## [v0.51.477] — 2026-06-17 — Release QL (gateway runs-API routing now opt-in) ### Fixed diff --git a/api/routes.py b/api/routes.py index 7bd1d9137..37b1d0e71 100644 --- a/api/routes.py +++ b/api/routes.py @@ -5402,6 +5402,9 @@ def _llm_wiki_config_path() -> str | None: # Cap WIKI walks to prevent self-DoS if WIKI_PATH points at /, /etc, /home, etc. # Real LLM wikis have under a few thousand files; 10k is generous and catches misconfig. _LLM_WIKI_MAX_FILES = 10000 +# Cap a single served wiki page at 2 MiB so a huge/binary file can't be slurped +# wholesale into memory + a JSON response (DoS / memory-blowup guard). +_LLM_WIKI_MAX_PAGE_BYTES = 2 * 1024 * 1024 # Refuse to walk these system roots even if explicitly configured. _LLM_WIKI_FORBIDDEN_ROOTS = frozenset( str(Path(p).expanduser().resolve()) for p in ("/", "/etc", "/usr", "/var", "/opt", "/sys", "/proc") @@ -5460,26 +5463,53 @@ def _llm_wiki_count_files(root: Path) -> int: def _llm_wiki_page_files(wiki_path: Path) -> list[Path]: pages: list[Path] = [] - # Defense in depth: refuse forbidden system roots. + # Defense in depth: refuse forbidden system roots, and resolve the wiki root + # ONCE as the single trust base for all containment checks below. try: - if str(wiki_path.resolve()) in _LLM_WIKI_FORBIDDEN_ROOTS: + wiki_real = wiki_path.resolve() + if str(wiki_real) in _LLM_WIKI_FORBIDDEN_ROOTS: return pages except Exception: return pages + + def _is_clean_relpath(rel: Path) -> bool: + # No dot-prefixed segment (dotfile/dotdir) anywhere in the path. + return not any(part.startswith(".") for part in rel.parts) + iterated = 0 for dirname in _LLM_WIKI_PAGE_DIRS: section = wiki_path / dirname if not section.exists() or not section.is_dir(): continue + # The section itself must resolve UNDER the real wiki root — guards a + # symlinked section (e.g. concepts -> /tmp/outside) from exposing files + # outside the wiki tree entirely. + try: + section_real = section.resolve() + section_real.relative_to(wiki_real) + except (OSError, ValueError): + continue for item in section.rglob("*.md"): iterated += 1 if iterated > _LLM_WIKI_MAX_FILES: return pages # bounded try: rel = item.relative_to(section) - if item.is_file() and not any(part.startswith(".") for part in rel.parts): - pages.append(item) - except Exception: + if not item.is_file() or not _is_clean_relpath(rel): + continue + # Resolve the real target and require it to live under BOTH the + # real wiki root and the real section, with no dot-prefixed + # segment on the resolved-relative path. This closes symlink + # escapes whose link name looks like a clean *.md page but whose + # target is an arbitrary / hidden / out-of-tree file (the read + # endpoint would otherwise serve it). + item_real = item.resolve() + item_real.relative_to(section_real) + rel_real = item_real.relative_to(wiki_real) + if not _is_clean_relpath(rel_real): + continue + pages.append(item) + except (OSError, ValueError): continue return pages @@ -6836,6 +6866,91 @@ def handle_get(handler, parsed) -> bool: return True if parsed.path == "/api/wiki/status": return _handle_llm_wiki_status(handler, parsed) + if parsed.path == "/api/wiki/browse": + wiki_root, _, _ = _llm_wiki_resolve_path() + if not wiki_root or not os.path.isdir(wiki_root): + return bad(handler, "Wiki not configured or directory not found", status=404) + page_paths = _llm_wiki_page_files(wiki_root) + pages = [] + for fp in sorted(page_paths, key=lambda p: str(p).lower()): + try: + rel = fp.relative_to(wiki_root) + except ValueError: + continue + try: + st = fp.stat() + except OSError: + continue + pages.append({"name": fp.name, "path": str(rel).replace("\\", "/"), "size": st.st_size, "mtime": int(st.st_mtime)}) + return j(handler, {"pages": pages}) + if parsed.path == "/api/wiki/page": + wiki_root, _, _ = _llm_wiki_resolve_path() + page_path = parse_qs(parsed.query or "").get("path", [""])[0] + if not wiki_root or not page_path: + return bad(handler, "Wiki not configured or path not provided", status=400) + # Reject a real `..` path SEGMENT (or absolute path), not the bare + # substring — a legitimate listed filename like `v1..v2.md` contains + # ".." without being traversal. Containment + the resolved-allowlist + # membership check below are the actual security boundary. + _page_parts = page_path.replace("\\", "/").split("/") + if os.path.isabs(page_path) or any(part == ".." for part in _page_parts): + return bad(handler, "Invalid path", status=400) + full_path = Path(os.path.join(wiki_root, page_path)) + if not _skill_path_within(Path(wiki_root), full_path): + return bad(handler, "Invalid path", status=400) + # Only serve files the browse/list path would surface (same allowlist: + # *.md under the wiki page-dirs, no dotfiles, forbidden-roots guard). + # Without this the read endpoint could return ANY file inside the wiki + # root (e.g. .env / .git/config / non-.md), since containment alone + # doesn't constrain which files are readable (Opus review finding). + # Capture each allowlisted page's STABLE IDENTITY (st_dev, st_ino) so the + # post-open fstat below can detect a file/parent-dir swapped in after the + # allowlist check (TOCTOU write-race, Codex finding) — a pathname re-open + # alone can't, since O_NOFOLLOW only guards the final component, not a + # swapped parent directory. + allowed_identity: dict[Path, tuple] = {} + try: + for _p in _llm_wiki_page_files(Path(wiki_root)): + try: + rp = _p.resolve() + st0 = rp.stat() + allowed_identity[rp] = (st0.st_dev, st0.st_ino) + except OSError: + continue + except Exception: + allowed_identity = {} + try: + resolved_target = full_path.resolve() + except OSError: + return bad(handler, "Page not found", status=404) + if resolved_target not in allowed_identity: + return bad(handler, "Page not found", status=404) + # Read the ALREADY-RESOLVED, allowlisted real path with O_NOFOLLOW so a + # symlink swapped in for the final component between the allowlist check + # and the read is refused rather than followed. Then fstat the open fd + # and require its (st_dev, st_ino) to match the identity captured during + # allowlisting — this closes a parent-directory swap that O_NOFOLLOW + # would otherwise follow. Any mismatch / vanished / swapped page returns + # a clean 404, never a 500. + try: + fd = os.open(str(resolved_target), os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + try: + st_open = os.fstat(fd) + if (st_open.st_dev, st_open.st_ino) != allowed_identity[resolved_target]: + return bad(handler, "Page not found", status=404) + raw = os.read(fd, _LLM_WIKI_MAX_PAGE_BYTES + 1) + finally: + os.close(fd) + if len(raw) > _LLM_WIKI_MAX_PAGE_BYTES: + raw = raw[:_LLM_WIKI_MAX_PAGE_BYTES] + content = raw.decode("utf-8", errors="replace") + except (FileNotFoundError, IsADirectoryError): + return bad(handler, "Page not found", status=404) + except OSError: + # ELOOP (symlink swapped in under O_NOFOLLOW) or any other read + # failure → clean 404, never a 500. + return bad(handler, "Could not read page", status=404) + return j(handler, {"content": content, "path": page_path}) if parsed.path == "/api/logs": return _handle_logs(handler, parsed) diff --git a/static/i18n.js b/static/i18n.js index e1abfe884..91f542f03 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -941,6 +941,10 @@ const LOCALES = { insights_skill_usage_col_views: 'Views', insights_skill_usage_col_share: 'Usage %', insights_skill_usage_col_patches: 'Patches', + wiki_browse: 'Browse wiki', + wiki_search_placeholder: 'Search pages...', + wiki_no_pages: 'No wiki pages found', + wiki_not_configured: 'Wiki not configured', workspace_desc: 'Add and switch workspaces for your sessions.', session_meta_messages: (n) => `${n} msg${n === 1 ? '' : 's'}`, session_meta_children: (n) => `${n} child${n === 1 ? '' : 'ren'}`, @@ -2975,6 +2979,10 @@ const LOCALES = { outline_title: 'Struttura', outline_empty: 'Nessuna domanda ancora.', outline_loading: 'Caricamento…', + wiki_browse: 'Browse wiki', + wiki_search_placeholder: 'Search pages...', + wiki_no_pages: 'No wiki pages found', + wiki_not_configured: 'Wiki not configured', }, ja: { @@ -4462,6 +4470,10 @@ const LOCALES = { outline_title: 'アウトライン', outline_empty: 'まだ質問はありません。', outline_loading: '読み込み中…', + wiki_browse: 'Browse wiki', + wiki_search_placeholder: 'Search pages...', + wiki_no_pages: 'No wiki pages found', + wiki_not_configured: 'Wiki not configured', }, ru: { @@ -5886,6 +5898,10 @@ const LOCALES = { outline_title: 'Outline', // TODO: translate outline_empty: 'No questions yet.', // TODO: translate outline_loading: 'Loading…', // TODO: translate + wiki_browse: 'Browse wiki', + wiki_search_placeholder: 'Search pages...', + wiki_no_pages: 'No wiki pages found', + wiki_not_configured: 'Wiki not configured', }, es: { @@ -7304,6 +7320,10 @@ const LOCALES = { outline_title: 'Esquema', outline_empty: 'Aún no hay preguntas.', outline_loading: 'Cargando…', + wiki_browse: 'Browse wiki', + wiki_search_placeholder: 'Search pages...', + wiki_no_pages: 'No wiki pages found', + wiki_not_configured: 'Wiki not configured', }, de: { @@ -8726,6 +8746,10 @@ const LOCALES = { outline_title: 'Gliederung', outline_empty: 'Noch keine Fragen.', outline_loading: 'Laden…', + wiki_browse: 'Browse wiki', + wiki_search_placeholder: 'Search pages...', + wiki_no_pages: 'No wiki pages found', + wiki_not_configured: 'Wiki not configured', }, zh: { @@ -10141,6 +10165,10 @@ const LOCALES = { outline_title: '大纲', outline_empty: '暂无问题。', outline_loading: '加载中…', + wiki_browse: 'Browse wiki', + wiki_search_placeholder: 'Search pages...', + wiki_no_pages: 'No wiki pages found', + wiki_not_configured: 'Wiki not configured', }, // Traditional Chinese (zh-Hant) @@ -11626,6 +11654,10 @@ const LOCALES = { outline_title: '大綱', outline_empty: '尚無問題。', outline_loading: '載入中…', + wiki_browse: 'Browse wiki', + wiki_search_placeholder: 'Search pages...', + wiki_no_pages: 'No wiki pages found', + wiki_not_configured: 'Wiki not configured', }, @@ -12926,6 +12958,10 @@ const LOCALES = { outline_title: 'Esboço', outline_empty: 'Ainda não há perguntas.', outline_loading: 'Carregando…', + wiki_browse: 'Browse wiki', + wiki_search_placeholder: 'Search pages...', + wiki_no_pages: 'No wiki pages found', + wiki_not_configured: 'Wiki not configured', }, ko: { offline_title: '연결이 끊겼습니다', @@ -14397,6 +14433,10 @@ const LOCALES = { outline_title: '개요', outline_empty: '아직 질문이 없습니다.', outline_loading: '로딩 중…', + wiki_browse: 'Browse wiki', + wiki_search_placeholder: 'Search pages...', + wiki_no_pages: 'No wiki pages found', + wiki_not_configured: 'Wiki not configured', }, fr: { @@ -15809,6 +15849,10 @@ const LOCALES = { outline_title: 'Plan', outline_empty: 'Pas encore de questions.', outline_loading: 'Chargement…', + wiki_browse: 'Browse wiki', + wiki_search_placeholder: 'Search pages...', + wiki_no_pages: 'No wiki pages found', + wiki_not_configured: 'Wiki not configured', }, tr: { @@ -17284,6 +17328,10 @@ const LOCALES = { outline_title: 'Ana Hat', outline_empty: 'Henüz soru yok.', outline_loading: 'Yükleniyor…', + wiki_browse: 'Browse wiki', + wiki_search_placeholder: 'Search pages...', + wiki_no_pages: 'No wiki pages found', + wiki_not_configured: 'Wiki not configured', @@ -18273,6 +18321,10 @@ const LOCALES = { outline_title: 'Zarys', outline_empty: 'Nie ma jeszcze pytań.', outline_loading: 'Ładowanie…', + wiki_browse: 'Browse wiki', + wiki_search_placeholder: 'Search pages...', + wiki_no_pages: 'No wiki pages found', + wiki_not_configured: 'Wiki not configured', settings_sidebar_density_compact: 'Kompaktowa', settings_sidebar_density_detailed: 'Szczegółowa', settings_desc_sidebar_density: 'Kontroluje, ile metadanych wyświetla lista sesji na lewym pasku bocznym.', diff --git a/static/panels.js b/static/panels.js index 9fec03bba..b62030d76 100644 --- a/static/panels.js +++ b/static/panels.js @@ -3597,11 +3597,98 @@ function _renderLlmWikiStatus(d) { `; } +async function _openWikiBrowser() { + const existing = document.getElementById('wikiBrowserOverlay'); + if (existing) { existing.style.display = 'flex'; return; } + + const overlay = document.createElement('div'); + overlay.id = 'wikiBrowserOverlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;'; + + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.style.display = 'none'; }); + document.addEventListener('keydown', function escHandler(e) { + if (e.key === 'Escape') { overlay.style.display = 'none'; document.removeEventListener('keydown', escHandler); } + }); + + const panel = document.createElement('div'); + panel.style.cssText = 'background:var(--bg);border:1px solid var(--border);border-radius:8px;width:min(720px,95vw);max-height:80vh;display:flex;flex-direction:column;overflow:hidden;'; + + panel.innerHTML = ` +
+ ${esc(t('wiki_browse'))} + +
+
+ +
+
+ `; + + overlay.appendChild(panel); + document.body.appendChild(overlay); + + const listEl = document.getElementById('wikiBrowserList'); + const contentEl = document.getElementById('wikiBrowserContent'); + const searchEl = document.getElementById('wikiBrowserSearch'); + let _pages = []; + + function _renderWikiPageList(filter) { + const q = (filter || '').toLowerCase(); + const visible = q ? _pages.filter(p => p.name.toLowerCase().includes(q)) : _pages; + if (!visible.length) { + listEl.innerHTML = `
${esc(t('wiki_no_pages'))}
`; + return; + } + listEl.innerHTML = visible.map(p => + `
${esc(p.name)}
` + ).join(''); + } + + window._wikiBrowserOpenPage = async function(path) { + contentEl.innerHTML = '
Loading...
'; + contentEl.style.display = 'block'; + listEl.style.display = 'none'; + try { + const data = await api('/api/wiki/page?path=' + encodeURIComponent(path)); + if (typeof renderMarkdownPreviewContent === 'function') { + contentEl.innerHTML = '
'; + renderMarkdownPreviewContent({content: data.content, el: document.getElementById('wikiBrowserMd')}); + } else { + contentEl.innerHTML = '
' + esc(data.content) + '
'; + } + } catch(e) { + contentEl.innerHTML = '
' + esc(e.message || String(e)) + '
'; + } + }; + + window._wikiBrowserBack = function() { + contentEl.style.display = 'none'; + listEl.style.display = ''; + }; + + searchEl.addEventListener('input', () => _renderWikiPageList(searchEl.value)); + + try { + const data = await api('/api/wiki/browse'); + _pages = Array.isArray(data && data.pages) ? data.pages : []; + if (!_pages.length) { + listEl.innerHTML = `
${esc(t('wiki_no_pages'))}
`; + } else { + _renderWikiPageList(''); + } + } catch(e) { + listEl.innerHTML = `
${esc(e.message || String(e))}
`; + } +} + /** * Bucket daily token rows for chart display. * Returns rows unchanged when length <= 30 (per-day resolution). diff --git a/static/workspace.js b/static/workspace.js index 5359a10eb..2381f5dd7 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -564,8 +564,9 @@ function setLargeMarkdownForceRenderVisible(visible){ } function renderMarkdownPreviewContent(data){ - showPreview('md'); - $('previewMd').innerHTML=renderMd(data.content); + const target=data&&data.el?data.el:$('previewMd'); + if(!data||!data.el) showPreview('md'); + target.innerHTML=renderMd(data.content); requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();}); } diff --git a/tests/test_issue2823_large_markdown_preview.py b/tests/test_issue2823_large_markdown_preview.py index 806eacaeb..7099e7aa2 100644 --- a/tests/test_issue2823_large_markdown_preview.py +++ b/tests/test_issue2823_large_markdown_preview.py @@ -54,12 +54,14 @@ def test_markdown_render_helper_runs_render_md_and_katex(): assert end != -1, "renderMarkdownPreviewContent() helper end not found" helper = WORKSPACE_JS[start:end] - render_pos = helper.find("$('previewMd').innerHTML=renderMd(data.content)") + target_pos = helper.find("const target=data&&data.el?data.el:$('previewMd')") + render_pos = helper.find("target.innerHTML=renderMd(data.content)") katex_pos = helper.rfind("renderKatexBlocks") - assert "showPreview('md')" in helper + assert target_pos != -1, "Helper must honor an explicit markdown render target" + assert "if(!data||!data.el) showPreview('md')" in helper assert render_pos != -1, "Helper must rich-render markdown" assert katex_pos != -1, "Helper must preserve KaTeX enhancement" - assert katex_pos > render_pos + assert target_pos < render_pos < katex_pos def test_large_markdown_fallback_sets_raw_content_before_size_gate(): diff --git a/tests/test_issue2941_wiki_browse.py b/tests/test_issue2941_wiki_browse.py new file mode 100644 index 000000000..e6cb48f11 --- /dev/null +++ b/tests/test_issue2941_wiki_browse.py @@ -0,0 +1,299 @@ +"""Static-analysis tests for the LLM Wiki browser feature (issue #2941). + +Verifies that: +1. /api/wiki/browse and /api/wiki/page route patterns exist in routes.py. +2. _renderLlmWikiStatus in panels.js references a browse action. +3. Path-traversal rejection (the ".." check) is present in the wiki page handler. +4. The four i18n keys are present in every locale block. +""" +from __future__ import annotations + +import json +import re +from pathlib import Path +from urllib.parse import urlparse + +REPO = Path(__file__).resolve().parents[1] + + +class _FakeHandler: + def __init__(self): + self.status = None + self.sent_headers: list[tuple[str, str]] = [] + self.body = bytearray() + self.wfile = self + + def send_response(self, code): + self.status = code + + def send_header(self, key, value): + self.sent_headers.append((key, value)) + + def end_headers(self): + pass + + def write(self, data): + self.body.extend(data if isinstance(data, (bytes, bytearray)) else data.encode("utf-8")) + + def get_json(self): + return json.loads(self.body.decode("utf-8")) + + +def test_wiki_browse_route_exists_in_routes(): + src = (REPO / "api" / "routes.py").read_text(encoding="utf-8") + assert '"/api/wiki/browse"' in src, "GET /api/wiki/browse route not found in routes.py" + + +def test_wiki_page_route_exists_in_routes(): + src = (REPO / "api" / "routes.py").read_text(encoding="utf-8") + assert '"/api/wiki/page"' in src, "GET /api/wiki/page route not found in routes.py" + + +def test_wiki_page_path_traversal_rejection(): + src = (REPO / "api" / "routes.py").read_text(encoding="utf-8") + # Traversal is rejected by a real `..` path SEGMENT check (not the bare + # substring, which would also reject a legit filename like `v1..v2.md`). + assert 'part == ".."' in src, "Segment-based path-traversal check not found in wiki page handler" + assert "_skill_path_within" in src.split("/api/wiki/page")[1].split("/api/")[0], ( + "Symlink-safe _skill_path_within guard not found in /api/wiki/page handler" + ) + + +def test_render_llm_wiki_status_references_browse(): + src = (REPO / "static" / "panels.js").read_text(encoding="utf-8") + assert "_renderLlmWikiStatus" in src, "_renderLlmWikiStatus not found in panels.js" + assert "_openWikiBrowser" in src, "_openWikiBrowser reference not found in panels.js" + + +def test_open_wiki_browser_function_exists(): + src = (REPO / "static" / "panels.js").read_text(encoding="utf-8") + assert "async function _openWikiBrowser" in src, "_openWikiBrowser function not defined in panels.js" + assert "/api/wiki/browse" in src, "/api/wiki/browse fetch not found in panels.js" + assert "/api/wiki/page" in src, "/api/wiki/page fetch not found in panels.js" + + +def test_wiki_browse_skips_pages_that_disappear_during_listing(monkeypatch, tmp_path): + from api import routes + + wiki_root = tmp_path / "wiki" + wiki_root.mkdir() + ok = wiki_root / "ok.md" + ok.write_text("# ok\n", encoding="utf-8") + missing = wiki_root / "gone.md" + + monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None)) + monkeypatch.setattr(routes, "_llm_wiki_page_files", lambda root: [missing, ok]) + + handler = _FakeHandler() + routes.handle_get(handler, urlparse("http://example.com/api/wiki/browse")) + + assert handler.status == 200 + assert handler.get_json()["pages"] == [ + { + "name": "ok.md", + "path": "ok.md", + "size": ok.stat().st_size, + "mtime": int(ok.stat().st_mtime), + } + ] + + +def test_wiki_page_vanished_between_check_and_read_returns_404_not_500(monkeypatch, tmp_path): + """TOCTOU: a page that disappears between is_file() and read_text() must + return a clean 404, not let OSError bubble to the generic 500 handler.""" + from api import routes + + wiki_root = tmp_path / "wiki" + wiki_root.mkdir() + + monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None)) + + # Path resolves inside the root and passes the containment guard, but the + # read itself raises FileNotFoundError (simulating a vanished/racing file). + class _GonePath(type(wiki_root)): + def is_file(self): # noqa: D401 - test stub + return True + + def read_text(self, *a, **k): + raise FileNotFoundError("vanished between list and read") + + real_path_join = routes.os.path.join + + monkeypatch.setattr(routes, "_skill_path_within", lambda root, p: True) + + orig_Path = routes.Path + + def _fake_Path(arg): + # Only wrap the wiki page target; leave the root resolution alone. + if isinstance(arg, str) and arg == real_path_join(str(wiki_root), "gone.md"): + return _GonePath(arg) + return orig_Path(arg) + + monkeypatch.setattr(routes, "Path", _fake_Path) + + handler = _FakeHandler() + routes.handle_get(handler, urlparse("http://example.com/api/wiki/page?path=gone.md")) + + assert handler.status == 404, f"expected clean 404 on vanished page, got {handler.status}" + + +def test_wiki_page_read_is_restricted_to_listed_pages(monkeypatch, tmp_path): + """The read endpoint must only serve files the browse/list path surfaces. + A secret file inside the wiki root (e.g. .env) that is NOT a listed page + must NOT be readable, even though it passes path-containment.""" + from api import routes + + wiki_root = tmp_path / "wiki" + (wiki_root / "concepts").mkdir(parents=True) + listed = wiki_root / "concepts" / "real.md" + listed.write_text("# real page\n", encoding="utf-8") + secret = wiki_root / ".env" + secret.write_text("DONOTLEAK=secretmarker_abc\n", encoding="utf-8") + + monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None)) + + # Attempt to read the secret (contained within the root, not a listed page). + handler = _FakeHandler() + routes.handle_get(handler, urlparse("http://example.com/api/wiki/page?path=.env")) + assert handler.status == 404, f"secret .env must not be readable, got {handler.status}" + assert b"secretmarker_abc" not in handler.body, "secret content leaked through /api/wiki/page" + + # The genuinely-listed page IS readable. + handler2 = _FakeHandler() + routes.handle_get(handler2, urlparse("http://example.com/api/wiki/page?path=concepts/real.md")) + assert handler2.status == 200, f"listed page should be readable, got {handler2.status}" + assert "real page" in handler2.get_json()["content"] + + +def test_wiki_symlink_page_cannot_escape_section_root(monkeypatch, tmp_path): + """A listed-looking *.md symlink whose target resolves OUTSIDE its section + dir (e.g. concepts/leak.md -> ../.env) must not be listed by browse nor + readable by /api/wiki/page — the resolved real path is the security check.""" + import os as _os + from api import routes + + wiki_root = tmp_path / "wiki" + (wiki_root / "concepts").mkdir(parents=True) + (wiki_root / "concepts" / "real.md").write_text("# real\n", encoding="utf-8") + secret = wiki_root / ".env" + secret.write_text("DONOTLEAK=leak_marker_xyz\n", encoding="utf-8") + + leak = wiki_root / "concepts" / "leak.md" + try: + leak.symlink_to(_os.path.join("..", ".env")) + except (OSError, NotImplementedError): + import pytest + pytest.skip("symlinks not supported on this platform") + + monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None)) + + # browse must NOT list the escaping symlink + h_browse = _FakeHandler() + routes.handle_get(h_browse, urlparse("http://example.com/api/wiki/browse")) + listed_names = [p["path"] for p in h_browse.get_json()["pages"]] + assert "concepts/leak.md" not in listed_names, "escaping symlink must not be listed" + + # page read of the symlink must 404 and must not leak the secret + h_page = _FakeHandler() + routes.handle_get(h_page, urlparse("http://example.com/api/wiki/page?path=concepts/leak.md")) + assert h_page.status == 404, f"symlink-escape read must 404, got {h_page.status}" + assert b"leak_marker" not in h_page.body and b"SECRET" not in h_page.body, "secret leaked via symlink page" + + +def test_wiki_symlink_to_hidden_same_section_target_blocked(monkeypatch, tmp_path): + """A *.md symlink whose target is a HIDDEN file in the same section + (concepts/link.md -> .hidden/secret.md) must not be listed or readable — + the dot-segment rule applies to the RESOLVED target, not just the link name.""" + import os as _os + from api import routes + + wiki_root = tmp_path / "wiki" + hidden_dir = wiki_root / "concepts" / ".hidden" + hidden_dir.mkdir(parents=True) + (hidden_dir / "secret.md").write_text("DONOTLEAK=hidden_marker_q\n", encoding="utf-8") + (wiki_root / "concepts" / "real.md").write_text("# real\n", encoding="utf-8") + link = wiki_root / "concepts" / "link.md" + try: + link.symlink_to(_os.path.join(".hidden", "secret.md")) + except (OSError, NotImplementedError): + import pytest + pytest.skip("symlinks not supported on this platform") + + monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None)) + + h_browse = _FakeHandler() + routes.handle_get(h_browse, urlparse("http://example.com/api/wiki/browse")) + listed = [p["path"] for p in h_browse.get_json()["pages"]] + assert "concepts/link.md" not in listed, "symlink to hidden target must not be listed" + + h_page = _FakeHandler() + routes.handle_get(h_page, urlparse("http://example.com/api/wiki/page?path=concepts/link.md")) + assert h_page.status == 404, f"symlink to hidden target must 404, got {h_page.status}" + assert b"hidden_marker_q" not in h_page.body, "hidden secret leaked via symlink" + + +def test_wiki_symlinked_section_cannot_expose_outside_tree(monkeypatch, tmp_path): + """A symlinked SECTION dir (concepts -> /tmp/outside) must not expose files + outside the real wiki root.""" + from api import routes + + wiki_root = tmp_path / "wiki" + wiki_root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (outside / "leak.md").write_text("DONOTLEAK=outside_marker_z\n", encoding="utf-8") + try: + (wiki_root / "concepts").symlink_to(outside, target_is_directory=True) + except (OSError, NotImplementedError): + import pytest + pytest.skip("symlinks not supported on this platform") + + monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None)) + + h_browse = _FakeHandler() + routes.handle_get(h_browse, urlparse("http://example.com/api/wiki/browse")) + listed = [p["path"] for p in h_browse.get_json()["pages"]] + assert listed == [], f"symlinked section must expose nothing, listed {listed}" + + +def test_wiki_legit_filename_with_dotdot_substring_opens(monkeypatch, tmp_path): + """A legit listed page whose filename merely CONTAINS '..' (e.g. v1..v2.md) + must still open — traversal rejection is per-segment, not substring.""" + from api import routes + + wiki_root = tmp_path / "wiki" + (wiki_root / "concepts").mkdir(parents=True) + (wiki_root / "concepts" / "v1..v2.md").write_text("# diff notes\n", encoding="utf-8") + + monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None)) + + h = _FakeHandler() + routes.handle_get(h, urlparse("http://example.com/api/wiki/page?path=concepts/v1..v2.md")) + assert h.status == 200, f"legit filename with '..' substring should open, got {h.status}" + assert "diff notes" in h.get_json()["content"] + + +def test_i18n_wiki_keys_in_all_locales(): + src = (REPO / "static" / "i18n.js").read_text(encoding="utf-8") + required_keys = [ + "wiki_browse", + "wiki_search_placeholder", + "wiki_no_pages", + "wiki_not_configured", + ] + # Locate all locale block boundaries by finding "_lang:" occurrences, + # then verify each required key appears in every locale block. + lang_positions = [m.start() for m in re.finditer(r"_lang:", src)] + assert lang_positions, "Could not find any locale blocks in i18n data" + + locale_chunks = [] + for idx, start in enumerate(lang_positions): + end = lang_positions[idx + 1] if idx + 1 < len(lang_positions) else len(src) + locale_chunks.append(src[start:end]) + + for i, chunk in enumerate(locale_chunks): + for key in required_keys: + assert key + ":" in chunk, ( + f"i18n key '{key}' missing from locale block {i + 1} " + f"(position ~{lang_positions[i]})" + )