Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions internal/codeguard/checks/quality/quality_ai_history.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package quality

import (
"strings"
"time"

"github.com/devr-tools/codeguard/internal/codeguard/checks/support"
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/codeguard/config/io.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions tests/checks/quality_ai_history_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"

"github.com/devr-tools/codeguard/pkg/codeguard"
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions tests/security/config_containment_paths_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading