diff --git a/pkg/mcp/brain/client/client.go b/pkg/mcp/brain/client/client.go index d9038e0..aff3e60 100644 --- a/pkg/mcp/brain/client/client.go +++ b/pkg/mcp/brain/client/client.go @@ -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 } diff --git a/pkg/mcp/brain/client/client_test.go b/pkg/mcp/brain/client/client_test.go index d196477..9a11da3 100644 --- a/pkg/mcp/brain/client/client_test.go +++ b/pkg/mcp/brain/client/client_test.go @@ -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()