diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index afe374166..e1a49a157 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -58,12 +58,20 @@ type JSONLStore struct { locks [numLockShards]sync.Mutex } +const ( + sessionDirPerm = 0o700 + sessionFilePerm = 0o600 +) + // NewJSONLStore creates a new JSONL-backed store rooted at dir. func NewJSONLStore(dir string) (*JSONLStore, error) { - err := os.MkdirAll(dir, 0o755) + err := os.MkdirAll(dir, sessionDirPerm) if err != nil { return nil, fmt.Errorf("memory: create directory: %w", err) } + if err := os.Chmod(dir, sessionDirPerm); err != nil { + return nil, fmt.Errorf("memory: secure directory permissions: %w", err) + } return &JSONLStore{dir: dir}, nil } @@ -121,7 +129,7 @@ func (s *JSONLStore) writeMeta(key string, meta sessionMeta) error { if err != nil { return fmt.Errorf("memory: encode meta: %w", err) } - return fileutil.WriteFileAtomic(s.metaPath(key), data, 0o644) + return fileutil.WriteFileAtomic(s.metaPath(key), data, sessionFilePerm) } // readMessages reads valid JSON lines from a .jsonl file, skipping @@ -230,11 +238,15 @@ func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error { f, err := os.OpenFile( s.jsonlPath(sessionKey), os.O_CREATE|os.O_WRONLY|os.O_APPEND, - 0o644, + sessionFilePerm, ) if err != nil { return fmt.Errorf("memory: open jsonl for append: %w", err) } + if err := f.Chmod(sessionFilePerm); err != nil { + f.Close() + return fmt.Errorf("memory: secure jsonl permissions: %w", err) + } _, writeErr := f.Write(line) if writeErr != nil { f.Close() @@ -452,7 +464,7 @@ func (s *JSONLStore) rewriteJSONL( buf.Write(line) buf.WriteByte('\n') } - return fileutil.WriteFileAtomic(s.jsonlPath(sessionKey), buf.Bytes(), 0o644) + return fileutil.WriteFileAtomic(s.jsonlPath(sessionKey), buf.Bytes(), sessionFilePerm) } func (s *JSONLStore) Close() error { diff --git a/pkg/memory/jsonl_test.go b/pkg/memory/jsonl_test.go index 356ff14ff..a8b69d03e 100644 --- a/pkg/memory/jsonl_test.go +++ b/pkg/memory/jsonl_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "runtime" "sync" "testing" @@ -36,6 +37,101 @@ func TestNewJSONLStore_CreatesDirectory(t *testing.T) { } } +func TestJSONLStore_TightensPermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file permission bits are not enforced on Windows") + } + + dir := filepath.Join(t.TempDir(), "nested", "sessions") + store, err := NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore: %v", err) + } + defer store.Close() + + if err := store.AddMessage(context.Background(), "s1", "user", "hello"); err != nil { + t.Fatalf("AddMessage: %v", err) + } + + dirInfo, err := os.Stat(dir) + if err != nil { + t.Fatalf("Stat dir: %v", err) + } + if got := dirInfo.Mode().Perm(); got != sessionDirPerm { + t.Fatalf("dir perms = %04o, want %04o", got, sessionDirPerm) + } + + jsonlInfo, err := os.Stat(store.jsonlPath("s1")) + if err != nil { + t.Fatalf("Stat jsonl: %v", err) + } + if got := jsonlInfo.Mode().Perm(); got != sessionFilePerm { + t.Fatalf("jsonl perms = %04o, want %04o", got, sessionFilePerm) + } + + metaInfo, err := os.Stat(store.metaPath("s1")) + if err != nil { + t.Fatalf("Stat meta: %v", err) + } + if got := metaInfo.Mode().Perm(); got != sessionFilePerm { + t.Fatalf("meta perms = %04o, want %04o", got, sessionFilePerm) + } +} + +func TestJSONLStore_TightensExistingLoosePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file permission bits are not enforced on Windows") + } + + dir := filepath.Join(t.TempDir(), "sessions") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + jsonlPath := filepath.Join(dir, "legacy.jsonl") + metaPath := filepath.Join(dir, "legacy.meta.json") + if err := os.WriteFile(jsonlPath, []byte(""), 0o644); err != nil { + t.Fatalf("WriteFile jsonl: %v", err) + } + if err := os.WriteFile(metaPath, []byte(`{"key":"legacy"}`), 0o644); err != nil { + t.Fatalf("WriteFile meta: %v", err) + } + + store, err := NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore: %v", err) + } + defer store.Close() + + if err := store.AddMessage(context.Background(), "legacy", "user", "hello"); err != nil { + t.Fatalf("AddMessage: %v", err) + } + + dirInfo, err := os.Stat(dir) + if err != nil { + t.Fatalf("Stat dir: %v", err) + } + if got := dirInfo.Mode().Perm(); got != sessionDirPerm { + t.Fatalf("dir perms = %04o, want %04o", got, sessionDirPerm) + } + + jsonlInfo, err := os.Stat(jsonlPath) + if err != nil { + t.Fatalf("Stat jsonl: %v", err) + } + if got := jsonlInfo.Mode().Perm(); got != sessionFilePerm { + t.Fatalf("jsonl perms = %04o, want %04o", got, sessionFilePerm) + } + + metaInfo, err := os.Stat(metaPath) + if err != nil { + t.Fatalf("Stat meta: %v", err) + } + if got := metaInfo.Mode().Perm(); got != sessionFilePerm { + t.Fatalf("meta perms = %04o, want %04o", got, sessionFilePerm) + } +} + func TestAddMessage_BasicRoundtrip(t *testing.T) { store := newTestStore(t) ctx := context.Background()