Skip to content

Commit 5d530b0

Browse files
fix: security + quality audit — 9 fixes for open-source readiness
Security: - Add chat ID validation to callback handler (H3) — prevents unauthorized users from approving permissions - Replace time-based message IDs with atomic counter (L8) — prevents millisecond collisions under concurrent access Bug fix: - Fix FormatToolName MCP parsing (H1) — extract service name from namespace segment (e.g., "claude_ai_Slack" → "Slack"). Slack/Notion icons now match correctly Code quality: - Replace interface{} with any (Go 1.18+ canonical alias) - Remove redundant nil checks in cmdPlan - Replace custom contains() test helper with strings.Contains - Update .gitignore: add .claude/, .DS_Store, organize sections - Fix CLAUDE.md/README MAX_RETRY_COUNT default (2 → 3, matches code) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c5375aa commit 5d530b0

8 files changed

Lines changed: 62 additions & 40 deletions

File tree

.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
1+
# Secrets
12
.env
3+
4+
# Runtime data
25
bot.log
36
bot.pid
47
*.lock
58
inbox.json
69
outbox.json
710
projects.json
11+
12+
# Build output
813
/pocket-claude
914

15+
# Claude Code session data
16+
.claude/
17+
18+
# macOS
19+
.DS_Store
20+
1021
# Skill eval test (isolated experiment - no impact on project)
1122
skill-eval-test/

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ Each project gets its own `Executor` with independent session, workDir, and addD
116116
| `INBOX_PATH` | `./inbox.json` | Incoming messages |
117117
| `OUTBOX_PATH` | `./outbox.json` | Outgoing results |
118118
| `LOCK_TIMEOUT_MINUTES` | `5` | Stale lock threshold |
119-
| `MAX_RETRY_COUNT` | `2` | Error retry limit |
119+
| `MAX_RETRY_COUNT` | `3` | Error retry limit |
120120
| `OUTBOX_POLL_INTERVAL_SECONDS` | `10` | Outbox poll interval |
121121
| `LOG_FILE` | `./bot.log` | Log file path |
122122
| `MESSAGE_TTL_MINUTES` | `10` | Message expiry time |

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ cp .env.example .env
100100
| `INBOX_PATH` | `./inbox.json` | Incoming message store |
101101
| `OUTBOX_PATH` | `./outbox.json` | Outgoing result store |
102102
| `LOCK_TIMEOUT_MINUTES` | `5` | Stale lock detection threshold |
103-
| `MAX_RETRY_COUNT` | `2` | Max retries for failed messages |
103+
| `MAX_RETRY_COUNT` | `3` | Max retries for failed messages |
104104
| `OUTBOX_POLL_INTERVAL_SECONDS` | `10` | Outbox polling interval |
105105
| `LOG_FILE` | `./bot.log` | Log file path |
106106
| `MESSAGE_TTL_MINUTES` | `10` | Auto-expire messages older than this |

internal/bot/bot.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"log/slog"
77
"strings"
88
"sync"
9+
"sync/atomic"
910
"time"
1011

1112
"github.com/GrapeInTheTree/pocket-claude/internal/config"
@@ -17,6 +18,13 @@ import (
1718
// maxConcurrentCallbacks limits goroutine spawning for callback handlers.
1819
const maxConcurrentCallbacks = 10
1920

21+
// msgCounter provides unique message IDs without millisecond collisions.
22+
var msgCounter atomic.Int64
23+
24+
func init() {
25+
msgCounter.Store(time.Now().UnixMilli())
26+
}
27+
2028
type Bot struct {
2129
api *tgbotapi.BotAPI
2230
cfg config.Config
@@ -263,7 +271,7 @@ func (b *Bot) handleMessage(msg *tgbotapi.Message) {
263271
}
264272

265273
inboxMsg := store.InboxMessage{
266-
ID: fmt.Sprintf("msg_%d", time.Now().UnixMilli()),
274+
ID: fmt.Sprintf("msg_%d", msgCounter.Add(1)),
267275
Text: text,
268276
Status: store.StatusPending,
269277
Timestamp: time.Now().UTC().Format(time.RFC3339),
@@ -283,6 +291,11 @@ func (b *Bot) handleMessage(msg *tgbotapi.Message) {
283291
}
284292

285293
func (b *Bot) handleCallback(cq *tgbotapi.CallbackQuery) {
294+
// Security: verify callback is from authorized chat
295+
if cq.Message != nil && cq.Message.Chat.ID != b.cfg.TelegramChatID {
296+
return
297+
}
298+
286299
data := cq.Data
287300
parts := strings.SplitN(data, ":", 2)
288301
if len(parts) != 2 {

internal/bot/commands.go

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ func (b *Bot) cmdBtw(msg *tgbotapi.Message) {
111111
}
112112

113113
inboxMsg := store.InboxMessage{
114-
ID: fmt.Sprintf("msg_%d", time.Now().UnixMilli()),
114+
ID: fmt.Sprintf("msg_%d", msgCounter.Add(1)),
115115
Text: "[BTW context note, just acknowledge briefly] " + text,
116116
Status: store.StatusPending,
117117
Timestamp: time.Now().UTC().Format(time.RFC3339),
@@ -551,7 +551,7 @@ func (b *Bot) cmdResearch(msg *tgbotapi.Message) {
551551
btwText := fmt.Sprintf("[Background task %s result from project %q] %s", taskID, projectName, injected)
552552

553553
inboxMsg := store.InboxMessage{
554-
ID: fmt.Sprintf("msg_%d", time.Now().UnixMilli()),
554+
ID: fmt.Sprintf("msg_%d", msgCounter.Add(1)),
555555
Text: "[Injected context — do NOT respond to this, just silently remember it for future reference] " + btwText,
556556
Status: store.StatusPending,
557557
Timestamp: time.Now().UTC().Format(time.RFC3339),
@@ -718,18 +718,15 @@ func (b *Bot) cmdPlan(msg *tgbotapi.Message) {
718718
return
719719
}
720720

721-
var projectName string
722-
if b.worker != nil {
723-
projectName = b.worker.ActiveProject()
724-
}
721+
projectName := b.worker.ActiveProject()
725722

726723
planPrompt := "[Plan mode: Create a detailed implementation plan for the following task. " +
727724
"Analyze the codebase and outline specific steps with file paths. " +
728725
"Do NOT execute anything yet — only plan. " +
729726
"Wait for my approval before making any changes.] " + text
730727

731728
inboxMsg := store.InboxMessage{
732-
ID: fmt.Sprintf("msg_%d", time.Now().UnixMilli()),
729+
ID: fmt.Sprintf("msg_%d", msgCounter.Add(1)),
733730
Text: planPrompt,
734731
Status: store.StatusPending,
735732
Timestamp: time.Now().UTC().Format(time.RFC3339),
@@ -742,7 +739,5 @@ func (b *Bot) cmdPlan(msg *tgbotapi.Message) {
742739
return
743740
}
744741

745-
if b.worker != nil {
746-
b.worker.Enqueue(inboxMsg)
747-
}
742+
b.worker.Enqueue(inboxMsg)
748743
}

internal/store/models.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,6 @@ type CLIResult struct {
7474
}
7575

7676
type PermissionDenial struct {
77-
ToolName string `json:"tool_name"`
78-
ToolInput map[string]interface{} `json:"tool_input,omitempty"`
77+
ToolName string `json:"tool_name"`
78+
ToolInput map[string]any `json:"tool_input,omitempty"`
7979
}

internal/worker/approval.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,26 @@ func extractToolDetail(d store.PermissionDenial) string {
135135
}
136136

137137
// FormatToolName converts internal tool names to readable labels.
138+
// MCP tool names follow the pattern: mcp__<namespace>__<action>
139+
// e.g., mcp__claude_ai_Slack__slack_send_message → 💬 Slack → Send Message
138140
func FormatToolName(raw string) string {
139141
if strings.HasPrefix(raw, "mcp__") {
140142
parts := strings.Split(raw, "__")
141143
if len(parts) >= 3 {
142-
service := parts[2]
144+
// Extract service name from namespace (e.g., "claude_ai_Slack" → "Slack")
145+
namespace := parts[1]
146+
service := namespace
147+
if idx := strings.LastIndex(namespace, "_"); idx >= 0 {
148+
service = namespace[idx+1:]
149+
}
150+
151+
// Extract and format action (e.g., "slack_send_message" → "Send Message")
143152
action := parts[len(parts)-1]
153+
// Remove service prefix from action if present (e.g., "slack_send_message" → "send_message")
154+
serviceLower := strings.ToLower(service)
155+
if strings.HasPrefix(action, serviceLower+"_") {
156+
action = action[len(serviceLower)+1:]
157+
}
144158
action = strings.ReplaceAll(action, "_", " ")
145159
words := strings.Fields(action)
146160
for i, w := range words {

internal/worker/approval_test.go

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package worker
22

33
import (
4+
"strings"
45
"testing"
56

67
"github.com/GrapeInTheTree/pocket-claude/internal/store"
@@ -84,14 +85,11 @@ func TestFormatToolName(t *testing.T) {
8485
{"Edit", "Edit", "✏️ File Edit"},
8586
{"Read", "Read", "📖 File Read"},
8687
{"unknown tool", "CustomTool", "🔧 CustomTool"},
87-
// mcp__claude_ai_Slack__slack_send_message splits on "__" to:
88-
// ["mcp", "claude_ai_Slack", "slack_send_message"]
89-
// service=parts[2]="slack_send_message", action=last="slack_send_message"
90-
{"MCP Slack", "mcp__claude_ai_Slack__slack_send_message", "🔌 slack_send_message → Slack Send Message"},
91-
{"MCP Notion", "mcp__claude_ai_Notion__notion_search", "🔌 notion_search → Notion Search"},
92-
// mcp__foo__bar__do_thing splits to ["mcp", "foo", "bar", "do_thing"]
93-
// service=parts[2]="bar", action=last="do_thing"
94-
{"MCP unknown service", "mcp__foo__bar__do_thing", "🔌 bar → Do Thing"},
88+
// mcp__claude_ai_Slack__slack_send_message → service="Slack", action="Send Message"
89+
{"MCP Slack", "mcp__claude_ai_Slack__slack_send_message", "💬 Slack → Send Message"},
90+
{"MCP Notion", "mcp__claude_ai_Notion__notion_search", "📝 Notion → Search"},
91+
// mcp__foo__bar__do_thing → namespace="foo", service="foo", action="do_thing"
92+
{"MCP unknown service", "mcp__foo__bar__do_thing", "🔌 foo → Do Thing"},
9593
}
9694

9795
for _, tt := range tests {
@@ -109,11 +107,11 @@ func TestBuildPermissionMessage(t *testing.T) {
109107
PermissionDenials: []store.PermissionDenial{
110108
{
111109
ToolName: "Bash",
112-
ToolInput: map[string]interface{}{"command": "rm -rf /tmp/test"},
110+
ToolInput: map[string]any{"command": "rm -rf /tmp/test"},
113111
},
114112
{
115113
ToolName: "Write",
116-
ToolInput: map[string]interface{}{"file_path": "/home/user/file.txt"},
114+
ToolInput: map[string]any{"file_path": "/home/user/file.txt"},
117115
},
118116
},
119117
Result: "I need to run a command",
@@ -142,10 +140,10 @@ func TestBuildPermissionMessageDedup(t *testing.T) {
142140
// Multiple denials for the same tool should be grouped
143141
result := &store.CLIResult{
144142
PermissionDenials: []store.PermissionDenial{
145-
{ToolName: "Bash", ToolInput: map[string]interface{}{"command": "ls"}},
146-
{ToolName: "Bash", ToolInput: map[string]interface{}{"command": "pwd"}},
147-
{ToolName: "Bash", ToolInput: map[string]interface{}{"command": "cat file"}},
148-
{ToolName: "Bash", ToolInput: map[string]interface{}{"command": "extra"}}, // 4th, should be capped at 3
143+
{ToolName: "Bash", ToolInput: map[string]any{"command": "ls"}},
144+
{ToolName: "Bash", ToolInput: map[string]any{"command": "pwd"}},
145+
{ToolName: "Bash", ToolInput: map[string]any{"command": "cat file"}},
146+
{ToolName: "Bash", ToolInput: map[string]any{"command": "extra"}}, // 4th, should be capped at 3
149147
},
150148
}
151149

@@ -172,14 +170,5 @@ func TestSanitizeUTF8(t *testing.T) {
172170
}
173171

174172
func contains(s, substr string) bool {
175-
return len(s) >= len(substr) && containsSubstr(s, substr)
176-
}
177-
178-
func containsSubstr(s, substr string) bool {
179-
for i := 0; i <= len(s)-len(substr); i++ {
180-
if s[i:i+len(substr)] == substr {
181-
return true
182-
}
183-
}
184-
return false
173+
return strings.Contains(s, substr)
185174
}

0 commit comments

Comments
 (0)