Harden AI memory persistence roots

This commit is contained in:
rcourtman 2026-03-29 14:28:48 +01:00
parent 4af6858405
commit 86aeec4745
6 changed files with 86 additions and 13 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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 {

View file

@ -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)

View file

@ -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)
}

View file

@ -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 {