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>
232 lines
7.6 KiB
Go
232 lines
7.6 KiB
Go
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)
|
|
}
|