Skip to content

Take a search term literally: _ and % were live wildcards - #263

Open
Exelord wants to merge 1 commit into
mainfrom
claude/bold-cannon-hmsma2
Open

Take a search term literally: _ and % were live wildcards#263
Exelord wants to merge 1 commit into
mainfrom
claude/bold-cannon-hmsma2

Conversation

@Exelord

@Exelord Exelord commented Aug 20, 2026

Copy link
Copy Markdown
Member

The finding

searchFilter built `%${term}%` and handed it to ILIKE, so a search
term was never taken literally. Measured on a seeded workspace:

search returned
web_app ["webXapp", "web_app"]
% every project in the workspace

Both scans were affected — the project scan and the
project \|\| '#' \|\| task pair scan behind the Tasks box.

The failure is over-matching only (a wildcard can never narrow), so what you
searched for is in the results — which caps the severity and is exactly
why it survives review: the answer looks right until you read it.
Underscores are ordinary in package names, so this is routine, not exotic.
And this box is the only way to reach anything past one page in a
1000-project workspace.

The sweep found the same class in core

listInvocations' --tag filter does tags LIKE ? over the serialized
JSON with the user's value interpolated raw. Measured: env=prod_us also
returned a run tagged prodXus; env=% returned every tagged run. That
reaches vx mcp's list_runs — so an AI agent filtering by tag was handed
rows that don't match the filter it asked for.

The fix

One escaper on the façade (escapeLikePattern — the
clampInt / parseDecimalInt / splitTaskId / escapeMarkdownCell
precedent, now five times). Two copies of "which characters are
metacharacters" is how the dashboard and vx mcp come to answer the same
search differently, and there is no third answer to drift toward: LIKE has
exactly two metacharacters plus the escape.

ESCAPE '\' is mandatory, not decorative, and the reason is
dialect-specific: Postgres defaults the LIKE escape to backslash, but
SQLite has no default at all — so an undeclared \% there matches a
literal backslash followed by anything, relocating the bug rather than
removing it. Both call sites declare it.

A correction to my own expectation, caught by the failure side

My first core pin asserted env=% would match a run tagged 100%. It does
not: jsonPairFragment emits the full "k":"v" pair including both
quotes
, so the tag filter is exact-value (positioned anywhere in the
blob), not a substring search on the value. The pin now asserts what
actually holds — % matches only a run tagged exactly %, and 100% is
correctly not a match either.

Refuted by measurement, so nobody re-treads it

I suspected an unbounded search term was a CPU vector: textParam has no
length cap, and a %_-alternating pattern is the classic LIKE-backtracking
shape. Measured on the real query — an 801-character
'%_'.repeat(400) pattern ran in 1.0 ms. The haystacks are short
identifiers, so there is nothing to backtrack over. A length cap would be
guarding a hazard that does not exist.

Differential

Neutralising the escaper alone — leaving both call sites and both
ESCAPE clauses in place, so the escape itself is what's isolated — fails
exactly 5:

  • core: the tag pin + the escaper unit
  • cloud: underscore, percent, and the pair scan — a second call site
    that could have been fixed on its own and drifted

Restore byte-identical. Three controls pass both ways, and without them
"no false positives" would be satisfied by matching nothing at all: an
ordinary term stays byte-identical through the escaper, a plain app
search still matches webXapp and web_app by substring, and the escaper
never introduces a wildcard of its own (callers own the surrounding %…%).

The façade widening cost exactly one pin, as every previous one has — the
package-boundaries export snapshot, which is the guard working.

Gates (from the repo root)

  • fmt 0, lint 0
  • core 2660 / 0, no skip line
  • cloud 1332 / 0 across 60 files, zero skips
  • docs site builds clean

No CACHE_VERSION / SCHEMA / wire / migration bump: this changes which rows
a filter matches, never a key or a stored byte.

Docs shipped in the same wave — cloud/api.md's two search rows now
state that the term is taken literally and that %/_ are not wildcards.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RW7aso5j5CrBo7cjyET23D


Generated by Claude Code

Searching a workspace for `web_app` also returned `webXapp`, and a bare
`%` returned every project — `searchFilter` interpolated the user's term
straight into an ILIKE pattern, on both the project scan and the
`project || '#' || task` pair scan. Over-matching only, so what you
searched for is in the results; that is why it survives a glance, and
underscores are ordinary in package names, so it is routine.

The sweep found the same class in core: `listInvocations`' `--tag`
filter does `tags LIKE ?` with the value interpolated raw, so
`env=prod_us` also matched a run tagged `prodXus`. That reaches
`vx mcp`'s `list_runs`, handing an agent rows that do not match the
filter it asked for.

One escaper on the façade rather than two copies of "which characters
are metacharacters", which is how the dashboard and `vx mcp` come to
answer the same search differently. ESCAPE '\' is mandatory rather than
decorative: Postgres defaults it to backslash but SQLite has no default
at all, so an undeclared \% there just relocates the bug.

Measured and refuted: an unbounded term is not a CPU vector. An
801-char `%_`-alternating pattern runs in 1.0ms, because the haystacks
are short identifiers — a length cap would guard a hazard that does not
exist.

Differential: neutralising the escaper alone, leaving both call sites
and both ESCAPE clauses, fails exactly 5. Three controls pass both ways
— without them "no false positives" is satisfied by matching nothing.

Gates from the root: fmt/lint 0, core 2660/0, cloud 1332/0 across 60
files with zero skips, docs build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RW7aso5j5CrBo7cjyET23D
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants