diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index 21dc4142eb..7d1302354b 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -681,6 +681,13 @@ func runDiagnostics(path string) doctorResult { result.Checks = append(result.Checks, lastTouchedTrackingCheck) // Don't fail overall check for last-touched tracking, just warn + // Check 14h: tracked runtime/sensitive files (GH#2535) + trackedRuntimeCheck := convertDoctorCheck(doctor.CheckTrackedRuntimeFiles(path)) + result.Checks = append(result.Checks, trackedRuntimeCheck) + if trackedRuntimeCheck.Status == statusError { + result.OverallOK = false // Sensitive files in git is a real problem + } + // Check 15a: Git working tree cleanliness (AGENTS.md hygiene) gitWorkingTreeCheck := convertWithCategory(doctor.CheckGitWorkingTree(path), doctor.CategoryGit) result.Checks = append(result.Checks, gitWorkingTreeCheck) diff --git a/cmd/bd/doctor/gitignore.go b/cmd/bd/doctor/gitignore.go index 7dbba06177..707263d04c 100644 --- a/cmd/bd/doctor/gitignore.go +++ b/cmd/bd/doctor/gitignore.go @@ -18,6 +18,19 @@ bd.sock bd.sock.startlock sync-state.json last-touched +.exclusive-lock + +# Daemon runtime (lock, log, pid) +daemon.* + +# Interactions log (runtime, not versioned) +interactions.jsonl + +# Push state (runtime, per-machine) +push-state.json + +# Lock files (various runtime locks) +*.lock # Local version tracking (prevents upgrade notification spam after git ops) .local_version @@ -43,6 +56,9 @@ dolt-server.log dolt-server.lock dolt-server.port +# Corrupt backup directories (created by bd doctor --fix recovery) +*.corrupt.backup/ + # Backup data (auto-exported JSONL, local-only) backup/ @@ -85,6 +101,10 @@ var requiredPatterns = []string{ "dolt-server.log", "dolt-server.lock", "dolt-server.port", + "daemon.*", + "interactions.jsonl", + "*.lock", + "*.corrupt.backup/", } // CheckGitignore checks if .beads/.gitignore is up to date. diff --git a/cmd/bd/doctor/tracked_runtime.go b/cmd/bd/doctor/tracked_runtime.go new file mode 100644 index 0000000000..d4a1ca9f76 --- /dev/null +++ b/cmd/bd/doctor/tracked_runtime.go @@ -0,0 +1,251 @@ +package doctor + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" +) + +// trackedRuntimePatterns are file patterns under .beads/ that should never be +// tracked by git. These are runtime artifacts, lock files, corrupt backups, +// and sensitive files that may have been committed before .beads/.gitignore +// covered them. +// +// Each entry is matched against the relative path within .beads/ using +// filepath.Match or prefix matching for directory patterns (trailing /). +var trackedRuntimePatterns = []string{ + // Lock files + "*.lock", + "*.pid.lock", + + // Daemon / server runtime + "daemon.pid", + "daemon.log", + "daemon.lock", + "dolt-server.pid", + "dolt-server.log", + "dolt-server.lock", + "dolt-server.port", + + // Socket files + "bd.sock", + "bd.sock.startlock", + ".exclusive-lock", + + // Runtime state + "interactions.jsonl", + "push-state.json", + "sync-state.json", + "last-touched", + ".local_version", + "redirect", + + // Sync / export state + ".sync.lock", + + // Ephemeral SQLite + "ephemeral.sqlite3", + "ephemeral.sqlite3-journal", + "ephemeral.sqlite3-wal", + "ephemeral.sqlite3-shm", +} + +// trackedRuntimeDirPrefixes are directory prefixes under .beads/ that should +// never be tracked. Any file whose relative path starts with one of these +// prefixes is flagged. +var trackedRuntimeDirPrefixes = []string{ + "dolt/", + "backup/", + "export-state/", +} + +// sensitiveFileNames are filenames that indicate a security concern if +// committed anywhere under .beads/. +var sensitiveFileNames = []string{ + ".beads-credential-key", + "credential-key", +} + +// corruptBackupPattern matches corrupt backup directories created by +// bd doctor --fix recovery (e.g. dolt.20260312T123507Z.corrupt.backup/). +const corruptBackupDirFragment = ".corrupt.backup/" + +// CheckTrackedRuntimeFiles detects files tracked by git under .beads/ that +// should be gitignored. These are runtime artifacts, lock files, corrupt +// backups, and sensitive files that may have been committed before the +// current .beads/.gitignore patterns existed. +// repoPath is the project root directory. +func CheckTrackedRuntimeFiles(repoPath string) DoctorCheck { + beadsDir := filepath.Join(repoPath, ".beads") + + // Get all files tracked by git under .beads/ + cmd := exec.Command("git", "ls-files", beadsDir) // #nosec G204 - args are constructed from known parts + cmd.Dir = repoPath + output, err := cmd.Output() + if err != nil { + return DoctorCheck{ + Name: "Tracked Runtime Files", + Status: StatusOK, + Message: "N/A (not a git repository)", + Category: CategoryGit, + } + } + + trackedFiles := strings.TrimSpace(string(output)) + if trackedFiles == "" { + return DoctorCheck{ + Name: "Tracked Runtime Files", + Status: StatusOK, + Message: "No .beads/ files tracked by git", + Category: CategoryGit, + } + } + + var flagged []string + var hasSensitive bool + + for _, line := range strings.Split(trackedFiles, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Get the path relative to .beads/ + rel, err := filepath.Rel(beadsDir, filepath.Join(repoPath, line)) + if err != nil { + continue + } + + if shouldFlagTrackedFile(rel) { + flagged = append(flagged, line) + + // Check for sensitive files + base := filepath.Base(rel) + for _, sensitive := range sensitiveFileNames { + if base == sensitive { + hasSensitive = true + } + } + } + } + + if len(flagged) == 0 { + return DoctorCheck{ + Name: "Tracked Runtime Files", + Status: StatusOK, + Message: "No runtime/sensitive files tracked", + Category: CategoryGit, + } + } + + status := StatusWarning + message := fmt.Sprintf("%d runtime/sensitive file(s) tracked by git", len(flagged)) + if hasSensitive { + status = StatusError + message = fmt.Sprintf("%d tracked file(s) include sensitive data (credential key)", len(flagged)) + } + + detail := strings.Join(flagged, ", ") + if len(detail) > 200 { + detail = fmt.Sprintf("%s... (%d total)", strings.Join(flagged[:3], ", "), len(flagged)) + } + + return DoctorCheck{ + Name: "Tracked Runtime Files", + Status: status, + Message: message, + Detail: detail, + Fix: "Run 'bd doctor --fix' to untrack, or manually: git rm --cached ", + Category: CategoryGit, + } +} + +// shouldFlagTrackedFile checks if a path relative to .beads/ is a runtime +// or sensitive file that should not be tracked by git. +func shouldFlagTrackedFile(rel string) bool { + base := filepath.Base(rel) + + // Check sensitive filenames anywhere in the tree + for _, sensitive := range sensitiveFileNames { + if base == sensitive { + return true + } + } + + // Check corrupt backup directories + if strings.Contains(rel, corruptBackupDirFragment) { + return true + } + + // Check directory prefixes + for _, prefix := range trackedRuntimeDirPrefixes { + if strings.HasPrefix(rel, prefix) { + return true + } + } + + // Only match patterns against top-level .beads/ files (not files in subdirs) + if strings.Contains(rel, "/") { + return false + } + + // Check filename patterns + for _, pattern := range trackedRuntimePatterns { + if matched, _ := filepath.Match(pattern, base); matched { + return true + } + } + + return false +} + +// FixTrackedRuntimeFiles untracks runtime/sensitive files from git. +// repoPath is the project root directory. +func FixTrackedRuntimeFiles(repoPath string) error { + beadsDir := filepath.Join(repoPath, ".beads") + + // Get all files tracked by git under .beads/ + cmd := exec.Command("git", "ls-files", beadsDir) // #nosec G204 - args are constructed from known parts + cmd.Dir = repoPath + output, err := cmd.Output() + if err != nil { + return nil // Not a git repo, nothing to do + } + + trackedFiles := strings.TrimSpace(string(output)) + if trackedFiles == "" { + return nil + } + + var toUntrack []string + for _, line := range strings.Split(trackedFiles, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + rel, err := filepath.Rel(beadsDir, filepath.Join(repoPath, line)) + if err != nil { + continue + } + + if shouldFlagTrackedFile(rel) { + toUntrack = append(toUntrack, line) + } + } + + if len(toUntrack) == 0 { + return nil + } + + // Untrack files (keeps local copies) + args := append([]string{"rm", "--cached", "--"}, toUntrack...) + cmd = exec.Command("git", args...) // #nosec G204 - args are constructed from known parts + cmd.Dir = repoPath + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to untrack files: %w\n%s", err, string(out)) + } + + return nil +} diff --git a/cmd/bd/doctor/tracked_runtime_test.go b/cmd/bd/doctor/tracked_runtime_test.go new file mode 100644 index 0000000000..8244e9489e --- /dev/null +++ b/cmd/bd/doctor/tracked_runtime_test.go @@ -0,0 +1,199 @@ +package doctor + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestShouldFlagTrackedFile(t *testing.T) { + tests := []struct { + name string + rel string + want bool + }{ + // Lock files + {"jsonl lock", ".jsonl.lock", true}, + {"daemon lock", "daemon.lock", true}, + {"dolt-monitor pid lock", "dolt-monitor.pid.lock", true}, + {"dolt-server lock", "dolt-server.lock", true}, + {"dolt-access lock", "dolt-access.lock", true}, + + // Daemon/server runtime + {"daemon pid", "daemon.pid", true}, + {"daemon log", "daemon.log", true}, + {"dolt-server pid", "dolt-server.pid", true}, + {"dolt-server log", "dolt-server.log", true}, + {"dolt-server port", "dolt-server.port", true}, + + // Socket and runtime + {"bd sock", "bd.sock", true}, + {"bd sock startlock", "bd.sock.startlock", true}, + {"exclusive lock", ".exclusive-lock", true}, + {"interactions jsonl", "interactions.jsonl", true}, + {"push-state json", "push-state.json", true}, + {"sync-state json", "sync-state.json", true}, + {"last-touched", "last-touched", true}, + {"local version", ".local_version", true}, + {"redirect", "redirect", true}, + {"sync lock", ".sync.lock", true}, + + // Ephemeral SQLite + {"ephemeral sqlite", "ephemeral.sqlite3", true}, + {"ephemeral wal", "ephemeral.sqlite3-wal", true}, + + // Dolt directory contents + {"dolt dir file", "dolt/config.yaml", true}, + {"dolt nested", "dolt/noms/LOCK", true}, + + // Backup directory + {"backup file", "backup/issues.jsonl", true}, + + // Export state + {"export state", "export-state/data.json", true}, + + // Corrupt backups + {"corrupt backup file", "dolt.20260312T123507Z.corrupt.backup/.bd-dolt-ok", true}, + {"corrupt backup config", "dolt.20260312T123507Z.corrupt.backup/config.yaml", true}, + + // Sensitive files + {"credential key", ".beads-credential-key", true}, + {"credential in backup", "dolt.20260312T135310Z.corrupt.backup/.beads-credential-key", true}, + + // Files that SHOULD be tracked (not flagged) + {"gitignore", ".gitignore", false}, + {"readme", "README.md", false}, + {"config yaml", "config.yaml", false}, + {"metadata json", "metadata.json", false}, + {"issues jsonl", "issues.jsonl", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldFlagTrackedFile(tt.rel) + if got != tt.want { + t.Errorf("shouldFlagTrackedFile(%q) = %v, want %v", tt.rel, got, tt.want) + } + }) + } +} + +func TestCheckTrackedRuntimeFiles_NoGitRepo(t *testing.T) { + dir := mkTmpDirInTmp(t, "bd-tracked-nogit-*") + check := CheckTrackedRuntimeFiles(dir) + if check.Status != StatusOK { + t.Fatalf("status=%q want %q", check.Status, StatusOK) + } + if !strings.Contains(check.Message, "N/A") { + t.Fatalf("message=%q want N/A", check.Message) + } +} + +func TestCheckTrackedRuntimeFiles_Clean(t *testing.T) { + dir := mkTmpDirInTmp(t, "bd-tracked-clean-*") + initRepo(t, dir, "main") + + // Commit only files that should be tracked + commitFile(t, dir, ".beads/config.yaml", "backend: dolt\n", "add config") + commitFile(t, dir, ".beads/metadata.json", "{}\n", "add metadata") + + check := CheckTrackedRuntimeFiles(dir) + if check.Status != StatusOK { + t.Fatalf("status=%q want %q (msg=%q)", check.Status, StatusOK, check.Message) + } +} + +func TestCheckTrackedRuntimeFiles_RuntimeFiles(t *testing.T) { + dir := mkTmpDirInTmp(t, "bd-tracked-runtime-*") + initRepo(t, dir, "main") + + // Commit runtime files that should not be tracked + commitFile(t, dir, ".beads/config.yaml", "backend: dolt\n", "add config") + commitFile(t, dir, ".beads/daemon.pid", "12345\n", "add daemon pid") + commitFile(t, dir, ".beads/daemon.log", "log data\n", "add daemon log") + commitFile(t, dir, ".beads/.jsonl.lock", "", "add lock") + + check := CheckTrackedRuntimeFiles(dir) + if check.Status != StatusWarning { + t.Fatalf("status=%q want %q (msg=%q)", check.Status, StatusWarning, check.Message) + } + if !strings.Contains(check.Message, "3") { + t.Fatalf("message=%q want to mention 3 files", check.Message) + } +} + +func TestCheckTrackedRuntimeFiles_SensitiveFiles(t *testing.T) { + dir := mkTmpDirInTmp(t, "bd-tracked-sensitive-*") + initRepo(t, dir, "main") + + // Commit a sensitive file (credential key in corrupt backup) + backupDir := filepath.Join(dir, ".beads", "dolt.20260312T135310Z.corrupt.backup") + if err := os.MkdirAll(backupDir, 0755); err != nil { + t.Fatal(err) + } + commitFile(t, dir, ".beads/dolt.20260312T135310Z.corrupt.backup/.beads-credential-key", "secret-key-data", "add credential") + + check := CheckTrackedRuntimeFiles(dir) + if check.Status != StatusError { + t.Fatalf("status=%q want %q (msg=%q)", check.Status, StatusError, check.Message) + } + if !strings.Contains(check.Message, "sensitive") { + t.Fatalf("message=%q want to mention sensitive", check.Message) + } +} + +func TestCheckTrackedRuntimeFiles_CorruptBackup(t *testing.T) { + dir := mkTmpDirInTmp(t, "bd-tracked-corrupt-*") + initRepo(t, dir, "main") + + backupDir := filepath.Join(dir, ".beads", "dolt.20260312T123507Z.corrupt.backup") + if err := os.MkdirAll(backupDir, 0755); err != nil { + t.Fatal(err) + } + commitFile(t, dir, ".beads/dolt.20260312T123507Z.corrupt.backup/.bd-dolt-ok", "", "add backup marker") + commitFile(t, dir, ".beads/dolt.20260312T123507Z.corrupt.backup/config.yaml", "backend: dolt\n", "add backup config") + + check := CheckTrackedRuntimeFiles(dir) + if check.Status != StatusWarning { + t.Fatalf("status=%q want %q (msg=%q)", check.Status, StatusWarning, check.Message) + } + if !strings.Contains(check.Message, "2") { + t.Fatalf("message=%q want to mention 2 files", check.Message) + } +} + +func TestFixTrackedRuntimeFiles(t *testing.T) { + dir := mkTmpDirInTmp(t, "bd-fix-tracked-*") + initRepo(t, dir, "main") + + // Commit runtime files + commitFile(t, dir, ".beads/config.yaml", "backend: dolt\n", "add config") + commitFile(t, dir, ".beads/daemon.pid", "12345\n", "add daemon pid") + commitFile(t, dir, ".beads/daemon.log", "log data\n", "add daemon log") + + // Verify they're flagged + check := CheckTrackedRuntimeFiles(dir) + if check.Status != StatusWarning { + t.Fatalf("pre-fix status=%q want %q", check.Status, StatusWarning) + } + + // Fix + if err := FixTrackedRuntimeFiles(dir); err != nil { + t.Fatalf("FixTrackedRuntimeFiles: %v", err) + } + + // Commit the untracking + runGit(t, dir, "commit", "-m", "untrack runtime files") + + // Verify fix worked + check = CheckTrackedRuntimeFiles(dir) + if check.Status != StatusOK { + t.Fatalf("post-fix status=%q want %q (msg=%q)", check.Status, StatusOK, check.Message) + } + + // Verify local files still exist + if _, err := os.Stat(filepath.Join(dir, ".beads", "daemon.pid")); os.IsNotExist(err) { + t.Fatal("daemon.pid should still exist locally after untracking") + } +} diff --git a/cmd/bd/doctor_fix.go b/cmd/bd/doctor_fix.go index c0a89ccfb0..230b35d266 100644 --- a/cmd/bd/doctor_fix.go +++ b/cmd/bd/doctor_fix.go @@ -255,6 +255,8 @@ func applyFixList(path string, fixes []doctorCheck) { err = doctor.FixRedirectTracking(path) case "Last-Touched Tracking": err = doctor.FixLastTouchedTracking(path) + case "Tracked Runtime Files": + err = doctor.FixTrackedRuntimeFiles(path) case "Git Hooks": err = fix.GitHooks(path) case "Sync Divergence":