Skip to content

Commit 401064d

Browse files
committed
fix(paths): keep IsSubpath case-sensitive; fold only for exclusion
Review found that making the shared IsSubpath case-insensitive on Windows/macOS weakened fail-closed containment gates. IsSubpath is used both for protected-path EXCLUSION and for allow/containment checks (rewind.legacyFallbackTranscriptPath, utils.openAllowedRoot). Folding is safe only for exclusion (over-match => over-exclude); for a fail-closed gate it fails open. On a case-sensitive volume under GOOS=darwin, a crafted `.Entire/metadata` trailer would pass containment yet resolve to a different on-disk directory than the strict check intended. Revert IsSubpath to case-sensitive (the correct primitive for containment) and add IsProtectedSubpath, which applies the OS-based case fold and is documented as exclusion-only. Route the exclusion callers through it (IsInfrastructurePath, the protected-dir loops in state.go/ephemeral.go/common.go); leave the rewind/utils containment gates on strict IsSubpath. Tests: IsSubpath is asserted case-sensitive on all OSes; folding moves to IsProtectedSubpath; add a legacyFallbackTranscriptPath case proving a case-variant metadata dir fails closed on every platform. Assisted-by: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Entire-Checkpoint: 01KXK5A53886X5ZEZHQFPGBEJ0
1 parent c937bf5 commit 401064d

6 files changed

Lines changed: 76 additions & 35 deletions

File tree

cmd/entire/cli/checkpoint/ephemeral.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1217,7 +1217,7 @@ func isProtectedCheckpointPath(relPath string) bool {
12171217
}
12181218
}
12191219
for _, dir := range agent.AllProtectedDirs() {
1220-
if paths.IsSubpath(filepath.Clean(filepath.FromSlash(dir)), cleanPath) {
1220+
if paths.IsProtectedSubpath(filepath.Clean(filepath.FromSlash(dir)), cleanPath) {
12211221
return true
12221222
}
12231223
}

cmd/entire/cli/paths/paths.go

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -134,48 +134,65 @@ func AbsPath(ctx context.Context, relPath string) (string, error) {
134134
}
135135

136136
// IsInfrastructurePath returns true if the path is part of CLI infrastructure
137-
// (i.e., inside the .entire directory)
137+
// (i.e., inside the .entire directory). It is used only to EXCLUDE infra paths
138+
// from checkpoints/tracking, so it matches case-insensitively on
139+
// case-insensitive filesystems via IsProtectedSubpath. Do not use it as a
140+
// containment/allow gate.
138141
func IsInfrastructurePath(path string) bool {
139-
return IsSubpath(EntireDir, path)
142+
return IsProtectedSubpath(EntireDir, path)
140143
}
141144

142145
// IsSubpath reports whether child is lexically under parent (or equal to it).
143146
// It uses filepath.Rel, which cleans both inputs and is traversal-resistant:
144147
// a crafted child like "/a/b/../../../etc/passwd" that escapes parent will
145148
// produce a relative path starting with ".." and be rejected.
146149
//
147-
// Matching honors the host OS's case sensitivity (see CaseInsensitiveFS): on
148-
// Windows/macOS ".Claude/x" is under ".claude" because they name the same
149-
// directory there, while on case-sensitive Linux they remain distinct. Folding
150-
// only ever widens containment to case variants of the same on-disk path; real
151-
// traversal escapes are still rejected regardless of case, so this cannot be
152-
// used to slip past a containment check.
150+
// Matching is case-SENSITIVE. This is the correct primitive for fail-closed
151+
// containment/allow checks (e.g. validating an attacker-influenced path stays
152+
// under an Entire-owned dir): on a case-sensitive volume a differently-cased
153+
// path names a different directory, so folding it in would fail open. For
154+
// EXCLUSION decisions that must also catch case variants on Windows/macOS, use
155+
// IsProtectedSubpath instead.
153156
func IsSubpath(parent, child string) bool {
154-
if CaseInsensitiveFS() {
155-
parent = strings.ToLower(parent)
156-
child = strings.ToLower(child)
157-
}
158157
rel, err := filepath.Rel(parent, child)
159158
if err != nil {
160159
return false
161160
}
162161
return !IsRelativeTraversal(rel)
163162
}
164163

164+
// IsProtectedSubpath reports whether child is under parent for the purpose of
165+
// EXCLUDING protected/infrastructure content from checkpoints and tracking.
166+
// Unlike IsSubpath it honors OS case-insensitivity (see CaseInsensitiveFS), so
167+
// a case variant of a protected dir (".Claude" vs ".claude") is still excluded
168+
// on Windows/macOS.
169+
//
170+
// SECURITY: never use this for allow/containment decisions. Case-folding widens
171+
// what counts as "inside" parent, which is safe only when the effect is to
172+
// exclude more. On a case-sensitive volume under a case-insensitive GOOS it
173+
// over-matches; for a fail-closed gate that would fail open. Use IsSubpath there.
174+
func IsProtectedSubpath(parent, child string) bool {
175+
if CaseInsensitiveFS() {
176+
return IsSubpath(strings.ToLower(parent), strings.ToLower(child))
177+
}
178+
return IsSubpath(parent, child)
179+
}
180+
165181
// CaseInsensitiveFS reports whether path comparisons should be case-insensitive
166182
// on the host OS. This is OS-based, not volume-based: Windows and macOS default
167183
// to case-insensitive filesystems, Linux to case-sensitive. Keying on GOOS keeps
168-
// the result deterministic; on an atypical volume (e.g. a case-sensitive macOS
169-
// APFS volume) the only effect is that the exclusion/containment checks treat a
170-
// differently-cased path as matching, which merely over-excludes — the safe
171-
// direction for filters whose job is to keep sensitive paths out.
184+
// the result deterministic. It must only influence EXCLUSION decisions (see
185+
// IsProtectedSubpath / Equal): on an atypical volume (e.g. a case-sensitive
186+
// macOS APFS volume) it treats a differently-cased path as matching, which is
187+
// safe only when the effect is to exclude more, never to widen an allow gate.
172188
func CaseInsensitiveFS() bool {
173189
return runtime.GOOS == osWindows || runtime.GOOS == osDarwin
174190
}
175191

176-
// Equal reports whether two paths refer to the same location, honoring the
177-
// host OS's case sensitivity (see CaseInsensitiveFS). Both inputs are cleaned
178-
// and slash-normalized before comparison.
192+
// Equal reports whether two paths refer to the same location, honoring the host
193+
// OS's case sensitivity (see CaseInsensitiveFS). Both inputs are cleaned and
194+
// slash-normalized before comparison. Like IsProtectedSubpath, this is intended
195+
// for EXCLUSION matching (e.g. protected files), not fail-closed containment.
179196
func Equal(a, b string) bool {
180197
a = filepath.Clean(filepath.FromSlash(a))
181198
b = filepath.Clean(filepath.FromSlash(b))

cmd/entire/cli/paths/paths_test.go

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -104,24 +104,39 @@ func TestCaseInsensitiveFS(t *testing.T) {
104104
}
105105
}
106106

107-
// TestIsSubpath_CaseSensitivity and TestEqual_CaseSensitivity assert the
108-
// OS-based folding: case variants of a protected path are matched on
109-
// Windows/macOS (where they name the same file) and remain distinct on
110-
// case-sensitive Linux.
111-
func TestIsSubpath_CaseSensitivity(t *testing.T) {
107+
// TestIsSubpath_AlwaysCaseSensitive locks in that IsSubpath — the fail-closed
108+
// containment primitive used by allow gates (rewind/utils) — never folds case
109+
// on any OS. A differently-cased path must not count as contained, or a
110+
// crafted, attacker-influenced value could fail open on a case-sensitive volume.
111+
func TestIsSubpath_AlwaysCaseSensitive(t *testing.T) {
112112
t.Parallel()
113-
// On a case-insensitive FS these name the same dir, so containment holds.
114-
got := IsSubpath(".claude", ".Claude/marker.txt")
115-
if got != CaseInsensitiveFS() {
116-
t.Errorf("IsSubpath(.claude, .Claude/marker.txt) = %v, want %v (GOOS=%s)",
117-
got, CaseInsensitiveFS(), runtime.GOOS)
113+
if IsSubpath(".entire/metadata", ".Entire/metadata") {
114+
t.Error("IsSubpath must be case-sensitive (fail-closed); .Entire/metadata must not be under .entire/metadata")
118115
}
119-
// Same case is always a subpath; traversal is always rejected, regardless of case.
120116
if !IsSubpath(".claude", ".claude/marker.txt") {
121117
t.Error("IsSubpath(.claude, .claude/marker.txt) = false, want true")
122118
}
123-
if IsSubpath(".claude", ".Claude/../../etc/passwd") {
124-
t.Error("IsSubpath must reject traversal even when case-folding")
119+
if IsSubpath(".claude", ".claude/../../etc/passwd") {
120+
t.Error("IsSubpath must reject traversal")
121+
}
122+
}
123+
124+
// TestIsProtectedSubpath_CaseSensitivity asserts OS-based folding for the
125+
// EXCLUSION helper: case variants match on Windows/macOS (where they name the
126+
// same on-disk path), stay distinct on case-sensitive Linux, and traversal is
127+
// always rejected.
128+
func TestIsProtectedSubpath_CaseSensitivity(t *testing.T) {
129+
t.Parallel()
130+
got := IsProtectedSubpath(".claude", ".Claude/marker.txt")
131+
if got != CaseInsensitiveFS() {
132+
t.Errorf("IsProtectedSubpath(.claude, .Claude/marker.txt) = %v, want %v (GOOS=%s)",
133+
got, CaseInsensitiveFS(), runtime.GOOS)
134+
}
135+
if !IsProtectedSubpath(".claude", ".claude/marker.txt") {
136+
t.Error("IsProtectedSubpath(.claude, .claude/marker.txt) = false, want true")
137+
}
138+
if IsProtectedSubpath(".claude", ".Claude/../../etc/passwd") {
139+
t.Error("IsProtectedSubpath must reject traversal even when case-folding")
125140
}
126141
}
127142

cmd/entire/cli/rewind_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,15 @@ func TestLegacyFallbackTranscriptPath(t *testing.T) {
7575
metadataDir: ".entire",
7676
want: "",
7777
},
78+
{
79+
// Containment is a fail-closed allow gate: it must stay case-SENSITIVE
80+
// on every OS. A case variant names a different on-disk dir on a
81+
// case-sensitive volume (which exists under GOOS=darwin), so folding it
82+
// in would fail open. Must return "" regardless of platform.
83+
name: "case-variant of metadata dir fails closed on all OSes",
84+
metadataDir: ".Entire/metadata/sess-123",
85+
want: "",
86+
},
7887
}
7988

8089
for _, tt := range tests {

cmd/entire/cli/state.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ func shouldIgnoreSessionTrackingPath(relPath string) bool {
245245

246246
for _, dir := range agent.AllProtectedDirs() {
247247
cleanDir := filepath.Clean(filepath.FromSlash(dir))
248-
if paths.IsSubpath(cleanDir, cleanPath) {
248+
if paths.IsProtectedSubpath(cleanDir, cleanPath) {
249249
return true
250250
}
251251
}

cmd/entire/cli/strategy/common.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ const (
352352
// registered agent config directories.
353353
func isProtectedPath(relPath string) bool {
354354
for _, dir := range protectedDirs() {
355-
if paths.IsSubpath(dir, relPath) {
355+
if paths.IsProtectedSubpath(dir, relPath) {
356356
return true
357357
}
358358
}

0 commit comments

Comments
 (0)