# fix(checkpoint): exclude protected dirs from first-checkpoint snapshot

`c937bf5`→[main](/content/gh/entireio/cli/commits/main/index.html)·

pjbgf·2d ago·5 files·+209 added/-7 removed

The first checkpoint of a session collected changed files via a raw 
`git status` parse in collectChangedFiles that only skipped `.entire/`
(IsInfrastructurePath). Agent-declared protected dirs/files — built-in
(.claude) and external protocol-v1 plugins (protected_dirs) — were
captured into the shadow-branch tree on session start, unlike the
session-tracking and rewind paths which already honor them.

Add isProtectedCheckpointPath, mirroring shouldIgnoreSessionTrackingPath
(the two can't share code: cli imports checkpoint), and apply it at the
three filter sites in collectChangedFiles.

Make path matching OS-based so case-insensitive filesystems exclude
correctly: add paths.CaseInsensitiveFS (windows/darwin), paths.Equal, and
fold case in IsSubpath on those platforms. On case-sensitive Linux the
behavior is unchanged. Route protected-file equality in state.go and the
new checkpoint helper through paths.Equal.

Regression tests: checkpoint first-checkpoint path (built-in + external
plugin protected dir/file) and OS-aware paths unit tests.

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Paulo Gomes <paulo@entire.io>

## Sessions

01KXK2W6DRHMN0WSE7M51XZVMEView transcript

## Changes

5

- cmd/entire/cli

- checkpoint

- Mcheckpoint_test.go+95

- Mephemeral.go+34/-5

- paths

- Mpaths.go+35

- Mpaths_test.go+44

- Mstate.go+1/-2

```  
13 unmodified lines  
14  
15  
16  
17  
18  
19  
20  
21  
83 unmodified lines  
105  
106  
107  
108  
109  
110  
111  
112  
113  
114  
115  
116  
117  
118  
119  
120  
121  
122  
123  
124  
125  
126  
127  
128  
129  
130  
131  
132  
133  
134  
135  
136  
137  
138  
139  
140  
141  
142  
143  
144  
145  
146  
147  
148  
149  
150  
151  
152  
153  
154  
155  
156  
157  
158  
159  
160  
161  
162  
163  
164  
165  
166  
167  
168  
169  
170  
171  
172  
173  
174  
175  
176  
177  
178  
179  
180  
181  
182  
183  
184  
185  
186  
187  
188  
189  
190  
191  
192  
193  
194  
195  
196  
197  
198  
199  
200  
201  
202  
203

13 unmodified lines  
	"time"

"github.com/entireio/cli/cmd/entire/cli/agent"  
	_ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" // register claude-code so its .claude protected dir is discoverable  
	"github.com/entireio/cli/cmd/entire/cli/agent/types"  
	"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"  
	"github.com/entireio/cli/cmd/entire/cli/paths"  
	"github.com/entireio/cli/cmd/entire/cli/testutil"  
83 unmodified lines  
	}  
}

// fakePluginAgent is a minimal agent stub used to prove that protected dirs  
// and files reported by an external-plugin-style agent (via the AllProtectedDirs  
// / AllProtectedFiles union) are honored by the first-checkpoint path, not just  
// the built-in claude-code .claude dir.  
type fakePluginAgent struct{}

var (  
	_ agent.Agent                  = (*fakePluginAgent)(nil)  
	_ agent.ProtectedFilesProvider = (*fakePluginAgent)(nil)  
)

func (fakePluginAgent) Name() types.AgentName                { return "terminalhire-plugin" }  
func (fakePluginAgent) Type() types.AgentType                { return "TerminalHire" }  
func (fakePluginAgent) Description() string                  { return "fake external plugin for tests" }  
func (fakePluginAgent) IsPreview() bool                      { return true }  
func (fakePluginAgent) ProtectedDirs() []string              { return []string{".terminalhire"} }  
func (fakePluginAgent) ProtectedFiles() []string             { return []string{".terminalhirerc"} }  
func (fakePluginAgent) GetSessionID(*agent.HookInput) string { return "" }

func (fakePluginAgent) DetectPresence(context.Context) (bool, error) { return false, nil }  
func (fakePluginAgent) ReadTranscript(string) ([]byte, error)        { return nil, nil }  
func (fakePluginAgent) ChunkTranscript(_ context.Context, c []byte, _ int) ([][]byte, error) {  
	return [][]byte{c}, nil  
}  
func (fakePluginAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) {  
	var out []byte  
	for _, c := range chunks {  
		out = append(out, c...)  
	}  
	return out, nil  
}  
func (fakePluginAgent) GetSessionDir(string) (string, error)                      { return "", nil }  
func (fakePluginAgent) ResolveSessionFile(dir, sid string) string                 { return dir + "/" + sid }  
func (fakePluginAgent) ReadSession(*agent.HookInput) (*agent.AgentSession, error) { return nil, nil } //nolint:nilnil // test stub  
func (fakePluginAgent) WriteSession(context.Context, *agent.AgentSession) error   { return nil }  
func (fakePluginAgent) FormatResumeCommand(string) string                         { return "" }

func TestCollectChangedFiles_ExcludesProtectedDirs(t *testing.T) {

t.Parallel()

// Register an external-plugin-style agent so its protected dir/file join the  
	// AllProtectedDirs/AllProtectedFiles union alongside the built-in .claude.  
	// Registration is additive and concurrency-safe; no test asserts the exact set.  
	agent.Register("terminalhire-plugin", func() agent.Agent { return fakePluginAgent{} })

tempDir := t.TempDir()  
	// Resolve symlinks so the repo root matches git's resolved path.  
	// On macOS, t.TempDir() returns /var/... but git resolves to /private/var/...  
tempDir, err := filepath.EvalSymlinks(tempDir)  
	require.NoError(t, err)

testutil.InitRepo(t, tempDir)  
testutil.WriteFile(t, tempDir, "base.txt", "base")  
testutil.GitAdd(t, tempDir, "base.txt")  
testutil.GitCommit(t, tempDir, "init")

// Disable any global core.excludesFile so a developer/CI-runner gitignore  
	// convention (e.g. one that ignores .claude) can't mask the leak. The fix  
	// must exclude protected dirs on its own, independent of gitignore state.  
cfgCmd := exec.CommandContext(context.Background(), "git", "config", "core.excludesFile", os.DevNull)  
cfgCmd.Dir = tempDir  
	require.NoError(t, cfgCmd.Run())

// Planted untracked, non-gitignored files.  
testutil.WriteFile(t, tempDir, ".claude/marker.txt", "MARKER-secret")         // built-in agent-protected dir  
testutil.WriteFile(t, tempDir, ".terminalhire/profile.json", "MARKER-plugin") // plugin-protected dir  
testutil.WriteFile(t, tempDir, ".terminalhirerc", "MARKER-plugin-file")       // plugin-protected file  
testutil.WriteFile(t, tempDir, ".entire/state.json", "{}")                    // infrastructure  
testutil.WriteFile(t, tempDir, "src/keep.txt", "user work")                   // ordinary

repo, err := git.PlainOpen(tempDir)  
	require.NoError(t, err)

result, err := collectChangedFiles(context.Background(), repo)  
	require.NoError(t, err)

require.NotContains(t, result.Changed, ".claude/marker.txt",  
		"built-in agent protected dir content must not be captured into the checkpoint")  
	require.NotContains(t, result.Changed, ".terminalhire/profile.json",  
		"external-plugin protected dir content must not be captured into the checkpoint")  
	require.NotContains(t, result.Changed, ".terminalhirerc",  
		"external-plugin protected file must not be captured into the checkpoint")  
	require.NotContains(t, result.Changed, ".entire/state.json",  
		"infrastructure dir must not be captured into the checkpoint")  
	require.Contains(t, result.Changed, "src/keep.txt",  
		"ordinary untracked files must still be captured")
}

// TestWriteCommitted_AgentField verifies that the Agent field is written  
// to both metadata.json and the commit message trailer.  
func TestWriteCommitted_AgentField(t *testing.T) {

```

Mcmd/entire/cli/checkpoint/checkpoint_test.go+95

```
1194 unmodified lines

1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
45 unmodified lines

1275
1276
1277
1249
1278
1279
1280
1252
1281
1282
1283
1284
1285
1286
1258
1287
1288
1289
1290
2 unmodified lines

1293
1294
1295
1267
1268
1296
1297
1298
1299
1300

1194 unmodified lines

return kept
}

// isProtectedCheckpointPath reports whether a repo-relative path must be kept  
// out of checkpoint snapshots: the .entire infrastructure dir, or any  
// registered agent's declared protected dir/file (e.g. .claude, or an external  
// plugin's protected_dirs).  
//  
// This mirrors shouldIgnoreSessionTrackingPath in the cli package. The two  
// cannot share an implementation because cli imports checkpoint, so the logic  
// is duplicated deliberately. The first-checkpoint path (collectChangedFiles)  
// must apply the same exclusions as the session-tracking and rewind paths, or  
// protected-dir content is captured into the shadow tree on session start  
// (see the DetectFileChanges / isProtectedPath call sites).  
func isProtectedCheckpointPath(relPath string) bool {  
	cleanPath := filepath.Clean(filepath.FromSlash(relPath))  
	if paths.IsInfrastructurePath(cleanPath) {  
		return true  
	}  
	for _, file := range agent.AllProtectedFiles() {  
		if paths.Equal(cleanPath, file) {  
			return true  
		}  
	}  
	for _, dir := range agent.AllProtectedDirs() {  
		if paths.IsSubpath(filepath.Clean(filepath.FromSlash(dir)), cleanPath) {  
			return true  
		}  
	}  
	return false  
}

// collectChangedFiles returns all changed files from git status for the first checkpoint.  
//  
// For the first checkpoint, we need to capture:  
45 unmodified lines

filename := entry[3:] // No TrimSpace needed with -z format

// Handle R/C (rename/copy) first - they have a second entry we must skip  
	// even if the new filename is an infrastructure path  
	// even if the new filename is a protected path  	if staging == 'R' || staging == 'C' {  
		// Renamed or copied: current entry is new name, next entry is old name  
		if !paths.IsInfrastructurePath(filename) {  
		if !isProtectedCheckpointPath(filename) {  
			changedSeen[filename] = struct{}{}  
		}  
		// The old name follows as the next NUL-separated entry - must always skip it  
		if i+1 < len(entries) && entries[i+1] != "" {  
			oldName := entries[i+1]  
			if staging == 'R' && !paths.IsInfrastructurePath(oldName) {  
			if staging == 'R' && !isProtectedCheckpointPath(oldName) {  
				// For renames, old file is effectively deleted  
				deletedSeen[oldName] = struct{}{}  
			}  
			continue  
		}

// Skip .entire directory for non-R/C entries  
	if paths.IsInfrastructurePath(filename) {  
		// Skip .entire and agent-protected dirs/files for non-R/C entries  
		if isProtectedCheckpointPath(filename) {  
			continue  
		}  
}

```

Mcmd/entire/cli/checkpoint/ephemeral.go+34/-5

```
20 unmodified lines

21
22
23
24
25
26
27
115 unmodified lines

143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
1 unmodified line

162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190

20 unmodified lines

EntireMetadataDir = ".entire/metadata"

osWindows = "windows"
	osDarwin  = "darwin"
// Metadata file names
115 unmodified lines

// It uses filepath.Rel, which cleans both inputs and is traversal-resistant:
// 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.
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)
}

// 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.
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.
func Equal(a, b string) bool {  
	a = filepath.Clean(filepath.FromSlash(a))  
	b = filepath.Clean(filepath.FromSlash(b))  
	if CaseInsensitiveFS() {  
		return strings.EqualFold(a, b)  
	}  
	return a == b  
}

// IsRelativeTraversal reports whether rel escapes its base directory.
// It accepts both OS-native paths and Git-style slash-normalized paths.
func IsRelativeTraversal(rel string) bool {
```

Mcmd/entire/cli/paths/paths.go+35

```
95 unmodified lines

96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145

95 unmodified lines

}
}

func TestCaseInsensitiveFS(t *testing.T) {

t.Parallel()
	want := runtime.GOOS == osWindows || runtime.GOOS == osDarwin
	if got := CaseInsensitiveFS(); got != want {
		t.Errorf("CaseInsensitiveFS() = %v, want %v (GOOS=%s)", got, want, runtime.GOOS)
	}
}

// TestIsSubpath_CaseSensitivity and TestEqual_CaseSensitivity assert the
// OS-based folding: case variants of a protected path are matched on
// Windows/macOS (where they name the same file) and remain distinct on
// case-sensitive Linux.
func TestIsSubpath_CaseSensitivity(t *testing.T) {

t.Parallel()
	// On a case-insensitive FS these name the same dir, so containment holds.
	got := IsSubpath(".claude", ".Claude/marker.txt")
	if got != CaseInsensitiveFS() {
		t.Errorf("IsSubpath(.claude, .Claude/marker.txt) = %v, want %v (GOOS=%s)",
			got, CaseInsensitiveFS(), runtime.GOOS)
	}
	// Same case is always a subpath; traversal is always rejected, regardless of case.
	if !IsSubpath(".claude", ".claude/marker.txt") {
		t.Error("IsSubpath(.claude, .claude/marker.txt) = false, want true")
	}
	if IsSubpath(".claude", ".Claude/../../etc/passwd") {
		t.Error("IsSubpath must reject traversal even when case-folding")
	}
}

func TestEqual_CaseSensitivity(t *testing.T) {

t.Parallel()
	if !Equal(".terminalhirerc", ".terminalhirerc") {
			Error("Equal should match identical paths")
	}
	got := Equal(".terminalhirerc", ".TerminalHireRC")
	if got != CaseInsensitiveFS() {
		t.Errorf("Equal(case variant) = %v, want %v (GOOS=%s)",
			got, CaseInsensitiveFS(), runtime.GOOS)
	}
	if Equal(".terminalhirerc", "other") {
		t.Error("Equal should not match distinct paths")
	}
}

func TestToRelativePath_MSYSPaths(t *testing.T) {

t.Parallel()
	if runtime.GOOS != "windows" {
```
