fix(paths): keep IsSubpath case-sensitive; fold only for exclusion · Entire

fix(paths): keep IsSubpath case-sensitive; fold only for exclusion

401064d→main· pjbgf·2d ago·6 files·+76 added/-35 removed

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

Sessions

01KXK5A53886X5ZEZHQFPGBEJ0View transcript

Changes

6

1216 unmodified lines

1217
1218
1219
1220
1220
1221
1222
1223

1216 unmodified lines

}
}
for _, dir := range agent.AllProtectedDirs() {
    if paths.IsSubpath(filepath.Clean(filepath.FromSlash(dir)), cleanPath) {
    if paths.IsProtectedSubpath(filepath.Clean(filepath.FromSlash(dir)), cleanPath) {
        return true
    }
}

Mcmd/entire/cli/checkpoint/ephemeral.go+1/-1

133 unmodified lines

134
135
136
137
137
138
139
140
141
139
142
143
144
145
1 unmodified line

147
148
149
147
148
149
150
151
152
150
151
152
153
154
155
156
154
155
156
157
157
158
159
1 unmodified line

161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
168
169
170
171
184
185
186
187
188
189
190
191
176
177
178
192
193
194
195
196
197
198

133 unmodified lines

}

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

// IsSubpath reports whether child is lexically under parent (or equal to it).
1 unmodified line

// a crafted child like "/a/b/../../../etc/passwd" that escapes parent will
// produce a relative path starting with ".." and be rejected.
//
// Matching honors the host OS's case sensitivity (see CaseInsensitiveFS): on
// Windows/macOS ".Claude/x" is under ".claude" because they name the same
// directory there, while on case-sensitive Linux they remain distinct. Folding
// only ever widens containment to case variants of the same on-disk path; real
// traversal escapes are still rejected regardless of case, so this cannot be
// used to slip past a containment check.
// Matching is case-SENSITIVE. This is the correct primitive for fail-closed
// containment/allow checks (e.g. validating an attacker-influenced path stays
// under an Entire-owned dir): on a case-sensitive volume a differently-cased
// path names a different directory, so folding it in would fail open. For
// EXCLUSION decisions that must also catch case variants on Windows/macOS, use
// IsProtectedSubpath instead.
func IsSubpath(parent, child string) bool {
    if CaseInsensitiveFS() {
        parent = strings.ToLower(parent)
        child = strings.ToLower(child)
    }
    rel, err := filepath.Rel(parent, child)
    if err != nil {
        return false
1 unmodified line

return !IsRelativeTraversal(rel)
}

// IsProtectedSubpath reports whether child is under parent for the purpose of
// EXCLUDING protected/infrastructure content from checkpoints and tracking.
// Unlike IsSubpath it honors OS case-insensitivity (see CaseInsensitiveFS), so
// a case variant of a protected dir (".Claude" vs ".claude") is still excluded
// on Windows/macOS.
//
// SECURITY: never use this for allow/containment decisions. Case-folding widens
// what counts as "inside" parent, which is safe only when the effect is to
// exclude more. On a case-sensitive volume under a case-insensitive GOOS it
// over-matches; for a fail-closed gate that would fail open. Use IsSubpath there.
func IsProtectedSubpath(parent, child string) bool {
    if CaseInsensitiveFS() {
        return IsSubpath(strings.ToLower(parent), strings.ToLower(child))
    }
    return IsSubpath(parent, child)
}

// CaseInsensitiveFS reports whether path comparisons should be case-insensitive
// on the host OS. This is OS-based, not volume-based: Windows and macOS default
// to case-insensitive filesystems, Linux to case-sensitive. Keying on GOOS keeps
// the result deterministic; on an atypical volume (e.g. a case-sensitive macOS
// APFS volume) the only effect is that the exclusion/containment checks treat a
// differently-cased path as matching, which merely over-excludes — the safe
direction for filters whose job is to keep sensitive paths out.
// the result deterministic. It must only influence EXCLUSION decisions (see
// IsProtectedSubpath / Equal): on an atypical volume (e.g. a case-sensitive
// macOS APFS volume) it treats a differently-cased path as matching, which is
// safe only when the effect is to exclude more, never to widen an allow gate.
func CaseInsensitiveFS() bool {
    return runtime.GOOS == osWindows || runtime.GOOS == osDarwin
}

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