Skip to content

feat: renovate updates and security improvments - #2

Merged
muzomer merged 8 commits into
mainfrom
feat-renovate-and-security-improvements
Sep 1, 2026
Merged

feat: renovate updates and security improvments#2
muzomer merged 8 commits into
mainfrom
feat-renovate-and-security-improvements

Conversation

@muzomer

@muzomer muzomer commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Harden path handling, pin CI supply chain, add Renovate

Three related strands: the extension acts on paths and file modes parsed out of patch text, CI hands a token to an unpinned toolchain, and dependencies had no update mechanism.

Refuse unsafe paths (src/patch/paths.ts, src/review/check.ts)

Every path this extension reads, writes, or deletes comes from patch text, and every place it lands normalises .. rather than confining it — join("/repo", "../../etc/x") is /etc/x, not an error. Containment now happens before the join. Refusing costs nothing: git and jj both emit repo-root-relative paths, and git's one cwd-relative mode omits outside files rather than reaching them with ...

Refuse symlinks and submodules (src/review/check.ts)

Git stores a symlink as a file whose content is its target, so it reached review looking like ordinary one-line text. Nothing interpreted a file mode, so both write sites wrote it back as text and resolved the destination — landing the write wherever the link pointed, outside the workspace. The path check couldn't catch this: a symlink escapes without a suspicious path anywhere.

Refused at checkReviewedFile, the single point both backends pass through, for every file whether marked or not. Regular modes are an allowlist — an unrecognised mode is one nobody has reasoned about. Write sites are hardened for when the working copy and the patch disagree: O_NOFOLLOW on discard, and an unlink before both the redirect and the cp in the jj helper.

The reachable case was an unmarked symlink in a jj review: unmarked files are never read from the working copy, so no staleness check runs, yet the file still becomes a restore instruction.

Pin the CI supply chain (.github/workflows/ci.yml)

CI passes GITHUB_TOKEN to a step, and three gaps compounded: no permissions block, both actions on floating tags that can be retagged onto attacker code, and a jj tarball installed as root with no pinned version or digest. Now contents: read, actions pinned to commit SHAs, and jj pinned to v0.44.0 against a recorded SHA-256. jj publishes no checksum file, so the digest was recorded by hand — it catches a later swap of the asset, it does not attest the build.

https://claude.ai/code/session_01V5yBUZqwwtDFrkKLtANyLw

Two notes on using it: the branch isn't pushed yet, so gh pr create needs a jj git push first — tell me if you want both. And the last section is a checklist for you specifically; if you'd rather it not live
in the PR body permanently, it fits better as a comment.

✻ Cooked for 22s · done 6:14 PM

❯ I mean PR description for all the changes in the branch, but please keep it precise and sh

Right — the branch has earlier commits too. Let me look at the whole thing.

Ran 4 shell commands

Here's the description for the whole branch, kept short:

Harden path handling, pin CI supply chain, add Renovate

Three related strands: the extension acts on paths and file modes parsed out of patch text, CI hands a token to an unpinned toolchain, and dependencies had no update mechanism.

Refuse unsafe paths (src/patch/paths.ts, src/review/check.ts)

Every path this extension reads, writes, or deletes comes from patch text, and every place it lands normalises .. rather than confining it — join("/repo", "../../etc/x") is /etc/x, not an error.
Containment now happens before the join. Refusing costs nothing: git and jj both emit repogit's one cwd-relative mode omits outside files rather than reaching them with ...

Refuse symlinks and submodules (src/review/check.ts)

Git stores a symlink as a file whose content is its target, so it reached review looking like ordinary one-line text. Nothing interpreted a file mode, so both write sites wrote it back as text and resolved the destination — landing the write wherever the link pointed, outside the workspace. The path check couldn't catch this: a symlink escapes without a suspicious path anywhere.

Refused at checkReviewedFile, the single point both backends pass through, for every file whether marked or not. Regular modes are an allowlist — an unrecognised mode is one nobody has reasoned about. Write sites are hardened for when the working copy and the patch disagree: O_NOFOLLOW on discard, and an unlink before both the redirect and the cp in the jj helper.

The reachable case was an unmarked symlink in a jj review: unmarked files are never read from the working copy, so no staleness check runs, yet the file still becomes a restore instruction.

Pin the CI supply chain (.github/workflows/ci.yml)

CI passes GITHUB_TOKEN to a step, and three gaps compounded: no permissions block, both actions on floating tags that can be retagged onto attacker code, and a jj tarball installed as root with no pinned version or digest. Now contents: read, actions pinned to commit SHAs, and jj pinned to v0.44.0 against a recorded SHA-256. jj publishes no checksum file, so the digest was recorded by hand — it catches a later swap of the asset, it does not attest the build.

Add Renovate (renovate.json5, package.json)

Hosted Renovate app, scoped to npm devDependencies and GitHub Actions. config:best-practipinGitHubActionDigests` — the SHAs pinned above are otherwise frozen forever, giving us
the supply-chain benefit and none of the security updates.

@types/bun: latest and typescript: ^5 are pinned first, because Renovate can act on neither: latest is a dist-tag, and every 5.x already satisfies ^5. Both are set to the versions bun.lock already resolved, so nothing installs differently.

A 7-day cooldown (minimumReleaseAge + internalChecksFilter: strict — the first alone s automerge fire unreviewed) holds back fresh uploads. Security fixes bypass it, or the
setting would delay CVE patches and make us less safe. hunkdiff never automerges: it's erased at compile time andbun test` gives zero signal on a bump.

Also

Beads issue tracking, .perles/config.yaml, and CLAUDE.md for agent context.

muzomer and others added 6 commits August 30, 2026 19:49
Git stores a symlink as a file whose content is its target, so one reaches
the review looking like ordinary one-line text. Nothing here interpreted a
file mode, so both write sites wrote it back as text — and both resolve the
destination, so the write landed wherever the link pointed, outside the
workspace. The path check could not see this: it settles the path text, and
a symlink escapes without a suspicious path anywhere.

The reachable case is an unmarked symlink in a jj review. An unmarked file is
never read from the working copy, so no staleness check runs on it, yet it
still becomes a restore instruction for the helper script.

Refuse at checkReviewedFile, the one place both backends pass through and
which runs for every file, marked or not. Regular modes are an allowlist: an
unrecognised mode is one nobody has reasoned about.

The write sites are hardened too, for when the working copy and the patch
disagree: O_NOFOLLOW when discarding, and an unlink before both the redirect
and the cp in the jj helper. The discard environment moves out of index.ts so
the integration test drives the real one rather than a copy that could not
have caught this.
The CI job hands GITHUB_TOKEN to a step as GH_TOKEN, so three supply-chain
gaps compounded: no permissions block (the token inherited whatever the
repository default happened to be), both actions on floating tags that can be
retagged onto attacker code, and a jj tarball installed as root with neither a
pinned version nor a digest check.

Declare permissions: contents: read, pin actions/checkout and setup-bun to
commit SHAs, and pin jj to v0.44.0 verified against a recorded SHA-256. jj
publishes no checksum file, so the digest was recorded by hand: it catches a
later swap of the asset, it does not attest the build.
@muzomer
muzomer force-pushed the feat-renovate-and-security-improvements branch from 7a59a62 to 88666a5 Compare August 30, 2026 19:31
muzomer and others added 2 commits September 1, 2026 18:07
Renovate cannot manage the 'latest' dist-tag, and a range as loose as '^5'
never produces a PR because every new 5.x already satisfies it — both bumps
would land silently in bun.lock instead of arriving as a reviewable diff.

'latest' was already decorative: CI runs 'bun install --frozen-lockfile', so
the lockfile pin won and the dist-tag only took effect when someone
regenerated the lockfile, moving the version with no diff anyone reviewed.

Both ranges are set to the versions bun.lock already resolved, so no install
changes. hunkdiff is left at ^0.20.0 — a caret on a 0.x version is already
tight (>=0.20.0 <0.21.0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V5yBUZqwwtDFrkKLtANyLw
Configures the hosted Mend Renovate app to manage npm devDependencies and
GitHub Action pins. ci.yml already SHA-pins actions by hand, but a hand-pinned
SHA is frozen forever — we get the supply-chain benefit and none of the
security updates. config:best-practices keeps the pin and moves it.

Cooldown against compromised uploads uses two settings that must go together:
minimumReleaseAge alone only marks an internal check as pending, so Renovate
still opens the PR and automerge can merge it unreviewed once the check flips
green. internalChecksFilter 'strict' suppresses the PR until the version is old
enough, turning the cooldown into an actual filter rather than a delay.

Because that cooldown would also delay a release that fixes a CVE, security
updates bypass both the age rule and the weekly schedule.

hunkdiff never automerges: it is imported only as 'import type', so it is
erased at compile time and 'bun test' passes identically on any version. Only
tsc covers it, for 4 interfaces. It is also 0.x, where Renovate calls a
breaking change a 'minor'.

Written as JSON5 so the reasoning lives next to the settings it explains.

Config is inert until the app is installed and Dependabot alerts are enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V5yBUZqwwtDFrkKLtANyLw
@muzomer
muzomer marked this pull request as ready for review September 1, 2026 16:20
@muzomer
muzomer merged commit 4a49cba into main Sep 1, 2026
1 check passed
@muzomer
muzomer deleted the feat-renovate-and-security-improvements branch September 1, 2026 16:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant