Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ I will acknowledge the report as soon as possible and aim to provide an initial

- Credentials/tokens are stored locally in `~/.martmart-cli/frisco-session.json` for Frisco and `~/.martmart-cli/delio-session.json` for Delio (with legacy read fallback from older `session.json` locations).
- Access to that file effectively grants API access in the user context.
- Files in `~/.martmart-cli/` (session files and `config.json`) are written with `0600` permissions on every save so only the owning user can read them; any pre-existing file with wider permissions is narrowed back to `0600`.
- `martmart mcp` uses the same local session; run it only in trusted local environments.
9 changes: 8 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ func Normalize(cfg *Config) (*Config, error) {
}

// Save persists the config file with 0600 permissions.
//
// os.WriteFile only applies the mode when creating a new file, so we also
// call os.Chmod afterwards to narrow permissions on pre-existing files that
// may have been written with wider modes by older versions.
func Save(cfg *Config) error {
norm, err := Normalize(cfg)
if err != nil {
Expand All @@ -134,5 +138,8 @@ func Save(cfg *Config) error {
if err != nil {
return err
}
return os.WriteFile(configFile, data, 0o600)
if err := os.WriteFile(configFile, data, 0o600); err != nil {
return err
}
return os.Chmod(configFile, 0o600)
}
34 changes: 34 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,37 @@ func TestSave_WritesCurrentConfigPath(t *testing.T) {
t.Fatalf("legacy config should not be written, stat err=%v", err)
}
}

func TestSave_EnforcesFileMode0600(t *testing.T) {
base := t.TempDir()
current := filepath.Join(base, "martmart-cli", "config.json")
legacy := filepath.Join(base, "frisco-cli", "config.json")
setTempConfigPaths(t, current, legacy)

// Fresh write creates the file with 0600.
if err := Save(&Config{DefaultProvider: "frisco", RateLimitRPS: 1, RateLimitBurst: 1}); err != nil {
t.Fatalf("Save (fresh): %v", err)
}
fi, err := os.Stat(current)
if err != nil {
t.Fatalf("stat after fresh Save: %v", err)
}
if got := fi.Mode().Perm(); got != 0o600 {
t.Errorf("fresh file mode: got %o, want 600", got)
}

// Pre-existing file with wider permissions must be narrowed back to 0600.
if err := os.Chmod(current, 0o644); err != nil {
t.Fatalf("Chmod 0644: %v", err)
}
if err := Save(&Config{DefaultProvider: "frisco", RateLimitRPS: 2, RateLimitBurst: 2}); err != nil {
t.Fatalf("Save (overwrite): %v", err)
}
fi, err = os.Stat(current)
if err != nil {
t.Fatalf("stat after overwrite Save: %v", err)
}
if got := fi.Mode().Perm(); got != 0o600 {
t.Errorf("overwrite file mode: got %o, want 600", got)
}
}
10 changes: 9 additions & 1 deletion internal/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ func Load() (*Session, error) {
}

// SaveProvider persists s to the provider session file with 0600 permissions.
//
// os.WriteFile only applies the mode when creating a new file, so we also
// call os.Chmod afterwards to narrow permissions on pre-existing files that
// may have been written with wider modes by older versions.
func SaveProvider(provider string, s *Session) error {
provider = NormalizeProvider(provider)
if provider == "" {
Expand All @@ -226,7 +230,11 @@ func SaveProvider(provider string, s *Session) error {
if err != nil {
return err
}
return os.WriteFile(SessionFilePath(provider), data, 0o600)
path := SessionFilePath(provider)
if err := os.WriteFile(path, data, 0o600); err != nil {
return err
}
return os.Chmod(path, 0o600)
}

// Save persists s to the active provider's session file with 0600 permissions.
Expand Down
44 changes: 44 additions & 0 deletions internal/session/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,50 @@ func TestLoadProvider_FallsBackToCurrentLegacyFilename(t *testing.T) {
}
}

func TestSaveProvider_EnforcesFileMode0600(t *testing.T) {
for _, provider := range []string{ProviderFrisco, ProviderDelio} {
t.Run(provider, func(t *testing.T) {
dir := t.TempDir()
setTempSession(t, dir)

s := &Session{
BaseURL: DefaultBaseURLForProvider(provider),
Token: "tok_" + provider,
Headers: map[string]string{},
}

// Fresh write creates the file with 0600.
if err := SaveProvider(provider, s); err != nil {
t.Fatalf("SaveProvider (fresh): %v", err)
}
path := SessionFilePath(provider)
fi, err := os.Stat(path)
if err != nil {
t.Fatalf("stat after fresh SaveProvider: %v", err)
}
if got := fi.Mode().Perm(); got != 0o600 {
t.Errorf("fresh file mode: got %o, want 600", got)
}

// Pre-existing file with wider permissions must be narrowed to 0600.
if err := os.Chmod(path, 0o644); err != nil {
t.Fatalf("Chmod 0644: %v", err)
}
s.Token = "tok_" + provider + "_v2"
if err := SaveProvider(provider, s); err != nil {
t.Fatalf("SaveProvider (overwrite): %v", err)
}
fi, err = os.Stat(path)
if err != nil {
t.Fatalf("stat after overwrite SaveProvider: %v", err)
}
if got := fi.Mode().Perm(); got != 0o600 {
t.Errorf("overwrite file mode: got %o, want 600", got)
}
})
}
}

func TestLoadProvider_FallsBackToLegacyDir(t *testing.T) {
base := t.TempDir()
newDir := filepath.Join(base, "martmart-cli")
Expand Down
Loading