{"openapi": "3.0.3", "info": {"title": "Trismag", "version": "0.4.31", "description": "Checks untrusted text for prompt-injection patterns and returns a risk level. Deterministic: no model, and the same answer every time. Checking text makes no outbound call; only /v1/tools given a `url` fetches anything.\n\nA LOW verdict means no known pattern matched. It is NOT a guarantee that the text is safe -- novel or obfuscated attacks return LOW. Use it as a fast first pass, never as the only control."}, "servers": [{"url": "https://api.trismag.dev"}], "components": {"securitySchemes": {"apiKey": {"type": "apiKey", "in": "header", "name": "X-Api-Key", "description": "Your Trismag key. Without it you get the free tier: a risk level, no findings. X-Payment is accepted as an older name for the same header."}, "bearerKey": {"type": "http", "scheme": "bearer", "description": "The same key, as Authorization: Bearer <key>."}}, "schemas": {"Error": {"type": "object", "required": ["error"], "description": "One status per meaning. 400: the request itself is wrong. 401: no key, or one we do not recognise. 402: a key we recognise with nothing left to spend, or no key on a call with no free tier. 413: body too large. 429: too fast. `error` is stable and machine-readable; `detail` is for people.", "properties": {"error": {"type": "string"}, "detail": {"type": "string"}, "keys_url": {"type": "string", "description": "Where to get a key, when that is the fix."}}}, "Verdict": {"type": "object", "properties": {"risk": {"type": "string", "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"], "description": "The single worst finding present, not an average. CRITICAL: the text tries to take control of the agent reading it. HIGH: tries to move data out or claims authority it does not have. MEDIUM: ADVISORY ONLY -- ordinary text with a link scores MEDIUM constantly; treat it as a log level, not a risk level, and block on HIGH and CRITICAL. LOW: nothing known matched, which is not a guarantee of safety."}, "rule_weight": {"type": "number", "format": "float", "description": "How much rule evidence fired, 0..1. NOT a probability and not calibrated: one CRITICAL finding is 0.45, two findings are 0.90. Useful for tiering policy, useless as a certainty."}, "tier": {"type": "string", "enum": ["preview", "paid"]}, "findings": {"type": "array", "description": "Only present with a key.", "items": {"type": "object", "properties": {"severity": {"type": "string"}, "label": {"type": "string"}, "detail": {"type": "string"}}}}, "scans_remaining": {"type": "integer", "nullable": true}}}}}, "security": [{}], "paths": {"/v1/triage": {"post": {"operationId": "checkTextForInjection", "summary": "Check whether text is trying to hijack an AI agent", "description": "Send `text` for a single piece of content, or `messages` for a conversation. Use `messages` when an attack could be split across turns so that no single message looks bad.", "security": [{"apiKey": []}, {"bearerKey": []}, {}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "description": "Exactly one of `text` or `messages`. Both, neither, null, or the wrong type is refused with 400 before any allowance is spent -- only one field is ever scanned, so accepting both would leave the other unchecked.", "oneOf": [{"required": ["text"], "not": {"required": ["messages"]}}, {"required": ["messages"], "not": {"required": ["text"]}}], "properties": {"text": {"type": "string", "minLength": 1, "description": "The untrusted text to check."}, "messages": {"type": "array", "minItems": 1, "items": {"type": "string"}, "description": "A conversation, oldest first. Use instead of text."}, "source": {"type": "string", "maxLength": 64, "description": "Optional label echoed back; where the text came from."}, "provenance": {"type": "string", "enum": ["untrusted", "trusted"], "description": "Declare \"untrusted\" for content the agent did not write and did not receive from its own user -- a feed post, a web page, an email, a tool result. Findings whose meaning depends on that (a request to describe the model's own setup, an instruction aimed at its output, a payment change) rise one level. Omitted or \"trusted\": no change."}}}}}}, "responses": {"200": {"description": "A verdict.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Verdict"}}}}, "400": {"description": "Malformed request: not JSON, not a JSON object, duplicate keys, both or neither of text/messages, a wrong type, or invalid Content-Length framing. Nothing was charged."}, "401": {"description": "A key we do not recognise (or, on a gated deployment, a missing access token)."}, "402": {"description": "Without a key: today's free previews are used up, or the conversation is longer than the free tier allows. With a key: its allowance is spent."}, "413": {"description": "Body larger than 65536 bytes. Nothing was charged."}, "415": {"description": "Content-Type is not application/json. Form bodies are refused on purpose."}, "429": {"description": "Per-minute limit for this key or, without a key, this address (repeats count). Retry-After says how many seconds."}}}}, "/v1/sanitize": {"post": {"operationId": "sanitizeText", "security": [{"apiKey": []}, {"bearerKey": []}], "summary": "Format untrusted text for human review", "description": "Replaces invisible characters, normalises Unicode and strips control codes, then returns the review text alongside the same risk level /v1/triage would give.\n\nThis is for text a PERSON is about to read. It is not a way to make hostile text safe for a tool-capable model: the words still say what they said, and a model that can act on them still can. Requires a key; there is no free tier on this route.", "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"text": {"type": "string", "description": "The text to format for human review. One piece at a time; 'messages' is not accepted here."}, "source": {"type": "string"}}, "required": ["text"]}}}}, "responses": {"200": {"description": "Human-review text and the original verdict; instructions may remain.", "content": {"application/json": {"schema": {"type": "object", "properties": {"tier": {"type": "string"}, "risk": {"type": "string", "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"]}, "review_text": {"type": "string"}, "clean_text": {"type": "string", "deprecated": true, "description": "Legacy alias of review_text; not safe for agent execution."}, "deprecation": {"type": "string"}, "text_changed": {"type": "boolean"}, "limitations": {"type": "string"}, "findings_count": {"type": "integer", "description": "How many findings were present in the original."}, "scans_remaining": {"type": "integer", "nullable": true}}}}}}, "400": {"description": "Wrong shape; nothing was charged."}, "401": {"description": "Key missing or not ours."}, "402": {"description": "Key has nothing left to spend."}, "413": {"description": "Body larger than 65536 bytes. Nothing was charged."}, "415": {"description": "Content-Type is not application/json."}, "429": {"description": "Per-minute limit for this key. Retry-After says how many seconds."}}}}, "/v1/tools": {"post": {"operationId": "checkMcpTools", "security": [{"apiKey": []}, {"bearerKey": []}, {}], "summary": "Check an MCP server's tools before connecting it", "description": "A tool description is injected into a model's context verbatim, so a hostile server can hide instructions there. Give a server URL and we run initialize and tools/list against it -- nothing is ever called -- or paste a tools/list array you already have. Exactly one of 'url' or 'tools'.\n\nA whole manifest is one scan, not one per tool. Without a key you get each tool's risk level and its declared capabilities; the findings detail needs a key.\n\nIt judges whether a description tries to manipulate the model, NOT whether the tool is safe: a shell tool is dangerous and honest, and reports a capability rather than a finding. Nested schema strings and metadata keys are checked. Paginated or oversized remote manifests are refused with scan_complete=false and risk=null. Metadata beyond depth 32 or 10000 nodes per traversal is also refused; never treat an incomplete scan as LOW.", "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "oneOf": [{"required": ["url"], "not": {"required": ["tools"]}}, {"required": ["tools"], "not": {"required": ["url"]}}], "properties": {"url": {"type": "string", "description": "Public https URL of an MCP server."}, "tools": {"type": "array", "items": {"type": "object"}, "minItems": 1, "description": "The 'tools' array from a tools/list response."}, "source": {"type": "string"}}}}}}, "responses": {"200": {"description": "Per-tool verdicts, worst first.", "content": {"application/json": {"schema": {"type": "object", "properties": {"tier": {"type": "string"}, "risk": {"type": "string", "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"], "description": "The worst verdict across every tool."}, "tools_scanned": {"type": "integer"}, "scan_complete": {"type": "boolean"}, "tools": {"type": "array", "items": {"type": "object"}}, "server": {"type": "object", "description": "serverInfo, when a URL was given."}, "limitations": {"type": "string"}}}}}}, "400": {"description": "Wrong shape, or the URL could not be checked completely. URL fetch attempts are metered before fetching."}, "401": {"description": "A key we do not recognise."}, "422": {"description": "Metadata traversal limit exceeded; scan_complete=false, risk=null. Scan was metered."}, "402": {"description": "Without a key: today's free previews are used up. With a key: its allowance is spent."}, "413": {"description": "Body larger than 65536 bytes. Nothing was charged."}, "415": {"description": "Content-Type is not application/json."}, "429": {"description": "Per-minute limit for this key or address. A repeated URL check always counts: it fetches again."}, "503": {"description": "Every URL-check slot is busy. Nothing was charged; retry shortly."}}}}, "/v1/keys": {"post": {"operationId": "requestApiKey", "summary": "Get a key (one per email address)", "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["email"], "properties": {"email": {"type": "string", "format": "email"}}}}}}, "responses": {"201": {"description": "Key issued. Shown once and never again."}, "400": {"description": "No usable email address."}, "409": {"description": "That address already has a key."}, "429": {"description": "This client already requested a key today."}, "503": {"description": "Today's issuance limit for the whole service is reached."}}}}, "/v1/keys/redeem": {"post": {"operationId": "redeemInvite", "summary": "Exchange an operator-issued invite code for an evaluation key", "description": "Invites are single-use and expire. The key is shown once. Evaluation keys have full findings and a daily ceiling instead of a monthly allowance. Most people should use /v1/keys instead.", "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["code"], "properties": {"code": {"type": "string", "description": "Sixteen characters; dashes optional."}}}}}}, "responses": {"201": {"description": "Key issued. Shown once and never again."}, "400": {"description": "Invite invalid, expired or already used."}, "429": {"description": "Too many attempts from this client today."}}}}, "/v1/usage": {"get": {"operationId": "checkRemainingScans", "summary": "How many scans your key has left", "security": [{"apiKey": []}, {"bearerKey": []}], "responses": {"200": {"description": "Remaining balance."}, "401": {"description": "Key missing or not ours."}}}}, "/v1/checkout": {"post": {"operationId": "preparePersonalCheckout", "summary": "Record personal-use purchase acceptance and prepare the personal Stripe link; unavailable while sales are closed", "security": [{"apiKey": []}, {"bearerKey": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": false, "required": ["accepted", "personal_use", "terms_version"], "properties": {"accepted": {"const": true}, "personal_use": {"const": true}, "terms_version": {"const": "2026-09-11-personal-v1"}}}}}}, "responses": {"200": {"description": "Personal checkout URL with opaque reference; no payment initiated."}, "400": {"description": "Invalid consent or account type."}, "401": {"description": "Account key required."}, "429": {"description": "Checkout attempt limit."}, "503": {"description": "Sales are closed."}}}}, "/health": {"get": {"operationId": "getServiceInfo", "summary": "Version, limits, and the service's stated limitations", "security": [], "responses": {"200": {"description": "Service description."}}}}}}