mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Harden AI memory persistence roots
This commit is contained in:
parent
4af6858405
commit
86aeec4745
6 changed files with 86 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
36
internal/ai/memory/paths.go
Normal file
36
internal/ai/memory/paths.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue