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:
parent
8b48b33622
commit
e2bc724bb4
2 changed files with 120 additions and 8 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue