fix: prevent internal prompts and duplicates in memory database

- Add server-side detection of SDK processor's internal system prompt
  in handleSessionInit, since CLAUDE_MNEMONIC_INTERNAL env var is not
  propagated by Claude Code to hook subprocesses
- Add cross-session duplicate detection (FindRecentPromptByTextGlobal)
  to catch same prompt text arriving from different session IDs
- Add hooks, mcpServers, and commands references to plugin.json per
  Claude Code plugin spec
- Remove MCP server injection from register-plugin.sh (now in plugin.json)
- Use ${CLAUDE_PLUGIN_ROOT} for statusline path instead of hardcoded path
- Add python3 fallback for plugin registration when jq is unavailable
- Replace hardcoded 1.0.0 version in findWorkerBinary with glob lookup
- Add cache copy verification in register-plugin.sh
- Add update-version Makefile target to keep metadata in sync
This commit is contained in:
2026-03-07 01:27:25 +00:00
parent 49e7efd27d
commit 7b979a3f95
11 changed files with 261 additions and 60 deletions
+22
View File
@@ -264,6 +264,28 @@ func (s *PromptStore) FindRecentPromptByText(ctx context.Context, claudeSessionI
return prompt.ID, prompt.PromptNumber, true
}
// FindRecentPromptByTextGlobal finds a recent prompt by exact text match within a time window
// across ALL sessions (not scoped to a single claude session ID).
// This catches duplicates when the same prompt arrives from different session IDs,
// e.g. when the SDK processor's callClaudeCLI subprocess fires with a different session ID.
// Returns (promptID, promptNumber, found).
func (s *PromptStore) FindRecentPromptByTextGlobal(ctx context.Context, promptText string, withinSeconds int) (int64, int, bool) {
cutoffEpoch := time.Now().Add(-time.Duration(withinSeconds) * time.Second).UnixMilli()
var prompt UserPrompt
err := s.db.WithContext(ctx).
Where("prompt_text = ? AND created_at_epoch >= ?",
promptText, cutoffEpoch).
Order("created_at_epoch DESC").
First(&prompt).Error
if err != nil {
return 0, 0, false
}
return prompt.ID, prompt.PromptNumber, true
}
// GetRecentUserPromptsByProject retrieves recent user prompts for a specific project.
func (s *PromptStore) GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*models.UserPromptWithSession, error) {
var results []struct {
+33
View File
@@ -333,6 +333,39 @@ func TestPromptStore_FindRecentPromptByText(t *testing.T) {
assert.False(t, notFound, "Should not find prompt outside time window")
}
func TestPromptStore_FindRecentPromptByTextGlobal(t *testing.T) {
promptStore, _, cleanup := testPromptStore(t)
defer cleanup()
ctx := context.Background()
// Save a prompt under session "claude-1"
id, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "What is the architecture?", 3)
require.NoError(t, err)
// Global search should find it without specifying session ID
foundID, foundNumber, found := promptStore.FindRecentPromptByTextGlobal(ctx, "What is the architecture?", 60)
assert.True(t, found, "Should find the prompt globally")
assert.Equal(t, id, foundID)
assert.Equal(t, 1, foundNumber)
// Global search should find it even when a different session ID would have been used
// (This is the core cross-session dedup scenario)
foundID2, foundNumber2, found2 := promptStore.FindRecentPromptByTextGlobal(ctx, "What is the architecture?", 60)
assert.True(t, found2, "Should find the prompt from any session")
assert.Equal(t, id, foundID2)
assert.Equal(t, 1, foundNumber2)
// Different text should not match
_, _, notFound := promptStore.FindRecentPromptByTextGlobal(ctx, "Different text", 60)
assert.False(t, notFound, "Should not find a different prompt")
// Should not find prompt outside time window
time.Sleep(100 * time.Millisecond)
_, _, notFound = promptStore.FindRecentPromptByTextGlobal(ctx, "What is the architecture?", 0)
assert.False(t, notFound, "Should not find prompt outside time window")
}
func TestPromptStore_GetRecentUserPromptsByProject(t *testing.T) {
promptStore, store, cleanup := testPromptStore(t)
defer cleanup()
+1
View File
@@ -57,6 +57,7 @@ type PromptReader interface {
GetAllPrompts(ctx context.Context) ([]*models.UserPromptWithSession, error)
GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*models.UserPromptWithSession, error)
FindRecentPromptByText(ctx context.Context, claudeSessionID, promptText string, withinSeconds int) (int64, int, bool)
FindRecentPromptByTextGlobal(ctx context.Context, promptText string, withinSeconds int) (int64, int, bool)
}
// PromptWriter defines write operations for prompts.
+48
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -44,7 +45,30 @@ func (s *Service) handleSessionInit(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Reject requests from internal CLI calls (callClaudeCLI runs from /tmp with no session ID).
// These are the memory extraction agent's own prompts, not real user prompts.
if req.ClaudeSessionID == "" {
log.Debug().Str("project", req.Project).Msg("Rejecting session init with empty claude session ID (internal call)")
writeJSON(w, SessionInitResponse{
Skipped: true,
Reason: "internal",
})
return
}
// Reject prompts that contain the internal system prompt (from SDK processor).
// The SDK processor sets CLAUDE_MNEMONIC_INTERNAL=1 on the subprocess env,
// but Claude Code does NOT propagate custom env vars to hook subprocesses,
// so that guard fails and the system prompt leaks into the database as a
// regular user prompt. This server-side check catches those cases reliably.
if strings.Contains(req.Prompt, "You are a memory extraction agent for Claude Code sessions") {
log.Debug().Str("project", req.Project).Msg("Rejecting session init with internal system prompt")
writeJSON(w, SessionInitResponse{
Skipped: true,
Reason: "internal",
})
return
}
// Privacy check
if privacy.IsEntirelyPrivate(req.Prompt) {
// Create session but skip processing
@@ -84,6 +108,24 @@ func (s *Service) handleSessionInit(w http.ResponseWriter, r *http.Request) {
return
}
// CROSS-SESSION DUPLICATE DETECTION: Same prompt text from ANY session within the window.
// This catches duplicates when the SDK processor's callClaudeCLI subprocess fires
// UserPromptSubmit with a different Claude session ID than the original hook.
if _, existingNum, found := s.promptStore.FindRecentPromptByTextGlobal(r.Context(), cleanedPrompt, DuplicatePromptWindowSeconds); found {
sessionID, _ := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, cleanedPrompt)
log.Debug().
Int64("sessionId", sessionID).
Int("promptNumber", existingNum).
Msg("Cross-session duplicate prompt detected")
writeJSON(w, SessionInitResponse{
SessionDBID: sessionID,
PromptNumber: existingNum,
})
return
}
// Create session (idempotent)
sessionID, err := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, cleanedPrompt)
if err != nil {
@@ -210,6 +252,12 @@ func (s *Service) handleObservation(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Reject requests from internal CLI calls (no session ID = internal callClaudeCLI)
if req.ClaudeSessionID == "" {
w.WriteHeader(http.StatusOK)
return
}
// Find session
sess, err := s.sessionStore.FindAnySDKSession(r.Context(), req.ClaudeSessionID)
+6 -1
View File
@@ -696,7 +696,12 @@ func (p *Processor) callClaudeCLI(ctx context.Context, prompt string) (string, e
// (hooks are triggered based on working directory)
cmd.Dir = "/tmp"
// Disable any plugin hooks by setting an env var that our hooks can check
// Disable any plugin hooks by setting an env var that our hooks can check.
// NOTE: This env var is NOT reliably propagated to hook subprocesses because
// Claude Code constructs its own environment for hooks. The server-side check
// in handleSessionInit (handlers_sessions.go) provides the reliable guard by
// detecting the system prompt signature in the prompt text. This env var is
// kept as a best-effort defense-in-depth measure for cases where it does work.
cmd.Env = append(os.Environ(), "CLAUDE_MNEMONIC_INTERNAL=1")
// Capture output with size limits to prevent unbounded memory usage