Three claims in this package's own README/SECURITY.md are stronger than the shipped code. All three are read
from lib/ (0.2.17 / 82cd4c0a), not inferred; the first two were reproduced.
1. The mirror write path follows symlinks (source and delete walks refuse them)
README/SECURITY.md: "symlinks refused and every joined path containment-checked (PATH_UNSAFE fails loud)".
What the code does: the source walk (lib/mirror.mjs walk) and the delete walk skip symlinks, and the
guards are lexical on strings (lib/paths.mjs assertRelPath/assertNestingSafe, lib/sanitize.mjs
relativeWithin). The write path does not re-check after the walk:
async function writeIfDifferent(target, content) {
...
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.writeFile(target, content)
}
Reproduced in a scratch directory: with a symlink at <repoDir>/sessions pointing elsewhere, the next push's
write landed OUTSIDE repoDir. The link can be planted locally, or materialized by this plugin's own
git merge --no-commit / git checkout --theirs from a remote tree.
Suggested fix: lstat the final path component (or open with O_NOFOLLOW) before writing, and assert the
resolved parent chain stays inside repoDir — the same containment the read direction already enforces.
2. sanitizeRemote misses a token in the username position
SECURITY.md: "Remote-URL credentials, tokens, and key=value secrets are redacted before reaching the model
or the log".
sanitizeRemote (lib/sanitize.mjs) redacts only url.password when BOTH username and password are non-empty,
plus known credential query keys. With the common PAT spelling there is no password:
https://ghp_ABCDEFGHIJKLMNOPQRSTUVWX@github.com/o/r.git -> unchanged
That renders through /sync status (lib/status.mjs -> lib/render.mjs), and /sync status is a read-only
surface that never asks for confirmation — so the token reaches the model conversation and the session log this
plugin then mirrors and pushes.
Suggested fix: redact the whole userinfo component (or the username when it matches a token shape), not only
user:password.
3. git fetch force-updates the tracking ref
lib/git.mjs guards startsWith('+') only for verb === 'push', but fetch() passes
+<branch>:refs/remotes/origin/<branch> positionally. A remote rewrite therefore force-updates the tracking ref,
and since every fetch is followed by merge --no-commit, the rewritten tree is merged rather than refused.
"Never force-pushes" stays literally true; the comment's +refspec invariant does not hold for fetch.
Filed by a user installing this plugin as their session-history transport. No CVE claimed, no exploit beyond the
local-repository scenario above; reporting here because these are the guarantees the README offers and two of
them are what made the install decision on their side.
Three claims in this package's own README/SECURITY.md are stronger than the shipped code. All three are read
from
lib/(0.2.17 /82cd4c0a), not inferred; the first two were reproduced.1. The mirror write path follows symlinks (source and delete walks refuse them)
README/SECURITY.md: "symlinks refused and every joined path containment-checked (PATH_UNSAFEfails loud)".What the code does: the source walk (
lib/mirror.mjswalk) and the delete walk skip symlinks, and theguards are lexical on strings (
lib/paths.mjsassertRelPath/assertNestingSafe,lib/sanitize.mjsrelativeWithin). The write path does not re-check after the walk:Reproduced in a scratch directory: with a symlink at
<repoDir>/sessionspointing elsewhere, the next push'swrite landed OUTSIDE
repoDir. The link can be planted locally, or materialized by this plugin's owngit merge --no-commit/git checkout --theirsfrom a remote tree.Suggested fix:
lstatthe final path component (or open withO_NOFOLLOW) before writing, and assert theresolved parent chain stays inside
repoDir— the same containment the read direction already enforces.2.
sanitizeRemotemisses a token in the username positionSECURITY.md: "Remote-URL credentials, tokens, andkey=valuesecrets are redacted before reaching the modelor the log".
sanitizeRemote(lib/sanitize.mjs) redacts onlyurl.passwordwhen BOTH username and password are non-empty,plus known credential query keys. With the common PAT spelling there is no password:
That renders through
/sync status(lib/status.mjs->lib/render.mjs), and/sync statusis a read-onlysurface that never asks for confirmation — so the token reaches the model conversation and the session log this
plugin then mirrors and pushes.
Suggested fix: redact the whole userinfo component (or the username when it matches a token shape), not only
user:password.3.
git fetchforce-updates the tracking reflib/git.mjsguardsstartsWith('+')only forverb === 'push', butfetch()passes+<branch>:refs/remotes/origin/<branch>positionally. A remote rewrite therefore force-updates the tracking ref,and since every fetch is followed by
merge --no-commit, the rewritten tree is merged rather than refused."Never force-pushes" stays literally true; the comment's
+refspecinvariant does not hold forfetch.Filed by a user installing this plugin as their session-history transport. No CVE claimed, no exploit beyond the
local-repository scenario above; reporting here because these are the guarantees the README offers and two of
them are what made the install decision on their side.