From 65a62bb108a7656e3115d5038a8d363d42a46296 Mon Sep 17 00:00:00 2001 From: nash Date: Thu, 4 Jun 2026 20:33:03 +0500 Subject: [PATCH] fix(security): tighten conversation-history file perms to 0600 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six of the ten provider conversation-history modules were writing the Q&A log world-readable (0644). The files routinely contain account IDs, ARNs, IPs, role names, and (in iam) entire policy fragments copied verbatim into the LLM answer — anything a non-owner on a shared host could grep. Add a tiny internal/secfile package that encapsulates three primitives — EnsurePrivateDir (0700), WritePrivate (0600), and ReadPrivate (open → fd-based chmod → ReadAll). The fd-based chmod removes the TOCTOU window that a path-based Chmod would leave open. All ten conversation.go modules (cloudflare, k8s, iam, flyio, railway, vercel, sentry, linear, notion, verda) now route Save / Load / MkdirAll through secfile. The chmod-on-load path also auto-repairs files installed by older 0644 binaries — users don't need to chmod the existing files manually. Lock the door behind us: a parse-based drift test walks the repo, finds every conversation.go, and fails the build if any of them calls os.WriteFile / os.ReadFile / os.MkdirAll directly. A tenth provider added next quarter cannot reintroduce this leak by copy-pasting the historic pattern. The test caught one mistake immediately — internal/verda/conversation.go was missed by the original audit and is now also patched. Mode bits are not meaningful on Windows; the Chmod calls inside secfile short-circuit there. Default ACLs already create owner-only on Windows. Closes #22 --- internal/cloudflare/conversation.go | 8 +- internal/flyio/conversation.go | 8 +- internal/flyio/conversation_test.go | 19 ++++- internal/iam/conversation.go | 8 +- internal/k8s/conversation.go | 8 +- internal/linear/conversation.go | 8 +- internal/notion/conversation.go | 8 +- internal/railway/conversation.go | 8 +- internal/secfile/drift_test.go | 93 ++++++++++++++++++++++ internal/secfile/secfile.go | 74 +++++++++++++++++ internal/secfile/secfile_test.go | 118 ++++++++++++++++++++++++++++ internal/sentry/conversation.go | 8 +- internal/vercel/conversation.go | 8 +- internal/verda/conversation.go | 8 +- 14 files changed, 354 insertions(+), 30 deletions(-) create mode 100644 internal/secfile/drift_test.go create mode 100644 internal/secfile/secfile.go create mode 100644 internal/secfile/secfile_test.go diff --git a/internal/cloudflare/conversation.go b/internal/cloudflare/conversation.go index 93bd8364..1515e6c7 100644 --- a/internal/cloudflare/conversation.go +++ b/internal/cloudflare/conversation.go @@ -9,6 +9,8 @@ import ( "strings" "sync" "time" + + "github.com/bgdnvk/clanker/internal/secfile" ) // ConversationEntry represents a single Q&A exchange @@ -151,7 +153,7 @@ func (h *ConversationHistory) Save() error { return err } - if err := os.MkdirAll(dir, 0755); err != nil { + if err := secfile.EnsurePrivateDir(dir); err != nil { return fmt.Errorf("failed to create conversation directory: %w", err) } @@ -161,7 +163,7 @@ func (h *ConversationHistory) Save() error { return fmt.Errorf("failed to marshal conversation history: %w", err) } - if err := os.WriteFile(filename, data, 0644); err != nil { + if err := secfile.WritePrivate(filename, data); err != nil { return fmt.Errorf("failed to write conversation file: %w", err) } @@ -179,7 +181,7 @@ func (h *ConversationHistory) Load() error { } filename := filepath.Join(dir, fmt.Sprintf("cloudflare_%s.json", sanitizeFilename(h.AccountID))) - data, err := os.ReadFile(filename) + data, err := secfile.ReadPrivate(filename) if err != nil { if os.IsNotExist(err) { // No history yet, that is fine diff --git a/internal/flyio/conversation.go b/internal/flyio/conversation.go index 44b36cce..314f1850 100644 --- a/internal/flyio/conversation.go +++ b/internal/flyio/conversation.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "time" + + "github.com/bgdnvk/clanker/internal/secfile" ) // ConversationEntry represents a single Q&A exchange. @@ -99,7 +101,7 @@ func (h *ConversationHistory) Save() error { return err } - if err := os.MkdirAll(dir, 0755); err != nil { + if err := secfile.EnsurePrivateDir(dir); err != nil { return fmt.Errorf("failed to create conversation directory: %w", err) } @@ -118,7 +120,7 @@ func (h *ConversationHistory) Save() error { } tmp := filename + ".tmp" - if err := os.WriteFile(tmp, data, 0644); err != nil { + if err := secfile.WritePrivate(tmp, data); err != nil { return fmt.Errorf("failed to write temp conversation file: %w", err) } if err := os.Rename(tmp, filename); err != nil { @@ -138,7 +140,7 @@ func (h *ConversationHistory) Load() error { if err != nil { return err } - data, err := os.ReadFile(path) + data, err := secfile.ReadPrivate(path) if err != nil { if os.IsNotExist(err) { return nil diff --git a/internal/flyio/conversation_test.go b/internal/flyio/conversation_test.go index 13f0314a..bce5b391 100644 --- a/internal/flyio/conversation_test.go +++ b/internal/flyio/conversation_test.go @@ -3,6 +3,7 @@ package flyio import ( "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -58,9 +59,25 @@ func TestSaveLoadRoundTrip(t *testing.T) { // File should land at ~/.clanker/conversations/flyio_acme.json. want := filepath.Join(dir, ".clanker", "conversations", "flyio_acme.json") - if _, err := os.Stat(want); err != nil { + info, err := os.Stat(want) + if err != nil { t.Fatalf("expected file %s: %v", want, err) } + if runtime.GOOS != "windows" { + // Saved files must not be world-readable — they contain raw + // operator Q&A (account IDs, ARNs, policy fragments). Drift + // guard for #22. + if mode := info.Mode().Perm(); mode != 0o600 { + t.Errorf("file mode = %04o, want 0600", mode) + } + convDir, err := os.Stat(filepath.Dir(want)) + if err != nil { + t.Fatalf("stat conv dir: %v", err) + } + if mode := convDir.Mode().Perm(); mode != 0o700 { + t.Errorf("conversations dir mode = %04o, want 0700", mode) + } + } loaded := NewConversationHistory("acme") if err := loaded.Load(); err != nil { diff --git a/internal/iam/conversation.go b/internal/iam/conversation.go index 0c603017..13f7a500 100644 --- a/internal/iam/conversation.go +++ b/internal/iam/conversation.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "time" + + "github.com/bgdnvk/clanker/internal/secfile" ) // ConversationEntry represents a single Q&A exchange @@ -146,7 +148,7 @@ func (h *ConversationHistory) Save() error { return err } - if err := os.MkdirAll(dir, 0755); err != nil { + if err := secfile.EnsurePrivateDir(dir); err != nil { return fmt.Errorf("failed to create conversation directory: %w", err) } @@ -156,7 +158,7 @@ func (h *ConversationHistory) Save() error { return fmt.Errorf("failed to marshal conversation history: %w", err) } - if err := os.WriteFile(filename, data, 0644); err != nil { + if err := secfile.WritePrivate(filename, data); err != nil { return fmt.Errorf("failed to write conversation file: %w", err) } @@ -174,7 +176,7 @@ func (h *ConversationHistory) Load() error { } filename := filepath.Join(dir, fmt.Sprintf("iam_%s.json", sanitizeFilename(h.AccountID))) - data, err := os.ReadFile(filename) + data, err := secfile.ReadPrivate(filename) if err != nil { if os.IsNotExist(err) { // No history yet, that is fine diff --git a/internal/k8s/conversation.go b/internal/k8s/conversation.go index 816c5eb1..b1d786d8 100644 --- a/internal/k8s/conversation.go +++ b/internal/k8s/conversation.go @@ -10,6 +10,8 @@ import ( "strings" "sync" "time" + + "github.com/bgdnvk/clanker/internal/secfile" ) // ConversationEntry represents a single Q&A exchange @@ -177,7 +179,7 @@ func (h *ConversationHistory) Save() error { return err } - if err := os.MkdirAll(dir, 0755); err != nil { + if err := secfile.EnsurePrivateDir(dir); err != nil { return fmt.Errorf("failed to create conversation directory: %w", err) } @@ -187,7 +189,7 @@ func (h *ConversationHistory) Save() error { return fmt.Errorf("failed to marshal conversation history: %w", err) } - if err := os.WriteFile(filename, data, 0644); err != nil { + if err := secfile.WritePrivate(filename, data); err != nil { return fmt.Errorf("failed to write conversation file: %w", err) } @@ -205,7 +207,7 @@ func (h *ConversationHistory) Load() error { } filename := filepath.Join(dir, fmt.Sprintf("k8s_%s.json", sanitizeFilename(h.ClusterName))) - data, err := os.ReadFile(filename) + data, err := secfile.ReadPrivate(filename) if err != nil { if os.IsNotExist(err) { // No history yet, that is fine diff --git a/internal/linear/conversation.go b/internal/linear/conversation.go index 30fea8c0..06ffae14 100644 --- a/internal/linear/conversation.go +++ b/internal/linear/conversation.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "time" + + "github.com/bgdnvk/clanker/internal/secfile" ) // ConversationEntry is a single Q&A turn against the Linear ask agent. @@ -129,7 +131,7 @@ func historyPath(workspaceID string) (string, error) { return "", err } dir := filepath.Join(home, ".clanker") - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := secfile.EnsurePrivateDir(dir); err != nil { return "", err } return filepath.Join(dir, fmt.Sprintf("linear-%s.json", safeSlug(workspaceID))), nil @@ -140,7 +142,7 @@ func (h *ConversationHistory) Load() error { if err != nil { return err } - data, err := os.ReadFile(path) + data, err := secfile.ReadPrivate(path) if err != nil { if os.IsNotExist(err) { return nil @@ -163,5 +165,5 @@ func (h *ConversationHistory) Save() error { if err != nil { return err } - return os.WriteFile(path, data, 0o600) + return secfile.WritePrivate(path, data) } diff --git a/internal/notion/conversation.go b/internal/notion/conversation.go index 12829e1f..f6e6c268 100644 --- a/internal/notion/conversation.go +++ b/internal/notion/conversation.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "time" + + "github.com/bgdnvk/clanker/internal/secfile" ) const ( @@ -128,7 +130,7 @@ func historyPath(workspaceName string) (string, error) { return "", err } dir := filepath.Join(home, ".clanker") - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := secfile.EnsurePrivateDir(dir); err != nil { return "", err } return filepath.Join(dir, fmt.Sprintf("notion-%s.json", safeSlug(workspaceName))), nil @@ -139,7 +141,7 @@ func (h *ConversationHistory) Load() error { if err != nil { return err } - data, err := os.ReadFile(path) + data, err := secfile.ReadPrivate(path) if err != nil { if os.IsNotExist(err) { return nil @@ -162,5 +164,5 @@ func (h *ConversationHistory) Save() error { if err != nil { return err } - return os.WriteFile(path, data, 0o600) + return secfile.WritePrivate(path, data) } diff --git a/internal/railway/conversation.go b/internal/railway/conversation.go index 7ca78b76..ccd310e0 100644 --- a/internal/railway/conversation.go +++ b/internal/railway/conversation.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "time" + + "github.com/bgdnvk/clanker/internal/secfile" ) // ConversationEntry represents a single Q&A exchange. @@ -112,7 +114,7 @@ func (h *ConversationHistory) Save() error { if err != nil { return err } - if err := os.MkdirAll(dir, 0755); err != nil { + if err := secfile.EnsurePrivateDir(dir); err != nil { return fmt.Errorf("failed to create conversation directory: %w", err) } @@ -120,7 +122,7 @@ func (h *ConversationHistory) Save() error { // Atomic write: temp + rename. tmp := filename + ".tmp" - if err := os.WriteFile(tmp, data, 0644); err != nil { + if err := secfile.WritePrivate(tmp, data); err != nil { return fmt.Errorf("failed to write temp conversation file: %w", err) } if err := os.Rename(tmp, filename); err != nil { @@ -140,7 +142,7 @@ func (h *ConversationHistory) Load() error { if err != nil { return err } - data, err := os.ReadFile(path) + data, err := secfile.ReadPrivate(path) if err != nil { if os.IsNotExist(err) { return nil diff --git a/internal/secfile/drift_test.go b/internal/secfile/drift_test.go new file mode 100644 index 00000000..2ee38c1d --- /dev/null +++ b/internal/secfile/drift_test.go @@ -0,0 +1,93 @@ +package secfile_test + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strings" + "testing" +) + +// TestConversationFilesDoNotWriteWorldReadable walks every +// internal//conversation.go file and fails the build if any +// of them call os.WriteFile / os.MkdirAll with a loose Unix mode +// literal, or call os.ReadFile (which bypasses the chmod-on-load +// repair). The intent is to lock in #22 so a tenth provider can't +// reintroduce the leak by copy-pasting the historic pattern. +func TestConversationFilesDoNotWriteWorldReadable(t *testing.T) { + root, err := repoRoot() + if err != nil { + t.Fatalf("locate repo root: %v", err) + } + + var files []string + if err := filepath.WalkDir(filepath.Join(root, "internal"), func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if filepath.Base(path) == "conversation.go" { + files = append(files, path) + } + return nil + }); err != nil { + t.Fatalf("walk: %v", err) + } + + if len(files) < 9 { + t.Fatalf("expected >= 9 conversation.go files (one per provider); found %d. Did the layout change?", len(files)) + } + + fset := token.NewFileSet() + for _, path := range files { + t.Run(relpath(root, path), func(t *testing.T) { + file, err := parser.ParseFile(fset, path, nil, parser.ParseComments) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + pkg, ok := sel.X.(*ast.Ident) + if !ok || pkg.Name != "os" { + return true + } + switch sel.Sel.Name { + case "WriteFile", "ReadFile": + t.Errorf("%s uses os.%s directly — must go through secfile.WritePrivate/ReadPrivate (drift guard for #22)", fset.Position(call.Pos()), sel.Sel.Name) + case "MkdirAll": + t.Errorf("%s uses os.MkdirAll directly — must go through secfile.EnsurePrivateDir (drift guard for #22)", fset.Position(call.Pos())) + } + return true + }) + }) + } +} + +func repoRoot() (string, error) { + // internal/secfile/drift_test.go → ../.. + abs, err := filepath.Abs(".") + if err != nil { + return "", err + } + return filepath.Clean(filepath.Join(abs, "..", "..")), nil +} + +func relpath(root, p string) string { + rel, err := filepath.Rel(root, p) + if err != nil { + return p + } + return strings.ReplaceAll(rel, string(filepath.Separator), "/") +} diff --git a/internal/secfile/secfile.go b/internal/secfile/secfile.go new file mode 100644 index 00000000..25246ab3 --- /dev/null +++ b/internal/secfile/secfile.go @@ -0,0 +1,74 @@ +// Package secfile holds small file-permission primitives used by the +// nine provider conversation-history modules. It exists to keep the +// hardening from drifting again (issue #22): every history-file Save +// goes through WritePrivate and every Load goes through ReadPrivate, +// so adding a tenth provider can't reintroduce world-readable Q&A. +// +// This is a security primitive (file modes + Chmod-repair), not a +// conversation-history abstraction. The larger refactor — extracting +// shared Load/Save logic across providers — is tracked in #25. +package secfile + +import ( + "io" + "os" + "runtime" +) + +// PrivateDirMode and PrivateFileMode are the modes we want every +// history file and its parent directory to end up at. 0o700 / 0o600 +// keeps conversation contents out of non-owner reach on shared boxes +// (CI runners, EC2 bastions, multi-UID containers). +const ( + PrivateDirMode os.FileMode = 0o700 + PrivateFileMode os.FileMode = 0o600 +) + +// EnsurePrivateDir creates dir (and parents) with 0o700, then Chmods +// it to 0o700 in case it already existed with looser perms. Chmod is +// a no-op on Windows; safe to call on every Save. +func EnsurePrivateDir(dir string) error { + if err := os.MkdirAll(dir, PrivateDirMode); err != nil { + return err + } + if runtime.GOOS == "windows" { + return nil + } + return os.Chmod(dir, PrivateDirMode) +} + +// WritePrivate writes data to path with 0o600. If the file already +// exists with looser perms (pre-fix users), WriteFile does NOT +// re-apply the mode — so we Chmod afterwards as a belt-and-braces +// repair. Chmod is a no-op on Windows. +func WritePrivate(path string, data []byte) error { + if err := os.WriteFile(path, data, PrivateFileMode); err != nil { + return err + } + if runtime.GOOS == "windows" { + return nil + } + // Idempotent: if WriteFile honored the mode (file is new), this + // is a no-op. If the file pre-existed with 0o644, this tightens it. + return os.Chmod(path, PrivateFileMode) +} + +// ReadPrivate opens path read-only, repairs its perms via the file +// descriptor (NOT the path — avoids TOCTOU where a local attacker +// could rename or symlink between read and chmod), then reads it +// all. Returns the file contents. +func ReadPrivate(path string) ([]byte, error) { + f, err := os.OpenFile(path, os.O_RDONLY, 0) + if err != nil { + return nil, err + } + defer f.Close() + + if runtime.GOOS != "windows" { + // Best-effort: ignore EPERM (read-only FS, NFS without chmod). + // We still want the read to succeed so the user isn't blocked. + _ = f.Chmod(PrivateFileMode) + } + + return io.ReadAll(f) +} diff --git a/internal/secfile/secfile_test.go b/internal/secfile/secfile_test.go new file mode 100644 index 00000000..140c3fe7 --- /dev/null +++ b/internal/secfile/secfile_test.go @@ -0,0 +1,118 @@ +package secfile + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestEnsurePrivateDir_CreatesAt0700(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mode bits not meaningful on Windows") + } + dir := filepath.Join(t.TempDir(), "fresh", "nested") + if err := EnsurePrivateDir(dir); err != nil { + t.Fatalf("EnsurePrivateDir: %v", err) + } + info, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != PrivateDirMode { + t.Errorf("dir mode = %04o, want %04o", got, PrivateDirMode) + } +} + +func TestEnsurePrivateDir_TightensExistingLooseDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mode bits not meaningful on Windows") + } + dir := filepath.Join(t.TempDir(), "loose") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := EnsurePrivateDir(dir); err != nil { + t.Fatalf("EnsurePrivateDir: %v", err) + } + info, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != PrivateDirMode { + t.Errorf("existing-loose dir mode = %04o, want %04o", got, PrivateDirMode) + } +} + +func TestWritePrivate_NewFileIs0600(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mode bits not meaningful on Windows") + } + path := filepath.Join(t.TempDir(), "history.json") + if err := WritePrivate(path, []byte(`{"hi":1}`)); err != nil { + t.Fatalf("WritePrivate: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != PrivateFileMode { + t.Errorf("new-file mode = %04o, want %04o", got, PrivateFileMode) + } +} + +func TestWritePrivate_TightensExistingLooseFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mode bits not meaningful on Windows") + } + path := filepath.Join(t.TempDir(), "history.json") + if err := os.WriteFile(path, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if err := WritePrivate(path, []byte("new")); err != nil { + t.Fatalf("WritePrivate: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != PrivateFileMode { + t.Errorf("overwritten-file mode = %04o, want %04o", got, PrivateFileMode) + } +} + +func TestReadPrivate_RepairsLoosePerms(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mode bits not meaningful on Windows") + } + path := filepath.Join(t.TempDir(), "history.json") + want := []byte(`{"entries":[]}`) + if err := os.WriteFile(path, want, 0o644); err != nil { + t.Fatal(err) + } + got, err := ReadPrivate(path) + if err != nil { + t.Fatalf("ReadPrivate: %v", err) + } + if !bytes.Equal(got, want) { + t.Errorf("contents = %q, want %q", got, want) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if mode := info.Mode().Perm(); mode != PrivateFileMode { + t.Errorf("post-read mode = %04o, want %04o (chmod-on-load did not run)", mode, PrivateFileMode) + } +} + +func TestReadPrivate_MissingFile(t *testing.T) { + _, err := ReadPrivate(filepath.Join(t.TempDir(), "nope.json")) + if err == nil { + t.Fatal("expected error reading missing file") + } + if !os.IsNotExist(err) { + t.Errorf("expected IsNotExist, got: %v", err) + } +} diff --git a/internal/sentry/conversation.go b/internal/sentry/conversation.go index 50beac62..6b487d36 100644 --- a/internal/sentry/conversation.go +++ b/internal/sentry/conversation.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "time" + + "github.com/bgdnvk/clanker/internal/secfile" ) // ConversationEntry is a single Q&A turn against the Sentry ask agent. @@ -134,7 +136,7 @@ func historyPath(orgSlug string) (string, error) { return "", err } dir := filepath.Join(home, ".clanker") - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := secfile.EnsurePrivateDir(dir); err != nil { return "", err } return filepath.Join(dir, fmt.Sprintf("sentry-%s.json", safeSlug(orgSlug))), nil @@ -145,7 +147,7 @@ func (h *ConversationHistory) Load() error { if err != nil { return err } - data, err := os.ReadFile(path) + data, err := secfile.ReadPrivate(path) if err != nil { if os.IsNotExist(err) { return nil @@ -168,5 +170,5 @@ func (h *ConversationHistory) Save() error { if err != nil { return err } - return os.WriteFile(path, data, 0o600) + return secfile.WritePrivate(path, data) } diff --git a/internal/vercel/conversation.go b/internal/vercel/conversation.go index 3389e3f9..a6d6bf16 100644 --- a/internal/vercel/conversation.go +++ b/internal/vercel/conversation.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "time" + + "github.com/bgdnvk/clanker/internal/secfile" ) // ConversationEntry represents a single Q&A exchange. @@ -93,7 +95,7 @@ func (h *ConversationHistory) Save() error { return err } - if err := os.MkdirAll(dir, 0755); err != nil { + if err := secfile.EnsurePrivateDir(dir); err != nil { return fmt.Errorf("failed to create conversation directory: %w", err) } @@ -114,7 +116,7 @@ func (h *ConversationHistory) Save() error { // Atomic write: write to temp file first, then rename. tmp := filename + ".tmp" - if err := os.WriteFile(tmp, data, 0644); err != nil { + if err := secfile.WritePrivate(tmp, data); err != nil { return fmt.Errorf("failed to write temp conversation file: %w", err) } if err := os.Rename(tmp, filename); err != nil { @@ -135,7 +137,7 @@ func (h *ConversationHistory) Load() error { if err != nil { return err } - data, err := os.ReadFile(path) + data, err := secfile.ReadPrivate(path) if err != nil { if os.IsNotExist(err) { return nil // No history yet — that is fine. diff --git a/internal/verda/conversation.go b/internal/verda/conversation.go index 5b0f6448..39bff4ea 100644 --- a/internal/verda/conversation.go +++ b/internal/verda/conversation.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "time" + + "github.com/bgdnvk/clanker/internal/secfile" ) // fileLocks serializes Save+Load per ScopeID so two concurrent @@ -127,11 +129,13 @@ func (h *ConversationHistory) Save() error { if err != nil { return err } - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := secfile.EnsurePrivateDir(dir); err != nil { return fmt.Errorf("create conversation dir: %w", err) } path := filepath.Join(dir, fmt.Sprintf("verda_%s.json", scopeName)) + // os.CreateTemp creates files at 0o600 on Unix by default — Rename + // below preserves that, so the persisted file ends up private. tmp, err := os.CreateTemp(dir, "verda_*.json.tmp") if err != nil { return fmt.Errorf("create tmp: %w", err) @@ -172,7 +176,7 @@ func (h *ConversationHistory) Load() error { return err } path := filepath.Join(dir, fmt.Sprintf("verda_%s.json", scopeName)) - data, err := os.ReadFile(path) + data, err := secfile.ReadPrivate(path) if err != nil { if os.IsNotExist(err) { return nil