feat(claudewatcher): exclude whole project directories from ingestion
CLAUDE_INGEST_SKIP_PROJECTS takes filepath.Match globs tested against the project directory NAME, and a matching directory is skipped during the directory WALK — so its files are never opened and their content never enters the process. That is a stronger property than filtering after reading, and the difference is observable: without SkipDir, WalkDir lists the directory's entries, and an unreadable one aborts the entire walk, silently stopping ingestion for every other project. Globs rather than exact names because a project directory is derived from the session's working directory, so a git worktree yields an ephemeral name no exact-match list could keep up with. An unparseable pattern refuses to run. filepath.Match returns an error ALONGSIDE a false match, so a bad pattern checked per-file would ingest the directory while the log complained — the failure nobody notices. Validated once, up front, in both entry points. This complements the client-name scrubber rather than replacing it. The scrubber drops individual turns whose content matches a client identity; this excludes whole workspaces whose content is confidential in ways no pattern reliably recognises. Learnings from excluded sessions are captured deliberately through the session-close ritual instead. Recorded caveat: the project directory reflects the session's working directory, NOT the repositories it touches. A session run from ~/dev that reads an excluded repo is not excluded by this. The scrubber remains the control for content; this is a control for workspaces. A mutation test earned the strongest assertion here: the original tests could not distinguish SkipDir from filtering after the walk, because neither opens files. The unreadable-directory test can. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
package claudewatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// recordingHandler captures log records so a test can assert that a file was
|
||||
// never even attempted — the absence of a read error is the evidence.
|
||||
type recordingHandler struct {
|
||||
mu sync.Mutex
|
||||
records []string
|
||||
}
|
||||
|
||||
func (h *recordingHandler) Enabled(context.Context, slog.Level) bool { return true }
|
||||
|
||||
func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
var sb strings.Builder
|
||||
sb.WriteString(r.Message)
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
sb.WriteString(" " + a.Key + "=" + a.Value.String())
|
||||
return true
|
||||
})
|
||||
h.records = append(h.records, sb.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *recordingHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
|
||||
func (h *recordingHandler) WithGroup(string) slog.Handler { return h }
|
||||
|
||||
func (h *recordingHandler) contains(substr string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, r := range h.records {
|
||||
if strings.Contains(r, substr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func session(t *testing.T, root, project, id string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, project)
|
||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||
return writeSession(t, dir, id, []string{
|
||||
`{"type":"user","sessionId":"` + id + `","message":"a prompt"}`,
|
||||
`{"type":"assistant","sessionId":"` + id + `","message":{"content":[{"type":"text","text":"an answer"}]}}`,
|
||||
})
|
||||
}
|
||||
|
||||
// A named project is not ingested, and its neighbours still are.
|
||||
func TestSkipProjects_skipsTheNamedProjectOnly(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "skipped")
|
||||
session(t, tmp, "-home-mathias-dev", "kept")
|
||||
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
SkipProjects: []string{"-home-mathias-dev-AGENTS-cobalt-dingo"},
|
||||
}))
|
||||
|
||||
require.Len(t, sink.batches, 1)
|
||||
assert.Equal(t, "kept", sink.batches[0].SessionID)
|
||||
assert.Equal(t, "-home-mathias-dev", sink.batches[0].ProjectID)
|
||||
}
|
||||
|
||||
// Globs are necessary, not a convenience. A project directory is derived from
|
||||
// the session's WORKING DIRECTORY, so a git worktree produces an ephemeral
|
||||
// name like "-home-mathias-dev--claude-worktrees-bridge-cse-017xoC...". An
|
||||
// exact-match list could never keep up with those.
|
||||
func TestSkipProjects_globMatchesEphemeralWorktreeNames(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
session(t, tmp, "-home-mathias-dev--claude-worktrees-bridge-cse-017xoCEcrc35", "worktree")
|
||||
session(t, tmp, "-home-mathias-dev", "kept")
|
||||
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
SkipProjects: []string{"*claude-worktrees-bridge-cse*"},
|
||||
}))
|
||||
|
||||
require.Len(t, sink.batches, 1)
|
||||
assert.Equal(t, "kept", sink.batches[0].SessionID)
|
||||
}
|
||||
|
||||
// The point of skipping at the directory walk: the file is never OPENED, so
|
||||
// its content never enters this process. Proven by making it unreadable — a
|
||||
// skipped file produces no read error because nothing tried to read it, while
|
||||
// the same file in a non-skipped project does.
|
||||
func TestSkipProjects_theFileIsNeverOpened(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "unreadable")
|
||||
require.NoError(t, os.Chmod(path, 0o000))
|
||||
t.Cleanup(func() { _ = os.Chmod(path, 0o644) })
|
||||
|
||||
rec := &recordingHandler{}
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
Logger: slog.New(rec),
|
||||
SkipProjects: []string{"*cobalt-dingo*"},
|
||||
}))
|
||||
|
||||
assert.Empty(t, sink.batches)
|
||||
assert.False(t, rec.contains("file failed"),
|
||||
"a skipped file must not even be attempted")
|
||||
}
|
||||
|
||||
// The control test for the one above: without the skip, the unreadable file IS
|
||||
// attempted and fails. Otherwise the assertion above could pass for the wrong
|
||||
// reason.
|
||||
func TestSkipProjects_withoutTheSkipTheSameFileIsAttempted(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "unreadable")
|
||||
require.NoError(t, os.Chmod(path, 0o000))
|
||||
t.Cleanup(func() { _ = os.Chmod(path, 0o644) })
|
||||
|
||||
rec := &recordingHandler{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: &memSink{},
|
||||
Logger: slog.New(rec),
|
||||
}))
|
||||
|
||||
assert.True(t, rec.contains("file failed"),
|
||||
"the unreadable file must fail when not skipped — otherwise the skip test proves nothing")
|
||||
}
|
||||
|
||||
// Patterns match the project directory NAME, not the whole path. A temp
|
||||
// directory or a home directory component must not accidentally match and
|
||||
// silently disable ingestion.
|
||||
func TestSkipProjects_matchesTheProjectNameNotTheFullPath(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
session(t, tmp, "-home-mathias-dev", "kept")
|
||||
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
// tmp is somewhere under /tmp; this must not match the project name.
|
||||
SkipProjects: []string{"*tmp*"},
|
||||
}))
|
||||
|
||||
require.Len(t, sink.batches, 1, "a path component outside the project name must not match")
|
||||
}
|
||||
|
||||
// An unparseable pattern must stop the watcher rather than be ignored. A
|
||||
// silently dropped skip rule means ingesting what was meant to be excluded,
|
||||
// which is the failure that cannot be noticed after the fact.
|
||||
func TestSkipProjects_invalidPatternRefusesToRun(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
session(t, tmp, "-home-mathias-dev", "kept")
|
||||
|
||||
sink := &memSink{}
|
||||
err := TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
SkipProjects: []string{"[unclosed"},
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "[unclosed")
|
||||
assert.Empty(t, sink.batches, "nothing may be ingested while a skip rule is broken")
|
||||
}
|
||||
|
||||
func TestSkipProjects_emptyListChangesNothing(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
session(t, tmp, "-home-mathias-dev", "kept")
|
||||
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp, Host: "koala", Sink: sink, SkipProjects: nil,
|
||||
}))
|
||||
|
||||
require.Len(t, sink.batches, 1)
|
||||
}
|
||||
|
||||
// The difference between SkipDir and filtering after the walk, made
|
||||
// observable: an unreadable skipped directory must not disturb anything.
|
||||
//
|
||||
// Without SkipDir, WalkDir reads the directory's entries, gets a permission
|
||||
// error, and that error aborts the ENTIRE walk — so one skipped project with
|
||||
// awkward permissions would silently stop ingesting every other project.
|
||||
// Returning SkipDir means its contents are never listed at all.
|
||||
//
|
||||
// Added after a mutation test showed the earlier assertions could not tell the
|
||||
// two implementations apart.
|
||||
func TestSkipProjects_anUnreadableSkippedDirDoesNotBreakTheWalk(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
session(t, tmp, "-home-mathias-dev", "kept")
|
||||
|
||||
blocked := filepath.Join(tmp, "-home-mathias-dev-AGENTS-cobalt-dingo")
|
||||
require.NoError(t, os.MkdirAll(blocked, 0o755))
|
||||
_ = writeSession(t, blocked, "secret", []string{
|
||||
`{"type":"user","sessionId":"secret","message":"should never be read"}`,
|
||||
})
|
||||
require.NoError(t, os.Chmod(blocked, 0o000))
|
||||
t.Cleanup(func() { _ = os.Chmod(blocked, 0o755) })
|
||||
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
SkipProjects: []string{"*cobalt-dingo*"},
|
||||
}))
|
||||
|
||||
require.Len(t, sink.batches, 1, "an unreadable skipped directory must not abort the walk")
|
||||
assert.Equal(t, "kept", sink.batches[0].SessionID)
|
||||
}
|
||||
Reference in New Issue
Block a user