@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 +31 -0
- package/bin/llm-wiki.mjs +2 -1
- package/package.json +1 -1
- package/src/export.mjs +10 -3
- package/src/scanner.mjs +21 -7
- package/src/templates.mjs +1 -0
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
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()) {
|
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
|
|
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 } = {}) {
|
|
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
|
-
|
|
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
|
}
|