Open your coding agent in a Python project that has almost nothing in it. Type: add input validation to this note-search endpoint: users send a search term and a limit.
That is the whole request. Without a recipe, an agent writes whatever validation it remembers, and nothing says whether it got the important part right.
Input validation means treating whatever a user sends as untrusted until the server has checked it. The classic way to get it wrong is SQL injection: the user’s text ends up inside the query itself, so a search term can turn into a command.
The standard picture: everything a user sends meets the trust boundary first. Only checked values reach the store, through a query whose text never changes.Broken copy: the mistake you picked is marked in red.
>add input validation to this note-search endpoint: users send a search term and a limit.
$ls
pyproject.toml
$cat pyproject.toml
[project]
name = "notes"
version = "0.1.0"
requires-python = ">=3.9"
One file. A brand-new Python app.
2 · Your agent
Your agent asks MM3 how.
You do nothing. Your agent sends your sentence to MM3 and a recipe comes back. It works out the language by itself, from pyproject.toml.
The five rules in plain words: check every value on the server before anything uses it; accept only what is on an allowed list; check type, length and range; reject a bad value instead of tidying it up; and build the query from fixed text, with the user’s values passed separately.
The recipe is not made up. It traces to the OWASP Application Security Verification Standard 5.0 and OWASP’s Input Validation Cheat Sheet. MM3 checked that what we cite is quoted from those pages. Rule 4 is our strict reading: the cheat sheet says to reject what falls outside the rules, and the recipe turns that into “never clean up and carry on”.
“Define what the application accepts and reject values outside those rules. Do not try to recognize every malicious string.”OWASP Input Validation Cheat Sheet (CC BY-SA 4.0)
$node mcp/call.mjs apply-pattern '{"request":"add input validation to this note-search endpoint: users send a search term and a limit."}'
1. Pattern: sec.pattern.input-validation (Input validation (at the trust boundary)), resolved from the request (alias match: "input validation").
2. Language: detect it from the project's OWN files
- pyproject.toml -> python
[...]
RULES R1: Every request value is validated at the trust boundary, on the server, before it reaches any downstream code.
SOURCES OWASP ASVS 5.0, V1 and V2 · OWASP Input Validation Cheat Sheet (tier A)
In plain words, the recipe has four parts:
The handlerTakes what the user sent, runs it through the validator, and stops with an error if it fails.
The validatorThe only code that decides what is acceptable: allowed characters, length 1 to 40, limit 1 to 50.
The query codeRuns the search with fixed SQL text; the values travel as separate parameters.
The storeA small in-memory table of notes that runs that one query.
The rule The query text is fixed; user values are never joined into it. (R5, OWASP ASVS 1.2.4)
3 · Your agent
It writes the code, in your language.
Still nothing for you to do. The agent writes the files the recipe names and runs the app on three requests: a normal search, a search term that is an attack (x'; DROP TABLE notes;--), and a limit of 999.
The normal search returns two notes. The attack and the 999 are both turned away with a 400 before any query is built. The agent made no mistakes that it had to fix; the first run worked.
{'term': "x'; DROP TABLE notes;--", 'limit': '5'} -> {'status': 400, 'error': 'term must be 1 to 40 letters, digits, spaces, underscores or hyphens'}
{'term': 'report', 'limit': '999'} -> {'status': 400, 'error': 'limit must be from 1 to 50'}
4 · MM3
MM3 checks it.
The check is a fixed list of 21 questions: 18 yes/no, one overall goal, two judgement calls. For this app, the result, run MM3-0239: pass. The panel shows three of the 18.
A score is how sure the checker is that the code is healthy on that line. Most questions are best answered “yes”; the last six are a hunt for the mistake (“is there any place where…”) and for those “no” is healthy, so the panel flips them.
The questions are locked with a fingerprint, so nobody can reword them later to make a result come out better. A check costs about $0.00006 (estimated from tokens).
PASSgood code accepted · MM3-0239 · goal 0.93
The values are checked and the query is fixed
0.99
Q1. Does the request handler call the validation code on the request values before it calls the data access code?
0.97
Q10. Is the statement text in the data access code a fixed string with placeholders?
0.93
Q17. Is there any statement text that is built by joining or formatting an input value into the string?(healthy answer: no)
The other fifteen pass too; the lowest score is 0.85.
5 · Try it
Is it just saying yes? Glue the input in.
A check that always says yes is worthless. So here is a copy that breaks exactly one rule: the search is built by joining the user’s text into the SQL instead of passing it separately. For normal input both versions return the same notes. They only differ when someone sends an apostrophe. Flip the switch to do it yourself.
Before · the recipe fixed text, values apart
SEARCH_STATEMENT = "SELECT id, title FROM notes WHERE title LIKE '%' || ? || '%' LIMIT ?"
store.execute(SEARCH_STATEMENT, [term, limit])
the database receivesSELECT … LIKE '%' || ? || '%' LIMIT ?+ values: ["x'; DROP TABLE notes;--", 5]
After · the mistake input joined into the text
statement = "SELECT id, title FROM notes WHERE title LIKE '%" + term + "%' LIMIT " + str(limit)
store.execute(statement, [])
the database receivesSELECT id, title FROM notes WHERE title LIKE '%x'; DROP TABLE notes;--%' LIMIT 5
The right side is the exact function MM3 judged, run MM3-0244 (Python). The text sent to the database is plain string joining of the same attack request from step 3; we did not run it against a real database. In the real app the validator would stop this term first; the glued query is dangerous the day some caller skips the validator.
The good copy: every value is checked at the trust boundary, then the fixed query runs.The broken copy: the mistake you picked is marked in red.
off: the good copy (Python)
Watch the marked check fall. The panel starts on the glued query; the buttons below it switch to the other four mistakes.
Without JavaScript, here are all fifteen pairs. Every row is a good copy and a copy with one mistake added; each broken copy was caught.
"""Downstream use: queries the store with a fixed statement and separate parameters."""SEARCH_STATEMENT = "SELECT id, title FROM notes WHERE title LIKE '%' || ? || '%' LIMIT ?"def search_notes(store, term, limit):"""Run the fixed search statement with the term and limit as parameters.""" return store.execute(SEARCH_STATEMENT, [term, limit])
0.93
Q17. Is there any statement text that is built by joining or formatting an input value into the string? (healthy answer: no)
0.97
Q10. Is the statement text in the data access code a fixed string with placeholders?
0.95
Q12. Is the statement text a constant that no request value is ever added to?
A score is how sure the checker is that the code is healthy on that line. The marked check is the one about the rule that gets broken.
6 · Try it
Pick your language.
The same questions work in Python, TypeScript and Go, because they ask about roles (the handler, the validator, the query code), not one language’s syntax. The recipe finds your language from your project’s own files.
Pick one. The panel swaps in that app’s real file and the real result. The mistake and the switch carry over.
7 · The twist
Five mistakes, three languages: 15 of 15 caught. One was only just.
We expected the glued query to be the loud one. It was loud (every language answered it as broken with 0.88 to 0.91), but the quietest was a different mistake: a missing length limit. In Python it came back “unsure” instead of “fail”. Still caught by our own rule (an unsure whose question for the broken rule is under 0.50 counts), but it is the one to watch.
For the question about the broken rule. Longer is surer. The line is 0.50: below it the answer is not a catch.
Input glued into the query
Python0.88
TypeScript0.91
Go0.89
Input passed on with no check
Python0.93
TypeScript0.95
Go0.96
Block-list instead of allow-list
Python0.92
TypeScript0.93
Go0.93
No length limit
Python0.66
TypeScript0.70
Go0.74
The shortest bars. Python came back “unsure” (amber), the other two “fail”.
Cleaned up, not rejected
Python0.78
TypeScript0.93
Go0.93
8 · The result
What it comes to.
One sentence from you. A recipe from OWASP’s own guidance. Working code that turns an attack string away. And a verdict you can read: yes to the good copies, no to every broken one, in three languages, naming the part that broke.
1 sentence from you
3 languages, the same 18 yes/no questions
15/15 broken copies caught, 3/3 good copies accepted
the quietest alarm: no length limit, MM3-0242 unsure(Python)
9 · A real app
The app your agent built, from one sentence
Four small files, written by a small model (Haiku) that had only the sentence and the recipe. Here are the three that do the work.
The validator validation/search.py
# Validator: the only code that decides whether a raw search request value is acceptable.
# Allow-list checks for type, length and range; a bad value raises ValidationError and is never rewritten.
import re
from dataclasses import dataclass
_TERM_RE = re.compile(r"[A-Za-z0-9 _-]{1,40}")
_LIMIT_RE = re.compile(r"[0-9]+")
class ValidationError(Exception):
pass
@dataclass(frozen=True)
class SearchParams:
term: str
limit: int
def validate_search(term, limit):
if not isinstance(term, str) or not _TERM_RE.fullmatch(term):
raise ValidationError("term must be 1 to 40 letters, digits, spaces, underscores or hyphens")
if not isinstance(limit, str) or not _LIMIT_RE.fullmatch(limit):
raise ValidationError("limit must be a whole number written with digits only")
value = int(limit)
if not 1 <= value <= 50:
raise ValidationError("limit must be from 1 to 50")
return SearchParams(term=term, limit=value)
The query code data/notes.py
# Downstream use: queries the store with a fixed statement and the validated values passed as parameters.
from store.note_store import NoteStore
SEARCH_SQL = "SELECT id, title FROM notes WHERE title LIKE '%' || ? || '%' LIMIT ?"
def search_notes(store: NoteStore, term, limit):
return store.execute(SEARCH_SQL, [term, limit])
The handler handlers.py
# Trust boundary: validates the raw request values first, then calls the downstream search only with validated values.
from data.notes import search_notes
from store.note_store import NoteStore
from validation.search import ValidationError, validate_search
def handle_search(store, request):
try:
params = validate_search(request.get("term"), request.get("limit"))
except ValidationError as err:
return {"status": 400, "error": str(err)}
return {"status": 200, "rows": search_notes(store, params.term, params.limit)}
def main():
store = NoteStore()
requests = [
{"term": "report", "limit": "5"},
{"term": "x'; DROP TABLE notes;--", "limit": "5"},
{"term": "report", "limit": "999"},
]
for request in requests:
print(request, "->", handle_search(store, request))
if __name__ == "__main__":
main()
And MM3 checked the same recipe
Same 21 locked questions as everywhere on this page. The second copy is the first with a single change: the search joins the term and the limit into the SQL text and passes no parameters.
PASSgood code accepted · MM3-0239 · goal 0.93
The query is fixed and the values are checked
0.99
Q1. Does the request handler call the validation code on the request values before it calls the data access code?
0.97
Q10. Is the statement text in the data access code a fixed string with placeholders?
0.93
Q17. Is there any statement text that is built by joining or formatting an input value into the string?
All eighteen yes/no questions pass; the lowest is 0.85.
FAILbroken copy caught · MM3-0244 · goal 0.91
The query is fixed and the values are checked
0.98
Q1. Does the request handler call the validation code on the request values before it calls the data access code?
0.92
Q10. Is the statement text in the data access code a fixed string with placeholders?
0.12
Q17. Is there any statement text that is built by joining or formatting an input value into the string?
Note the overall goal still reads 0.91. The catch is the marked question, not the headline.
Honestly: one run each. The broken copy keeps the good query and adds the glued one next to it, so Q10 still sees the good one. The agent’s own files above were not the ones judged here: these two runs are on the earlier test copies of the same recipe in Python (same file names, same locked questions).
10 · Passed?
Five mistakes, three languages
What we did
Passed?
Good code is accepted
✅ 3 of 3 languages
A query built by joining strings is caught
✅ 3 of 3
Input passed on with no check is caught
✅ 3 of 3
A block-list instead of an allow-list is caught
✅ 3 of 3
Cleaning the input up instead of rejecting it is caught
✅ 3 of 3
A missing length limit is caught
⚠️ 3 of 3, but Python only “unsure”
Total
15 of 15 broken copies caught, 3 of 3 good copies accepted.
Run ids are in the Receipts below. Each bar is one run of the check on one broken copy; there is one run per copy, so no measure of run-to-run noise.
Why it matters
The glued query and the safe query return the same rows for normal input, so a quick test or a quick read of the diff will not tell them apart. One sentence got you the validation; one check reads the code against the five rules, and says out loud which one broke.
Reality check
What we tested
One sentence in Python, plus the same recipe in TypeScript and Go; each written correctly, then broken five different ways. 3 good copies and 15 broken copies, all judged by one locked list of 21 questions.
What should work
Any small service that takes values from a user and queries a store: the checker can tell a fixed query with separate values from one with the input joined in.
What we learned the hard way
The length limit is the quiet one. Every other mistake scored 0.78 or better as “wrong”; the missing length limit scored 0.66 to 0.74, and in Python the whole run came back “unsure” (MM3-0242). Our guess, not tested: the real validator is still in the file next to the broken one.
The headline goal never dropped. In all 15 broken copies it stayed between 0.79 and 0.93. The catch was always in one specific question. Read the failing question, not the top number.
Not yet
The check reads code; it does not run it. In the agent’s Python app, a limit with 5,000 digits slips past the digit check and crashes with an unhandled error instead of a 400 (Python refuses to turn that many digits into a number). Also not yet: a real database, and a broken copy written by someone other than us.
Receipts
Good copies: TypeScript MM3-0233, Python MM3-0239, Go MM3-0245. Broken copies, rules 1 to 5: TypeScript MM3-0234 to MM3-0238, Python MM3-0240 to MM3-0244, Go MM3-0246 to MM3-0250. Python length limit, the unsure one: MM3-0242. All 18 ran on the current locked check.
The agent capture (steps 1 to 3 and the files in section 9) is a fresh run. Its check was a free dry run only (accepted, 21 questions). The verdicts on this page come from the recipe’s test copies, listed above, so the agent’s own files have not had their paid check yet.
How the broken copies were made: one function added next to the good code, and comments that claimed the opposite removed. The added function names are descriptive (the glued query is called search_notes_concat), which is a hint; a neutral hand-made copy is next.
We ran the agent’s Python app (the three requests in step 3). The TypeScript and Go copies and all broken copies were not compiled or run for this page.
An earlier version of the check wrongly flagged good code: it asked about a missing length limit too bluntly, and the good Python app came back “unsure” (MM3-0209). We reworded two questions and locked a new version; 18 older runs no longer count, and the numbers here are from the new one. The dry run still notes that three questions read as two joined; they stay as locked.
Most answers in these runs were reused from earlier runs on the same code (19 of 21), so the cost is small: the 18 counted runs cost $0.001192 together (MM3 cost record, estimated from tokens).
Sources
OWASP, “ASVS 5.0, V1 Encoding and Sanitization” (link)
OWASP, “ASVS 5.0, V2 Validation and Business Logic” (link)
OWASP, “Input Validation Cheat Sheet” (cheatsheetseries.owasp.org). OWASP content is CC BY-SA 4.0: attribution and ShareAlike apply.