diff --git a/internal/codeguard/checks/quality/quality_ai_history.go b/internal/codeguard/checks/quality/quality_ai_history.go index 130585f..49367fe 100644 --- a/internal/codeguard/checks/quality/quality_ai_history.go +++ b/internal/codeguard/checks/quality/quality_ai_history.go @@ -1,6 +1,7 @@ package quality import ( + "strings" "time" "github.com/devr-tools/codeguard/internal/codeguard/checks/support" @@ -15,6 +16,11 @@ func recordSlopHistory(env support.Context, artifact *core.Artifact) { if artifact == nil || artifact.SlopScore == nil { return } + // Patch validation evaluates repository-controlled content in a temporary + // workspace and must not persist any state to repository-configured paths. + if strings.TrimSpace(env.DiffText) != "" { + return + } cfg := env.Config.Checks.QualityRules.AIChecks if !aiCheckEnabled(cfg.SlopHistory) { return diff --git a/internal/codeguard/config/io.go b/internal/codeguard/config/io.go index ec3a96a..e675351 100644 --- a/internal/codeguard/config/io.go +++ b/internal/codeguard/config/io.go @@ -105,6 +105,10 @@ func containConfigArtifactPaths(cfg *core.Config, baseDir string) error { if _, err := containedPath(baseDir, performanceHistoryPath); err != nil { return fmt.Errorf("performance_rules.score_history: %w", err) } + slopHistoryPath := cachefile.DerivedPath(cfg.Cache.Path, ".slop-history") + if _, err := containedPath(baseDir, slopHistoryPath); err != nil { + return fmt.Errorf("quality_rules.ai_checks.slop_history: %w", err) + } for i := range cfg.ExternalReports { resolved, err := containedPath(baseDir, cfg.ExternalReports[i].Path) if err != nil { diff --git a/tests/checks/quality_ai_history_test.go b/tests/checks/quality_ai_history_test.go index 4eb6cce..33fe4af 100644 --- a/tests/checks/quality_ai_history_test.go +++ b/tests/checks/quality_ai_history_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "github.com/devr-tools/codeguard/pkg/codeguard" @@ -107,6 +108,32 @@ func TestSlopScoreHistoryHonorsToggle(t *testing.T) { } } +func TestSlopScoreHistoryDoesNotWriteDuringPatchValidation(t *testing.T) { + dir := t.TempDir() + writeSlopFixture(t, dir) + cfg := slopHistoryTestConfig(dir, "quality-ai-history-patch") + diff := strings.Join([]string{ + "diff --git a/service.go b/service.go", + "index 4adc33a..59b9f08 100644", + "--- a/service.go", + "+++ b/service.go", + "@@ -1,5 +1,6 @@", + " package sample", + " ", + " func Run() error {", + "+\t// create error object", + " \terr := doThing()", + " \t_ = err", + }, "\n") + + if _, err := codeguard.RunPatch(context.Background(), cfg, diff); err != nil { + t.Fatalf("run patch: %v", err) + } + if _, err := os.Stat(codeguard.SlopHistoryPath(cfg)); !os.IsNotExist(err) { + t.Fatalf("expected no history file during patch validation, stat err = %v", err) + } +} + func TestSlopScoreHistoryCapsEntries(t *testing.T) { dir := t.TempDir() writeSlopFixture(t, dir) diff --git a/tests/security/config_containment_paths_test.go b/tests/security/config_containment_paths_test.go index 0962592..3a8cd94 100644 --- a/tests/security/config_containment_paths_test.go +++ b/tests/security/config_containment_paths_test.go @@ -121,3 +121,24 @@ func TestLoadConfigRejectsPerformanceHistorySymlinkEscape(t *testing.T) { t.Fatalf("expected a performance-history symlink containment error, got %v", err) } } + +func TestLoadConfigRejectsSlopHistorySymlinkEscape(t *testing.T) { + dir := t.TempDir() + outside := filepath.Join(t.TempDir(), "victim.json") + cacheDir := filepath.Join(dir, ".codeguard") + if err := os.Mkdir(cacheDir, 0o750); err != nil { + t.Fatalf("create cache directory: %v", err) + } + if err := os.Symlink(outside, filepath.Join(cacheDir, "cache.slop-history.json")); err != nil { + t.Skipf("symlinks unsupported on this platform: %v", err) + } + path := writeConfig(t, dir, ".codeguard/cache.json", "") + + _, err := service.LoadConfigFile(path) + if err == nil { + t.Fatal("expected a derived slop-history symlink escape to be rejected") + } + if !strings.Contains(err.Error(), "slop_history") || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("expected a slop-history symlink containment error, got %v", err) + } +}