Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/views/editor/extensions/mention-extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
30 changes: 25 additions & 5 deletions packages/views/editor/extensions/mention-extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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})`;
},
});
14 changes: 14 additions & 0 deletions packages/views/issues/utils/strip-mention-markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 8 additions & 2 deletions packages/views/issues/utils/strip-mention-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
/(?<![\\])\[(@?)((?:\\.|[^\]])+)\]\(mention:\/\/\w+\/[^)]+\)/g,
(_, prefix: string, rawLabel: string) => {
const label = rawLabel.replace(/\\\[/g, "[").replace(/\\\]/g, "]");
const label = rawLabel
.replace(/\\\[/g, "[")
.replace(/\\\]/g, "]")
.replace(/\\\\/g, "\\");
return `${prefix}${label}`;
},
);
Expand Down
6 changes: 3 additions & 3 deletions server/cmd/multica/cmd_issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
43 changes: 43 additions & 0 deletions server/internal/handler/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<B-uuid>)" — 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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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")

Expand Down Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions server/internal/handler/squad_briefing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 + ")"
}
Loading
Loading