feat(#2941): add read-only LLM Wiki browser in the Insights panel

Surfaces the Hermes agent's LLM Wiki as a read-only knowledge-base
observability section in Insights (status counts + browsable read-only
pages). Unconfigured shows a clear "Unavailable" empty state.

Strictly read-only, auth-gated. The wiki file-serving path is hardened
against the full traversal/symlink/TOCTOU class (Codex + Opus review, each
vector regression-tested + toothless-checked):
- _llm_wiki_page_files resolves the wiki root once as the trust base;
  skips a section whose resolved path escapes the root; requires each *.md
  target's RESOLVED path under both real root and real section; rejects
  dot-prefixed segments on lexical AND resolved-relative paths (closes
  symlink-to-hidden/out-of-tree + symlinked-section escapes). Browse + read
  share this allowlist.
- /api/wiki/page rejects abs paths + a real '..' SEGMENT (legit v1..v2.md
  still opens); requires the resolved target in the allowlist; reads the
  resolved path with O_NOFOLLOW; and fstat-verifies the open fd's
  (st_dev, st_ino) against the identity captured at allowlist time — closing
  final-component AND parent-directory symlink-swap TOCTOU races. Any
  mismatch/vanish -> clean 404, never a 500.

Co-authored-by: Rod Boev <rod.boev@gmail.com>
This commit is contained in:
nesquena-hermes
2026-06-17 18:43:57 +00:00
parent d59cae5984
commit 4e5eebd0d6
7 changed files with 573 additions and 11 deletions
+6
View File
@@ -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
+120 -5
View File
@@ -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)
+52
View File
@@ -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.',
+88 -1
View File
@@ -3597,11 +3597,98 @@ function _renderLlmWikiStatus(d) {
</div>
<div class="wiki-status-footer">
<span>${esc(toggleNote)}</span>
<a href="${esc(docsUrl)}" target="_blank" rel="noopener noreferrer">Docs</a>
<div style="display:flex;align-items:center;gap:8px;">
${isReady || isEmpty ? `<button class="wiki-browse-btn" onclick="_openWikiBrowser()">${esc(t('wiki_browse'))}</button>` : ''}
<a href="${esc(docsUrl)}" target="_blank" rel="noopener noreferrer">Docs</a>
</div>
</div>
</div>`;
}
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 = `
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--border);">
<strong style="font-size:14px;">${esc(t('wiki_browse'))}</strong>
<button onclick="document.getElementById('wikiBrowserOverlay').style.display='none'" style="background:none;border:none;cursor:pointer;font-size:18px;color:var(--muted);">&#x2715;</button>
</div>
<div style="padding:10px 16px;border-bottom:1px solid var(--border);">
<input id="wikiBrowserSearch" type="text" placeholder="${esc(t('wiki_search_placeholder'))}" style="width:100%;padding:6px 10px;background:var(--input-bg,var(--bg));border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:13px;box-sizing:border-box;" />
</div>
<div id="wikiBrowserList" style="flex:1;overflow-y:auto;padding:8px 0;min-height:80px;"></div>
<div id="wikiBrowserContent" style="display:none;flex:1;overflow-y:auto;padding:16px;border-top:1px solid var(--border);"></div>`;
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 = `<div style="padding:12px 16px;color:var(--muted);font-size:13px;">${esc(t('wiki_no_pages'))}</div>`;
return;
}
listEl.innerHTML = visible.map(p =>
`<div class="wiki-browser-item" data-path="${esc(p.path)}" style="padding:7px 16px;cursor:pointer;font-size:13px;border-radius:4px;margin:0 6px;" onmouseover="this.style.background='var(--hover,rgba(255,255,255,0.07))'" onmouseout="this.style.background=''" onclick="window._wikiBrowserOpenPage(this.dataset.path)">${esc(p.name)}</div>`
).join('');
}
window._wikiBrowserOpenPage = async function(path) {
contentEl.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:13px;">Loading...</div>';
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 = '<button onclick="window._wikiBrowserBack()" style="margin-bottom:10px;background:none;border:1px solid var(--border);border-radius:4px;padding:3px 10px;cursor:pointer;font-size:12px;color:var(--text);">&#8592; Back</button><div id="wikiBrowserMd"></div>';
renderMarkdownPreviewContent({content: data.content, el: document.getElementById('wikiBrowserMd')});
} else {
contentEl.innerHTML = '<button onclick="window._wikiBrowserBack()" style="margin-bottom:10px;background:none;border:1px solid var(--border);border-radius:4px;padding:3px 10px;cursor:pointer;font-size:12px;color:var(--text);">&#8592; Back</button><pre style="white-space:pre-wrap;word-break:break-word;font-size:12px;margin:0;">' + esc(data.content) + '</pre>';
}
} catch(e) {
contentEl.innerHTML = '<button onclick="window._wikiBrowserBack()" style="margin-bottom:10px;background:none;border:1px solid var(--border);border-radius:4px;padding:3px 10px;cursor:pointer;font-size:12px;color:var(--text);">&#8592; Back</button><div style="color:var(--error,#f55);">' + esc(e.message || String(e)) + '</div>';
}
};
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 = `<div style="padding:12px 16px;color:var(--muted);font-size:13px;">${esc(t('wiki_no_pages'))}</div>`;
} else {
_renderWikiPageList('');
}
} catch(e) {
listEl.innerHTML = `<div style="padding:12px 16px;color:var(--error,#f55);font-size:13px;">${esc(e.message || String(e))}</div>`;
}
}
/**
* Bucket daily token rows for chart display.
* Returns rows unchanged when length <= 30 (per-day resolution).
+3 -2
View File
@@ -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();});
}
@@ -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():
+299
View File
@@ -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]})"
)