diff --git a/packages/views/editor/extensions/mention-extension.test.ts b/packages/views/editor/extensions/mention-extension.test.ts index 15542752c2..60d0e69de2 100644 --- a/packages/views/editor/extensions/mention-extension.test.ts +++ b/packages/views/editor/extensions/mention-extension.test.ts @@ -81,4 +81,45 @@ describe("mention tokenizer", () => { expect(token!.attributes.label).toBe("MUL-123"); expect(token!.attributes.type).toBe("issue"); }); + + it("round-trips a label containing a literal backslash", () => { + // renderMarkdown must escape the literal backslash so the scanner + // doesn't eat the next char; tokenizer unescapes after brackets. + const md = renderMarkdown({ + attrs: { id: "ops-1", label: "Ops\\Bot", type: "agent" }, + }); + expect(md).toBe("[@Ops\\\\Bot](mention://agent/ops-1)"); + + const token = tokenize(md); + expect(token).toBeDefined(); + expect(token!.attributes.label).toBe("Ops\\Bot"); + }); + + it("rejects an empty mention label", () => { + // `[](mention://all/all)` is invisible markdown — the regex's `+` + // already requires one char, so the start search returns -1. + expect(startFn("[](mention://all/all)")).toBe(-1); + }); + + it("rejects a bare-@ mention label", () => { + // `[@](mention://all/all)` matches via regex backtracking (`@?` → empty, + // label captures `@`). Tokenize must reject it so a re-render doesn't + // emit `[@@](...)` and slip past the backend's non-empty guard. + const token = tokenize("[@](mention://all/all)"); + expect(token).toBeUndefined(); + }); + + it("round-trips a label with a backslash adjacent to a bracket", () => { + // `\[` in the name means literal `\` then literal `[` — order of + // escapes matters (backslash first) so the producer emits + // `\\\[`, which the consumer unescapes back to `\[`. + const md = renderMarkdown({ + attrs: { id: "x", label: "foo\\[bar", type: "agent" }, + }); + expect(md).toBe("[@foo\\\\\\[bar](mention://agent/x)"); + + const token = tokenize(md); + expect(token).toBeDefined(); + expect(token!.attributes.label).toBe("foo\\[bar"); + }); }); diff --git a/packages/views/editor/extensions/mention-extension.ts b/packages/views/editor/extensions/mention-extension.ts index 380b0be930..eabb1f8cbb 100644 --- a/packages/views/editor/extensions/mention-extension.ts +++ b/packages/views/editor/extensions/mention-extension.ts @@ -52,8 +52,22 @@ export const BaseMentionExtension = Mention.extend({ /^\[@?((?:\\.|[^\]])+)\]\(mention:\/\/(\w+)\/([^)]+)\)/, ); if (!match) return undefined; - // Unescape backslash-escaped brackets that renderMarkdown may produce. - const rawLabel = match[1]?.replace(/\\\[/g, "[").replace(/\\\]/g, "]"); + // Unescape backslash sequences produced by renderMarkdown / the Go + // backend's util.EscapeMentionLabel. `\\` must be unescaped LAST so + // it doesn't eat a backslash that's part of a `\[` / `\]` pair. + const rawLabel = match[1] + ?.replace(/\\\[/g, "[") + .replace(/\\\]/g, "]") + .replace(/\\\\/g, "\\"); + // Mirror util.parseMentionAt's empty-label guard: a `[@](...)` payload + // backtracks into the label group (regex `@?` matches empty, `@` + // becomes the single captured char). Without this check tokenize would + // accept it, renderMarkdown would emit `[@@](...)`, and the backend's + // non-empty check would no longer reject it — a back door to @all. + const labelAfterAt = rawLabel?.startsWith("@") + ? rawLabel.slice(1) + : rawLabel; + if (!labelAfterAt) return undefined; return { type: "mention", raw: match[0], @@ -67,9 +81,15 @@ export const BaseMentionExtension = Mention.extend({ renderMarkdown: (node: any) => { const { id, label, type = "member" } = node.attrs || {}; const prefix = type === "issue" ? "" : "@"; - // Escape square brackets in the label so the markdown link syntax - // is not broken when the name contains [ or ] (e.g. "David[TF]"). - const safeLabel = (label ?? id).replace(/\[/g, "\\[").replace(/\]/g, "\\]"); + // Escape `\`, `[`, `]` in the label so the markdown link syntax is not + // broken when the name contains them (e.g. "David[TF]" or "Ops\Bot"). + // Mirrors the backend's util.EscapeMentionLabel — `\` must be escaped + // FIRST so a name like `foo\[bar` produces `foo\\\[bar`, not `foo\\[bar` + // (which the scanner would consume as `\\` + raw `[`). + const safeLabel = (label ?? id) + .replace(/\\/g, "\\\\") + .replace(/\[/g, "\\[") + .replace(/\]/g, "\\]"); return `[${prefix}${safeLabel}](mention://${type}/${id})`; }, }); diff --git a/packages/views/issues/utils/strip-mention-markdown.test.ts b/packages/views/issues/utils/strip-mention-markdown.test.ts index a393ada8d7..4971e35b14 100644 --- a/packages/views/issues/utils/strip-mention-markdown.test.ts +++ b/packages/views/issues/utils/strip-mention-markdown.test.ts @@ -26,6 +26,20 @@ describe("stripMentionMarkdown", () => { ).toBe("@David[TF]"); }); + it("handles escaped backslash in names", () => { + expect( + stripMentionMarkdown("[@Ops\\\\Bot](mention://agent/id-1)"), + ).toBe("@Ops\\Bot"); + }); + + it("handles backslash adjacent to bracket in names", () => { + // Name "foo\[bar" → producer emits `[@foo\\\[bar](...)`. Strip + // unescapes brackets first, then the remaining `\\` becomes `\`. + expect( + stripMentionMarkdown("[@foo\\\\\\[bar](mention://agent/id-2)"), + ).toBe("@foo\\[bar"); + }); + it("handles multiple mentions in one string", () => { expect( stripMentionMarkdown( diff --git a/packages/views/issues/utils/strip-mention-markdown.ts b/packages/views/issues/utils/strip-mention-markdown.ts index c5da0cc1f4..b5236f8167 100644 --- a/packages/views/issues/utils/strip-mention-markdown.ts +++ b/packages/views/issues/utils/strip-mention-markdown.ts @@ -4,17 +4,23 @@ * Handles: * - Simple mentions: `[@Name](mention://agent/id)` → `@Name` * - Escaped brackets in names: `[@David\[TF\]](mention://agent/id)` → `@David[TF]` + * - Escaped backslash in names: `[@Ops\\Bot](mention://agent/id)` → `@Ops\Bot` * - Issue mentions (no @): `[MUL-123](mention://issue/id)` → `MUL-123` * - Does NOT touch regular markdown links: `[docs](https://...)` stays unchanged * - Does NOT touch backslash-escaped mentions: `\[@Name](mention://...)` stays unchanged * - * The regex mirrors the tokenizer in mention-extension.ts. + * The regex + unescape mirrors the tokenizer in mention-extension.ts and the + * Go-side util.UnescapeMentionLabel. Brackets are unescaped BEFORE `\\` so + * a legitimate `\[` pair isn't broken by collapsing the leading `\`. */ export function stripMentionMarkdown(text: string): string { return text.replace( /(? { - const label = rawLabel.replace(/\\\[/g, "[").replace(/\\\]/g, "]"); + const label = rawLabel + .replace(/\\\[/g, "[") + .replace(/\\\]/g, "]") + .replace(/\\\\/g, "\\"); return `${prefix}${label}`; }, ); 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..f6b50dea45 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -506,6 +506,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 +744,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 +800,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 +822,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 + } + // Producers escape `\`, `[`, `]` in labels for grammar; undo that for the + // equality check so a legitimately bracketed or backslash-bearing name + // doesn't false-positive. + if util.UnescapeMentionLabel(m.Label) == 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 +909,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/handler/squad_briefing.go b/server/internal/handler/squad_briefing.go index 4c899dfaac..e77ec9c8df 100644 --- a/server/internal/handler/squad_briefing.go +++ b/server/internal/handler/squad_briefing.go @@ -213,8 +213,10 @@ func formatRosterRow(name, kind, role, mention string) string { } // formatMention emits a mention markdown string that round-trips through -// util.ParseMentions. The label is the human display name; the link target -// uses the mention:// scheme with the entity type and UUID. +// util.ParseMentions. The label is the human display name (with `[`/`]` +// escaped so names containing brackets — e.g. "David[TF]" or "Alice [QA" +// — don't make the resulting markdown ambiguous to the scanner); the link +// target uses the mention:// scheme with the entity type and UUID. func formatMention(name, mentionType, id string) string { - return "[@" + name + "](mention://" + mentionType + "/" + id + ")" + return "[@" + util.EscapeMentionLabel(name) + "](mention://" + mentionType + "/" + id + ")" } diff --git a/server/internal/mention/canonicalize.go b/server/internal/mention/canonicalize.go new file mode 100644 index 0000000000..f8f9818afc --- /dev/null +++ b/server/internal/mention/canonicalize.go @@ -0,0 +1,188 @@ +package mention + +import ( + "context" + "fmt" + + "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 `@