@sdsrs/llm-wiki 0.6.4 → 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,36 @@
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
+
3
34
  ## 0.6.4 (2026-07-11)
4
35
 
5
36
  Six fixes from a full-pipeline self-test pass (as a real user across every
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}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/llm-wiki",
3
- "version": "0.6.4",
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/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()) {
package/src/scanner.mjs CHANGED
@@ -51,38 +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 } = {}) {
74
84
  const st = fs.statSync(srcDir, { throwIfNoEntry: false })
75
85
  if (!st) throw new Error(`source directory not found: ${srcDir}`)
76
86
  if (!st.isDirectory()) throw new Error(`source path is not a directory: ${srcDir}`)
77
87
  const cfg = loadKbConfig(kbRoot)
78
88
  const files = []
79
89
  const skipped = []
80
- 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 })) {
81
92
  if (exclude.some(pat => rel.includes(pat))) { skipped.push({ rel, reason: 'excluded' }); continue }
82
93
  const ext = path.extname(rel).toLowerCase()
83
94
  if (!SUPPORTED_EXTS.includes(ext)) { skipped.push({ rel, reason: `unsupported ${ext}` }); continue }
84
95
  const abs = path.join(srcDir, rel)
85
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 }
86
100
  const entry = { rel, ext, bytes, hash: sha256File(abs), lang: 'unknown', tokens: 0 }
87
101
  if (TEXT_EXTS.includes(ext)) {
88
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
  }