fix(mcp/brain/client): enforce 0600 on ~/.claude/brain.key

Refuse to load brain.key when its mode is more permissive than 0600 —
NewFromEnvironment carries the config error into Call() so callers
get a clear "brain.key has insecure permissions, expected 0600"
rather than a silent credential leak. Read path stats first; does not
auto-chmod (would mask the misconfiguration).

Write path uses coreio.Local.WriteMode and follows up with explicit
os.Chmod 0600, correcting any pre-existing 0644 file on next write.

Tests: write overwrites 0644 → 0600; read of 0644 fixture errors and
leaves the mode untouched.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=998
This commit is contained in:
Snider 2026-04-25 17:58:13 +01:00
parent 8b48b33622
commit e2bc724bb4
2 changed files with 120 additions and 8 deletions

View file

@ -14,8 +14,10 @@ package client
import (
"context"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"sync"
"time"
@ -25,6 +27,7 @@ import (
const (
DefaultURL = "https://api.lthn.sh"
brainKeyFileMode = fs.FileMode(0o600)
defaultAgentID = "cladius"
defaultTimeout = 30 * time.Second
defaultMaxAttempts = 3
@ -64,6 +67,7 @@ type Client struct {
baseDelay time.Duration
maxResponseBytes int64
circuitBreaker *CircuitBreaker
configErr error
}
// RememberInput is the request body for POST /v1/brain/remember.
@ -180,12 +184,24 @@ func New(options Options) *Client {
// NewFromEnvironment reads CORE_BRAIN_* settings and ~/.claude/brain.key.
func NewFromEnvironment() *Client {
return New(Options{
apiKey, configErr := apiKeyFromEnvironment()
client := New(Options{
URL: envOr("CORE_BRAIN_URL", DefaultURL),
Key: apiKeyFromEnvironment(),
Key: apiKey,
Org: core.Env("CORE_BRAIN_ORG"),
AgentID: core.Env("CORE_BRAIN_AGENT_ID"),
})
client.configErr = configErr
return client
}
// WriteBrainKey stores the OpenBrain API key at ~/.claude/brain.key with owner-only permissions.
func WriteBrainKey(apiKey string) error {
home := core.Env("HOME")
if home == "" {
return core.E("brain.client", "HOME not set", nil)
}
return writeBrainKeyFile(brainKeyPath(home), apiKey)
}
// NewCircuitBreaker creates a circuit breaker with OpenBrain defaults.
@ -267,6 +283,9 @@ func (c *Client) List(ctx context.Context, input ListInput) (map[string]any, err
// Call performs one OpenBrain API request through retry and circuit-breaker policy.
func (c *Client) Call(ctx context.Context, method, path string, body any) (map[string]any, error) {
if c.configErr != nil {
return nil, c.configErr
}
if c.apiKey == "" {
return nil, core.E("brain.client", "no API key (set CORE_BRAIN_KEY or create ~/.claude/brain.key)", nil)
}
@ -485,16 +504,53 @@ func envOr(key, fallback string) string {
return fallback
}
func apiKeyFromEnvironment() string {
func apiKeyFromEnvironment() (string, error) {
if apiKey := core.Trim(core.Env("CORE_BRAIN_KEY")); apiKey != "" {
return apiKey
return apiKey, nil
}
home := core.Env("HOME")
if home == "" {
return ""
return "", nil
}
if data, err := coreio.Local.Read(core.JoinPath(home, ".claude", "brain.key")); err == nil {
return core.Trim(data)
apiKey, err := readBrainKeyFile(brainKeyPath(home))
if err != nil {
if core.Is(err, fs.ErrNotExist) {
return "", nil
}
return "", err
}
return ""
return apiKey, nil
}
func brainKeyPath(home string) string {
return core.JoinPath(home, ".claude", "brain.key")
}
func readBrainKeyFile(path string) (string, error) {
info, err := coreio.Local.Stat(path)
if err != nil {
return "", err
}
if brainKeyModeInsecure(info.Mode().Perm()) {
return "", core.E("brain.client", "brain.key has insecure permissions, expected 0600", nil)
}
data, err := coreio.Local.Read(path)
if err != nil {
return "", err
}
return core.Trim(data), nil
}
func writeBrainKeyFile(path, apiKey string) error {
if err := coreio.Local.WriteMode(path, core.Trim(apiKey)+"\n", brainKeyFileMode); err != nil {
return err
}
if err := os.Chmod(path, brainKeyFileMode); err != nil {
return core.E("brain.client", "chmod brain.key", err)
}
return nil
}
func brainKeyModeInsecure(mode fs.FileMode) bool {
return mode.Perm()&^brainKeyFileMode != 0
}

View file

@ -6,6 +6,9 @@ import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@ -193,6 +196,59 @@ func TestClientCall_Bad_ContextCancellation(t *testing.T) {
}
}
func TestWriteBrainKey_Good_Uses0600(t *testing.T) {
home := t.TempDir()
path := filepath.Join(home, ".claude", "brain.key")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("create fixture dir: %v", err)
}
if err := os.WriteFile(path, []byte("old-key\n"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
t.Setenv("HOME", home)
if err := WriteBrainKey("test-key"); err != nil {
t.Fatalf("WriteBrainKey failed: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat brain key: %v", err)
}
if got := info.Mode().Perm(); got != brainKeyFileMode {
t.Fatalf("expected brain.key mode %v, got %v", brainKeyFileMode, got)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read brain key: %v", err)
}
if got := string(data); got != "test-key\n" {
t.Fatalf("expected written key, got %q", got)
}
}
func TestBrainKeyFile_Bad_RejectsInsecurePermissions(t *testing.T) {
path := filepath.Join(t.TempDir(), "brain.key")
if err := os.WriteFile(path, []byte("test-key\n"), brainKeyFileMode); err != nil {
t.Fatalf("write fixture: %v", err)
}
if err := os.Chmod(path, 0o644); err != nil {
t.Fatalf("chmod fixture: %v", err)
}
if _, err := readBrainKeyFile(path); err == nil {
t.Fatal("expected insecure permissions error")
} else if !strings.Contains(err.Error(), "brain.key has insecure permissions, expected 0600") {
t.Fatalf("expected insecure permissions error, got %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat brain key: %v", err)
}
if got := info.Mode().Perm(); got != 0o644 {
t.Fatalf("read should not chmod brain.key, got mode %v", got)
}
}
func readRequestBody(t *testing.T, r *http.Request) map[string]any {
t.Helper()