diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index a3a0d1f71..c9a4a6b48 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -174,6 +174,10 @@ history, and chat sessions must not keep legacy plaintext files on the runtime primary path once the process can read them. Plaintext AI persistence files may only serve as migration input and must be rewritten immediately into encrypted-at-rest storage on load. +That same persistence boundary also governs AI memory package storage roots: +fixed store files such as change history, incident memory, and remediation +history must resolve through normalized owned data directories and fixed +storage-leaf joins instead of raw `filepath.Join(dataDir, ...)` paths. The same migration-only rule applies to guest knowledge under `internal/ai/knowledge/`: legacy `.json` knowledge files and plaintext `.enc` knowledge files may only serve as migration input, and the knowledge store diff --git a/internal/ai/memory/changes.go b/internal/ai/memory/changes.go index 69a7c60c8..638f43d81 100644 --- a/internal/ai/memory/changes.go +++ b/internal/ai/memory/changes.go @@ -7,7 +7,6 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" "sort" "sync" "sync/atomic" @@ -88,7 +87,7 @@ func NewChangeDetector(cfg ChangeDetectorConfig) *ChangeDetector { previousState: make(map[string]ResourceSnapshot), changes: make([]Change, 0), maxChanges: cfg.MaxChanges, - dataDir: cfg.DataDir, + dataDir: normalizeOptionalMemoryDataDir(cfg.DataDir), } // Load existing changes from disk @@ -355,7 +354,10 @@ func (d *ChangeDetector) saveToDisk() error { copy(changes, d.changes) d.mu.RUnlock() - path := filepath.Join(d.dataDir, "ai_changes.json") + path, err := memoryPersistencePath(d.dataDir, changeHistoryFileName) + if err != nil { + return err + } data, err := json.MarshalIndent(changes, "", " ") if err != nil { return err @@ -375,7 +377,10 @@ func (d *ChangeDetector) loadFromDisk() error { return nil } - path := filepath.Join(d.dataDir, "ai_changes.json") + path, err := memoryPersistencePath(d.dataDir, changeHistoryFileName) + if err != nil { + return err + } if st, err := os.Stat(path); err == nil { const maxOnDiskBytes = 10 << 20 // 10 MiB safety cap if st.Size() > maxOnDiskBytes { diff --git a/internal/ai/memory/incidents.go b/internal/ai/memory/incidents.go index b1910149c..494874799 100644 --- a/internal/ai/memory/incidents.go +++ b/internal/ai/memory/incidents.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" "sort" "strings" "sync" @@ -284,7 +283,6 @@ const ( defaultIncidentMaxIncidents = 500 defaultIncidentMaxEvents = 120 defaultIncidentMaxAgeDays = 90 - incidentFileName = "ai_incidents.json" maxIncidentFileSize = 20 * 1024 * 1024 // 20MB incidentStartMatchTolerance = 10 * time.Minute projectedIncidentChangeLimit = 256 @@ -316,11 +314,15 @@ func NewIncidentStore(cfg IncidentStoreConfig) *IncidentStore { maxIncidents: maxIncidents, maxEvents: maxEvents, maxAge: time.Duration(maxAgeDays) * 24 * time.Hour, - dataDir: cfg.DataDir, + dataDir: normalizeOptionalMemoryDataDir(cfg.DataDir), } if store.dataDir != "" { - store.filePath = filepath.Join(store.dataDir, incidentFileName) + filePath, err := memoryPersistencePath(store.dataDir, incidentHistoryFileName) + if err != nil { + panic(fmt.Sprintf("invalid AI incident persistence path for %q: %v", store.dataDir, err)) + } + store.filePath = filePath if err := store.loadFromDisk(); err != nil { log.Warn().Err(err).Msg("failed to load incident history from disk") } else if len(store.incidents) > 0 { diff --git a/internal/ai/memory/memory_coverage_test.go b/internal/ai/memory/memory_coverage_test.go index 343340e6f..25c15545c 100644 --- a/internal/ai/memory/memory_coverage_test.go +++ b/internal/ai/memory/memory_coverage_test.go @@ -298,7 +298,7 @@ func TestNewIncidentStore_LoadsFromDisk(t *testing.T) { func TestNewIncidentStore_LoadError(t *testing.T) { tmpDir := t.TempDir() - path := filepath.Join(tmpDir, incidentFileName) + path := filepath.Join(tmpDir, incidentHistoryFileName) if err := os.WriteFile(path, []byte("{"), 0600); err != nil { t.Fatalf("write file: %v", err) } @@ -309,6 +309,27 @@ func TestNewIncidentStore_LoadError(t *testing.T) { } } +func TestMemoryPersistencePathCanonicalizesDataDir(t *testing.T) { + baseDir := t.TempDir() + inputDir := " " + filepath.Join(baseDir, "nested", "..", "memory") + string(os.PathSeparator) + ". " + + path, err := memoryPersistencePath(inputDir, incidentHistoryFileName) + if err != nil { + t.Fatalf("memoryPersistencePath() error = %v", err) + } + + wantDir := filepath.Clean(filepath.Join(baseDir, "nested", "..", "memory") + string(os.PathSeparator) + ".") + if path != filepath.Join(wantDir, incidentHistoryFileName) { + t.Fatalf("memoryPersistencePath() = %q, want %q", path, filepath.Join(wantDir, incidentHistoryFileName)) + } +} + +func TestMemoryPersistencePathRejectsBlankDir(t *testing.T) { + if _, err := memoryPersistencePath(" \t ", incidentHistoryFileName); err == nil { + t.Fatal("expected blank memory data dir to be rejected") + } +} + func TestIncidentStore_RecordAlertFired_Existing(t *testing.T) { store := NewIncidentStore(IncidentStoreConfig{}) store.RecordAlertFired(nil) diff --git a/internal/ai/memory/paths.go b/internal/ai/memory/paths.go new file mode 100644 index 000000000..c9d40a839 --- /dev/null +++ b/internal/ai/memory/paths.go @@ -0,0 +1,36 @@ +package memory + +import ( + "fmt" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" +) + +const ( + changeHistoryFileName = "ai_changes.json" + incidentHistoryFileName = "ai_incidents.json" + remediationHistoryFileName = "ai_remediations.json" + incidentFileName = incidentHistoryFileName +) + +func normalizeOptionalMemoryDataDir(dir string) string { + trimmed := strings.TrimSpace(dir) + if trimmed == "" { + return "" + } + + normalized, err := securityutil.NormalizeStorageDir(trimmed) + if err != nil { + return "" + } + return normalized +} + +func memoryPersistencePath(dataDir string, leaf string) (string, error) { + normalizedDir := normalizeOptionalMemoryDataDir(dataDir) + if normalizedDir == "" { + return "", fmt.Errorf("memory data directory is required") + } + return securityutil.JoinStorageLeaf(normalizedDir, leaf) +} diff --git a/internal/ai/memory/remediation.go b/internal/ai/memory/remediation.go index 731ae7f6d..80ce088dd 100644 --- a/internal/ai/memory/remediation.go +++ b/internal/ai/memory/remediation.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" "sort" "sync" "time" @@ -80,7 +79,7 @@ func NewRemediationLog(cfg RemediationLogConfig) *RemediationLog { r := &RemediationLog{ records: make([]RemediationRecord, 0), maxRecords: cfg.MaxRecords, - dataDir: cfg.DataDir, + dataDir: normalizeOptionalMemoryDataDir(cfg.DataDir), } // Load existing records from disk @@ -346,7 +345,10 @@ func (r *RemediationLog) saveToDisk() error { copy(records, r.records) r.mu.RUnlock() - path := filepath.Join(r.dataDir, "ai_remediations.json") + path, err := memoryPersistencePath(r.dataDir, remediationHistoryFileName) + if err != nil { + return err + } data, err := json.MarshalIndent(records, "", " ") if err != nil { return err @@ -366,7 +368,10 @@ func (r *RemediationLog) loadFromDisk() error { return nil } - path := filepath.Join(r.dataDir, "ai_remediations.json") + path, err := memoryPersistencePath(r.dataDir, remediationHistoryFileName) + if err != nil { + return err + } if st, err := os.Stat(path); err == nil { const maxOnDiskBytes = 10 << 20 // 10 MiB safety cap if st.Size() > maxOnDiskBytes {