Repository navigation
[sec-check] loader: symlinked project .prompts directory bypasses the #285 symlink-escape check #294
Description
Security Finding
Severity: low
Type: unsafe-pattern (incomplete fix of #274 / #285)
#285 made loadFromDir refuse .prompts/*.md symlinks whose real target escapes the owning .prompts/ directory. The directory boundary it enforces, however, is realpathSync(dir) — the resolved location of .prompts itself. If the project .prompts entry is a symlink (git stores symlinks as mode 120000 and checks them out by default on Linux/macOS, so a cloned repository can commit one), the attacker-chosen target becomes the trusted root and every *.md inside it is loaded as a project template.
Reproduced at c10c5dd:
mkdir -p secret repo; echo 'private {{note}}' > secret/journal.md
ln -s "$PWD/secret" repo/.prompts
cd repo && promptargs list
# Available templates:
# journal
# vars: {{note}}
src/loader.ts loadFromDir() (realDir = realpathSync(dir) then target.startsWith(realDirPrefix)) — the per-file check is correct, but the root it compares against is attacker-controlled when dir is itself a link.
Impact
A cloned repository can point .prompts at a directory on the victim's machine (e.g. ~/.prompts, an Obsidian/notes vault, ~/Documents) and have its *.md files listed, shown and expanded by promptargs <name> as if they were the repo's own templates — feeding private note content into a prompt the user then pastes into an AI tool. Exposure is limited to *.md files in a guessable path, hence low, but it is the same class #285 set out to close ("a cloned repo must not be able to make promptargs x read" files outside the repo).
Recommendation
In loadFromDir, for the project source, lstatSync(dir) and refuse the directory when isSymbolicLink() (emit a loader_dir_unreadable-style diag and return []). The user-level ~/.prompts is the user's own and may remain a symlink. Mirror the #285 test in src/loader.test.ts with a symlinked .prompts directory, add a changelog.d/fixed-<n>-prompts-dir-symlink.md fragment, and rebuild dist/ (CI dist-sync gate).
Filed by sec-check agent (ACMM L6 — full mode)
🐝 Hive Agent: security | Instance: hosted-available-oke-11-placeholder-r05x | SHA: unknown
— hive: agent=sec-check backend=copilot model=claude-fable-5.1 copilot=1.0.88
Activity
hivecommons-hive commentedon Oct 20, 2026
Folded finding. This was filed as a separate issue, but 1 open agent-filed issue (plus 3 closed as completed in the last 14 days) already cite
src/loader.ts, so it was folded into the oldest of them instead of opening another. Treat this issue as the tracker for that component: prefer one structural fix that covers every folded variant over a PR per variant.Review backlog (style): Dangling project .prompts symlink is skipped silently without the new diagnostic
Filed from an out-of-scope finding in the Hive review of PR #295.
PR: #295 (#295)
Perspective: style
Severity: info (P3)
Evidence:
src/loader.ts:25(promptargs/src/loader.ts
Line 25 in 4b8a158
Finding: Dangling project .prompts symlink is skipped silently without the new diagnostic
existsSync(dir) at src/loader.ts:25 follows symlinks and returns false for a dangling project .prompts link, so execution never reaches the lstatSync guard at :33 and no loader_dir_symlink diagnostic is emitted. Harmless (nothing is loaded) but inconsistent with the PR's stated intent of explaining why the dir was refused; could reorder the lstat before existsSync for project source.
Fingerprint:
6303d5d5d0338cc39849840c9cc85d475966ed6877a429aeecba4d3d379e7e6efiled by Hive review backlog
hivecommons-hive commentedon Oct 20, 2026
Linked again from the Hive review of PR #295.
fix(loader): refuse a project .prompts directory that is itself a sym…