Review Linear Issues and Security Fixes

Review Linear ENCLI-270 and ENCLI-271. Confirm whether they are duplicate, if not whether they are a security concern.
I've verified enough to answer confidently.
Verdict
1. Are they duplicates? — Yes.
ENCLI-270 and ENCLI-271 are the same report:
| ENCLI-270 | ENCLI-271 | |
|---|---|---|
| GitHub source | issue #1757 | issue #1759 |
| Created | 2026-07-15 05:52 | 2026-07-15 05:57 |
| Status | Canceled (05:57:54) | Triage (label: Bug) |
| Title/body | Identical (270 has slightly richer markdown formatting) | Same content, plain-text |
Same reporter, same repro, same root-cause analysis, filed 5 minutes apart. ENCLI-270 was cancelled essentially at the moment 271 was created — the team already de-duped by keeping 271 (the cleaner one, with the Bug label). No further action needed on the duplicate question; 271 is the live copy.
2. Is the underlying bug real? — Yes, confirmed in code.
The reporter's root-cause analysis is accurate. There are three code paths that decide what's excluded:
state.go:shouldIgnoreSessionTrackingPath(session tracking) — consultsIsInfrastructurePath+agent.AllProtectedFiles()+agent.AllProtectedDirs()✓strategy/common.go:isProtectedPath(rewind/filtering) — consultsagent.AllProtectedDirs()✓checkpoint/ephemeral.go:collectChangedFiles(first-checkpoint path, ~line 1252/1268) — onlypaths.IsInfrastructurePath(filename), which is solely a.entire/subpath check ✗
So on the first checkpoint of a session, protected-dir content (e.g. .claude/, a plugin's .terminalhire/) is captured into the shadow tree, exactly as reported. The documented contract ("excluded from checkpoints and diffs") is violated.
3. Is it a security concern? — Low. Local-only; not a data-exfiltration bug. The reporter's own triage was correct.
I traced where the captured content can go:
- Shadow branch (
entire/<sha7>-<hash6>) holds the raw file content.PrePushpushes onlyrefs.Push/ theentire/checkpoints/v1branch — never shadow branches, which are cleaned up locally (cleanupPushedShadowBranches). So the content is never pushed. entire/checkpoints/v1(the pushable branch) stores redacted transcripts + metadata (FilesTouchedis a list of path names, not content). It does not carry the shadow tree's file blobs. AndFilesTouchedis derived from the session-tracking path, which does honor protected dirs — so protected paths shouldn't even appear there..git/entire-sessions/<id>.jsonholds the content locally too — also never pushed.
Net: the leak is confined to local git objects. An attacker would already need filesystem access to .git/, at which point they could read the working tree directly. It does not cross the trust boundary the SECURITY.md rubric cares about (remote actor / auto-push exfiltration).
That said, it's not zero-risk and shouldn't be dismissed as cosmetic:
- Protected dirs like
.claude/routinely containsettings.local.jsonwith API keys / MCP tokens. Writing them into extra local git objects broadens their footprint (backups,git fsck/reflog recovery, disk forensics, accidentalgit pushof a shadow ref). - It's a broken security contract, agent-agnostic, and fires 100% of the time on session start — so it silently affects every user, and any future change that does push shadow content would turn this into a real leak.
Recommendation: treat 271 as a valid bug (privacy/hygiene), not a security-embargo issue — keep it on the public tracker rather than routing to security@. The fix is exactly what the reporter proposed: have collectChangedFiles also consult agent.AllProtectedDirs()/AllProtectedFiles(), mirroring shouldIgnoreSessionTrackingPath, plus a regression test in ephemeral_test.go. Worth noting the current branch is protected-dirs — this looks like the fix is already in progress.
Done. Full checkpoint package passes and lint is clean (the fmtappendf diagnostics are pre-existing in unrelated tests, untouched by this change).
Summary
Fix (cmd/entire/cli/checkpoint/ephemeral.go): added isProtectedCheckpointPath, which checks .entire/ plus agent.AllProtectedDirs() and agent.AllProtectedFiles(), and wired it into the three filter sites in collectChangedFiles. This makes the first-checkpoint path apply the same protected-dir exclusions the session-tracking (state.go) and rewind (common.go) paths already use.
Regression test (cmd/entire/cli/checkpoint/checkpoint_test.go): TestCollectChangedFiles_ExcludesProtectedDirs now covers both leak vectors the report described:
- Built-in
.claude/(real claude-code agent, via blank import). - External-plugin-style
.terminalhire/dir and.terminalhirercfile, via a minimalfakePluginAgentregistered into the registry — exercising theAllProtectedDirs/AllProtectedFilesunion explicitly. - Asserts
.entire/is still excluded and an ordinary untracked file (src/keep.txt) is still captured. - Disables
core.excludesFileso the machine's global gitignore can't mask the leak (the exact machine-dependence the report flagged).
Verification: passes with the fix; without it, the failure output shows all three protected paths captured — confirming the leak is agent-agnostic and fires on first checkpoint, exactly as ENCLI-271 reported. gofmt clean, golangci-lint 0 issues.
Not yet run: full mise run check (integration + e2e canary). No commit made — say the word and I'll run the full check and prepare a commit on protected-dirs.
Done. Added filepath.EvalSymlinks(tempDir) right after t.TempDir(), matching the existing convention in checkpoint_test.go:1977 and tree_surgery_equiv_test.go:214. Test passes, lint clean.
You were right to flag it: the reporter reproduced on macOS, where t.TempDir() returns a /var/... symlink that git resolves to /private/var/.... My assertions use git-relative paths so they'd probably survive the mismatch, but collectChangedFiles derives its working dir from wt.Filesystem().Root(), and normalizing the root up front removes that variable entirely and keeps the regression test consistent with the rest of the package.
The fmtappendf diagnostics remain pre-existing in unrelated test functions — untouched by this change.
During the execution path, does all callers have some level traversal enforcement, as the IsInfrastructurePath feels weak from a security PoV.
Good — that confirms the picture. Here's the traced answer.
Direct answer
Traversal to the filesystem is enforced on the checkpoint-write path — but not by IsInfrastructurePath/isProtectedCheckpointPath. Those are exclusion (deny) filters, not traversal guards, and you're right not to lean on them for that. The actual traversal enforcement sits at the choke point where a path becomes a filesystem read:
Execution path for the first-checkpoint case:
collectChangedFiles— paths come fromgit status --porcelain -z -uall(ephemeral.go:1250). With-z, output is repo-root-relative, slash-clean, no C-quoting and no..segments — so traversal isn't injectable at this layer to begin with.isProtectedCheckpointPathfilters (exclusion only).- Surviving paths →
normalizeRepoRelativeTreePath→normalizeGitTreePath(parse_tree.go:391), which rejects absolute paths, empty segments, and any./..segment. Invalid paths are logged (logInvalidGitTreePath) and skipped. - Only then does
filepath.Join(repoRoot, relPath)→createBlobFromFileread the file (ephemeral.go:809-816).
So every caller that turns a path into a read passes through normalizeGitTreePath first — that's the "some level of traversal enforcement" you were asking about, and it's independent of the exclusion filter. Note also the fallback in normalizeRepoRelativeTreePath:1030: even when ToRelativePath returns "" (which is exactly what it does on a .. path), it still routes through normalizeGitTreePath, so the .. is caught rather than silently passed. And createBlobFromFile uses os.Lstat + os.Readlink for symlinks (ephemeral.go:887,902) — it stores the link target text like git does, so it will not follow a symlink to exfiltrate content outside the repo.
The other two exclusion callers get equally safe input: state.go:shouldIgnoreSessionTrackingPath filepath.Cleans its argument, and common.go:969 reads from go-git worktree.Status() keys (clean, relative) and only for line-count metadata, not stored content.
Where IsInfrastructurePath is genuinely weak
You're right that it "feels weak," but the real failure mode isn't traversal escape — it's a false negative (failing to exclude something sensitive → leak). On that axis:
- Good:
IsSubpathusesfilepath.Rel+..rejection, so there's no naive-prefix bug —.entiremaliciousdoes not match.entire. That part is sound. - Real gap — case-insensitivity.
filepath.Relis case-sensitive. I verified:On a case-insensitive filesystem — macOS and Windows, i.e. the reporter's platform —.Claudeand.claudeare the same directory, but a path recorded in that casing bypasses the exclusion and gets captured. This is a legitimate leak vector for the very content this fix exists to protect, and it applies to both the agent protected-dir check and the.entireinfra check.
There's a secondary consideration (not a bug today): the exclusion filter is the only gate protecting sensitive content — normalizeGitTreePath doesn't help here, since a clean relative .claude/secret passes it fine. So exclusion correctness is load-bearing, which is exactly why the case gap matters.
Recommendation
Harden the match to be case-insensitive for the protected/infra comparison. The subtlety: on genuinely case-sensitive Linux FS, .Claude and .claude are different dirs, so unconditional lowercasing would over-exclude — but over-exclusion is harmless here (worst case: a same-name-different-case dir is kept out of a checkpoint), whereas under-exclusion leaks secrets. So case-insensitive matching is the safe asymmetry.
Want me to add a case-insensitive variant (e.g. a subpathFold helper in paths) and extend the regression test with a .Claude/… case? I'd scope it to the exclusion checks only, not the tree-write traversal guard, and mirror it into state.go/common.go so all three exclusion paths agree.
Make this OS-based, so that operational systems (Windows) that are case insensitive handle correctly as well
All green. Here's what I changed to make the case handling OS-based.
Summary
Made path matching OS-based so case-insensitive filesystems (Windows, macOS) correctly exclude protected content, while case-sensitive Linux keeps exact matching (no over-exclusion).
cmd/entire/cli/paths/paths.go:
- Added
osDarwin = "darwin"const. - Added
CaseInsensitiveFS()— returns true onwindows/darwin(keyed onruntime.GOOS, deterministic; documented that an atypical case-sensitive macOS volume only ever over-excludes, the safe direction). - Added
Equal(a, b)— path equality honoringCaseInsensitiveFS()(cleans + slash-normalizes, thenstrings.EqualFoldon case-insensitive OSes, exact==otherwise). - Made
IsSubpathfold case (strings.ToLowerboth sides beforefilepath.Rel) whenCaseInsensitiveFS(). Traversal (.., absolute) is still rejected regardless of case, so folding only widens containment to case variants of the same on-disk path — it can't be used to slip past a containment check.
This centralizes the fix: all exclusion/containment callers of IsSubpath now inherit correct behavior — IsInfrastructurePath, my isProtectedCheckpointPath (dir check), strategy/common.go:isProtectedPath, state.go:shouldIgnoreSessionTrackingPath, plus the two security allow-checks (utils.go:openAllowedRoot, rewind.go:legacyFallbackTranscriptPath), which I verified are not weakened.
Protected-file equality (which used raw ==) now routes through paths.Equal:
checkpoint/ephemeral.go(my helper)cli/state.go:shouldIgnoreSessionTrackingPath
Tests (paths_test.go, OS-aware — green on any platform): TestCaseInsensitiveFS, TestIsSubpath_CaseSensitivity (asserts .claude vs .Claude/x matches iff CaseInsensitiveFS(), same-case always matches, traversal always rejected), TestEqual_CaseSensitivity.
Verification: paths, checkpoint, strategy tests pass; go vet clean; golangci-lint 0 issues on paths and checkpoint, and no state.go findings. On Linux the runtime behavior of IsSubpath is unchanged (folding inactive), so nothing regresses here; the new behavior activates on Windows/macOS.
Note on why I didn't add a case-variant case to the end-to-end ephemeral test: on macOS/Windows, creating .Claude/x after .claude/y writes into the same directory, so git would report it as .claude/x — an FS-based case test can't reliably represent the scenario cross-platform. The pure-helper unit tests cover the case logic deterministically; the ephemeral test covers the wiring.
Still not run: full mise run check. No commit made.
The background test run produced no output and no test process is running — it appears to have been dropped. Rather than rely on it, let me re-run the affected packages synchronously (including state.go's package) to have a verified result before committing.
Committed on protected-dirs as 262fd3fe4.
- Author & committer: both
Paulo Gomes <paulo@entire.io>— matches theSigned-off-by, so the DCO check passes (overrode the defaultpjbgf@linux.com). - Signature:
G— GPG signature valid, so the GPG agent was unlocked and signing succeeded. - 5 files, +209/-7, with the DCO/assist trailers. The repo's
prepare-commit-msghook also appended itsEntire-Checkpointtrailer.
Pre-commit state: mise run fmt and full mise run lint were clean; paths, checkpoint, and strategy package tests passed. Note I did not complete a full mise run test/test:ci run (the background run was dropped and you declined the synchronous re-run), so the broader cli package suite and integration/e2e canary are unverified for this commit — worth running before pushing.
Case folding weakens path containment Medium Severity
IsSubpath now lowercases both paths on Windows and macOS for protected-path exclusion, but the same helper is used for fail-closed containment checks (e.g. legacyFallbackTranscriptPath). On a case-sensitive volume with GOOS darwin, a trailer value like .Entire/metadata can pass as under .entire/metadata while resolving to a different on-disk directory than before.
The commit failed because your GPG agent refused to sign (it's locked/timed out) — the changes are staged and ready. Please unlock your GPG key, then I'll retry the same commit.
You can unlock it by running (in the session, with the ! prefix) something like:
or just re-trigger your pinentry. Once it's unlocked, tell me and I'll re-run the commit (nothing else needs redoing — everything is staged, fmt/lint/tests are green).
Committed as 401064d77, working tree clean.
- Author & committer: both
Paulo Gomes <paulo@entire.io>(DCO passes). - Signature:
G— valid GPG signature. - 6 files, +76/-35.
What this fixes
The reviewer was right — my earlier commit weakened a fail-closed check. The correction:
IsSubpathreverted to case-sensitive. It's the containment primitive for fail-closed allow gates (rewind.legacyFallbackTranscriptPath,utils.openAllowedRoot), where over-matching fails open. Now a case-variant like.Entire/metadatais rejected on every OS, including case-sensitive volumes underGOOS=darwin.- New
paths.IsProtectedSubpathcarries the OS-based case fold, documented as exclusion-only (over-matching there just over-excludes — the safe direction). The exclusion callers use it:IsInfrastructurePathand the protected-dir loops instate.go,ephemeral.go,common.go.paths.Equal(protected files) keeps the same exclusion-only fold. - Tests:
TestIsSubpath_AlwaysCaseSensitivelocks in the strict behavior on all platforms; folding assertions moved toTestIsProtectedSubpath_CaseSensitivity; andTestLegacyFallbackTranscriptPathgains a case-variant case proving the containment gate fails closed regardless of OS.
Verified before commit: go build clean; paths, checkpoint, strategy, and the cli rewind test pass; golangci-lint 0 issues across the touched packages.
Still outstanding from before: a full mise run test/test:ci (unit + integration + e2e canary) hasn't been completed for this branch — worth running before pushing. Two commits are now stacked on this work (262fd3fe4, 401064d77), both local-only.