@sdsrs/llm-wiki 0.6.3 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/README.md +3 -2
- package/bin/llm-wiki.mjs +6 -1
- package/package.json +1 -1
- package/src/convert.mjs +8 -4
- package/src/export.mjs +13 -3
- package/src/scanner.mjs +24 -7
- package/src/templates.mjs +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,68 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.5 (2026-07-11)
|
|
4
|
+
|
|
5
|
+
Three security/robustness fixes from a full architecture-and-security audit
|
|
6
|
+
(M1, the release-blocking milestone). Suite 185 → 187.
|
|
7
|
+
|
|
8
|
+
**Behavior change (opt-out available):** `scan` now refuses a **symlinked source
|
|
9
|
+
file whose target resolves outside the source directory**, instead of silently
|
|
10
|
+
reading it into `raw/` and into a publishable wiki page. An attacker-supplied
|
|
11
|
+
corpus containing e.g. `notes.md -> ~/.llm-wiki/config.json` (or `~/.ssh/id_rsa`,
|
|
12
|
+
`/dev/zero`) could otherwise exfiltrate the victim's API key into a shared KB or
|
|
13
|
+
hang the process. Escaping links now appear in the scan report as
|
|
14
|
+
`skipped (symlink escapes source dir)`. **To restore the old follow-anywhere
|
|
15
|
+
behavior for a trusted, curated corpus, pass `--follow-symlinks`.** In-tree
|
|
16
|
+
symlinks are unaffected.
|
|
17
|
+
|
|
18
|
+
- **fix(scan): symlinked files escaping the source tree are refused** (audit
|
|
19
|
+
HIGH-1). `walk()` blocked symlinked *directories* (loop safety) but followed
|
|
20
|
+
symlinked *files* and read the link target; the content was copied into `raw/`
|
|
21
|
+
and `raw/_originals/`. `scan` now `realpath`-resolves each symlinked file and
|
|
22
|
+
skips any whose target is outside the source dir; `--follow-symlinks` opts back
|
|
23
|
+
in.
|
|
24
|
+
- **fix(scan): a file-size cap** (audit MEDIUM-1). Reads were unbounded — a
|
|
25
|
+
multi-GB (or `/dev/zero`-symlinked) file could OOM/hang the process. Files over
|
|
26
|
+
`maxFileBytes` (new `wiki.config.json` key, default 50 MB) are now skipped with
|
|
27
|
+
a clear reason rather than read whole.
|
|
28
|
+
- **fix(export): `--out` managed-layer guard is now a prefix check** (audit
|
|
29
|
+
MEDIUM-2). The guard rejected `--out <kb>/raw` and `--out <kb>/wiki` exactly
|
|
30
|
+
but let a *subdirectory* (`--out <kb>/raw/sub`) through, where the export's
|
|
31
|
+
re-run `rmSync` would then wipe part of the immutable `raw/` layer. It now
|
|
32
|
+
rejects the KB root and anything inside `raw/`/`wiki/` at any depth.
|
|
33
|
+
|
|
34
|
+
## 0.6.4 (2026-07-11)
|
|
35
|
+
|
|
36
|
+
Six fixes from a full-pipeline self-test pass (as a real user across every
|
|
37
|
+
command). No breaking changes, no KB migration, no config change. Suite 179 →
|
|
38
|
+
185.
|
|
39
|
+
|
|
40
|
+
- **fix(convert): PDF conversion was 100% broken** and is now restored. The code
|
|
41
|
+
imported `pdf-parse/lib/pdf-parse.js` — the v1 subpath — but the pinned
|
|
42
|
+
dependency is pdf-parse v2, whose export map has no such subpath, so *every*
|
|
43
|
+
PDF failed at import time before any parsing. Switched to the v2 API
|
|
44
|
+
(`new PDFParse({ data }).getText()`). It slipped through because there was no
|
|
45
|
+
PDF fixture and the only test fed garbage bytes, which the import failure also
|
|
46
|
+
satisfied; added a real `test/fixtures/hello.pdf` + a positive extraction test.
|
|
47
|
+
- fix(ask): `ask --retrieve-only` no longer prints nothing (exit 0) when
|
|
48
|
+
retrieval locates zero pages — the state right after `init`/`scan`, before the
|
|
49
|
+
wiki is built. It now emits a diagnostic to stderr (stdout stays pipe-clean),
|
|
50
|
+
matching the LLM path which already errored.
|
|
51
|
+
- fix(convert): a source file whose whole basename is emoji/symbols
|
|
52
|
+
(e.g. `😀.md`) no longer produces a hidden `raw/.md` that `status` skips
|
|
53
|
+
(silently dropping the source from the incremental workflow). `slugify` now
|
|
54
|
+
falls back to `untitled`, so the existing `-N` collision handler yields
|
|
55
|
+
visible, shell-safe names (`untitled.md`, `untitled-2.md`, …).
|
|
56
|
+
- fix(scan): a missing or non-directory source path now gives a human-readable
|
|
57
|
+
error ("source directory not found: X" / "source path is not a directory: X")
|
|
58
|
+
instead of a raw `ENOENT`/`ENOTDIR`; also improves `status --src`.
|
|
59
|
+
- fix(export): `export --out path/with/missing/dirs/graph.graphml` now creates
|
|
60
|
+
the parent directory (matching the markdown-export sibling) instead of leaking
|
|
61
|
+
a raw `ENOENT`.
|
|
62
|
+
- docs: the layout diagram placed `llms.txt` under `wiki/`, but `index` writes it
|
|
63
|
+
to the KB root (the llms.txt convention); corrected the diagram and command
|
|
64
|
+
table.
|
|
65
|
+
|
|
3
66
|
## 0.6.3 (2026-07-11)
|
|
4
67
|
|
|
5
68
|
Two cosmetic fixes from an independent review of 0.6.0–0.6.2; no behavior
|
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ npx @sdsrs/llm-wiki@0 ask "what did we decide about X?"
|
|
|
84
84
|
| `init [dir]` | scaffold a knowledge base (raw/, wiki/, AGENTS.md, wiki.config.json) |
|
|
85
85
|
| `scan <srcDir>` | inventory a source dir: dedup, batches, incremental diff, token estimate |
|
|
86
86
|
| `convert` | convert files from the scan plan into `raw/` markdown |
|
|
87
|
-
| `index` | rebuild `wiki/index.md`, `graph.json`, `llms.txt` from page frontmatter |
|
|
87
|
+
| `index` | rebuild `wiki/index.md`, `wiki/graph.json`, and root `llms.txt` from page frontmatter |
|
|
88
88
|
| `ask <question>` | answer from the KB using full pages (never chunks), with citations |
|
|
89
89
|
| `lint` | mechanical checks + a semantic worklist for the agent |
|
|
90
90
|
| `status` | incremental state: uncompiled raw files, source-dir diff, affected wiki pages |
|
|
@@ -216,7 +216,8 @@ my-kb/
|
|
|
216
216
|
raw/ immutable converted source documents (agents only read)
|
|
217
217
|
wiki/
|
|
218
218
|
sources/ entities/ concepts/ comparisons/ full pages, one file each
|
|
219
|
-
index.md graph.json
|
|
219
|
+
index.md graph.json generated by `index`
|
|
220
|
+
llms.txt flat page listing for agents, KB root (generated by `index`)
|
|
220
221
|
AGENTS.md the contract every maintaining agent follows
|
|
221
222
|
wiki.config.json thresholds, batch size, language
|
|
222
223
|
```
|
package/bin/llm-wiki.mjs
CHANGED
|
@@ -26,8 +26,9 @@ program.command('scan <srcDir>')
|
|
|
26
26
|
.description('inventory a source directory: dedup, batches, token estimate')
|
|
27
27
|
.option('--kb <dir>', 'knowledge base root', '.')
|
|
28
28
|
.option('--exclude <pattern...>', 'substring patterns to skip')
|
|
29
|
+
.option('--follow-symlinks', 'follow symlinked files that resolve outside the source dir (unsafe: only for trusted corpora)')
|
|
29
30
|
.action(async (srcDir, opts) => {
|
|
30
|
-
const r = await scanSource(srcDir, opts.kb, { exclude: opts.exclude ?? [] })
|
|
31
|
+
const r = await scanSource(srcDir, opts.kb, { exclude: opts.exclude ?? [], followSymlinks: !!opts.followSymlinks })
|
|
31
32
|
console.log(`files: ${r.files.length} (skipped ${r.skipped.length})`)
|
|
32
33
|
console.log(`duplicates: ${r.duplicates.exact.length} exact, ${r.duplicates.near.length} near`)
|
|
33
34
|
console.log(`incremental: +${r.incremental.added} ~${r.incremental.changed} -${r.incremental.removed} =${r.incremental.unchanged}`)
|
|
@@ -74,6 +75,10 @@ program.command('ask <question>')
|
|
|
74
75
|
if (!Number.isFinite(k) || k < 1) { console.error(`invalid -k value: ${opts.k} (expected a positive integer)`); process.exit(1) }
|
|
75
76
|
const r = await askKb(opts.kb, question, { k, retrieveOnly: opts.retrieveOnly })
|
|
76
77
|
if (opts.retrieveOnly) {
|
|
78
|
+
if (r.pages.length === 0) {
|
|
79
|
+
console.error('no pages located — the KB has no matching wiki pages (build them with the wiki-build skill, or check that wiki/ contains pages)')
|
|
80
|
+
return
|
|
81
|
+
}
|
|
77
82
|
for (const h of r.pages) console.log(`${h.score.toFixed(3)} ${h.relPath} [${(h.sources ?? ['bm25']).join('+')}]`)
|
|
78
83
|
} else {
|
|
79
84
|
console.log(r.answer)
|
package/package.json
CHANGED
package/src/convert.mjs
CHANGED
|
@@ -4,10 +4,14 @@ import path from 'node:path'
|
|
|
4
4
|
export const SUPPORTED_EXTS = ['.md', '.markdown', '.txt', '.html', '.htm', '.pdf', '.docx']
|
|
5
5
|
|
|
6
6
|
export function slugify(name) {
|
|
7
|
-
|
|
7
|
+
const slug = name.toLowerCase()
|
|
8
8
|
.replace(/\.[a-z0-9]+$/i, '')
|
|
9
9
|
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
|
10
10
|
.replace(/^-+|-+$/g, '')
|
|
11
|
+
// A basename made entirely of emoji/symbols slugs to '' → a hidden `raw/.md`
|
|
12
|
+
// dotfile that `status` skips (source silently dropped). Never return empty;
|
|
13
|
+
// the caller's -N collision handler disambiguates untitled/untitled-2/…
|
|
14
|
+
return slug || 'untitled'
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
function titleFrom(markdown, fallback) {
|
|
@@ -44,9 +48,9 @@ export async function convertFile(srcPath) {
|
|
|
44
48
|
return { markdown: `# ${article.title || base}\n\n${md}`, title: article.title || base, warnings }
|
|
45
49
|
}
|
|
46
50
|
if (ext === '.pdf') {
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
return { markdown:
|
|
51
|
+
const { PDFParse } = await import('pdf-parse')
|
|
52
|
+
const { text } = await new PDFParse({ data: fs.readFileSync(srcPath) }).getText()
|
|
53
|
+
return { markdown: text, title: titleFrom(text, base), warnings }
|
|
50
54
|
}
|
|
51
55
|
if (ext === '.docx') {
|
|
52
56
|
const mammoth = await import('mammoth')
|
package/src/export.mjs
CHANGED
|
@@ -60,9 +60,16 @@ export function exportMarkdownPages(kbRoot, { out } = {}) {
|
|
|
60
60
|
const p = kbPaths(kbRoot)
|
|
61
61
|
const outDir = path.resolve(out ?? path.join(kbRoot, 'wiki-md'))
|
|
62
62
|
// Never let --out resolve onto a managed KB layer: the marker guard below would
|
|
63
|
-
// later rmSync it, wiping the immutable raw/ inputs or the wiki/ pages
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
// later rmSync it, wiping the immutable raw/ inputs or the wiki/ pages. Prefix
|
|
64
|
+
// (not exact) match — `--out <kb>/raw/sub` is inside raw/ and just as destructive
|
|
65
|
+
// (audit MEDIUM-2). The KB root itself is rejected too.
|
|
66
|
+
const rawDir = path.resolve(kbRoot, 'raw')
|
|
67
|
+
const wikiDir = path.resolve(p.wiki)
|
|
68
|
+
const within = (parent, child) => {
|
|
69
|
+
const rel = path.relative(parent, child)
|
|
70
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
|
|
71
|
+
}
|
|
72
|
+
if (outDir === path.resolve(kbRoot) || within(rawDir, outDir) || within(wikiDir, outDir)) {
|
|
66
73
|
throw new Error(`refusing to export into the KB's managed layers (${path.relative(kbRoot, outDir) || '.'}) — pass a dedicated --out directory`)
|
|
67
74
|
}
|
|
68
75
|
if (fs.existsSync(outDir) && !fs.statSync(outDir).isDirectory()) {
|
|
@@ -301,6 +308,9 @@ export function exportGraph(kbRoot, { format, out } = {}) {
|
|
|
301
308
|
if (!render) throw new Error(`unknown format: ${format} (expected ${Object.keys(RENDERERS).join(' | ')} | markdown)`)
|
|
302
309
|
const graph = loadGraph(kbRoot)
|
|
303
310
|
const outPath = out ?? path.join(kbRoot, `graph.${EXT[format]}`)
|
|
311
|
+
// Match the markdown-export sibling: create the parent of an explicit --out so
|
|
312
|
+
// `--out new/dir/graph.graphml` writes instead of leaking a raw ENOENT.
|
|
313
|
+
fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true })
|
|
304
314
|
fs.writeFileSync(outPath, render(graph))
|
|
305
315
|
return { out: outPath, nodeCount: graph.nodes.length, edgeCount: graph.edges.length }
|
|
306
316
|
}
|
package/src/scanner.mjs
CHANGED
|
@@ -51,35 +51,52 @@ export function estimateTokens(text) {
|
|
|
51
51
|
|
|
52
52
|
// Symlinked directories are not followed (loop safety) but are recorded in
|
|
53
53
|
// `skippedDirs` so they surface in the scan report instead of vanishing silently.
|
|
54
|
-
// Symlinked files
|
|
55
|
-
|
|
54
|
+
// Symlinked files are followed ONLY when their target resolves inside the source
|
|
55
|
+
// tree (`opts.rootReal`). A link escaping the tree would pull an arbitrary readable
|
|
56
|
+
// file (e.g. ~/.llm-wiki/config.json, ~/.ssh/id_rsa, /dev/zero) into raw/ and into a
|
|
57
|
+
// publishable page — the HIGH-1 exfiltration/DoS vector. `opts.followSymlinks` opts
|
|
58
|
+
// back into follow-anywhere for a trusted, curated corpus.
|
|
59
|
+
function* walk(dir, base, skippedDirs, exclude, opts) {
|
|
56
60
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
57
61
|
if (e.name.startsWith('.') || e.name === 'node_modules') continue
|
|
58
62
|
const abs = path.join(dir, e.name)
|
|
59
63
|
if (e.isSymbolicLink()) {
|
|
60
64
|
const rel = path.relative(base, abs)
|
|
61
65
|
if (exclude.some(pat => rel.includes(pat))) { skippedDirs.push({ rel, reason: 'excluded' }); continue }
|
|
62
|
-
let st
|
|
63
|
-
try { st = fs.statSync(abs) } catch { skippedDirs.push({ rel, reason: 'broken symlink' }); continue }
|
|
66
|
+
let st, real
|
|
67
|
+
try { st = fs.statSync(abs); real = fs.realpathSync(abs) } catch { skippedDirs.push({ rel, reason: 'broken symlink' }); continue }
|
|
64
68
|
if (st.isDirectory()) { skippedDirs.push({ rel, reason: 'symlinked directory (not followed)' }); continue }
|
|
69
|
+
if (!opts.followSymlinks) {
|
|
70
|
+
const relToRoot = path.relative(opts.rootReal, real)
|
|
71
|
+
if (relToRoot.startsWith('..') || path.isAbsolute(relToRoot)) {
|
|
72
|
+
skippedDirs.push({ rel, reason: 'symlink escapes source dir' }); continue
|
|
73
|
+
}
|
|
74
|
+
}
|
|
65
75
|
yield rel
|
|
66
|
-
} else if (e.isDirectory()) yield* walk(abs, base, skippedDirs, exclude)
|
|
76
|
+
} else if (e.isDirectory()) yield* walk(abs, base, skippedDirs, exclude, opts)
|
|
67
77
|
else yield path.relative(base, abs)
|
|
68
78
|
}
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
// `persist: false` runs a read-only scan (e.g. for `status`) that does not
|
|
72
82
|
// overwrite the .scan-plan.json a previous explicit `scan` produced.
|
|
73
|
-
export async function scanSource(srcDir, kbRoot, { exclude = [], persist = true } = {}) {
|
|
83
|
+
export async function scanSource(srcDir, kbRoot, { exclude = [], persist = true, followSymlinks = false } = {}) {
|
|
84
|
+
const st = fs.statSync(srcDir, { throwIfNoEntry: false })
|
|
85
|
+
if (!st) throw new Error(`source directory not found: ${srcDir}`)
|
|
86
|
+
if (!st.isDirectory()) throw new Error(`source path is not a directory: ${srcDir}`)
|
|
74
87
|
const cfg = loadKbConfig(kbRoot)
|
|
75
88
|
const files = []
|
|
76
89
|
const skipped = []
|
|
77
|
-
|
|
90
|
+
const rootReal = fs.realpathSync(srcDir)
|
|
91
|
+
for (const rel of walk(srcDir, srcDir, skipped, exclude, { followSymlinks, rootReal })) {
|
|
78
92
|
if (exclude.some(pat => rel.includes(pat))) { skipped.push({ rel, reason: 'excluded' }); continue }
|
|
79
93
|
const ext = path.extname(rel).toLowerCase()
|
|
80
94
|
if (!SUPPORTED_EXTS.includes(ext)) { skipped.push({ rel, reason: `unsupported ${ext}` }); continue }
|
|
81
95
|
const abs = path.join(srcDir, rel)
|
|
82
96
|
const bytes = fs.statSync(abs).size
|
|
97
|
+
// Size cap (audit MEDIUM-1): an unbounded read OOMs on a hostile multi-GB file;
|
|
98
|
+
// gate before sha256File/readFileSync below, which both read the whole file.
|
|
99
|
+
if (bytes > cfg.maxFileBytes) { skipped.push({ rel, reason: `too large (${bytes} bytes > ${cfg.maxFileBytes} cap)` }); continue }
|
|
83
100
|
const entry = { rel, ext, bytes, hash: sha256File(abs), lang: 'unknown', tokens: 0 }
|
|
84
101
|
if (TEXT_EXTS.includes(ext)) {
|
|
85
102
|
const text = fs.readFileSync(abs, 'utf8')
|
package/src/templates.mjs
CHANGED
|
@@ -11,6 +11,7 @@ export const DEFAULT_CONFIG = {
|
|
|
11
11
|
language: 'auto',
|
|
12
12
|
linkStyle: 'wikilink',
|
|
13
13
|
askTokenBudget: 32000,
|
|
14
|
+
maxFileBytes: 52428800,
|
|
14
15
|
vectorEnabled: false,
|
|
15
16
|
relationTypes: ['implements', 'uses', 'depends_on', 'part_of', 'instance_of', 'derived_from', 'contrasts_with', 'causes'],
|
|
16
17
|
}
|