mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
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:
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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);">✕</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);">← 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);">← 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);">← 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
@@ -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():
|
||||
|
||||
@@ -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]})"
|
||||
)
|
||||
Reference in New Issue
Block a user