From 5c04da4c69557ee2a5d8e5c6d9df229fa327d84b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=BA?= Date: Mon, 18 May 2026 23:03:52 -0400 Subject: [PATCH 1/4] fix(mention): canonicalize @mention labels server-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mention markdown `[@Label](mention://type/UUID)` has two parts: the visible label and the routable UUID. Mention routing reads only the UUID, but the rendered comment shows only the label, so a label that disagrees with its UUID silently misroutes — the UI shows "@A" while agent B is triggered and replies. This already happened in the squad coordination flow (a leader's delegation paired one roster row's label with another row's UUID). Defend against it at write time, in the place every comment passes through: on `CreateComment` / `UpdateComment` and the agent task- completion comment path, rewrite each mention's label to the current name of the entity its UUID resolves to. - Agent / squad: if the UUID resolves in the workspace, replace the label with the entity's current name. If it doesn't resolve, keep the mention markdown as-is — `ParseMentions` will still find no live trigger target, and the existing top-level "mention only others" trigger-suppression keeps working. - Member: resolve via `GetMemberByUserAndWorkspace`, not `GetUser` — the latter is workspace-unscoped and would leak the name of any user in the system. If the user is not a member of this workspace, strip the mention entirely. - When canonicalization changes a label, log a warning with the original label, resolved entity name, and UUID. This gives a searchable signal for the prompt/UI bug that produced the mismatch in the first place. Care taken in the regex layer: - Issue-identifier expansion (`MUL-123` → markdown link) previously re-scanned inside mention labels, so an agent named "MUL-117 reviewer" would have its label rewritten into nested markdown. Split skip-region detection into `findSkipRegions` (code fences / inline code, shared with canonicalization) and `expandSkipRegions` (also covers existing link spans, used by issue expansion). - Canonical labels are markdown-escaped before being spliced back in, so a name containing `]` or `\` can't break the surrounding link. --- server/cmd/multica/cmd_issue.go | 6 +- server/internal/handler/comment.go | 44 +++ server/internal/mention/canonicalize.go | 198 ++++++++++++ server/internal/mention/canonicalize_test.go | 299 +++++++++++++++++++ server/internal/mention/expand.go | 37 ++- server/internal/mention/expand_test.go | 29 ++ server/internal/service/task.go | 7 + server/internal/util/mention.go | 132 +++++++- server/internal/util/mention_test.go | 49 +++ 9 files changed, 787 insertions(+), 14 deletions(-) create mode 100644 server/internal/mention/canonicalize.go create mode 100644 server/internal/mention/canonicalize_test.go diff --git a/server/cmd/multica/cmd_issue.go b/server/cmd/multica/cmd_issue.go index fc72c04b3f..6f7752f1f1 100644 --- a/server/cmd/multica/cmd_issue.go +++ b/server/cmd/multica/cmd_issue.go @@ -1640,10 +1640,10 @@ func resolveAssignee(ctx context.Context, client *cli.APIClient, name string, ki func normalizeAssigneeLookupInput(raw string) string { input := strings.TrimSpace(raw) - if m := util.MentionRe.FindStringSubmatch(input); len(m) == 4 && m[0] == input { - switch m[2] { + if matches := util.FindMentionMatches(input); len(matches) == 1 && matches[0].Start == 0 && matches[0].End == len(input) { + switch matches[0].Type { case "member", "agent", "squad": - return m[3] + return matches[0].ID } } input = strings.TrimLeftFunc(input, func(r rune) bool { diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 1626a2ed4a..2a280fe3b7 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http" "strconv" + "strings" "time" "github.com/go-chi/chi/v5" @@ -506,6 +507,15 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { } } + // Canonicalize agent/member/squad mention labels so the visible label + // always matches the entity the UUID actually resolves to. Without this, + // an author (typically an LLM) can post a comment whose mention link + // says "[@A](mention://agent/)" — the UI renders @A but the + // routing layer triggers B, so the wrong agent picks up the task while + // humans see the right name. Done before ExpandIssueIdentifiers so the + // issue-identifier pass operates on already-cleaned content. + req.Content = mention.CanonicalizeMentions(r.Context(), h.Queries, issue.WorkspaceID, req.Content) + // Expand bare issue identifiers (e.g. MUL-117) into mention links. req.Content = mention.ExpandIssueIdentifiers(r.Context(), h.Queries, issue.WorkspaceID, req.Content) @@ -735,6 +745,7 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue if err != nil { continue } + logMentionLabelMismatch(m, squad.Name, "squad", issue.ID, comment.ID) leaderID := squad.LeaderID // Prevent self-trigger only when the agent's last activity on this // issue was itself a leader task. An agent that holds both the @@ -790,6 +801,7 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid { continue } + logMentionLabelMismatch(m, agent.Name, "agent", issue.ID, comment.ID) // Private-agent gate (member→private requires allowed_principals; // agent→agent always passes). if !h.canAccessPrivateAgent(ctx, agent, authorType, authorID, wsID) { @@ -811,6 +823,33 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue } } +// logMentionLabelMismatch emits a warning when the visible mention label +// disagrees with the resolved entity's canonical name. Observability for +// the canonicalization defense in mention.CanonicalizeMentions — after that +// pass ran on write, this warn should be effectively silent in steady state. +// A non-zero rate means: historical rows that predate canonicalization, or +// some write path that bypasses it (e.g. a future endpoint that forgets to +// call CanonicalizeMentions). +func logMentionLabelMismatch(m util.Mention, canonicalName, kind string, issueID, commentID pgtype.UUID) { + if m.Label == "" || canonicalName == "" { + return + } + // Editor escapes `[` and `]` in labels for grammar; undo that for the + // equality check so a legitimately-bracketed name doesn't false-positive. + unescaped := strings.ReplaceAll(strings.ReplaceAll(m.Label, `\[`, `[`), `\]`, `]`) + if unescaped == canonicalName { + return + } + slog.Warn("mention label / UUID mismatch", + "kind", kind, + "id", m.ID, + "label", m.Label, + "resolved_name", canonicalName, + "issue_id", uuidToString(issueID), + "comment_id", uuidToString(commentID), + ) +} + func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) { commentId := chi.URLParam(r, "commentId") @@ -871,6 +910,11 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) { // NOTE: See CreateComment — Markdown is sanitized at render/edit time, not here. + // Canonicalize mention labels on edit too: an author editing their own + // comment can introduce the same label/UUID mismatch addressed in + // CreateComment, so we run the same defense before persisting. + req.Content = mention.CanonicalizeMentions(r.Context(), h.Queries, wsUUID, req.Content) + comment, err := h.Queries.UpdateComment(r.Context(), db.UpdateCommentParams{ ID: commentUUID, Content: req.Content, diff --git a/server/internal/mention/canonicalize.go b/server/internal/mention/canonicalize.go new file mode 100644 index 0000000000..dfebbe017c --- /dev/null +++ b/server/internal/mention/canonicalize.go @@ -0,0 +1,198 @@ +package mention + +import ( + "context" + "fmt" + "strings" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/multica-ai/multica/server/internal/util" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// NameResolver looks up the canonical display name for an entity referenced +// by a mention link. Implemented by *db.Queries in production. +// +// Note on member lookups: GetUser is workspace-unscoped (the user table has +// no workspace_id). For member mentions, callers must gate the user lookup +// behind GetMemberByUserAndWorkspace — otherwise a `mention://member/` +// for a globally-valid user who is not a workspace member would canonicalize +// to that outsider's name, leaking the name and leaving the link in place +// for downstream routing-style checks. +type NameResolver interface { + GetAgentInWorkspace(ctx context.Context, arg db.GetAgentInWorkspaceParams) (db.Agent, error) + GetSquadInWorkspace(ctx context.Context, arg db.GetSquadInWorkspaceParams) (db.Squad, error) + GetMemberByUserAndWorkspace(ctx context.Context, arg db.GetMemberByUserAndWorkspaceParams) (db.Member, error) + GetUser(ctx context.Context, id pgtype.UUID) (db.User, error) +} + +// lookupOutcome carries the decision lookupCanonicalName reached for a +// single mention. The three states distinguish "rewrite to canonical name" +// from "leave the link alone" from "demote to plain text" — collapsing the +// last two into a single boolean failure breaks the trigger-suppression +// invariant in commentMentionsOthersButNotAssignee, which relies on author +// intent surviving canonicalization for agent/squad mentions. +type lookupOutcome int + +const ( + // lookupResolved means the UUID resolves and the label should be rewritten. + lookupResolved lookupOutcome = iota + // lookupKeepAsIs means the UUID does not resolve, but the mention link + // should be preserved verbatim. Used for agent and squad mentions whose + // UUID is unknown / cross-workspace / deleted — the label was authored + // by whoever wrote the comment (not derived from a DB lookup), so there + // is no name-leak risk, and preserving the link keeps the author's + // routing intent visible to downstream gates. + lookupKeepAsIs + // lookupStrip means the link must be demoted to plain `@