Take a search term literally: _ and % were live wildcards - #263
Open
Exelord wants to merge 1 commit into
Open
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The finding
searchFilterbuilt`%${term}%`and handed it to ILIKE, so a searchterm was never taken literally. Measured on a seeded workspace:
web_app["webXapp", "web_app"]%Both scans were affected — the project scan and the
project \|\| '#' \|\| taskpair 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'--tagfilter doestags LIKE ?over the serializedJSON with the user's value interpolated raw. Measured:
env=prod_usalsoreturned a run tagged
prodXus;env=%returned every tagged run. Thatreaches
vx mcp'slist_runs— so an AI agent filtering by tag was handedrows that don't match the filter it asked for.
The fix
One escaper on the façade (
escapeLikePattern— theclampInt/parseDecimalInt/splitTaskId/escapeMarkdownCellprecedent, now five times). Two copies of "which characters are
metacharacters" is how the dashboard and
vx mcpcome to answer the samesearch 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 isdialect-specific: Postgres defaults the LIKE escape to backslash, but
SQLite has no default at all — so an undeclared
\%there matches aliteral 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 tagged100%. It doesnot:
jsonPairFragmentemits the full"k":"v"pair including bothquotes, 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%, and100%iscorrectly not a match either.
Refuted by measurement, so nobody re-treads it
I suspected an unbounded search term was a CPU vector:
textParamhas nolength cap, and a
%_-alternating pattern is the classic LIKE-backtrackingshape. Measured on the real query — an 801-character
'%_'.repeat(400)pattern ran in 1.0 ms. The haystacks are shortidentifiers, 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
ESCAPEclauses in place, so the escape itself is what's isolated — failsexactly 5:
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
appsearch still matches
webXappandweb_appby substring, and the escapernever 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-boundariesexport snapshot, which is the guard working.Gates (from the repo root)
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 twosearchrows nowstate 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