Skip to content

Commit 9e3b426

Browse files
committed
fix(mcpinit): atomic settings write, allowlist sanitizer, hook consistency
- settings.go: replace os.WriteFile with temp file + os.Rename for POSIX atomicity; make backup failure a hard error instead of silent - init.go: switch sanitizeName from denylist (strip backticks/newlines) to allowlist [a-zA-Z0-9 -_.] to prevent prompt injection via project names interpolated into MEMORY.md - hook.go: add ghost_list_projects as step 1 in SessionStart hook output, matching the MEMORY.md redirect instructions
1 parent c993e40 commit 9e3b426

4 files changed

Lines changed: 47 additions & 16 deletions

File tree

internal/mcpinit/hook.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
func HandleSessionStartHook(stdin io.Reader, stdout io.Writer) {
1515
_, _ = io.Copy(io.Discard, stdin) // drain stdin
1616
fmt.Fprintln(stdout, "Ghost memory is active. Before starting work:")
17-
fmt.Fprintln(stdout, "1. Call ghost_project_context with the project name")
18-
fmt.Fprintln(stdout, "2. Save discoveries with ghost_memory_save during work")
17+
fmt.Fprintln(stdout, "1. Call ghost_list_projects to discover known projects")
18+
fmt.Fprintln(stdout, "2. Call ghost_project_context with the project name")
19+
fmt.Fprintln(stdout, "3. Save discoveries with ghost_memory_save during work")
1920
}

internal/mcpinit/init.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -236,20 +236,23 @@ func importMemories(w io.Writer) ([]projectInfo, error) {
236236
return infos, nil
237237
}
238238

239-
// sanitizeName strips characters that could inject markdown directives or
240-
// newlines into MEMORY.md files loaded by Claude Code.
239+
// sanitizeName allowlists safe characters for project names interpolated into
240+
// MEMORY.md files that Claude Code auto-loads (prevents prompt injection).
241241
func sanitizeName(name string) string {
242242
var sb strings.Builder
243243
for _, r := range name {
244-
if r == '\n' || r == '\r' || r == '`' {
245-
continue
244+
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
245+
(r >= '0' && r <= '9') || r == '-' || r == '_' || r == ' ' || r == '.' {
246+
sb.WriteRune(r)
246247
}
247-
sb.WriteRune(r)
248248
}
249249
s := sb.String()
250250
if len(s) > 64 {
251251
s = s[:64]
252252
}
253+
if s == "" {
254+
s = "unknown"
255+
}
253256
return s
254257
}
255258

internal/mcpinit/init_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ func TestHandleSessionStartHook(t *testing.T) {
1313
HandleSessionStartHook(strings.NewReader(`{"event":"SessionStart"}`), &out)
1414

1515
output := out.String()
16+
if !strings.Contains(output, "ghost_list_projects") {
17+
t.Error("hook output should mention ghost_list_projects")
18+
}
1619
if !strings.Contains(output, "ghost_project_context") {
1720
t.Error("hook output should mention ghost_project_context")
1821
}

internal/mcpinit/settings.go

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,17 @@ func loadSettings(path string) (*settingsFile, error) {
7575
return s, nil
7676
}
7777

78-
// save writes the settings back to disk, creating a .bak backup first.
78+
// save writes the settings back to disk atomically, creating a .bak backup first.
7979
func (s *settingsFile) save() error {
80+
dir := filepath.Dir(s.path)
81+
if err := os.MkdirAll(dir, 0755); err != nil {
82+
return fmt.Errorf("create dir %s: %w", dir, err)
83+
}
84+
8085
// Backup existing file.
81-
if _, err := os.Stat(s.path); err == nil {
82-
data, err := os.ReadFile(s.path)
83-
if err == nil {
84-
_ = os.WriteFile(s.path+".bak", data, 0600)
86+
if data, err := os.ReadFile(s.path); err == nil {
87+
if err := os.WriteFile(s.path+".bak", data, 0600); err != nil {
88+
return fmt.Errorf("backup %s: %w", s.path+".bak", err)
8589
}
8690
}
8791

@@ -91,11 +95,31 @@ func (s *settingsFile) save() error {
9195
}
9296
out = append(out, '\n')
9397

94-
dir := filepath.Dir(s.path)
95-
if err := os.MkdirAll(dir, 0755); err != nil {
96-
return fmt.Errorf("create dir %s: %w", dir, err)
98+
// Atomic write: temp file + rename.
99+
tmp, err := os.CreateTemp(dir, ".settings-*.json")
100+
if err != nil {
101+
return fmt.Errorf("create temp file: %w", err)
102+
}
103+
tmpPath := tmp.Name()
104+
105+
if _, err := tmp.Write(out); err != nil {
106+
tmp.Close()
107+
os.Remove(tmpPath)
108+
return fmt.Errorf("write temp file: %w", err)
109+
}
110+
if err := tmp.Close(); err != nil {
111+
os.Remove(tmpPath)
112+
return fmt.Errorf("close temp file: %w", err)
97113
}
98-
return os.WriteFile(s.path, out, 0600)
114+
if err := os.Chmod(tmpPath, 0600); err != nil {
115+
os.Remove(tmpPath)
116+
return fmt.Errorf("chmod temp file: %w", err)
117+
}
118+
if err := os.Rename(tmpPath, s.path); err != nil {
119+
os.Remove(tmpPath)
120+
return fmt.Errorf("rename temp file: %w", err)
121+
}
122+
return nil
99123
}
100124

101125
// getPermissions extracts the permissions.allow string slice.

0 commit comments

Comments
 (0)