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]})"
+ )