@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 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 llms.txt generated by `index`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/llm-wiki",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Compile messy document directories into Karpathy-style llm_wiki knowledge bases — standalone Q&A CLI + Claude Code/Codex skills",
5
5
  "directories": {
6
6
  "test": "test"
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
- return name.toLowerCase()
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 pdfParse = (await import('pdf-parse/lib/pdf-parse.js')).default
48
- const data = await pdfParse(fs.readFileSync(srcPath))
49
- return { markdown: data.text, title: titleFrom(data.text, base), warnings }
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 themselves.
64
- const forbidden = [path.resolve(kbRoot), path.resolve(kbRoot, 'raw'), path.resolve(p.wiki)]
65
- if (forbidden.includes(outDir)) {
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 keep working: reads below follow the link as before.
55
- function* walk(dir, base = dir, skippedDirs = [], exclude = []) {
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
- for (const rel of walk(srcDir, srcDir, skipped, exclude)) {
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
  }