Addresses all four findings from the Phase-4 security audit on PR #857:
- MED (symlink following): walk() now uses readdirSync(withFileTypes)
and never follows symlinks — file or directory — skipping each with a
warning. Belt-and-braces: every kept entry is realpath-asserted to
resolve inside docs/adr/, so a committed symlink can no longer inline
outside content into INDEX.md or move the allocation counter.
- MED (fragile traversal): per-entry try/catch with skip+warn for broken
symlinks / EACCES / unresolvable entries, a visited-realpath set guards
cycles, and the top-level dispatch is wrapped so --check fails with
"adr-index: fatal: <msg>" instead of a raw stack trace.
- LOW (markdown injection): slug and rel are now routed through
mdCell/mdLinkText (escaping |, [, ]) and link targets go through
encodeURI plus %28/%29 for parens, so a filename containing
"|", "](", "[..](..)" can no longer break the table or forge links.
- INFO (Windows fail-open): abs.split('/') replaced with path.basename /
path.relative + sep-normalized display paths, so --check cannot pass
vacuously on Windows. Also: Math.max(...) -> reduce (no -Infinity on
an empty set).
Adds `node scripts/adr-index.mjs --self-test`: builds an adversarial
fixture tree in a temp dir (outside-tree file symlink, escaping directory
symlink, broken symlink, symlink loop, hostile "|]()"-laden filename),
runs the script against it as a subprocess, and asserts 12 properties:
symlinks skipped with warnings, no stack trace, hostile row escaped,
link target URI-encoded, counter unaffected.
Regenerating INDEX.md after these changes produces no diff (all current
filenames are benign), and `--check` still passes: 348 ADR files, next
available ADR-317.
Note: CI wiring (a workflow step invoking --check) should land only
after this commit, per the audit — this commit is what makes the gate
safe to expose to PR-controlled trees.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_012Jib2gQyJpqCoo2xYAbb4X
Verified ground truth on origin/main: 336 ADR files (289 plain-counter,
47 namespaced), 27 duplicated plain numbers spanning 61 files
(ADR-272 x5; ADR-264/252/194/144 x3; 22 numbers x2). Corrects issue
#845's ~15+ estimate and its ADR-040 x3 claim — 040/040a/040b is the
intentional sub-ADR convention, not a collision.
- ADR-316: policy — duplicates are frozen historical artifacts, never
renamed; cite duplicated numbers as "ADR-NNN (slug)"; new numbers
come from a single canonical counter whose source of truth is the
generated index. 316 chosen because 305-315 are claimed on
feat/pir-adrs (PR #847).
- docs/adr/INDEX.md: generated canonical index with next-available
number header, per-file title/path/date/status and DUPLICATE flags.
- scripts/adr-index.mjs: regenerates the index; --check exits nonzero
on any duplicate outside the frozen list (CI gate for #845).
Refs #845, #837
Co-Authored-By: claude-flow <ruv@ruv.net>