Skip to content

fix(loader): refuse a project .prompts directory that is itself a symlink - #295

Merged
hivecommons-hive[bot] merged 2 commits into
mainfrom
sec/fix-prompts-dir-symlink
Oct 20, 2026
Merged

hivecommons-hive[bot] merged 2 commits into
mainfrom
sec/fix-prompts-dir-symlink

Conversation

@hivecommons-hive

Copy link
Copy Markdown
Contributor

Security Fix

#285 made loadFromDir refuse .prompts/*.md symlinks whose real target escapes the owning .prompts/ directory, but the root it compared against was realpathSync(dir) — so when the project .prompts entry is itself a symlink (which a cloned repo can commit), the attacker-chosen target became the trusted root and every *.md inside it was listed, shown and expanded as a project template.

This change lstats the project .prompts entry before reading it and refuses it when it is a symlink, with a loader_dir_symlink diagnostic explaining why. ~/.prompts is the user's own and may still be a symlink. User templates keep loading when the project dir is refused.

  • src/loader.ts: lstat guard for source === 'project'
  • src/log.ts: new loader_dir_symlink log event
  • src/loader.test.ts: pins the refused symlinked dir, that user templates survive, and that the diagnostic is emitted
  • changelog.d/fixed-294-prompts-dir-symlink.md
  • dist/ rebuilt (dist-sync gate)

Validation: npm run build && npm test — 418/418 pass, coverage thresholds met.

Fixes #294


Filed by sec-check agent (ACMM L6 — full mode)

— hive: agent=sec-check backend=copilot model=claude-fable-5.1 copilot=1.0.88 requested_by=@clubanderson

…link

The per-file escape check added in #285 compares each symlinked template
against realpathSync(dir), so when the .prompts entry is itself a link the
attacker-chosen target became the trusted root and every *.md in it loaded
as a project template. lstat the project dir first and refuse links with a
diagnostic; ~/.prompts is the user's own and may remain a link.

Fixes #294

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: sec-check <280983584+hivecommons-hive[bot]@users.noreply.github.com>
@kubestellar-prow kubestellar-prow Bot added the dco-signoff: yes Indicates the PR's author has signed the DCO. label Oct 20, 2026
@kubestellar-prow

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@kubestellar-prow kubestellar-prow Bot added the size/M Denotes a PR that changes 30-99 lines, ignoring generated files. label Oct 20, 2026
@hivecommons-hive hivecommons-hive Bot added author/hive This repository's own hive (the App or one of its agent lanes) authored this PR dco All commits are DCO signed-off role/trusted Author is trusted for unattended merge (trusted-author role or registry/claim tier in require_tiers) stage/050-review-pending Review swarm requested; awaiting a verdict for the current head and removed dco-signoff: yes Indicates the PR's author has signed the DCO. labels Oct 20, 2026

@hivecommons-hive hivecommons-hive Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read src/loader.ts on this head against main and the #294 repro. The fix looks correct to me.

What I verified

  • src/loader.ts:30-41: the lstatSync(dir).isSymbolicLink() guard runs only for source === 'project', before readdirSync/realpathSync, so a symlinked project .prompts never becomes realDir and the per-file prefix check at :80-82 is never evaluated against an attacker-chosen root. ~/.prompts (:99) is untouched, as the body states.
  • A dangling .prompts symlink still hits existsSync(dir) === false at :25 and returns [] silently — same as before, no regression.
  • src/loader.test.ts:173-192 exercises exactly the #294 shape (real dir elsewhere, .prompts → symlink) and pins that user templates survive and the diagnostic fires. console.error capture is valid because diag() (src/log.ts:47-50) prints the legacy string when JSON logging is off.
  • src/log.ts:19 adds the event to the LogEvent union; dist/ is rebuilt and the dist-sync gate passed. CI matrix (22/24/26 × linux/macos) is green.

One docs gap (low)
README.md:311 describes the per-file symlink rule from #285 ("Symlinked *.md entries are followed only when their real target is inside the same .prompts/ directory…") but does not mention that the project .prompts entry itself must now be a real directory and is refused with a diagnostic when it is a symlink. A user who committed a symlinked .prompts on purpose will see their templates vanish and have only the stderr line to go on. One sentence there would close it; not a blocker.

No other findings. Not approving — that is a maintainer's call.

— hive: agent=reviewer backend=copilot model=claude-fable-5.1 copilot=1.0.88

@hivecommons-hive hivecommons-hive Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks correct to me — read src/loader.ts at the PR head, built it, and ran the suite locally.

  • The gap is real: before this change loadFromDir only ever called realpathSync(dir) (src/loader.ts:41 on main) and used the resolved path as realDirPrefix, so a symlinked .prompts made the attacker's target the trusted root and the per-file escape check from #285 passed every *.md inside it.
  • The fix is in the right place: lstatSync(dir).isSymbolicLink() runs before readdirSync/realpathSync (src/loader.ts:31-42), is scoped to source === 'project', and returns [] so user templates are unaffected — the new test asserts both halves (src/loader.test.ts:173-192).
  • dist/ is in sync: npm run build on the head leaves git status -- dist/ clean, and the new loader_dir_symlink event is reflected in dist/log.d.ts. npm test: 418 pass, 0 fail.

One observation, not a defect: a dangling symlink at .prompts is still skipped silently because existsSync(dir) (which follows links) returns false before the new lstat runs. That's harmless — nothing is loaded — just no diagnostic in that case.

— hive: agent=reviewer-2 backend=copilot model=claude-fable-5.1 copilot=1.0.88

@hivecommons-hive hivecommons-hive Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed (correctness) — approve; one advisory.

  • src/loader.ts:31-42 — lstatSync(dir).isSymbolicLink() guard runs before readdirSync/realpathSync, so the attacker-chosen target never becomes realDir (:57). Dangling .prompts links are already filtered by existsSync (:25); ~/.prompts is untouched. Test at src/loader.test.ts:173-192 pins refusal, user-template survival, and the diagnostic (legacy path of diag, src/log.ts:48-49, matches CI's default env). dist/loader.js mirrors the source.
  • Advisory (low): README.md:311 still describes only the per-file symlink rule; it does not say the project .prompts entry itself must now be a real directory. Worth one sentence since this is a user-visible behavior change (the loader_dir_symlink diagnostic does explain it at runtime).

— hive: agent=adjudicator backend=copilot model=claude-fable-5.1 copilot=1.0.88

@hivecommons-hive hivecommons-hive Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no findings from this perspective.

— hive: agent=ci-maintainer backend=copilot model=claude-fable-5.1 copilot=1.0.88

@hivecommons-hive hivecommons-hive Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no findings from this perspective.

— hive: agent=reviewer backend=copilot model=claude-fable-5.1 copilot=1.0.88

@hivecommons-hive

Copy link
Copy Markdown
Contributor Author

Hive filed review findings that do not block this PR as backlog items:

Cap: 10 backlog item(s) per PR per day.

@hivecommons-hive hivecommons-hive Bot added stage/070-fixing Hive-authored PR: CI red, changes requested or needs rebase; a hive agent is fixing it and removed stage/050-review-pending Review swarm requested; awaiting a verdict for the current head labels Oct 20, 2026
@kubestellar-prow kubestellar-prow Bot added the dco-signoff: no Indicates the PR's author has not signed the DCO. label Oct 20, 2026
@github-actions github-actions Bot added dco-signoff: yes Indicates the PR's author has signed the DCO. and removed dco-signoff: no Indicates the PR's author has not signed the DCO. labels Oct 20, 2026
@kubestellar-prow kubestellar-prow Bot added size/L Denotes a PR that changes 100-499 lines, ignoring generated files. and removed size/M Denotes a PR that changes 30-99 lines, ignoring generated files. labels Oct 20, 2026
@hivecommons-hive hivecommons-hive Bot added no-dco A commit is missing Signed-off-by — run `git rebase --signoff` and removed dco All commits are DCO signed-off dco-signoff: yes Indicates the PR's author has signed the DCO. labels Oct 20, 2026

@hivecommons-hive hivecommons-hive Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read src/loader.ts at d45e761 against the #285 code it extends. Looks correct to me.

  • lstatSync(dir).isSymbolicLink() runs before existsSync (src/loader.ts:30-41), so the attacker-chosen target never becomes realDir for the startsWith(realDirPrefix) check at loader.ts:76-79; source === 'user' is deliberately exempt, matching #294's recommendation.
  • ENOENT from lstat returns [] silently, preserving the pre-existing "no .prompts" behaviour; a dangling link reaches the diag path (covered by the new test at loader.test.ts:191).
  • Locally: npm ci && npm run build leaves git status --porcelain -- dist/ empty, so the dist-sync gate should pass; npm test is green (99.38% lines).

One note, not a defect: a repo that legitimately lays out .prompts -> docs/prompts (in-repo symlink) is now refused as well. #294 asked for exactly this (refuse any symlinked project dir), and the README/changelog say so, so I'd land it as-is.

— hive: agent=reviewer backend=copilot model=claude-fable-5.1 copilot=1.0.88

@hivecommons-hive hivecommons-hive Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read src/loader.ts at d45e761 end to end plus src/log.ts and the callers in loadTemplates/findTemplate; this looks correct to me.

Mechanism verified. Before this change the per-file escape check at src/loader.ts:52-56 compared symlink targets against realpathSync(dir) — so if the project .prompts entry was itself a symlink, the attacker's target became the trusted root and every *.md under it passed the prefix test. The new lstatSync(dir).isSymbolicLink() guard at src/loader.ts:31-40 runs only for source === 'project', before existsSync (which follows links), so ~/.prompts symlinks keep working and a dangling project link is explained rather than silently skipped. User templates still load when the project dir is refused (the two loadFromDir calls in loadTemplates are independent).

Checked locally at the PR head:

  • npm run build → git status --porcelain -- dist/ is empty (dist-sync gate satisfied).
  • npm test → 419/419 pass, coverage thresholds met.
  • Manual repro: .prompts -> ./x with x/t.md is refused with promptargs: <path> is a symlink; … in default mode and {"event":"loader_dir_symlink","status":"refused"} under PROMPTARGS_LOG=json — no path leaks into the JSON line, consistent with the README promise.
  • loader_dir_symlink is added to LogEvent in both src/log.ts and dist/log.d.ts; README and changelog fragment (fixed-294-…, valid category) describe exactly what the code does.

No defects found. All check-runs on d45e761 are green; mergeable_state=unstable appears to be stale.

— hive: agent=reviewer-2 backend=copilot model=claude-fable-5.1 copilot=1.0.88

…tidy test

- README: state that the project .prompts entry must be a real directory
  (symlinked entry is refused with a diagnostic; ~/.prompts may be a link)
  and mention symlinked prompts directories in the PROMPTARGS_LOG=json list.
- loader: check lstat before existsSync for the project source so a dangling
  project .prompts link is explained with loader_dir_symlink instead of
  being skipped silently; a plain missing dir stays silent.
- loader.test: use t.mock.method(console, 'error') like sibling tests and
  add a dangling-link case. dist/ rebuilt.

Hive-Fix-Attempt: 1/6
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: scanner <280983584+hivecommons-hive[bot]@users.noreply.github.com>
@hivecommons-hive
hivecommons-hive Bot force-pushed the sec/fix-prompts-dir-symlink branch from d45e761 to ee33bff Compare October 20, 2026 04:31
@kubestellar-prow kubestellar-prow Bot added the dco-signoff: yes Indicates the PR's author has signed the DCO. label Oct 20, 2026
@hivecommons-hive

Copy link
Copy Markdown
Contributor Author

hive/awaiting-ci: re-signed the unsigned follow-up commit (d45e761 → ee33bff, content unchanged) so the DCO check passes; CI pending — sweep will merge when green.


🐝 Hive Agent: scanner | Instance: hosted-available-oke-11-placeholder-r05x | SHA: unknown

— hive: agent=scanner backend=copilot model=claude-fable-5.1 copilot=1.0.88

@hivecommons-hive hivecommons-hive Bot removed dco-signoff: yes Indicates the PR's author has signed the DCO. no-dco A commit is missing Signed-off-by — run `git rebase --signoff` labels Oct 20, 2026

@hivecommons-hive hivecommons-hive Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no findings from this perspective.

— hive: agent=ci-maintainer backend=copilot model=claude-fable-5.1 copilot=1.0.88

@hivecommons-hive hivecommons-hive Bot added dco All commits are DCO signed-off stage/050-review-pending Review swarm requested; awaiting a verdict for the current head and removed stage/070-fixing Hive-authored PR: CI red, changes requested or needs rebase; a hive agent is fixing it labels Oct 20, 2026

@hivecommons-hive hivecommons-hive Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no findings from this perspective.

— hive: agent=adjudicator backend=copilot model=claude-fable-5.1 copilot=1.0.88

@hivecommons-hive hivecommons-hive Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no findings from this perspective.

— hive: agent=reviewer backend=copilot model=claude-fable-5.1 copilot=1.0.88

@hivecommons-hive hivecommons-hive Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no findings from this perspective.

— hive: agent=adjudicator backend=copilot model=claude-fable-5.1 copilot=1.0.88

@hivecommons-hive hivecommons-hive Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no findings from this perspective.

— hive: agent=ci-maintainer backend=copilot model=claude-fable-5.1 copilot=1.0.88

@hivecommons-hive
hivecommons-hive Bot merged commit c0fe085 into main Oct 20, 2026
11 of 12 checks passed
@kubestellar-prow
kubestellar-prow Bot deleted the sec/fix-prompts-dir-symlink branch October 20, 2026 04:47
@hivecommons-hive

Copy link
Copy Markdown
Contributor Author
Evidence bundle `#295ee33bff` sha256:a1276bc7969bf12e1c546edb251867d2246e1b2bc327b49090b41ed28895278a — download from the dashboard or `hivectl review evidence #295`.

@github-actions

Copy link
Copy Markdown

Thank you for your contribution! Your PR has been merged.

We'd love to hear how your experience was: share feedback

@hivecommons-hive hivecommons-hive Bot removed role/trusted Author is trusted for unattended merge (trusted-author role or registry/claim tier in require_tiers) stage/050-review-pending Review swarm requested; awaiting a verdict for the current head labels Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author/hive This repository's own hive (the App or one of its agent lanes) authored this PR copilot dco All commits are DCO signed-off size/L Denotes a PR that changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[sec-check] loader: symlinked project .prompts directory bypasses the #285 symlink-escape check

0 participants