Skip to content

isMention over-matches: a longer handle flags the shorter one it contains (@sammy flags sam) #1278

Open

Description

@lilyshen0722

ActivityService.computeFlags (backend/services/activityService.ts:517-521) matches a human mention with a bare substring test:

const mentionNeedle = lowerUsername ? `@${lowerUsername}` : '';
// ...
isMention: Boolean(mentionNeedle && lowerContent.includes(mentionNeedle)),

includes has no right boundary, so any handle that is a prefix of another handle is flagged by messages addressed to the longer one.

Reproduces (verified at origin/main e86a4a4a, and pinned as the last case in backend/__tests__/unit/services/activityMentionNeedle.test.js, added by #1277):

computeFlags({ content: '@sammy is on it', username: 'sam', ... }).isMention === true

Why it matters more than it looks. The mentions filter (:591) is the only surface a human has for "messages addressed to me", and ADR-017 makes that filter a pull surface — nothing pushes, so its signal-to-noise is the whole product. A user with a short handle sees every message addressed to anyone whose handle starts with theirs. The shorter the handle, the worse it gets, and short handles are the desirable ones.

Also asymmetric with the agent path. resolveHumanMentionUserIds in agentMentionService.ts extracts handles with a [a-z0-9_-] character class after the @ — i.e. it tokenizes. So the same message is parsed one way for agent routing and another for the human mentions filter, and only the human side over-matches.

Fix shape. A right-boundary match, e.g. new RegExp('@' + escapeRegExp(lowerUsername) + '(?![a-z0-9_-])'). Note \b alone is not sufficient — _ and - are legal in handles and \b treats - as a boundary, so @sam-local would still flag sam. Any fix must escape the username before interpolating it: usernames are user-controlled and reach a regex here.

The test added in #1277 records the current behaviour deliberately, so a fix trips a change-detector rather than sliding through silently. Update that case as part of the fix.

Found while pinning the wake-cue claim in TASK-074.

Activity

  1. lilyshen0722 commented on Aug 26, 2026

    @lilyshen0722
    ContributorAuthor

    Confirmed, and one part of the framing needs widening before the fix lands: the agent path is not a correct reference implementation — it is a different wrongness.

    resolveHumanMentionUserIds doesn't tokenize the username; it consumes what extractMentions produced, and that is /@([a-z0-9_-]{2,})/gi (agentMentionService.ts:176), followed by an anchored new RegExp('^' + username + '$', 'i') lookup (:1046). Two consequences:

    • A username containing anything outside [a-z0-9_-] can never be extracted. @sam.xu yields the handle sam, and the anchored lookup then resolves sam — a different person — or nobody. That is the same defect this issue is about, arriving by truncation instead of by prefix, on the side cited as correct.
    • A one-character username can never be mentioned at all, by either path.

    Why that matters for the fix. (?![a-z0-9_-]) is the complement of a charset that nothing enforces. User.username is { type: String, required: true, unique: true } (models/User.ts:177) — no match, no validator — and authController only .trim()s it at registration (:272). Agent usernames are normalized to [a-z0-9-] by normalizeSegment (agentIdentityService.ts:103), so the assumption holds for bots and for nobody else.

    So the proposed regex fixes sam/sammy and leaves sam/sam.xu matching. Not an argument against shipping it — it is strictly better than includes and it is the cheap half. But the durable fix is to enforce the handle charset at registration, because neither path can be correct against a field with no charset. Worth its own row; happy to file it if you'd rather keep this issue scoped to the boundary.

    One thing your fix note gets exactly right and is worth keeping even so: escape before interpolating. It is safe today only because extractMentions constrains the value upstream, not because the value is trusted — and :1046 already interpolates a username into a RegExp with no escape, so the day something feeds it an unfiltered handle, that is where it lands.

    (Separately: I asked on #1277 whether this had been reported. It had — this issue predates my question by 13 minutes and my board query couldn't see a GitHub issue. Retracted there.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions