Skip to content

Commit 324995d

Browse files
authored
Merge pull request #1764 from entireio/protected-dirs
fix(checkpoint): exclude protected dirs from first-checkpoint snapshot
2 parents 469a30c + 401064d commit 324995d

7 files changed

Lines changed: 254 additions & 11 deletions

File tree

cmd/entire/cli/checkpoint/checkpoint_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
"time"
1515

1616
"github.com/entireio/cli/cmd/entire/cli/agent"
17+
_ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" // register claude-code so its .claude protected dir is discoverable
18+
"github.com/entireio/cli/cmd/entire/cli/agent/types"
1719
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
1820
"github.com/entireio/cli/cmd/entire/cli/paths"
1921
"github.com/entireio/cli/cmd/entire/cli/testutil"
@@ -103,6 +105,99 @@ func TestCopyMetadataDir_SkipsSymlinks(t *testing.T) {
103105
}
104106
}
105107

108+
// fakePluginAgent is a minimal agent stub used to prove that protected dirs
109+
// and files reported by an external-plugin-style agent (via the AllProtectedDirs
110+
// / AllProtectedFiles union) are honored by the first-checkpoint path, not just
111+
// the built-in claude-code .claude dir.
112+
type fakePluginAgent struct{}
113+
114+
var (
115+
_ agent.Agent = (*fakePluginAgent)(nil)
116+
_ agent.ProtectedFilesProvider = (*fakePluginAgent)(nil)
117+
)
118+
119+
func (fakePluginAgent) Name() types.AgentName { return "terminalhire-plugin" }
120+
func (fakePluginAgent) Type() types.AgentType { return "TerminalHire" }
121+
func (fakePluginAgent) Description() string { return "fake external plugin for tests" }
122+
func (fakePluginAgent) IsPreview() bool { return true }
123+
func (fakePluginAgent) ProtectedDirs() []string { return []string{".terminalhire"} }
124+
func (fakePluginAgent) ProtectedFiles() []string { return []string{".terminalhirerc"} }
125+
func (fakePluginAgent) GetSessionID(*agent.HookInput) string { return "" }
126+
127+
func (fakePluginAgent) DetectPresence(context.Context) (bool, error) { return false, nil }
128+
func (fakePluginAgent) ReadTranscript(string) ([]byte, error) { return nil, nil }
129+
func (fakePluginAgent) ChunkTranscript(_ context.Context, c []byte, _ int) ([][]byte, error) {
130+
return [][]byte{c}, nil
131+
}
132+
func (fakePluginAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) {
133+
var out []byte
134+
for _, c := range chunks {
135+
out = append(out, c...)
136+
}
137+
return out, nil
138+
}
139+
func (fakePluginAgent) GetSessionDir(string) (string, error) { return "", nil }
140+
func (fakePluginAgent) ResolveSessionFile(dir, sid string) string { return dir + "/" + sid }
141+
func (fakePluginAgent) ReadSession(*agent.HookInput) (*agent.AgentSession, error) { return nil, nil } //nolint:nilnil // test stub
142+
func (fakePluginAgent) WriteSession(context.Context, *agent.AgentSession) error { return nil }
143+
func (fakePluginAgent) FormatResumeCommand(string) string { return "" }
144+
145+
// TestCollectChangedFiles_ExcludesProtectedDirs verifies that the
146+
// first-checkpoint path keeps agent-protected dirs (e.g. .claude) and the
147+
// .entire infrastructure dir out of the checkpoint snapshot, while ordinary
148+
// untracked files are still captured. Regression for protected-dir content
149+
// leaking into the shadow tree on session start.
150+
func TestCollectChangedFiles_ExcludesProtectedDirs(t *testing.T) {
151+
t.Parallel()
152+
153+
// Register an external-plugin-style agent so its protected dir/file join the
154+
// AllProtectedDirs/AllProtectedFiles union alongside the built-in .claude.
155+
// Registration is additive and concurrency-safe; no test asserts the exact set.
156+
agent.Register("terminalhire-plugin", func() agent.Agent { return fakePluginAgent{} })
157+
158+
tempDir := t.TempDir()
159+
// Resolve symlinks so the repo root matches git's resolved path.
160+
// On macOS, t.TempDir() returns /var/... but git resolves to /private/var/...
161+
tempDir, err := filepath.EvalSymlinks(tempDir)
162+
require.NoError(t, err)
163+
164+
testutil.InitRepo(t, tempDir)
165+
testutil.WriteFile(t, tempDir, "base.txt", "base")
166+
testutil.GitAdd(t, tempDir, "base.txt")
167+
testutil.GitCommit(t, tempDir, "init")
168+
169+
// Disable any global core.excludesFile so a developer/CI-runner gitignore
170+
// convention (e.g. one that ignores .claude) can't mask the leak. The fix
171+
// must exclude protected dirs on its own, independent of gitignore state.
172+
cfgCmd := exec.CommandContext(context.Background(), "git", "config", "core.excludesFile", os.DevNull)
173+
cfgCmd.Dir = tempDir
174+
require.NoError(t, cfgCmd.Run())
175+
176+
// Planted untracked, non-gitignored files.
177+
testutil.WriteFile(t, tempDir, ".claude/marker.txt", "MARKER-secret") // built-in agent-protected dir
178+
testutil.WriteFile(t, tempDir, ".terminalhire/profile.json", "MARKER-plugin") // plugin-protected dir
179+
testutil.WriteFile(t, tempDir, ".terminalhirerc", "MARKER-plugin-file") // plugin-protected file
180+
testutil.WriteFile(t, tempDir, ".entire/state.json", "{}") // infrastructure
181+
testutil.WriteFile(t, tempDir, "src/keep.txt", "user work") // ordinary
182+
183+
repo, err := git.PlainOpen(tempDir)
184+
require.NoError(t, err)
185+
186+
result, err := collectChangedFiles(context.Background(), repo)
187+
require.NoError(t, err)
188+
189+
require.NotContains(t, result.Changed, ".claude/marker.txt",
190+
"built-in agent protected dir content must not be captured into the checkpoint")
191+
require.NotContains(t, result.Changed, ".terminalhire/profile.json",
192+
"external-plugin protected dir content must not be captured into the checkpoint")
193+
require.NotContains(t, result.Changed, ".terminalhirerc",
194+
"external-plugin protected file must not be captured into the checkpoint")
195+
require.NotContains(t, result.Changed, ".entire/state.json",
196+
"infrastructure dir must not be captured into the checkpoint")
197+
require.Contains(t, result.Changed, "src/keep.txt",
198+
"ordinary untracked files must still be captured")
199+
}
200+
106201
// TestWriteCommitted_AgentField verifies that the Agent field is written
107202
// to both metadata.json and the commit message trailer.
108203
func TestWriteCommitted_AgentField(t *testing.T) {

cmd/entire/cli/checkpoint/ephemeral.go

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,6 +1195,35 @@ func filterGitIgnoredFiles(ctx context.Context, repo *git.Repository, files []st
11951195
return kept
11961196
}
11971197

1198+
// isProtectedCheckpointPath reports whether a repo-relative path must be kept
1199+
// out of checkpoint snapshots: the .entire infrastructure dir, or any
1200+
// registered agent's declared protected dir/file (e.g. .claude, or an external
1201+
// plugin's protected_dirs).
1202+
//
1203+
// This mirrors shouldIgnoreSessionTrackingPath in the cli package. The two
1204+
// cannot share an implementation because cli imports checkpoint, so the logic
1205+
// is duplicated deliberately. The first-checkpoint path (collectChangedFiles)
1206+
// must apply the same exclusions as the session-tracking and rewind paths, or
1207+
// protected-dir content is captured into the shadow tree on session start
1208+
// (see the DetectFileChanges / isProtectedPath call sites).
1209+
func isProtectedCheckpointPath(relPath string) bool {
1210+
cleanPath := filepath.Clean(filepath.FromSlash(relPath))
1211+
if paths.IsInfrastructurePath(cleanPath) {
1212+
return true
1213+
}
1214+
for _, file := range agent.AllProtectedFiles() {
1215+
if paths.Equal(cleanPath, file) {
1216+
return true
1217+
}
1218+
}
1219+
for _, dir := range agent.AllProtectedDirs() {
1220+
if paths.IsProtectedSubpath(filepath.Clean(filepath.FromSlash(dir)), cleanPath) {
1221+
return true
1222+
}
1223+
}
1224+
return false
1225+
}
1226+
11981227
// collectChangedFiles returns all changed files from git status for the first checkpoint.
11991228
//
12001229
// For the first checkpoint, we need to capture:
@@ -1246,16 +1275,16 @@ func collectChangedFiles(ctx context.Context, repo *git.Repository) (changedFile
12461275
filename := entry[3:] // No TrimSpace needed with -z format
12471276

12481277
// Handle R/C (rename/copy) first - they have a second entry we must skip
1249-
// even if the new filename is an infrastructure path
1278+
// even if the new filename is a protected path
12501279
if staging == 'R' || staging == 'C' {
12511280
// Renamed or copied: current entry is new name, next entry is old name
1252-
if !paths.IsInfrastructurePath(filename) {
1281+
if !isProtectedCheckpointPath(filename) {
12531282
changedSeen[filename] = struct{}{}
12541283
}
12551284
// The old name follows as the next NUL-separated entry - must always skip it
12561285
if i+1 < len(entries) && entries[i+1] != "" {
12571286
oldName := entries[i+1]
1258-
if staging == 'R' && !paths.IsInfrastructurePath(oldName) {
1287+
if staging == 'R' && !isProtectedCheckpointPath(oldName) {
12591288
// For renames, old file is effectively deleted
12601289
deletedSeen[oldName] = struct{}{}
12611290
}
@@ -1264,8 +1293,8 @@ func collectChangedFiles(ctx context.Context, repo *git.Repository) (changedFile
12641293
continue
12651294
}
12661295

1267-
// Skip .entire directory for non-R/C entries
1268-
if paths.IsInfrastructurePath(filename) {
1296+
// Skip .entire and agent-protected dirs/files for non-R/C entries
1297+
if isProtectedCheckpointPath(filename) {
12691298
continue
12701299
}
12711300

cmd/entire/cli/paths/paths.go

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const (
2121
EntireMetadataDir = ".entire/metadata"
2222

2323
osWindows = "windows"
24+
osDarwin = "darwin"
2425
)
2526

2627
// Metadata file names
@@ -133,15 +134,25 @@ func AbsPath(ctx context.Context, relPath string) (string, error) {
133134
}
134135

135136
// IsInfrastructurePath returns true if the path is part of CLI infrastructure
136-
// (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.
137141
func IsInfrastructurePath(path string) bool {
138-
return IsSubpath(EntireDir, path)
142+
return IsProtectedSubpath(EntireDir, path)
139143
}
140144

141145
// IsSubpath reports whether child is lexically under parent (or equal to it).
142146
// It uses filepath.Rel, which cleans both inputs and is traversal-resistant:
143147
// a crafted child like "/a/b/../../../etc/passwd" that escapes parent will
144148
// produce a relative path starting with ".." and be rejected.
149+
//
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.
145156
func IsSubpath(parent, child string) bool {
146157
rel, err := filepath.Rel(parent, child)
147158
if err != nil {
@@ -150,6 +161,47 @@ func IsSubpath(parent, child string) bool {
150161
return !IsRelativeTraversal(rel)
151162
}
152163

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+
181+
// CaseInsensitiveFS reports whether path comparisons should be case-insensitive
182+
// on the host OS. This is OS-based, not volume-based: Windows and macOS default
183+
// to case-insensitive filesystems, Linux to case-sensitive. Keying on GOOS keeps
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.
188+
func CaseInsensitiveFS() bool {
189+
return runtime.GOOS == osWindows || runtime.GOOS == osDarwin
190+
}
191+
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.
196+
func Equal(a, b string) bool {
197+
a = filepath.Clean(filepath.FromSlash(a))
198+
b = filepath.Clean(filepath.FromSlash(b))
199+
if CaseInsensitiveFS() {
200+
return strings.EqualFold(a, b)
201+
}
202+
return a == b
203+
}
204+
153205
// IsRelativeTraversal reports whether rel escapes its base directory.
154206
// It accepts both OS-native paths and Git-style slash-normalized paths.
155207
func IsRelativeTraversal(rel string) bool {

cmd/entire/cli/paths/paths_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,65 @@ func TestIsInfrastructurePath(t *testing.T) {
9696
}
9797
}
9898

99+
func TestCaseInsensitiveFS(t *testing.T) {
100+
t.Parallel()
101+
want := runtime.GOOS == osWindows || runtime.GOOS == osDarwin
102+
if got := CaseInsensitiveFS(); got != want {
103+
t.Errorf("CaseInsensitiveFS() = %v, want %v (GOOS=%s)", got, want, runtime.GOOS)
104+
}
105+
}
106+
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) {
112+
t.Parallel()
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")
115+
}
116+
if !IsSubpath(".claude", ".claude/marker.txt") {
117+
t.Error("IsSubpath(.claude, .claude/marker.txt) = false, want true")
118+
}
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")
140+
}
141+
}
142+
143+
func TestEqual_CaseSensitivity(t *testing.T) {
144+
t.Parallel()
145+
if !Equal(".terminalhirerc", ".terminalhirerc") {
146+
t.Error("Equal should match identical paths")
147+
}
148+
got := Equal(".terminalhirerc", ".TerminalHireRC")
149+
if got != CaseInsensitiveFS() {
150+
t.Errorf("Equal(case variant) = %v, want %v (GOOS=%s)",
151+
got, CaseInsensitiveFS(), runtime.GOOS)
152+
}
153+
if Equal(".terminalhirerc", "other") {
154+
t.Error("Equal should not match distinct paths")
155+
}
156+
}
157+
99158
func TestToRelativePath_MSYSPaths(t *testing.T) {
100159
t.Parallel()
101160
if runtime.GOOS != "windows" {

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: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,15 +238,14 @@ func shouldIgnoreSessionTrackingPath(relPath string) bool {
238238
}
239239

240240
for _, file := range agent.AllProtectedFiles() {
241-
cleanFile := filepath.Clean(filepath.FromSlash(file))
242-
if cleanPath == cleanFile {
241+
if paths.Equal(cleanPath, file) {
243242
return true
244243
}
245244
}
246245

247246
for _, dir := range agent.AllProtectedDirs() {
248247
cleanDir := filepath.Clean(filepath.FromSlash(dir))
249-
if paths.IsSubpath(cleanDir, cleanPath) {
248+
if paths.IsProtectedSubpath(cleanDir, cleanPath) {
250249
return true
251250
}
252251
}

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)