Reference¶
The models, the parser, the clients and the test helpers of spot-sdk-python 2. The overview shows how they fit together, and Writing plugins how to use them.
The email view¶
spot_sdk.email.EmailView is the message as every analyzer sees it. Core builds it once, at intake, with spot_sdk.mime.parse. Anyone can send a message, so the parser never fails on its content: it keeps what does not decode as written, and records what mail clients may read differently as an anomaly.
| Field | What it holds |
|---|---|
id, received_at, source | the stored email's id, when it arrived, and the retriever's id (or "api") |
size_bytes, sha256 | of the raw message |
envelope | the SMTP envelope, when an MTA gave it: mail_from, rcpt_to, client_address, client_name, helo |
headers | the headers, in order, each one that fits the room left in their budget: unfolded, RFC 2047 words decoded (undecodable ones kept as written), raw 8-bit bytes read as UTF-8. X-SPOT-* headers are removed, in any case. header(name) returns every value of a header, case-insensitively |
subject, from_ (from in JSON), reply_to, to, cc, date, message_id, in_reply_to, references | parsed from the first header of each name in the whole message, even past the headers kept. An address that does not parse is kept whole, with an empty domain; a domain is read as browsers read a host (see url_host, without the percent-decoding); an unusable date is None |
authentication | the results of Authentication-Results headers whose authserv-id is one of SPOT's own MTAs, and only above the first header from another one, so headers added on the way do not count. This holds when those MTAs remove incoming headers that carry their authserv-id (RFC 8601, section 5) |
parts | the MIME tree, depth-first: "1", "1.1", "1.2.1". Each Part has its content type, charset, disposition, filename, content id, decoded size and SHA-256. Containers (multipart/* and message/*, an attached message included) have size 0 and no hash: an attached message's leaves (1.2.1, ...) are fetched with SpotClient.part, the whole message with SpotClient.raw. attachments lists the parts marked as attachments, or named and not empty, so it may list an attached message |
text, html | the first text/plain and text/html body parts, decoded with their charset (latin-1 when it is unknown), cut to 1 MiB of JSON |
visible_text | the text when it is not blank, else what a browser shows of the HTML: scripts, styles and text hidden by inline styles are left out. Either way, without the characters that render as nothing (spot_sdk.mime.visible): a text of them alone is blank |
urls | every URL, with found_in: header (List-Unsubscribe), text, html_href (an attribute, with the link's anchor_text) or html_text (written in the HTML's visible text). host is what a browser would reach (see url_host) |
truncated | some content was cut at a bound |
anomalies | what the parser read past, as Anomaly(code, location, detail) |
Anomalies¶
Each anomaly has a code, a location ("part:1.2", "header:Subject@1", "param:Content-Type.boundary@1.2", "url:3" (the index in urls of the link concerned), "envelope", "source") and, for the ambiguities, a detail holding the values concerned as JSON strings. The location and the detail take at most 200 bytes of JSON each. The codes are constants of spot_sdk.mime, listed in ANOMALY_CODES, and each has a signal name in spot_sdk.signals.ANOMALY_SIGNALS (mime.<code>).
| Code | Meaning |
|---|---|
param_malformed | an RFC 2231 parameter that does not decode was dropped |
param_duplicate | a parameter, or a section of one, is given twice: the first counts |
param_conflict | two values of a parameter differ (plain and RFC 2231 forms, two boundaries, a filename and a name) |
boundary_unusable | a boundary is empty or does not decode: the next candidate counts |
boundary_too_long | a boundary is longer than MAX_BOUNDARY_LENGTH |
boundary_missing | a multipart part was not split: no usable boundary appears in it |
boundary_ambiguous | a multipart body holds the delimiters of two candidate boundaries |
header_duplicate | a part repeats Content-Type, Content-Disposition, Content-Transfer-Encoding or MIME-Version |
transfer_encoding_junk | a base64 body goes on after its padding |
html_hidden_text | text a reader may see was left out of visible_text (hidden by a style, in <head>, <template>, ...); the detail is how many characters, counted as visible_text counts, never the text; absent when some hidden text could not be counted (a <template> not read) |
html_style_unresolved | an inline style hides text only at rendering time (var(), calc()): the text was kept |
html_limit | the HTML is over the parser's budget: its text and URLs were read by pattern, and hidden text is not detected |
invisible_characters | characters that render as nothing (a zero-width space, a soft hyphen, a tag...) were removed from visible_text, or from a text part they left blank; the detail is how many, the location the part they were removed from. Also noted where the view keeps them as written: the subject, the display names of an address header, a link's anchor text and a file name, located at that field: header:Subject@1 (the header holding the name, for a display name), url:3 (one note per link), param:Content-Disposition.filename@1.2 (param:Content-Type.name@1.2 for a name given there only) |
bidi_control | a string holds explicit bidi embedding, override or isolate controls (U+202A to U+202E, U+2066 to U+2069), which reorder what a reader sees (invoice<RLO>fdp.exe shows as invoiceexe.pdf); noted where invisible_characters is, which a string holding them gets too; the detail is how many |
depth_limit, header_limit, part_limit, url_limit | a bound was hit: nesting, headers, parts or URLs beyond it were not read, or were left out of the view's list (url_limit also when a URL was cut) |
host_limit | the message's hosts hold more than MAX_A_LABEL_CHARACTERS characters of distinct A-labels, read in the view's order: the from, reply_to, to and cc domains, then the URLs, the first entry a list leaves out included. A host whose A-labels not yet decoded do not fit in what is left keeps all its A-labels encoded (xn--), as UTS 46 maps them. The From domain is always decoded, and url_host(url.url) reads a host with all its A-labels decoded |
text_truncated | a string was cut at its budget: the text, the HTML or the visible text, a header value, a string of a part, a field read from a header, the envelope or the source |
anomaly_limit | more anomalies were met than are recorded |
Bounds¶
The parser bounds its work on hostile input, and the size of the view. Each bound hit is an anomaly; those that leave content out set truncated.
| Bound | Value |
|---|---|
headers per part (MAX_HEADERS) | 10 000 |
nesting (MAX_DEPTH) | 32 |
parts (MAX_PARTS) | 10 000 |
bytes read of the text and HTML parts, and of each header value (max_text_bytes) | 1 MiB |
URLs (MAX_URLS) | 10 000 |
characters of distinct A-labels the hosts decode (MAX_A_LABEL_CHARACTERS) | 32 768 |
anomalies (MAX_ANOMALIES, MAX_ANOMALIES_PER_CODE) | 1 000, 100 of each code, always the first of each |
The view's size¶
The view's JSON, as model_dump_json(by_alias=True) writes it in UTF-8, takes at most MAX_VIEW_BYTES, 7 172 781 bytes (about 6.84 MiB), whatever the message, with max_text_bytes at most its default. Core refuses a view over 8 MiB, so no message is refused for its view's size.
Each list and each long string of the view has a budget, in bytes of its JSON, escapes included: a control character takes six bytes (\u0001), a quote or a backslash two. A list keeps its first entries, but headers keeps each header that fits the room left, so one long header does not hide the ones after it. A string keeps its start, and no value is altered. Each cut sets truncated and is an anomaly.
| Field | Budget, in bytes of JSON | Anomaly |
|---|---|---|
text, html, visible_text (MAX_TEXT_BYTES, max_text_bytes' default) | 1 MiB each | text_truncated |
headers (MAX_HEADERS_BYTES) | 1 MiB | header_limit |
parts (MAX_PARTS_BYTES), each string of a part | 1 MiB, 1 KiB | part_limit, text_truncated |
urls (MAX_URLS_BYTES), each url, each host and anchor_text | 1 MiB, 8 KiB, 1 KiB | url_limit |
the fields read from the headers, the envelope and the source (MAX_FIELDS_BYTES) | 410 KiB: subject 16 KiB; reply_to, to, cc, references, authentication and the envelope's rcpt_to 64 KiB each; each other string 1 KiB | text_truncated |
anomalies (MAX_ANOMALIES_BYTES) | about 450 KiB: each location and detail 200 bytes | |
| the keys and the fields of fixed size | 1 KiB |
The fields read from the headers come from the first header of each name in the whole header block, even past MAX_HEADERS or when the headers list leaves them out, so padding the headers cannot push them out. A text full of control characters, quotes or backslashes holds fewer than a million characters in its 1 MiB. The URLs and the visible text are read from the whole decoded body (its first max_text_bytes bytes), not from the stored cut, so a link after a long run of control characters is still found.
Parsing in a plugin¶
from spot_sdk.mime import parse, part_bytes
view = parse(
raw,
email_id=email_id,
source="api",
received_at=received_at,
trusted_authserv_ids={"mx.example.com"},
)
data, content_type = part_bytes(raw, "1.2")
part_bytes numbers parts as parse does, and raises PartNotFoundError (a KeyError) for an unknown id or a container (multipart/* or message/*): it serves leaves only. Plugins usually get the view from core and the parts from the gateway; view_from_eml wraps parse for tests.
visible(text) removes the characters that render as nothing: Unicode 16.0's Default_Ignorable_Code_Point, 4 174 code points, such as the zero-width space and joiners, the soft hyphen, bidi controls, fillers, variation selectors and tag characters. They split words a reader sees whole. parse builds visible_text with it; the subject, the headers, display names, anchor text, text and html keep them, as written, so call it on them before matching words.
from spot_sdk.mime import visible
visible("p\u200b\u0430\u200bypal") # "p\u0430ypal"
visible("Pay\u00adPal") # "PayPal"
url_host(text) is the function parse uses to fill Url.host, for a plugin that meets a URL outside the view (a redirect target, a QR code's text, a settings value): the host a browser would reach, "" when there is none. A backslash reads as a slash, and www. is accepted without a scheme. The host is the one Python's urlsplit finds, after the userinfo and before the port, and there is none where urlsplit refuses the URL: a bracket without its pair, brackets misplaced or around no IPv6 or IPvFuture address, or a character NFKC turns into a delimiter (U+2100 is a/c). The SDK finds it in linear time, where urlsplit takes time quadratic in a run of combining marks. The host is read as browsers read it, as the WHATWG URL Standard has them do:
- percent-decoded first, as UTF-8 (a NUL becomes U+FFFD, as everywhere in the view);
- mapped as UTS 46 maps it, by the
idnapackage: case, width, the ideographic full stop, the characters it ignores (the soft hyphen, a variation selector); - each A-label (
xn--) decoded from Punycode when it decodes to a label UTS 46 leaves as it is: valid code points, in NFC. Symbols and emoji, which IDNA 2008 refuses, decode as they do in browsers (xn--g6his a heart); - without its trailing dots.
The SDK reads the host; it does not decide whether a browser would accept it. A host browsers refuse, such as one holding a space or a slash once percent-decoded, a label against the Bidi rule or a joiner out of context, is read all the same and kept: it is evidence.
An A-label stays encoded when it is not Punycode (xn--zz), decodes to ASCII alone (xn--a-), or decodes to a label UTS 46 would change: a fullwidth or a capital letter, a soft hyphen, a full stop, a code point it does not map. Browsers refuse such an A-label, and mapping it would read it as another host: xn--mi7ca7anb7c.com, a fullwidth paypal, would read as paypal.com. A label UTS 46 does not map stays as written, lower-cased.
A host that can be no DNS name is only lower-cased: neither mapped nor decoded. It is counted on what a resolver could see, without its invisible characters (those visible removes) and its trailing dots: a host that keeps more than 1 012 characters, a label more than 252, or more than 63 combining marks in a row as UTS 46 maps them, can be no DNS name. A DNS name holds 253 characters at most, and a label 63 (RFC 1035), and the count can shrink no further: UTS 46 removes only invisible characters, and NFC composes at most 4 code points into one. A run of 64 marks keeps at least 61 code points that are not ASCII, and an A-label spells each with a character at least, after its xn--. So padding never hides a host: pay, 250 soft hyphens, then pal.example reads as paypal.example, as browsers read it. An A-label longer than 63 characters stays encoded, as it is a DNS label itself. These bounds keep the time each host takes to read in check, since CPython reorders combining marks for NFC in time quadratic in their run, and Python decodes Punycode in time quadratic in a label. A host is mapped once while it is among the last 256 the process mapped. url_host never raises. Address.domain is read the same way, without the percent-decoding.
from spot_sdk.mime import url_host
url_host("HTTPS://Mail.XN--PYPAL-4VE.example/c") # "mail.p\u0430ypal.example"
url_host("https://p%D0%B0ypal.example./") # "p\u0430ypal.example"
url_host("http://xn--g6h.example/") # "\u2665.example"
url_host("www.Example.org/e") # "www.example.org"
url_host("mailto:alice@example.com") # ""
parse decodes each A-label once a message, and at most 32 768 characters of distinct A-labels (MAX_A_LABEL_CHARACTERS), 520 of the longest: Python decodes Punycode a character at a time. The hosts are read in the view's order: the domains of from, reply_to, to and cc, then the URLs' hosts. A list reads the first entry it leaves out before leaving it out, so that entry's host counts too. A host whose A-labels not yet decoded do not fit in what is left keeps all its A-labels encoded, as UTS 46 maps them, and the view notes host_limit; a later host whose A-labels fit is still decoded. One host holds far fewer A-label characters than the budget, so the From domain, read first, is always decoded.
A sender can spend the budget on purpose, to leave a lookalike link encoded (xn--pypal-4ve.com): host_limit is the evidence. url_host(url.url) reads one host with all its A-labels decoded, for a host to compare with a brand.
Verdicts and signals¶
AnalyzerVerdict (spot_sdk.verdict):
| Field | Meaning |
|---|---|
verdict | VerdictClass: malicious, suspicious, spam, benign or unknown |
score | 0-100: how likely the email is malicious; a benign verdict has a low score |
confidence | 0-100: how sure the analyzer is of its own verdict |
signals | the evidence, as Signals |
summary | one human-readable sentence; with unknown, why |
details | analyzer-specific data, stored and shown raw |
model_version | the model behind the verdict, when there is one |
A Signal has a name (dotted and lower-case: ^[a-z0-9_]+(\.[a-z0-9_]+)+$), a weight in score points (-100 to 100), an evidence and a location. Workflows can make a signal decisive, by exact name or by prefix (misp.*).
AnalyzerVerdict.unknown(reason, *, details=None, model_version=None) is the answer of an analyzer that cannot decide: unknown, a score and a confidence of 0, and the summary No verdict: <reason>.
AnalyzerVerdict.from_probability(probability, *, signal, location=None, details=None, model_version=None) is the verdict of a model that gives the probability that the email is phishing, from 0 to 1: the score is round(100 * probability), the class follows it as the default malicious_threshold and suspicious_threshold of VerdictPolicy have it (else benign), the confidence is max(p, 1 - p) in percent, and the one signal, signal, weighs the score with the probability as its evidence (0.9312) and location. A probability out of 0-1, or NaN, gives unknown, with a summary saying so. details and model_version go to the verdict either way.
spot_sdk.signals names the signals of the official analyzers:
| Constant | Name |
|---|---|
AUTH_SPF_FAIL | auth.spf_fail |
AUTH_DMARC_FAIL | auth.dmarc_fail |
HEADER_REPLY_TO_MISMATCH | header.reply_to_mismatch |
URL_TEXT_HREF_MISMATCH | url.text_href_mismatch |
TEXT_HOMOGLYPH_MIXED_SCRIPT | text.homoglyph_mixed_script |
NLP_PHISHING_PROBABILITY | nlp.phishing_probability |
ML_PHISHING_PROBABILITY | ml.phishing_probability |
MISP_IOC_MATCH | misp.ioc_match |
LLM_PREFIX | llm.: analyzer-llm emits llm.<indicator> |
MIME_PREFIX, ANOMALY_SIGNALS | mime.: ANOMALY_SIGNALS[code] for each anomaly code |
The plugin runtime¶
create_plugin_app¶
def create_plugin_app(
manifest: Manifest,
settings_cls: type[SettingsT],
*,
analyze: AnalyzeHandler[SettingsT] | None = None,
respond: RespondHandler[SettingsT] | None = None,
sync: SyncHandler[SettingsT] | None = None,
lifespan: Lifespan[FastAPI] | None = None,
health: HealthCheck | None = None,
load: Loader[SettingsT, LoadedT] | None = None,
) -> FastAPI: ...
| Handler | Signature | Route |
|---|---|---|
analyze | (AnalyzeRequest, settings) -> AnalyzerVerdict; with load, (AnalyzeRequest, settings, loaded) | POST /internal/analyze |
respond | (RespondRequest, settings) -> None | POST /internal/respond, answered 204 |
sync | (settings) -> int, the documents written | POST /internal/sync (202) and GET /internal/state |
The manifest's kind and the handlers must match: the declared kind without its handler, or a handler of another kind, raises ValueError. A mail retriever has no handler.
Each handler may be async def, or an object whose __call__ is async def (run on the event loop), or def (run in a worker thread).
SyncState (spot_sdk.app): in_progress, last_sync_at, last_finished_at, last_sync_count, last_error.
load(settings), a plain function, loads what the analyzer needs once the app starts, after lifespan's startup, in a daemon thread: the app answers meanwhile. It needs analyze, which then gets what it returned as its third argument; create_plugin_app raises ValueError otherwise, and for an async load.
| The load | /health | /internal/analyze |
|---|---|---|
| running | starting, reason loading | unknown: No verdict: loading. |
| raised | unhealthy, reason the load failed (OSError), for good | unknown: No verdict: the load failed (OSError). |
| returned | health's answer; healthy without one | the handler's verdict |
A failed load is logged as a handler's failure: by the exception's type and place, never its message.
The app's OpenAPI document, app.openapi(), describes the contract as the app runs it; the app does not serve it (/openapi.json is 404). Core generates the plugin contract's document from it. The request bodies are components of the app's own document, so read it from the plugin app itself: another FastAPI app that includes its routes leaves their $ref dangling.
| Route | Request body | Answers |
|---|---|---|
POST /internal/analyze | AnalyzeRequest | 200 AnalyzerVerdict, 401, 422, 500 |
POST /internal/respond | RespondRequest | 204 with no body, 401, 422, 500 |
POST /internal/sync | none | 202 SyncState, 401 |
GET /internal/state | none | 200 SyncState, 401 |
Every /internal/* route declares the HTTP Bearer scheme (HTTPBearer), the plugin's own token; /health and /manifest declare none. The 401 and 500 answers are an ErrorAnswer ({"detail": "unauthorized"}, "analysis failed", "response failed"), and the 422 an InvalidRequestAnswer: the fields refused (type, loc, msg), never the values sent.
health, a def or async def without arguments, returns the plugin's state as a PluginHealth (spot_sdk.app, frozen, and validated again wherever it is read): status, one of healthy, starting and unhealthy, and an optional reason of at most 200 characters, without control characters (C0 and DEL), which holds no content: /health is open, and core's logs and the dashboard show the reason. GET /health calls the check on each request and always answers 200 with {"status": ..., "version": ...}, and "reason" when there is one; without health, status is healthy.
Prefer an async def check that reads state the plugin keeps: a def check runs in the handlers' thread pool, so under load it waits behind them. Report the expected conditions (starting, "the model did not load") as a PluginHealth, never by raising: a check that raises, or returns no valid PluginHealth, answers unhealthy with the reason the health check failed, and logs an ERROR line on every call, by the exception's type and place only.
Logs and correlation¶
Call configure_logging() once, at the top of the module uvicorn imports the app from, not in a lifespan: uvicorn writes Started server process and Waiting for application startup. before the lifespan runs.
from spot_sdk import configure_logging, create_plugin_app
configure_logging()
app = create_plugin_app(MANIFEST, Settings, analyze=analyze)
Each record is then written to stdout, and to LOG_FILE when it is set: one JSON object on one line (LOG_FORMAT=json, the default), or, for development, a readable line, the traceback after it (LOG_FORMAT=text: time, level, logger, [correlation ID], message). Any other LOG_FORMAT raises ValueError. LOG_LEVEL sets the level, INFO by default.
| Field of a JSON line | Holds |
|---|---|
timestamp | the time, in UTC to the millisecond: 2026-10-13T09:06:55.186Z |
level, logger | the record's level and logger name |
message | the message, its arguments filled in |
correlation_id | the current correlation ID; left out outside any work |
exception, stack | the traceback of logger.exception, the stack of stack_info=True |
| any other | the record's extra= fields, as JSON; all as text when JSON cannot hold one of them (a NaN, a tuple as a key) |
uvicorn's lines take the same form: its access lines without the query string, and an exception that no handler caught, a lifespan's failure included, by its type and frames, never its message. Python's warnings are log lines (py.warnings), and httpx, httpx2, httpcore and httpcore2 log from WARNING: httpx and httpx2 write each request's URL at INFO, and httpcore and httpcore2 the raw headers at DEBUG. Under --reload or several --workers, the parent process, which never imports the app, writes uvicorn's own text lines.
A line never holds an email's content (a subject, a body, a header value, an address), a secret, a token or a query string. Log an exception whose message can quote one of them by its type and place, as the SDK logs a handler's failure: logger.exception writes the message. And give extra= no field that can hold one: the line writes every extra field.
Each request to the plugin app runs under the X-Correlation-ID core sent, or a new ID when it has none, or one that is not 1 to 128 letters, digits and ., _, :, -; the answer carries it, a 500 included (CorrelationMiddleware, which wraps the whole app: a middleware the plugin adds runs under the ID too). While a handler runs, on the event loop or in a worker thread, and during the sync it starts, spot_sdk.correlation.current() returns the ID, every log line holds it, and the SDK's clients send it on to SPOT.
Work the plugin starts itself has none: run it under correlated(), a new ID, or the one given when it is valid, so that its lines and its calls to SPOT share it. A mail retriever, for each message:
from spot_sdk.correlation import correlated
for raw in messages:
with correlated():
await client.ingest(raw)
PluginSettings¶
spot_sdk.settings.PluginSettings is a pydantic-settings class. Subclass it with the plugin's own fields.
| Field | Source | Default |
|---|---|---|
spot_plugin_token (SecretStr) | /run/secrets/spot_plugin_token | empty: every /internal call is refused |
spot_plugin_id | SPOT_PLUGIN_ID | empty |
spot_gateway_url | SPOT_GATEWAY_URL | http://api-gateway:8000 |
spot_knowledge_url | SPOT_KNOWLEDGE_URL | http://knowledge:8000/api/v1/knowledge |
Precedence: constructor, /run/secrets files, environment, dotenv (when the subclass sets env_file). Names are case-insensitive. Tests pass _secrets_dir=tmp_path to read another directory.
Behind an egress proxy that needs a user and a password, core forwards the proxy variables (HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and their lower-case forms) without them, and mounts them as /run/secrets/spot_proxy_credentials (PROXY_SECRET), user:password percent-encoded as a URL carries them: they are never in the container's configuration (docker inspect does not show them). create_plugin_app first calls apply_proxy_credentials(secrets_dir=None, environ=None), which puts them back into each proxy variable of the process whose URL names no user, so the plugin's HTTP clients reach the proxy authenticated. After the call the process's proxy variables hold them, and a child process it starts inherits them. An httpx2 client reads them when it is built: build one after create_plugin_app (one built above the app = create_plugin_app(...) line does not get them), or, in a plugin that builds no app, after calling apply_proxy_credentials() itself. The SDK's own clients build theirs per call, so they get them wherever they are made. It returns whether the file exists, and never logs them; a file that does not hold user:password as a URL carries them raises ValueError naming the file, never its content.
The manifest declares each setting as a SettingField (spot_sdk.manifest): its type (string, integer, number, boolean, url, secret), required, default, description, secret, and enum, the values allowed. SettingField refuses an enum that no value can satisfy, so the plugin fails when it builds its Manifest, not when an admin configures it: an empty one, or one whose values do not fit type (integer: int, never bool; number: int or float, never bool; boolean: bool; string, url and secret: str).
multiline (default false) marks a value of several lines: the dashboard renders a textarea and keeps the line breaks. It is for string and secret settings only; on any other type the manifest is refused.
"misp_ca_cert": SettingField(
type=SettingType.STRING,
multiline=True,
description="PEM CA certificate (-----BEGIN CERTIFICATE-----...)",
)
minimum and maximum bound an integer setting (by ints) or a number setting, and pattern a string setting, so that core refuses a value out of them when it is saved, rather than the plugin at its next start. The pattern is matched against the whole value, as Python's re.fullmatch reads it ([a-z]+ refuses ab-cd): core checks it, and the dashboard shows it as a hint only, since browsers read regular expressions otherwise. A label without them reads as unbounded. The manifest is refused when a bound is on another type, the minimum exceeds the maximum, the pattern does not compile, or the default or an enum value is out of them; and when a number's default or enum value is not finite (NaN escapes every bound, and neither it nor infinity is JSON).
"MAX_LENGTH": SettingField(
type=SettingType.INTEGER,
default=512,
minimum=1,
maximum=512,
description="How many tokens of the text the model reads",
),
"MODEL_NAME": SettingField(
type=SettingType.STRING,
default="decision_tree",
pattern="^[A-Za-z0-9][A-Za-z0-9_.-]*$",
),
spot_sdk.testing.assert_bounds_agree(MANIFEST, Settings) checks, in the plugin's tests, that the manifest states the bounds of the settings model. Pydantic searches a field's pattern anywhere in the value: anchor it, ^...$, so that it means what the manifest's does, and write the same text in both; the anchors change nothing to a whole match.
Model input¶
spot_sdk.model_input holds the text a phishing model reads and the manifest its files carry, so that training (spot-models) and the model analyzers read the same text.
model_text(subject, body, version="spot-text-1") -> str raises ValueError for a version outside TEXT_VERSIONS. The steps of spot-text-1, in order:
- HTML entities are decoded and the characters that render as nothing are removed (
spot_sdk.mime.visible). - The angle markers of MeAJOR v2,
<|URL|>and<|EMAIL|>, become the wordsURLandEMAIL;<|SYMBOL|>,<|SIMBOL|>,<|EMOJI|>,<|EMAIL_SEPARATOR|>,<|FILE|>,<|PHONE|>and<|PGP|>are dropped. This comes before the tags, as they look like one. <script>and<style>elements are dropped with their contents (an unclosed one to the end), as the email view'svisible_textleaves them out. Then tags are dropped:<, an optional/, a name of letters, digits,_and-with an optionalprefix:(Outlook's<o:p>), then attributes up to>; or<!or<?up to>(comments, doctypes, XML declarations). A>inside a quoted attribute or a comment ends the tag (<a title='x>y'>leavesy'>). Anything else stays:1 < 2 and 3 > 1, and the angle brackets ofBob <bob@corp.example>and<https://evil.example/x>, whose address and URL are replaced in the next step. Escaped markup (<b>x</b>) was decoded in step 1, so it is dropped as well.- Each URL (
http://,https://orwww., up to the next space or angle bracket: trailing punctuation and a closing parenthesis go with it) and each address becomes the wordURLorEMAIL. An address has a local part of up to 64 characters, labels of up to 63 and up to 8 dots after the@: a longer local part, or one glued to a longer run of letters, digits and.+-, is not an address and stays as written. - The bracket markers
[URL]and[EMAIL_ADDRESS]becomeURLandEMAIL; the markers of MeAJOR v2 that name an entity type ([NAME],[ORGANIZATION],[DATE], ... 22 in all, listed inmodel_input.py) are dropped, leaving what is glued to them ([NAME]sgivess). Any other bracketed text ([EXTERNAL],[PATCH]) stays. - Whitespace is collapsed and the ends trimmed. Case is kept: an uncased tokenizer lowers it.
Every step runs in linear time, so a 1 MiB body costs the same as any other text. A released normalisation never changes: tests/data/model_text_golden.json holds its outputs, and a change to any of them (one that comes from spot_sdk.mime.visible or from Python's HTML entity and Unicode tables included) is a new name (spot-text-2), never an edit of spot-text-1. A model keeps reading the one named in its manifest.
ModelManifest is the schema of a model directory's spot-model.json (frozen, unknown fields refused): name, version (up to 64 characters of letters, digits, ., _, + and -, starting with a letter or digit), text and max_tokens, with the property label. read_manifest(directory) and write_manifest(directory, manifest) read and write it; read_manifest raises ValueError naming MANIFEST_FILE and saying missing, unreadable or invalid with the failing field names, never their values.
Model files and URLs¶
spot_sdk.artifacts.fetch(url, *, cache=CACHE) -> Path gives a local path for what a setting names. A value without a scheme, which does not start with //, is a local path and comes back as it is: a Hugging Face id is one, so a plugin that takes them tells them apart first. A URL must be http or https with a host and no credentials (a presigned URL gives access), and its path must end with .tar.gz, .tgz, .tar or .zip (a query may follow). Its archive is downloaded once into cache/<the URL's SHA-256> (CACHE is ~/.cache/spot-sdk/artifacts): it is downloaded and extracted in a temporary directory beside it, then renamed into place, so a stop half-way leaves nothing. fetch returns that directory, as the archive holds it: a plugin whose archives wrap the model in a directory descends into it itself. Only files and directories are extracted: a member that would land outside the directory, a link or a special file is refused. A server silent for 60 s fails the download.
A refusal or a failure raises FetchError, logged at ERROR: its message names the URL as redact shows it, and why; of a failed download, the error's type only, as its text can quote the URL. A URL that redact refuses is refused without being quoted. When another process put the same URL's archive in place meanwhile, fetch returns it.
spot_sdk.urls.redact(url) is a URL as logs and details may show it: its scheme, host, port and path, without the userinfo, query and fragment, where credentials and a presigned URL's signature are. It raises ValueError, which quotes nothing of the URL, for a value it cannot show safely, which is not one a well-formed URL needs: one it cannot parse, or whose port is not a number (in https://user:password%40host, the password reads as the port); a host with a % (%40 is an encoded @); and an @ anywhere but in the userinfo, its query and fragment included: in https:user:password@host or user:password@host, no // precedes the host, and in https://pass#word@host a # or ? ends the userinfo early.
Clients¶
The three clients are asynchronous and use httpx2. Each takes a transport= argument, which tests set to an httpx2.MockTransport.
SpotClient¶
spot_sdk.clients.SpotClient(base_url, token, *, timeout=30.0), or SpotClient.from_settings(settings).
| Method | Route | Scope |
|---|---|---|
ingest(raw, *, envelope=None, priority="normal", wait_for_verdict=False, timeout_ms=30_000) -> IngestionResult | POST /internal/ingest | ingest |
report_action(job_id, action, *, applied) -> None | POST /internal/jobs/{job_id}/action | ingest |
raw(email_id) -> bytes | GET /internal/emails/{email_id}/raw | content:read |
part(email_id, part_id) -> tuple[bytes, str] | GET /internal/emails/{email_id}/parts/{part_id} | content:read |
result(job_id) -> OrchestrationResult | GET /internal/results/{job_id} | results |
ingest sends the message as base64 and refuses an empty one. With wait_for_verdict, the call waits timeout_ms on top of the client's timeout. part returns the media type without its parameters. An error status raises SpotAPIError, whose message names the route and the status (POST /internal/ingest answered HTTP 413) and whose status_code holds the status. So does an answer that does not validate (... answered HTTP 200 with a malformed answer), with no validation error attached. Neither ever holds the message.
KnowledgeClient¶
spot_sdk.knowledge.KnowledgeClient(url, token, *, source="", timeout=15.0, retrieval_limits=None), or one of:
KnowledgeClient.from_settings(settings, *, retrieval_limits=None): the plugin's store, token and id (as the source);KnowledgeClient.for_analysis(request): the same, fromPluginSettings(), with the request's retrieval limits.
| Method | What it does |
|---|---|
fetch(tags=None, text=..., top_k=5, min_score=0.0, strategy="similarity") | semantic search with a tag filter; top_k and min_score are capped by the retrieval limits |
replace_source(docs) | makes docs the provider's whole set of documents |
upsert(doc), bulk_upsert(docs) | write single documents |
delete(doc_id) | deletes one of the plugin's own documents (DELETE /sources/{source}/documents/{doc_id}); it needs the plugin's id (SPOT_PLUGIN_ID), as replace_source does |
A document's id is unique within its source, the plugin that wrote it: two providers may use the same id, and an upsert is idempotent on (source, id). The store sets source to the writing plugin's id.
Tag expressions: a, a+b (and), a|b (or), a+b|c (and binds tighter). KnowledgeTag (spot_sdk.knowledge_tags) names the shared tags, and chunk_text(text, max_chars=2000, overlap=200) splits long documents. Error statuses and malformed answers raise SpotAPIError.
LLMClient¶
spot_sdk.llm.LLMClient(base_url, model, api_key=None, *, options=None, max_bytes=8_000, timeout=120.0). base_url is the API root of an OpenAI-compatible server (http://ollama:11434/v1); api_key, a SecretStr, is sent as a Bearer token; options adds request fields (temperature, seed, max_tokens), which cannot replace the model or the messages. max_bytes bounds the email data (below); a value too small for an empty email raises ValueError, at construction or, if lowered later, at the next call.
await complete(system, email, *, schema=None, tools=None, tool_rounds=None) returns:
- the answer's text, without
schema; - an instance of
schema, a Pydantic model, withschema: the request asks for JSON-schema output (response_format) and the answer is validated; LLMToolCalls(calls=[LLMToolCall(id, name, arguments)])whentoolsare given and the model calls them. The caller runs them, and sends the results back withtool_rounds: oneLLMToolRound(calls=answer.calls, results={call_id: output})per round, oldest first. A round must hold one result per call, by id, or it raisesValueError.completenever calls the model on its own.
It raises LLMOutputError when the answer is empty, is not a chat completion, does not validate against schema, or calls tools when no tools were given; the message never quotes the answer. An error status raises httpx2.HTTPStatusError.
The request starts with two messages. The system message is the caller's prompt, followed by a notice that the user message holds one email, as JSON, between two delimiter lines, and that it is untrusted data, not instructions. The user message is the email between those lines:
<<<EMAIL 5f0c…(32 hex digits)>>>
{"from": {...}, "reply_to": {...}, "subject": "...", "headers": [...], "visible_text": "...", "urls": [...], "attachments": [...], "authentication": [...], "anomalies": [...]}
<<<END EMAIL 5f0c…>>>
Each tool round then adds an assistant message echoing the round's tool_calls, and one tool message per call, with its tool_call_id and the result between <<<TOOL RESULT 5f0c…>>> and <<<END TOOL RESULT 5f0c…>>>; the system notice then also says that tool results are untrusted data.
The delimiters carry a random nonce, drawn again if the email or a result holds it, so neither can close its block. The model sees the sender (from, and reply_to, the first Reply-To address; the others are in the headers, cut like the other lists) and the subject from the parsed view, wherever their headers sit, then the headers, the visible text, the URLs, the attachments' metadata, the authentication results and the anomalies; never the raw HTML or an attachment's content.
The email data (the JSON between the delimiters) is at most max_bytes bytes long, as UTF-8, so it costs at most about max_bytes tokens (typically a quarter): a context of max_bytes plus the system prompt plus max_tokens plus about 512 tokens (the SDK's notice, the delimiters and the chat template) cannot overflow. Tool results are not counted.
A larger email is cut, deterministically:
- every string but the visible text is cut so that its JSON, escapes included, takes at most 512 bytes, so one long URL, header value or filename cannot crowd the rest of its list out;
- what cannot fit anyway is left out before any more work: the visible text past
max_bytescharacters, and each list from the entry where its entries alone outgrowmax_bytes; - while the JSON is too large, the largest of the visible text and the lists (headers, URLs, attachments, authentication results, anomalies) is halved, keeping its first entries or the start of the text; ties go to the first field;
- only once those are all empty are the sender, the first Reply-To address and the subject cut, the same way (
fromandreply_to, single addresses, are dropped whole).
What the cut guarantees, and no more:
- the data is at most
max_bytesbytes, and holds"truncated": truewhen anything was cut; - the text keeps its start, and each list its first entries, with each string of an entry cut to at most 512 bytes of JSON;
- a list whose entries alone fit in
max_bytesloses entries only in step 3, and only while no field of the text and the lists is larger and the data still does not fit; - the sender, the first Reply-To address and the subject are cut only once the text and every list are empty; together they take at most 3 684 bytes of JSON, whatever their characters, so they cannot crowd the rest out beyond that;
- the other Reply-To addresses reach the model only through the headers list, which is cut like the other lists;
- the system prompt, the notice and the delimiters are never cut;
- only the model sees this cut: core and the other analyzers get the whole view.
Hosting a plugin¶
Manifest.from_labels(labels) is the inverse of Manifest.labels(): Manifest.from_labels(m.labels()) == m. It reads the labels of an image, as the catalog and the plugin test do, and ignores labels of other prefixes. It raises ValueError: pydantic's ValidationError for a missing or invalid field, a JSON error for a label that is not JSON.
spot_sdk.hosting holds how core hosts a plugin and reads its answers. The plugin-manager creates every plugin container with it, and the plugin test creates the container under test with it too, so that a plugin runs in SPOT as it ran in its test.
| Name | What it is |
|---|---|
HARDENING | the options of every plugin container, as the Docker SDK names them: all capabilities dropped, no-new-privileges, at most 512 processes |
limits(resources) | mem_limit and nano_cpus from a Resources |
isolated(egress) | whether a plugin runs on a network without a route out: every class, from SPOT 2.1 (spot/spot#148) |
setting_text(value) | a setting as text: true or false, an empty text for None |
environment(settings, *, plugin_id, kind, version, gateway_url, knowledge_url, proxy=...) | the container's environment: the settings in upper case, then proxy, then SPOT_GATEWAY_URL, SPOT_KNOWLEDGE_URL, SPOT_PLUGIN_ID, SPOT_PLUGIN_KIND and SPOT_PLUGIN_VERSION. No credential is in it |
secrets_archive(secrets) | a tar of /run/secrets/<name>, each file mode 0444, to put in the container before it starts |
validation_cause(error) | where and why a pydantic error says an answer is invalid, as core names it (score: less_than_equal), never the value |
TOKEN_SECRET | spot_plugin_token, the secret file that holds the plugin's token |
MAX_ANSWER_BYTES, IDENTITY | the 1 MiB core reads of an answer, and the Accept-Encoding: identity header it sends |
read_answer(response, max_bytes=MAX_ANSWER_BYTES) | a streamed answer's body, as sent, up to max_bytes. Raises AnswerRefusedError when it is encoded, and its subclass AnswerTooLargeError when it is longer than max_bytes |
parse_verdict(body) | the AnalyzerVerdict of an answer. Raises InvalidResponseError, naming where and why, never the content |
Test helpers¶
spot_sdk.testing:
| Helper | What it does |
|---|---|
view_from_eml(eml, **meta) | parses a path or bytes with parse; email_id, source ("test") and received_at have defaults, and the other keywords go to parse |
make_view(**fields) | an EmailView of an empty message with the fields given; when only text is given, visible_text is the text without the characters that render as nothing, as parse makes it (a visible_text given wins) |
assert_contract(app, eml, *, token, timeout=10.0) | waits up to timeout seconds for GET /health to leave starting, as a plugin that loads a model does; it must then be healthy (an unhealthy plugin fails with its reason). Then posts an AnalyzeRequest for the parsed .eml to the app's /internal/analyze with the token, checks the 200 and the AnalyzerVerdict, checks that the same call without the token is 401, and returns the verdict |
assert_bounds_agree(manifest, settings_cls) | checks that each setting of the manifest has the bounds of its field (MAX_LENGTH: max_length): ge as minimum, le as maximum, pattern as pattern, an integer's gt and lt as the next value in; a number's gt or lt, which no inclusive bound states, a field's pattern not anchored around all its branches (^...$), which pydantic searches, a setting without a field and each bound stated otherwise fail, all listed in the AssertionError |
assert_provider_contract(app, knowledge, *, token, timeout=10.0) | runs two syncs of a context provider's app as core does, the app writing to knowledge (a FakeKnowledgeClient with the provider's id as source), and returns the second sync's SyncState. Checks: /internal/sync and /internal/state answer 401 without the token and with another; /internal/sync answers 202 with a sync in progress; /internal/state reports that sync once it ends, within timeout seconds, without error and with the number of the source's documents as last_sync_count; the sync writes at least one document of the source, each a valid KnowledgeDocument; the second sync removes a document of the source it does not write, and writes the same ids |
FakeSpot(*, verdict=BENIGN, score=0) | SpotClient's methods in memory; ingested lists the IngestionRequests, add_email(raw, *, email_id=None) and add_result(result) fill it, and what it lacks is a 404 SpotAPIError. app(*, token, knowledge, llm_answer=None) serves the same store over HTTP, as a FastAPI app (see the plugin test engine) |
FakeKnowledgeClient(retrieval_limits=None, *, source="") | the Knowledge Store in memory, its documents kept by source and id, with naive word matching; delete and replace_source touch only the client's source (delete of a document it does not hold raises SpotAPIError 404, as the store answers), and an upsert without a source takes the client's, while one of another source is refused (ValueError, nothing of the batch stored) when the client has a source; for_analysis(request) shares the store with the request's limits; add(*docs), which stores without awaiting, all() and clear() |
FakeLLM(answers) | complete answers from the script: text (validated against schema), a model, LLMToolCalls (refused without tools), or an exception to raise; requests lists each call as LLMRequest(system, email, schema, tools, tool_rounds) |
The plugin test engine¶
With the testing extra (pip install "spot-sdk-python[testing]"), spot_sdk.testing also runs a plugin's image as SPOT runs it. Without the extra, import spot_sdk.testing and the helpers above work as before; corpus, engine and container raise an ImportError that names the extra. Import them by name. The guide shows spot-cli plugin test, which uses it, from SPOT 2.1.0-beta.2.
| Module | What it has |
|---|---|
spot_sdk.testing.container | run_plugin_test(image, corpus, *, settings, secret_files, timeout=30.0, start_timeout=120.0, docker=None) creates the network and the container, serves the fake SPOT, checks the plugin and removes what it created, then returns the Report. It must run in a container of the Docker daemon it uses. Raises PluginTestError when the run cannot be made |
spot_sdk.testing.corpus | load_corpus(directory) reads phish/, benign/ and corpus.yaml (CorpusFile: settings, trusted_authserv_ids, knowledge, llm, expect); Expectation has verdict, signals and no_signals. The Corpus holds the messages, the seed documents and the sha256 of them all |
spot_sdk.testing.engine | check_plugin(client, ...) runs the checks over HTTP; serve(app, *, host, port, timeout=5) serves an ASGI app with uvicorn in a thread |
spot_sdk.testing.report | Report, Check, EmailResult, PluginTestError, and junit(report), the report as JUnit XML. Report.passed is true when every check passed and every message completed and met its expectation |
spot_sdk.testing.metrics | count(results) gives Metrics: the counts, the confusion matrix, the rates and the mean score; wilson(successes, n) the 95 % Wilson interval, None when n is 0. spot-cli benchmark report counts with the same functions |
Workflows and results¶
These models are those of core's workflow API and results:
Workflow:stagesofWorkflowStage(each withAnalyzerConfigs:weight,timeout_ms,required,failure_strategy,condition),verdict_policyandon_verdict(responder ids by verdict); the mode comes with each job, not the workflow; itsidis a letter or digit, then up to 127 letters, digits, dots, underscores or hyphens (WORKFLOW_ID_PATTERN), and another id is refused;VerdictPolicy:method(AggregationMethod),malicious_threshold(80),suspicious_threshold(50),decisive_signals(DecisiveSignal(signal, verdict)),decision_stages;OrchestrationResult: the verdict, score anddecided_by, theAttributedSignals strongest first, eachAnalyzerOutcome, and the policy;IngestionRequest,IngestionResultandIngestionStatus(accepted,analyzed,timeout).