Scanner version history
analyzer v33 31 versions shipped
The scanner reads every server's published source — never running it — to infer what it can do and surface security signals for review. This is what each version detects, and when it changed. A bump re-checks the whole catalogue with the new version, so older servers benefit without waiting for a new release.
What it detects today
v33The detector families running at the current version. Each raises findings tagged by confidence (attested / reported / inferred) and severity, which combine into a composite risk grade — see methodology.
-
capabilitiesInfers a server's permission surface — filesystem, shell, network, secrets, database, untrusted-input — from imports, call-sites, tool names, and input schemas. Six bits feed every signal below.
-
tool_surfaceMCP tool registrations read from source — SDK calls and decorators across JavaScript, Python and Go, chosen by file extension rather than by the repository's recorded language — plus low-level sites that declare the whole tool list in one place. Names are taken only from literal text: a site whose names are imported or built at run time is recorded as a tool surface with no readable names (in the scan output; not yet published on this site).
-
tool_poisoningHidden instructions, zero-width / bidi unicode, ANSI terminal escapes (line-jumping), pseudo-tags, and base64-smuggled directives in tool names, descriptions, and schema fields.
-
exfil_comboA single tool — or a server's tools together — that both reads data (files, secrets, a database) and reaches the network. Severity follows what is read.
-
toxic_flowThe lethal trifecta: untrusted-content ingestion + private-data access + network exfil reachable in one session. Checked per-tool, per-server, and across directly-connected servers.
-
tool_shadowA tool impersonating one exposed by a verified server (including homoglyph / look-alike names), and cross-server steering that points the agent at another named tool.
-
dangerous_codeCommitted secrets, dynamic exec, secret-to-network and secret-to-shell taint, suspicious endpoints, logged credentials, over-broad OAuth scopes, base64-decode-to-exec obfuscation, telemetry beacons, and clipboard sinks.
-
prompt_injectionHidden-channel instructions in the README, package description, and bundled skill prose an assistant reads before calling the server (indirect / second-order injection).
-
supply_chainInstall-time hooks, bundled .vsix / .zip archives and editor extensions (opened and folded into the scan), dynamic require of a variable, transport posture (0.0.0.0 bind with no auth), and IDN-homograph hosts.
-
impersonationTyposquat (edit-distance) and homoglyph radar against popular and official names — a name one keystroke or one look-alike character away from a trusted package.
-
rug_pullTool definitions tracked over time: an alert when a tool quietly gains a dangerous capability or a hidden instruction after you installed it.
Version history
newest first-
Nine in ten servers with a published tool list showed none of it
new detection fewer false alarmsA server's tools are the thing you actually judge it by, and the scanner was missing nearly all of them. Take the servers whose tool list a registry already publishes independently, and where the code scan succeeded: 106 of them, and in 96 the scan found no tools at all. The causes turned out to be mundane and specific. A tool's name is usually written on the line below the call that registers it — that is simply how a code formatter lays out a call with three arguments — and only the same line was ever read. Python servers were scanned with JavaScript-only patterns whenever the repository's language was not recorded, which was true of most of them. One widely-copied project scaffold registers tools through a plain function rather than a method, and the pattern required a method. All three are fixed, and so is the opposite problem: text sitting inside a comment or a Python docstring, which is exactly where an author writes a usage example, was being read as a real registration. The scanner also learns to recognise servers that declare their whole tool list in one place instead of one call at a time — and when the names in such a place are imported from elsewhere or built while the server runs, it records that a tool list exists and refuses to invent either the names or the number of them.
Technical detail
- The gap was measured before anything was changed, not estimated. 106 servers carry a tool list attested by the Smithery registry and also have a successful code scan; the scan found zero tools in 96 of them. Each of those 96 artifacts was re-fetched at the exact revision it had been analysed at and its registration sites counted with comments stripped, which attributes the misses to a handful of concrete shapes rather than to a hunch: 21 repositories are Python `@mcp.tool()`, 17 are JavaScript `.registerTool(`, 16 are a bare `tool(...)` call, 7 more are `.tool(`, and a further 23 fall to the remaining families — 21 of them declaring the whole tool list at a single low-level site, and 2 written in Go, which no JavaScript-or-Python scan could read at all. All tool detection also moved out of the permission scan it had shared a walk with since the first version of the analyzer, into a module of its own, because the two make different kinds of claim — a permission is quotable evidence for a reviewer, a tool name is an identity fact that is published as a count and appended to the definition history a rug-pull alert is diffed against.
- Registrations written across more than one line are now read. Of 322 real `.registerTool(` call sites, 308 — 95.7% — put the name literal on the line after the opening parenthesis, and only 2 sites, in a single repository, matched what the scanner looked for. The scan now reads a bounded window past the call instead of the anchor line alone, and the same fix covers `.tool(`. A bare `tool("name", {…})` free function, which one popular TypeScript scaffold exports, is now matched too, but only in a file that imports an MCP SDK: `tool(` on its own is far too ordinary a word to accept unqualified. Three further registration shapes are covered for completeness rather than because the measurement demanded them — `.addTool({ name })` from the `fastmcp` and `litemcp` packages, Python's `add_tool` and `mcp.tool(fn)` call forms, and the parenthesis-less `@mcp.tool` decorator that one Python SDK accepts and the official one rejects outright.
- Which patterns run is now decided by a file's extension, not by the repository's recorded language. The language field is a guess about a whole artifact and it is empty for 70 of the 106 servers measured, in which case it fell back to JavaScript — so a Python server with no recorded language had its `.py` files skipped as "not source code", and its entire tool surface with them. That single fallback is the largest measured cause of the misses. Go servers, which no JavaScript-or-Python pass could ever have read, get their own patterns for the same reason. The permission scan keeps its language-scoped rule, because the code patterns it looks for genuinely are language-specific in a way a registration idiom is not.
- Servers that declare their whole tool list at one site are detected for the first time — the low-level SDK handler in both JavaScript and Python, the newer string-keyed form of it, and hand-rolled JSON-RPC dispatch with no SDK at all. Whether the names can be read out of such a site is a separate question with four answers, established by resolving all twelve JavaScript cases by hand: a literal list written inline (1), a list defined higher up in the same file (6), a name list imported from another module (2), and a list genuinely assembled while the server runs (3). The first two are readable from the text and are implemented; the rest are not, and they are recorded as a tool surface with no readable names rather than padded with a guess. Guessing would be worse than silence here, because tool names are published as a count that is treated as evidence a server is real and are appended to an append-only history that a change alert is computed from — an invented name is a fabricated alert. Nothing on this site displays that low-level surface record yet: no page and no stored field carries it, deliberately, so that no wording can claim we know a server's tool list while "we could not read the names" is still a real answer. Publishing it, with copy that says exactly that, is the follow-up.
- The false-positive side is closed at the same time, each case pinned by a fixture. The detector previously had no comment suppression of any kind — the measurement's own first pass found `.registerTool(` in 33 repositories, and 16 of those were prose inside a comment block in one shared scaffold. JavaScript comments and Python triple-quoted docstrings are now masked, and the docstring half matters most: a Python usage example is written in the registration idiom itself, and reading every `.py` file in every scan multiplied how often that mattered. Python's patterns are gated on an MCP import, because one popular agent library's decorator is character-for-character identical to the MCP one. The client-side schema that parses a tool list response is spelled out in full so it can never be mistaken for the server-side one that serves it, and hand-rolled dispatch is anchored on the dispatch keyword rather than on the bare string `"tools/list"`, which turns up in READMEs, transcripts and test fixtures. Names are attributed only within the statement that actually contains the registration, after proximity alone was found reaching past a one-line handler into an unrelated list below it. And a registration in a test, an example or a build script is skipped outright rather than downgraded, since a mock tool would both overstate the surface and forge a definition change.
-
A package's own build output was never read, and its longest lines were skipped whole
new detection fewer false alarmsTwo whole categories of a package's code were reaching none of the checks. A line longer than a thousand characters was skipped in full rather than read in part — the shape every bundler produces — and the files that shape lives in, a package's own build output (`.min.js`, `.bundle.js`, the JavaScript glue a WebAssembly build emits, `.smithery/`), were never written to disk at all, so nothing looked at them. Both are read now: build output because it is the code that runs when the package is installed, and a long line by an overlapping walk whose overlap is computed from the longest text any of the patterns can match, then asserted to tile the line with no gap. Third-party dependency trees committed into a package — `node_modules/`, `site-packages/` — are still not read, because they are someone else's project, but they are now counted and reported, so a package that keeps its code inside one reads as incompletely analysed rather than as analysed and clean. Six narrower ways for text to slip past a match are closed alongside, and one tier of the second-stage ruleset is demoted to carry no weight in a grade after repeated measurement showed it matching ordinary code. This was measured over 420 real packages, run through both the old and the new analyzer, before the change was taken: the published capability surface moves by one bit in aggregate, and almost all of the movement is incorrect results going away.
Technical detail
- A source line longer than 1,000 characters is no longer skipped whole. The guard exists for a reason — a bundler packs an entire module onto one line, and a quotation from it is unusable as evidence — but skipping the line skipped the code with it, and that is the shape a published package most often ships. Such lines are now walked in overlapping windows, and the overlap is derived rather than guessed: every pattern used in the windowed scan is parsed for an upper bound on the length of text it can match, a pattern carrying an unbounded repetition makes the analyzer refuse to start rather than scan unsoundly, and the overlap is computed from that maximum plus a margin, so the constant and the patterns cannot drift apart. The windows are asserted to tile the line with no gap, checked by sweeping a maximum-length match across every offset of a long line. Whether a position sits inside a comment or a string is a property of the whole line, so it is worked out once on the whole line and carried into each window; windowing cannot strip a guard, and a suppression has to hold in every window covering the text rather than in one of them. Past a bounded number of characters the tail of a very long line is genuinely unread, and that residue is counted rather than passed over in silence.
- A package's own build output now reaches the scanner at all. Files named `*.min.js`, `*.bundle.js` or `*_bg.js`, and anything under `.smithery/`, were filtered out before the archive was unpacked — never written to disk, so neither these checks nor the second-stage ruleset ever saw a byte of them. For a package that publishes only built output that was the entire package, and it was reported as analysed. Those files are now unpacked and scanned. The credential checks stay switched off on them, because a key-shaped string inside generated output is far more often a build hash than a secret; everything else runs.
- What is still not read is now on the record. Committed third-party dependency trees — `node_modules/`, `bower_components/`, `_vendor/`, `site-packages/` — remain excluded, because that code belongs to another project and attributing it to this package would be a false statement about this package. But the number of files skipped that way is now reported with the analysis, so a package that commits its dependencies reads as incompletely analysed and at lower confidence, instead of as fully analysed and empty. Declaration and data files (`.d.ts`, `.map`) are handled a third way: they are unpacked, and the second-stage ruleset scans them, but the line-based checks skip them and say so. Those files cannot execute, so a behavioural finding attributed to one would be incorrect by construction rather than weak evidence of something true — the one place where skipping, not downgrading, is the honest answer.
- Six narrower coverage gaps in the text matching are closed, each pinned by a fixture that fails if the fix is removed. A regular expression whose body contains a slash followed by an asterisk read as opening a block comment that never closed, so every line below it looked like documentation to the eight checks that skip comments; the block-comment walk is now bounded, and past that bound the opener is treated as unbalanced and scanning resumes as code. A file could be excused from the data-exfiltration check by repeating one word three times — inside an HTML comment no markdown renderer displays — because the check counted total occurrences of its security-guidance markers rather than distinct ones. Seven directory forms that assistants ingest as instructions (`.clinerules/`, `.windsurf/rules/`, `.claude/commands/`, `.claude/agents/`, `.github/instructions/` and siblings) were not classified as agent-rule files, and a `.md` is not a source file either, so their contents reached no check at all; only the single-file form and one directory were recognised before. The invisible-character list was missing the Unicode variation selectors, which advance nothing and can be chained; a run of them with no character to modify is now shown, while a single selector following an ordinary character — a real and common artifact in published documentation — stays exempt. The per-line exemption for a scattered invisible character can only see one line, so a channel that keeps every line under the threshold was exempt on every line; the same exempted text is now looked at once more across the whole file. And a Python tool decorator consumed the lines of its own argument list, so a command written across them was never examined.
- One tier of the vendored second-stage ruleset is demoted and no longer contributes to a grade. The two rules that trace a credential from the environment into a shell command, and their two siblings that trace one into a network call, were repeatedly found matching ordinary code — dependency-injection destructuring, a parameter destructure, a test mock — five distinct shapes across five reviews, each of them invisible in the code being reviewed at the time. Their findings are still recorded and still shown, at low confidence; they cannot move a grade on their own. Over the same 420 packages the credential arm fired on none of the 195 that the second stage actually scanned, which is not evidence that the rule is safe — only that this corpus gave it nothing to be right or wrong about. The route back is written down rather than left open: narrow the rule to the forms that resist shadowing, and measure again.
- The effect was measured on real packages before the change was taken, not after. 420 artifacts were fetched once and run through both the old and the new analyzer; on the 240 that mirror the catalogue's composition, published capability bits went from 488 to 487. Seven packages lose a bit and all seven are verified removals of incorrect results — one class accounts for most of them, a regular-expression anchor inside a template literal that read as a shell command, which produced a `shell` bit on nine of nine such lines before the change and on none of nine after. Four packages gain a bit; three were checked against their source and are correct. The fourth is the accepted cost of this change, and it is worth stating plainly: its evidence comes from third-party interface code redistributed inside a bundled archive, credited to the package that ships the archive. That shape appeared on one artifact in 240, which projects to roughly 175 packages across the catalogue — about four in a thousand — that may newly publish a bit sourced that way. No package moved from not being scanned by the second-stage ruleset to being scanned by it. Scanning costs about 3.5% more wall clock per package, which lengthens a full pass over the catalogue by roughly a third of a day.
-
Renaming one file to INDEX.JS hid a package from almost every check
new detection fewer false alarms performanceAn audit of the analyzer found one bypass that cost a single keystroke and beat nearly everything: the check that decides "is this a source file" was case-sensitive, so a package published at dist/index.JS instead of dist/index.js was skipped by the capability scan, every dangerous-code check, and transport detection at once. It still installed and ran exactly the same. The package was not reported as unreadable — it was reported as analysed, with an empty result. That check now ignores case, as every one of its neighbours already did. Five more evasions are closed alongside it: an unclosed quote mark anywhere on a line switched off four checks for the rest of that line; a line starting with an asterisk was assumed to be documentation, so a payload written as a multiplication continuation was invisible to eight checks; a decoy request to the package's own backend cancelled the real third-party download beside it; two comment lines excused a whole bundled script from the remote-execution check; and that same check had never worked at all in Python, JavaScript, Ruby or Perl, where a shell command has nowhere to live but a quoted string. Separately, findings that were being deleted because of where a file sat are now kept at low confidence instead, one check that was reporting sandboxed code as high severity is corrected downward, and a way to make a wide-open tool parameter look validated is closed.
Technical detail
- A package could hide from the capability, dangerous-code and transport scans by publishing its code at an upper-case extension. The "is this source" test required a lower-case `.js`/`.ts`/`.py`, while the sibling test that counts code files ignored case — so `dist/index.JS` produced no permissions, no tools, no dangerous-code findings and no transport, and the server still presented as analysed rather than as having no readable source. Node loads that path without complaint on a Linux filesystem, from the very entry point the package manifest names. This is the same hole a previous version closed for files whose path merely looked like a test, one keystroke cheaper and, until now, still a hard skip rather than a downgrade.
- Five detector bypasses are closed, each pinned by a test that fails if the fix is removed. (1) An unmatched quote character — a regular expression like `/['"]/`, or an apostrophe in a comment — made everything after it on the line count as being "inside a string", which is where four checks stop looking. An opening quote with no closing partner is no longer treated as a string at all. (2) A line beginning with an asterisk was assumed to continue a documentation block, but an ordinary multiplication written across two lines has the identical shape, so `* eval(atob(payload));` executed while reading as a comment to eight checks. Whether a line is inside a comment block is now worked out once per file from the enclosing comment markers, and only lines with no live code on them are excluded. (3) The rule that stops a bundled dashboard's call to its own backend from counting as third-party data ingestion was applied to the whole line, so one decoy relative request cancelled a real remote fetch-and-read beside it — the ingestion leg of the three-way "lethal trifecta" combination. It is now applied to each request separately. (4) A bundled script could be excused from the remote-execution check by adding two comment lines, because one of the three markers meant to prove a file is genuine threat-detection content was a single bare word. That marker is demoted to a supporting role, and the strong markers must now appear as real content rather than in comments. (5) The remote-execution check for bundled scripts had never fired for Python, JavaScript, Ruby or Perl: it ignores quoted strings, and in those languages a shell command can only BE a quoted string. It now recognises the execution constructs of each language (`os.system`, `subprocess`, `execSync`/`spawn`, Ruby `system` and backticks, Perl `qx`), so a genuine `curl … | sh` inside a bundled installer is caught while a printed installation instruction still is not.
- Findings are no longer deleted because of where a file sits. The committed-credential scan skipped whole files under a vendored, example, sample or single-letter `t/` directory, and the credential-in-logs check skipped anything under `scripts/`. Every one of those decisions reads a path, and the path is chosen by whoever publishes the package — so one directory rename removed a real committed key or a logged token entirely. Those findings are now kept at low confidence, the same downgrade-not-skip treatment already applied to test-path evidence, prompt markers and permission evidence. Expect a modest number of new low-severity notes on packages that ship vendored test certificates or example configuration; none of them can move a grade.
- Three checks were reporting more, or less, than the evidence supports. A sandbox call with a fixed, self-contained argument (`vm.runInNewContext("1+1")`) was inflated from a medium note to a high finding whenever any other call shared its line, because the end of its argument list was found by looking for the last bracket anywhere on the line. The credential-in-logs check had the mirror problem: an unrelated later call on the same line — `list.slice(0, 3)` — matched its list of "this is safe" shapes and suppressed a real leak. Both now read the call's own argument list. And the credential-in-logs check had never matched the most common logger name in this ecosystem: the pattern for `logger.` was written so that it required the letters "logge" and treated the final "r" as optional, so `log.info(secret)` never fired. Python's standard `logging.` calls are now covered too.
- Smaller consistency fixes. A tool parameter can no longer look validated by carrying an empty constraint — an empty list of allowed values, an empty pattern or format, a maximum length of zero — which was the cheapest way to switch off the "accepts unconstrained free text" note on a dangerous tool. A Python project declaring an empty `[project.scripts]` table no longer masks a populated `[tool.poetry.scripts]` table later in the same file, so its command-line entry point is detected. The package listing text is now scanned with the same rules as a README, which is what the finding was already labelled as — previously identical text produced a finding through one path and not the other. And quoted evidence can no longer be cut through the middle of an emoji or other astral character, which was publishing a broken character into the findings feed, where it is not valid XML.
- Scanning stays responsive on large packages. The per-file preparation step — splitting every file into lines, and now working out its comment blocks — ran in one uninterrupted burst before either of the two long scanning phases reached their first pause. On a large tree that made the scanner look unresponsive to its own health check, which the worker then misreads as a network failure and retries forever. It now pauses on the same cadence as the phases that follow it.
-
A file could hide from the scanner by being too big, and eight smaller bypasses
new detection fewer false alarms capability model performanceThe largest gap this analyzer has had: any single file over one megabyte was skipped entirely — not scanned, not counted, not mentioned — and the scan still reported success. Padding a file with a megabyte of comments removed it from every check while the package kept working exactly as before. That file is now read up to the limit and scanned, and when anything is read only in part the report says so instead of implying a complete pass. The same hole existed one level down, inside bundled archives, and is closed the same way. Alongside it, eight narrower bypasses are fixed, two sources of false alarms removed, and one bad habit corrected: a credential sharing a line with any other finding used to be reprinted in full on the public page. It is now masked wherever it appears — including the case where the credential check itself stays quiet.
Technical detail
- A file above the one-megabyte limit is no longer invisible. It was dropped before any check ran, contributed nothing to the file count, and produced no note anywhere — so a package whose main file had been padded past the limit was published as fully analysed on evidence that excluded it. The first megabyte is now scanned, and the number of partly-read files is reported with the analysis. The bundled-archive reader had the identical gap for entries inside a `.vsix` or `.zip`, closed the same way and under a shared ceiling so a package cannot fill the budget with oversized decoys to push real files out. A separate limit inside the deeper taint scanner sat slightly lower than the reader's, leaving a narrow band where a file was read here but ignored there; the two now match.
- Eight ways to switch a check off are closed, each pinned by a test that fails if the fix is removed. Three quote marks inside an ordinary Python string opened a phantom docstring and hid the next two hundred lines from five checks. A decoy that survived the comment filter but failed the check's own test still hid the real call after it on the same line. A trailing comment containing a truncated example address switched off all three network-address checks, including the cloud-metadata address used for credential theft. A single word like `sample` in a comment switched off all fourteen credential patterns for that line — a comment claiming a key is fake is not evidence, since the key is committed either way. Two throwaway marker words excused an entire bundled script from the remote-execution check. And permission scanning skipped whole files whose path looked like a test, an example, or a build script — so a package whose code sat at `dist/test-server.js` published an empty capability surface while shipping shell and network access. Those files are now scanned and their evidence shown, marked as coming from a non-shipping path.
- Two sources of false alarms are gone. Transport detection was reading vendored dependency code, so a package that merely bundled a copy of a web framework was labelled as listening on the network — and that label then unlocked a further scan of bind address and authentication posture it had no business running. And the check for committed private keys could never match a PGP key block, because the pattern required a form real PGP armour does not use.
- A credential is no longer reprinted in full. The credential check has always masked what it reports, but every neighbouring check printed the raw source line — so a key sharing a line with a network call, or sitting inside a URL, was republished intact on the server's page. Masking is now a property of the quoted snippet itself, so it applies to every check, including the case where the credential check stays silent because the key is glued to surrounding text. That last case is now reported as well.
- Servers whose only dependencies are development tools are no longer described as having no MCP interface. The classifier treated any parsed dependency list as proof that a missing MCP library meant something — but a project listing only linters and test runners has told us nothing about what it uses at runtime. Those servers now read as unknown rather than as checked-and-empty; roughly two thousand are expected to move. Two widely used PHP MCP packages were also missing from the library list, which alone accounted for thirty-four servers being described as having no MCP interface while declaring one.
- Scanning is faster. The danger scan was rebuilding sixteen regular expressions per line of source; they are now built once, which removes about half its cost, and an archive check that opened and read every file separately now reuses the bytes already in hand.
-
Seven ways a server could hide from a detector, and one that made grades too harsh
new detection fewer false alarms performanceA review of the analyzer found seven small tricks that would let a published package switch a detector off — each costing a single line, none changing what the code actually does. A comment containing three quote marks turned off five checks for the rest of a Python file. Writing a payload inside a generator method hid the line from eight checks. Putting a decoy string earlier on the same line hid the real call after it. All seven are closed, and each is now pinned by a test that fails if the fix is removed. Separately, and pulling the other way: some findings were being reported as more severe than the analyzer itself judged them. The analyzer already downgrades a token found in a test file, or a sandbox call with a fixed argument — but that judgement was discarded before it reached the grade, so those appeared as high-severity. Grades that were inflated for this reason will come down.
Technical detail
- Seven detector bypasses are closed. A line reading `# """` in a Python file was read as the start of a docstring, and text inside a docstring is deliberately not scanned — so one comment silently disabled the dynamic-execution, network-address, credential-logging, OAuth-scope and clipboard checks for everything below it. This also fired by accident on ordinary files whose quote marks happened not to pair up. A payload written as a generator method (`*run() { … }`) looked like a documentation comment to a guard shared by eight detectors. Each detector also tested only the FIRST match on a line and then discarded the entire line if that one match sat in a comment or a string, so an unused decoy string mentioning the same function name hid the real call that followed it. A trailing `// eg`, or any line containing a schema `.describe(...)` call, switched off the address check — including the cloud-metadata address used for credential theft. A single `index=0` anywhere in a bundled script excused the whole file from the bundled-script check. A decoy address earlier on a line prevented the cloud-metadata address after it from being examined at all. And a skill manifest at the very top of a package caused every file in it to be reported as a bundled skill script.
- Findings now carry the severity the analyzer actually assigned them. The analyzer deliberately lowers some findings — a credential-shaped string inside a test file, a sandbox call whose argument is a fixed literal, an everyday dynamic module import — but that decision was dropped when the finding was stored, and the grade was then rebuilt by pattern-matching the finding's text, which pushed all three back up to high. Servers affected by this were graded more harshly than the evidence supported. Existing findings keep their current grade until each server is re-analysed.
- Permission surfaces may grow slightly. A server with a very large number of permission-related lines had its permission summary computed from only the first 200 of them — a display limit that had leaked into a published claim — so a server whose only shell or credential access appeared later in the list was shown as not using it at all. The summary is now derived from all of the evidence; only the quoted examples are limited.
- Two kinds of file are read that previously were not, and several crafted files no longer stall the scanner. Text saved in UTF-16 was discarded as if it were a binary, which meant a README or skill file written that way was fully readable by an AI agent and completely invisible here. Four patterns could be made to run for minutes on a single crafted file — one of them for hours across a package — and are now bounded. A file packed with thousands of hidden-instruction comments is now reported once with a count, rather than once per comment.
-
Detector evasions closed, two scan-stalling inputs bounded, and several false alarms narrowed
performance fewer false alarmsTwo crafted inputs could make the scanner spend minutes on a single file instead of milliseconds, stalling every other scan behind it. A source file packed onto one very long line could take almost two minutes to permission-scan, and a malformed private-key block — one where the closing marker appears before the opening one — could take several minutes to reject. Both are now bounded. As part of the first fix, minified and bundled one-line files are no longer scanned for permissions, matching how every other detector already treats them: their contents are machine-generated and unquotable, so a permission inferred from one was never usable evidence. The permission surface of a server that ships only minified code may therefore be smaller than before; its danger, secret, and prompt scans are unchanged.
Technical detail
- The permission pattern loop now skips minified/bundled lines (the same lineLooksMinified guard the danger, manifest, transport-posture and exec-sink scanners already applied, and which this file's own aliased-import pass applied 65 lines earlier). Two 'untrusted' body-read patterns paired an unbounded argument run with an unbounded trailing run, which is quadratic when the trailing context never matches: measured 17.8s on a 400KB single line and 115.8s at the 1 MiB per-file read cap. Those patterns are additionally bounded to 200 characters per run — a real response-body read chain is short.
- The PEM private-key block is now matched in three separate linear steps (locate the opening marker, search a bounded window after it for the closing marker, then require key material between them) instead of one expression pairing two lazy runs around the body class. The single-expression form left a roughly cubic search space: with the closing marker placed BEFORE the opening one the match can never complete, so the engine explored all of it — measured 4.8s at 2KB and 40.9s at 4KB of body, extrapolating to ~325s at the previous 8KB bound. Detection semantics are unchanged: a bare header mention is still not a finding, and a genuine committed key still is.
- Several detectors could be silently disabled by attacker-chosen text. A shared guard treated the first `//` anywhere on a line as the start of a comment, so an ordinary URL string earlier on the same line suppressed every later match — across eight detectors, and real malware naturally puts its call-home URL and its decode-and-run sink in one statement. That guard is now aware of quoting, and `#` only counts as a comment in languages that have it (it was previously silencing JavaScript lines containing a CSS colour or a URL fragment). Findings in files whose PATH looks like a test or example are now shown at low confidence rather than dropped entirely, since a file's location is chosen by whoever publishes it and the file still runs when installed. A bundled skill script is no longer excused merely for sitting in a directory whose name resembles a threat-hunting toolkit, and a command wrapped in quotes inside a shell script is recognised as a command rather than as text.
- Fewer false alarms: a tool description saying ‘use MCP tools to…’ — close to boilerplate in this ecosystem — no longer reads as an instruction to call a different named tool, and a list of long API operation names is no longer mistaken for an encoded payload. A network address containing a JavaScript spread (`{...opts}`) is checked normally again; previously that one token disabled the whole address check on the line, including the AWS instance-metadata address used for credential theft. Placeholder-credential recognition was extended from 5 of the 14 supported vendor prefixes to all of them, so an obvious demo token (npm, GitLab, HuggingFace, SendGrid, Stripe and others) is no longer reported as a committed secret. Tool names beginning show/ship/start/make/author/token no longer suppress the purpose-mismatch check by accident.
- A deeply nested tool schema from a third-party server can no longer exhaust the stack while permissions are inferred; that walk is now depth- and size-bounded like its two siblings, and the surrounding fallback covers the whole derivation rather than only the JSON parse.
-
Dependency parsing reaches beyond npm and PyPI, a code-file count, and a hidden-character scan gap closed
capability model new detectionThe dependency graph and supply-chain surface previously came only from a server's npm or PyPI manifest, so a server written in Go, Rust, C#, Java, Kotlin, Swift, PHP, or Ruby — or a Python project declaring its dependencies in setup.py rather than requirements.txt/pyproject.toml — showed up as if it had no dependencies at all. The analyzer now reads each ecosystem's own manifest format and folds the declared dependencies into the same graph. Every analyzed server also now carries a language-agnostic count of source code files, independent of any one ecosystem's manifest. Separately, the check for invisible characters smuggled into a server's tool descriptions caught a wider set of blank, zero-advance code points than the matching check on READMEs, skill files, and agent-rule files an assistant reads before using a server; both checks now share a single character list so they can no longer drift apart, and the file-side scan flags those additional code points too.
Technical detail
- Dependency manifests parsed: Go (go.mod), Rust (Cargo.toml), C# (.csproj PackageReference), Java/Kotlin (pom.xml, build.gradle / build.gradle.kts), Swift (Package.swift), PHP (composer.json), Ruby (Gemfile), and Python setup.py (in addition to the existing requirements.txt / pyproject.toml parsing). Each parser extracts declared package names and feeds them into the same dependency graph and supply-chain checks as the npm/PyPI path.
- Added a language-agnostic code-file count to the analysis output, derived from the scanned tree rather than any single ecosystem's manifest, so file-count-based signals aren't blind to non-JS/Python codebases.
- Hidden-unicode parity: the character class is now defined once (HIDDEN_UNICODE_CLASS in src/analysis/text.ts) and consumed by BOTH the prompt/README/skill-file scanner and the tool-description/schema scanner, so the two can never diverge again. The file-side scan gained the code points it previously omitted: Mongolian vowel separator (U+180E), Hangul fillers (U+115F, U+1160, U+3164, U+FFA0), and interlinear-annotation marks (U+FFF9–FFFB). The existing context-benign exemptions and the same-character-scatter suppression are unchanged, layered on top of the shared class.
-
Fewer false alarms on self-directed endpoints and clipboard; precision on purpose-corroborated services
fewer false alarmsStopped flagging endpoint and OAuth-scope requests to a server's own declared vendor as dangerous — a Telegram bot hitting Telegram's API, or a Google Drive helper reading Drive, is doing its job, not leaking data — and suppressed the browser `navigator.clipboard` signal from webview and plugin UI contexts where clipboarding is user-initiated, not covert. Semgrep evidence paths are now stored repo-relative so re-scans from a different scratch location surface the same source locations.
Technical detail
- Purpose-corroborated services: endpoint and OAuth-scope evidence whose target matches the server's dep-corroborated declared vendor (a Telegram server hardcoding `api.telegram.org`, a Google Drive helper scoped to `https://www.googleapis.com/auth/drive`) are tagged `expected=1` in the database, visible to the UI as an informational signal rather than alarming. Closes a broad false-alarm class across vendor-specific servers.
- Clipboard in UI contexts: the `navigator.clipboard` web API signal is now suppressed in webview and plugin contexts (as opposed to server code), because browser clipboard access is user-initiated interaction in a UI, not a covert server sink. The shell (pbcopy/xclip) and clipboardy-dependency arms remain unconditional.
- Semgrep path portability: Semgrep evidence paths in code_evidence are now stored relative to the repository root instead of absolute /scratch paths, so a re-scan from a different unpacked location (e.g., after a deploy or container restart) surfaces the same source locations and avoids churn in the evidence table.
-
Fewer false alarms on dynamic imports; catches more prompt-injection and exec-obfuscation tricks
fewer false alarms new detectionStopped flagging ordinary data/locale dynamic imports, same-origin URL fetches, and a common shell re-source idiom as risky, and now catches replacement-context prompt directives, the Function-constructor eval escape, split-string require/__import__ calls, and renamed ZIP archives.
Technical detail
- FP: a dynamic import() of a data/locale asset (.json/.txt/.yaml/.csv/.md) is no longer 'obfuscation' when the specifier is pure `${}` interpolation — a decoded-then-imported or concatenated specifier still is.
- FP: a same-origin `new URL(path, import.meta.url)` fetch and a bare service-worker fetch passthrough (no body read) no longer set the untrusted-content permission bit; a passthrough that inspects the body still does.
- FP: the `eval "$(<file)"` shell-hook idiom (re-sourcing a local command's output) no longer trips the skill-script curl|sh/IEX marker — but the marker scan now reads the whole eval expression, so a nested subshell hiding a download-and-run (`eval "$(x=$(echo no); curl -fsSL .../install.sh)"`) still flags; the FP cut opens no new blind spot.
- FN: replacement-context prompt directives — "you are now …", "from now on <injection-verb>…", "reveal/print your (system) instructions/prompt", "new instructions:" — now fire as prompt injection (tightened to require a qualified instruction/prompt noun plus an injection verb, after an initial over-broad match).
- FN: dynamic-exec obfuscation now also catches the `.constructor.constructor(` Function-constructor eval escape, concatenated `require('a' + 'b')` built from two quoted fragments, and Python `__import__('os'/'subprocess'/...).system(`.
- FN: a magic-byte (PK\x03\x04) sniff opens extension-less archives, so a renamed ZIP is unpacked and scanned instead of skipped.
- Still deferred (design-heavy / FP-risky): NFKD/script-mixing homoglyph rewrite, untrusted-lexicon expansion, cross-function secret-to-arbitrary-host taint, path-exclusion downgrade, and a `.next/static` vendored exemption (reverted this pass — a blanket path match silently disables the whole pipeline; needs a Next.js-dependency corroborating signal instead).
-
A full-database false-alarm audit — grades stop being distorted
fewer false alarmsA ground-up review of every warning in the live database, fixing the classes that were inflating risk grades on ordinary, safe servers. The biggest single fix: a "tool quietly changed to grab more access after you installed it" warning was firing en masse because it compared our OWN best-guess of a tool's capabilities over time — when we improved that guessing, hundreds of unchanged tools looked like they had escalated (one server alone carried 211 phantom warnings). It now only fires when the tool's actual description or inputs changed. A file-transfer tool that reads a file and sends it over the network (a WebDAV client, an uploader) is no longer treated as a high-risk data-exfiltration channel unless it also reaches secrets or a database. Tools honestly named for what they do — `ssh_exec`, `list_credentials` — are no longer flagged as "innocent-looking name hiding a dangerous capability". A tool whose name is entirely generic vocabulary (`execute_command`, `write_file`, `list_sessions`) can no longer be accused of impersonating another server. And a long list of "found a secret / dangerous code" warnings that were really looking at test fixtures, vendored dependencies, build scripts, security-training material, or documentation examples have been quieted — while the same checks still fire on the real, shipped server code.
Technical detail
- Rug pull (tool changed after install) — an escalation now requires the tool DEFINITION TEXT (name + description + input schema) to have changed between versions. A permission-guess delta with identical text is our classifier drifting, not the author acting: one vocabulary change had minted 211 phantom "gained secrets" findings on a single Smithery catalog. More than 10 tools "escalating" in the same window on one server now collapse into a single catalog-wide review prompt instead of flooding the feed.
- Exfiltration combo — a single tool that reads the filesystem and reaches the network is now MEDIUM on its own (the ordinary file-transfer shape — WebDAV/upload/get_file — was 344 of 433 high findings); HIGH now requires a second read/ingest leg (secrets, a database, or untrusted-content ingestion) that widens the take beyond the files the tool was pointed at.
- Capability-vs-purpose — the shell-capability announce test now flattens snake_case first, so `ssh_exec` / `send_command` / `get_command_history` (which honestly name what they do) are no longer "benign-looking", and a tool whose name DECLARES credential handling (`list_credentials`, `get_credential`) is no longer flagged for carrying the secrets bit. Separately, the word "command" next to a chat platform (Discord/Telegram/Midjourney) is read as a slash/bot command, not a shell spawn — which had put a false shell capability across whole image-generation catalogs.
- Tool shadowing — a tool whose name is made ENTIRELY of universal vocabulary (`execute_command`, `write_file`, `list_sessions`, `all`, `terminal`) can no longer be flagged as impersonating a "verified" server; that shared dialect isn't an impersonation tell. The distinctive-name, sensitive-name, and look-alike-character (homoglyph) paths are unchanged.
- Dangerous code — `new Function(code)` is now a MEDIUM review note, the same tier as `eval(code)` (229 servers' only high finding was a bare Function constructor — expression evaluators, schema compilers); the malware "decode a blob then run it" shape still reaches high separately. The webpack `new Function('id','return require(id)')` bundler shim is recognised as benign glue. A credential-in-a-log-line embedded inside a string literal (code carried as data) or in a build `scripts/` file no longer flags.
- Committed secrets — key material inside vendored dependency trees (`vendor/`, `third_party/`, Perl `t/`), sample/example certificates, and scanner-output reports (CodeQL/SARIF/CVE dumps) is no longer reported as a shipped secret; and a committed `.env` no longer flags on stock default values (`postgres`/`admin`), localhost connection strings, or when it sits under a sample/example directory (including the common `.env.exampe` typo).
- Bundled scripts — a `curl | sh` or PowerShell `IEX` string echoed inside a print statement (an install instruction) or a code comment no longer flags, and a skill that IS threat-detection content (a YARA / Sigma / Splunk rule set, a malware field guide) is recognised as documenting attack shapes rather than performing them.
- Hidden text in prompt/skill files — a run of the SAME zero-width character (a docs-generator heading anchor, a copy-paste artifact) or a cluster of mis-encoded bytes (mojibake) is no longer read as smuggled text. [CORRECTED 2026-08-11: this entry originally said a genuine hidden channel "needs at least two DISTINCT invisible characters". That was never what shipped, and it understated the detection. What the code applies is a DENSITY rule: a single repeated zero-width character is flagged once it appears in three or more SEPARATE places on a line, because a channel has to interleave the character with the visible text while a docs generator leaves one anchor or one contiguous run. Two or fewer runs stay exempt, mixed invisible characters and any text-reordering control are flagged regardless, and mis-encoded C1 bytes keep the unconditional exemption because mojibake and a C1 channel are the same shape. The tool-description scanner has no scatter exemption at all.] Hidden-content warnings in test / fixture / benchmark / vendored files are downgraded to low (an evaluation corpus legitimately contains sample payloads). And the "instructs the model to send a secret somewhere" check is suppressed when the surrounding prose is defensive ("do not send…", "this prevents the token from leaking") or the whole file profiles as security guidance (a pentest playbook, a malware analysis guide).
- Environment-secret-to-network (Semgrep) — when this finding is corroborated by another in-house danger signal it now escalates to MEDIUM rather than high: reading an API key from the environment and calling the vendor's own endpoint is what every authenticated API client does.
- Permissions — repo `scripts/` directories and build-hook config files (electron afterSign, forge/vite/rollup/webpack config) no longer contribute to a server's capability profile: their `child_process` and network calls run at build or publish time on the maintainer's machine, not in the served runtime. And the untrusted-content-ingestion bit no longer fires on a same-origin relative `fetch('/api/…')` — a bundled dashboard talking to its own backend, not third-party ingestion.
-
Precision + reach: quieter clipboard/docstring noise, new eval sinks, code-aware trifecta
fewer false alarms new detectionA pass that both quiets three false-alarm classes and catches more real risk. The biggest noise cut: a copy-to-clipboard button in a bundled web dashboard or desktop app is no longer mistaken for a server secretly reading your clipboard — that check now understands it's looking at browser/UI code, not the server. Example "exec()" / "eval()" mentions inside Python documentation text no longer read as running code, and a "listen on all interfaces" setting inside a build or dev-server config file (Vite, webpack, docker-compose) is no longer treated as the server exposing itself without a password. On the detection side: the classic global-scope eval trick `(0, eval)(…)`, running code via `setTimeout("…string…")`, and a module name glued together to hide it (`require('child_' + 'process')`) are now caught; a harmless bundler helper (`new Function('return this')`) is no longer flagged. Finally, the "lethal trifecta" check (a tool that can read private data, take in outside content, and reach the network) now also considers what the published source code actually does, not just the tool descriptions — closing a blind spot across roughly 700 servers whose risky combination was visible only in their code.
Technical detail
- Clipboard — the browser `navigator.clipboard` signal is suppressed in a browser-context file (a .jsx/.tsx file, a path under a frontend directory like web/ frontend/ renderer/ dashboard/, or a file that touches the DOM / imports a UI framework). `navigator.clipboard` does not exist in a Node server, so its presence is browser UI, not a covert server sink — this was the single largest evidence class (~6.3k rows). The genuine server-side arms (pbcopy/xclip shell calls, the clipboardy dependency) are unchanged.
- Dynamic exec — eval()/exec() sitting inside a Python triple-quoted docstring (multi-line documentation prose, including reStructuredText ``exec()`` inline literals) is no longer flagged; the single-line string guard could not see a multi-line docstring. A real exec() call outside a docstring still flags.
- Transport posture — a 0.0.0.0 bind inside a build / dev-server / container config (vite/webpack/rollup/next/… .config, docker-compose, Dockerfile) is no longer reported as "network-listening, no auth": that is dev tooling, not the MCP server's own listen.
- New sinks — indirect eval `(0, eval)(x)` (runs in global scope, dodges a bare-eval grep) and `setTimeout`/`setInterval` called with a STRING body (eval-equivalent) are now flagged; a concat-obscured `require('child_' + 'process')` is caught by the obfuscation constructed-specifier detector; and the `new Function('return this')` global-this bundler shim is suppressed as benign glue.
- Lethal trifecta — detectToxicFlow now folds in the CODE-derived permission surface (what the analyzed source actually imports/calls) alongside the tool-description surface, so a server whose third leg (a private-data read, or ingesting a fetched response body) is proven only in source still trips the union. Scoped to the trifecta only, server-level only, and stays a medium review prompt tagged as source-derived — a pure recall gain, ~737 servers (see ADR 0006).
- Refactor — the danger analyzers were decomposed from a single 1100-line module into per-detector modules under src/lib/analyzers/dangers/ (secrets / execSinks / network / credLog / clipboard / transportPosture / skillScripts) with the per-file dispatch and shared file-context in dangers/index.ts. No behavior change from the split itself.
-
Three more false-alarm fixes — a live-data precision pass
fewer false alarms capability modelA sweep of the live findings to quiet three warnings that were firing on ordinary, safe code. A Python multi-line import written across several lines — `from pydantic import (` and the like — is no longer mistaken for a hidden dynamic import; that one pattern was over 90% of the "obfuscated dynamic import" notes. A server reading its own configured API key from the environment to call its own service (the universal shape of every authenticated API wrapper) no longer reads as the high-severity "reads a secret and sends it out" combo — only an arbitrary filesystem read flowing to the network does. And keys committed inside test data and fixture folders whose names the scanner didn't previously recognise (testdata/, test_assets/, test-fixtures/, unittests/, and bare test.js / tests.rs files) are now treated as fixtures rather than shipped credentials.
Technical detail
- Obfuscation — the dynamic require()/import() detector now runs on JavaScript/TypeScript only and fires only when the imported path is CONSTRUCTED (string concatenation, a template interpolation, or a decode call such as atob). A Python `from x import (…)` multi-line static import is not an import() call and no longer flags; a bare `await import(distEntry)` of a plain variable is ordinary code-splitting and no longer flags. (~55k of ~60k findings cleared.)
- Capabilities — a NAMED credential environment variable (OPENAI_API_KEY, GITHUB_TOKEN, …) is the credential the operator handed the server to authenticate; reading it no longer counts as a sensitive read for the exfiltration combo, so "network + secret" drops from high to a low review note (~550 high findings reclassified). Credential-FILE harvesting (~/.aws, ~/.ssh, other apps' .env) rides the filesystem bit and stays high. The secret-capability pattern also now requires the credential word to be the trailing token of an UPPER_SNAKE name, so config like MAX_TOKENS / TOKEN_OPTIMIZER_MODEL and PUBLIC/PUBLISHABLE keys no longer trip it.
- Dangerous code — the test/example/fixture path filter now recognises compound and bare-basename test directories it previously missed (testdata/, testing/, unittests/, test_assets/, test-fixtures/, picky-test-data/, and bare test.js / tests.rs / test.pem), so dummy keys and fixtures in those trees are no longer reported as committed secrets. Bounded so real source like src/testRunner.ts, latest/ and attestation/ stays analyzed.
-
Fewer false alarms — a live-data accuracy pass
fewer false alarms capability modelA sweep of the live findings to quiet warnings that were firing on ordinary, safe code. Tools whose job is to run a database query or some code ("execute SQL", "run plugin code") or to "process" data are no longer mistaken for running shell commands — that one homonym was the single biggest source of the "a harmless-looking tool can reach a shell" warning. A tool that downloads or fetches a file to disk is no longer read as if it were freely reading your filesystem and sending it out, which was the biggest "reads and sends your files" false alarm. A committed .env file is now only called out when it actually holds a credential (a value under a key like PASSWORD or API_KEY) — shipping plain configuration like a port number no longer trips it. And the Function-constructor / sandbox-eval warning is reserved for the genuinely risky case of running a variable; a fixed, self-contained snippet is now a gentler review note. Finally, these accuracy fixes now take effect across every already-scanned server immediately, instead of waiting for each one to be re-fetched.
Technical detail
- Capabilities — retired the `exec`/`execute`/`process` shell keywords (run-a-query/code/workflow homonyms: `execute_sql`, `dynamodb_batch_execute`, `figma_execute`, "process the data", "the onboarding process"). They drove the dominant `purpose_mismatch` benign-name-carries-shell false positive (~388 of 759 live findings cleared). A genuine shell tool still earns the bit from command/cmd/shell/terminal/bash/spawn/subprocess, and a real `child_process` call is still caught in source.
- Capabilities — an INBOUND transfer (`download_file`/`download_audio`→destination_dir/`fetch_artifact`→output directory) now sheds its `fs` bit: pulling a remote resource and writing it to disk is a write target, not the read leg of an exfiltration channel. Cleared the dominant `exfil_combo` (filesystem + network) false positive (~265 single-tool findings removed); an OUTBOUND `upload_directory`/`backup_files` keeps `fs` because reading many local files and shipping them IS the exfil shape.
- Propagation — tool-safety detection re-derives each tool's inferred capability mask from the current classifier at detection time, rather than trusting a mask captured before a fix shipped. Every classifier correction (this release's and prior ones) now reaches the whole back catalogue on the next risk computation, with no re-scan.
- Dangerous code — a committed `.env` is flagged HIGH only when a populated, non-placeholder value sits under a secret-NAMED key (PASSWORD/TOKEN/SECRET/API_KEY/…); a `.env` of pure configuration (PORT/NODE_ENV/HOST/LOG_LEVEL) no longer reads as a committed secret. A real vendor token under any key name is still caught line-by-line. PUBLIC/PUBLISHABLE keys are excluded as public-by-design.
- Dangerous code — `new Function()` / `vm.runInNewContext()` with an all-string-literal static body (a templating / eval-lite helper) is now medium rather than high; a call that runs a variable or interpolated expression (opaque input) stays high, and a base64-decoded body feeding an exec sink stays high via the obfuscation detector.
- Prompt injection — the skill-exfil prose heuristic (a credential + an outbound sink in one breath) now skips fenced code blocks: a documented `curl -H "Authorization: Bearer $TOKEN" …` is a usage example, not a natural-language instruction the agent follows. It was the dominant skill-exfil false positive across real skill / agent-rule repos. A genuine prose payload elsewhere in the file still trips, and an executable payload shipped in a skill is still caught by the code-sink scanners.
-
Sharper capability & injection detection, plus a red-team evasion sweep
fewer false alarms new detectionAn accuracy pass on the permission and prompt-injection checks. Some warnings that were firing wrongly now stay quiet: the word "token" on its own (which means a cryptocurrency or an LLM unit far more often than a credential) no longer flags a tool as handling secrets, and a tool whose whole job is to move one named file over the network is no longer read as freely reading your filesystem. At the same time, several real risks that were slipping through are now caught: credential names written with an underscore or space ("api_key", "access token") are recognised as secrets; bulk "back up / sync / export everything" tools keep their filesystem flag because reading many files and sending them is exactly the pattern worth watching; the hidden-instruction check now catches the common "ignore the previous instructions" / "ignore your prior directives" phrasings; an instruction to send a secret to a named site ("send $API_KEY to evil.com") is now detected; and the committed-secret scanner no longer discards a genuine key just because it happens to contain a short run of x's. A red-team sweep then closed evasion gaps a malicious server could exploit: many more committed-secret formats are recognised (GitHub fine-grained, GitLab, npm, Stripe, Anthropic, HuggingFace, Google OAuth, Slack app, SendGrid, and encrypted private keys), invisible "tag"-character smuggling is caught inside a tool's own description (not just its docs), shell access is seen through modern runners (execa, zx, Bun, Deno), and the supply-chain check now inspects the `prepare` install script and IPv6 all-interface binds.
Technical detail
- Capabilities — dropped the bare `token` secrets keyword (a crypto/LLM homonym that drove false `exfil_combo`/`purpose_mismatch`/`tool_shadow` across DeFi servers); a file-transfer tool (`send_file`/`upload_file`/`download_media`) sheds the `fs` bit when its only filesystem signal is a generic file/path word and it reaches the network (the file is the payload, not an arbitrary read).
- Capabilities (false-negative closures) — credential compounds spelled with a separator now match via concatenated adjacent tokens, so `api_key`, `api key`, `access_token`, `access key`, and `private key` earn the secrets bit (without re-admitting the bare `token`/`key` homonyms); bulk/collection transfer verbs (backup, mirror, sync, export, import) no longer clear the `fs` bit, because reading many files and shipping them is the exfiltration shape, not a single-file transfer.
- Prompt injection — the model-imperative rule now matches an article or possessive between the verb and the noun (`ignore the previous instructions`, `ignore your prior directives`, `disregard the above`), the dominant phrasing it previously missed.
- Skill-exfil — the outbound-sink check accepts a literal attacker hostname (`send $API_KEY to evil.com`) in addition to url/endpoint/$env destinations, still gated by a concrete-secret co-occurrence so a bare hostname alone can't trip it; the sink span also no longer breaks on the dot inside an inline `process.env.X` reference, catching the canonical `send process.env.API_KEY to <host>` payload.
- Dangerous code — the placeholder filter that suppresses crafted demo credentials (`sk-…PLACEHOLDER…`) now requires a longer `xxxxxxxx` filler run, so a real high-entropy key carrying an incidental short `xxxx` substring is no longer dropped.
- Secret scanner (red-team) — added token formats the patterns were blind to: GitHub fine-grained PAT (`github_pat_`), GitLab (`glpat-`), npm (`npm_`), Stripe live (`sk_live_`/`rk_live_`), Anthropic (`sk-ant-`) and OpenAI service-account (`sk-svcacct-`) keys whose hyphenated bodies the old `sk-` pattern stopped at, HuggingFace (`hf_`), Google OAuth client secret (`GOCSPX-`), Slack app token (`xapp-`), SendGrid (`SG.`); plus encrypted PKCS#8 PEM private-key blocks.
- Tool poisoning (red-team) — the Unicode Tags block (U+E0000–E007F) used to smuggle an invisible directive is now flagged in tool names/descriptions and schema fields (previously README/skill prose only); the hidden-character set also gained the Mongolian vowel separator, Hangul fillers, and interlinear-annotation marks.
- Capabilities (red-team) — the shell bit is derived from modern process runners (execa, zx's tagged template, `Bun.spawn`, `Deno.Command`), and the secrets bit from `os.getenv(...)` and destructured `const { API_KEY } = process.env`. The separator-credential matching was also tightened so a `private key-value store` / `keyspace` data-structure name is not misread as a secret.
- Supply chain / transport (red-team) — install-hook scanning now covers `prepare`, `prepublishOnly`, and `prepack` (npm runs `prepare` on a plain install — the modern `postinstall` substitute), and the all-interfaces bind check now matches the IPv6 wildcard (`::` / `0:0:0:0:0:0:0:0`).
- Confused deputy (red-team) — the capability-vs-purpose check now flags a bare-noun-named tool that carries a shell capability (`weather`, `invoice` — names that announce nothing and slipped the benign-verb gate), while still ignoring honestly-named action tools (`run_command`, `deploy_service`).
- Loose schema (red-team) — command/code-injection params are matched on the separator-stripped name (so `shellCommand` / `cmd_line` / `bash_script` no longer evade by renaming), and the schema walk now descends `additionalProperties`, `anyOf`/`oneOf`/`allOf`, and `$defs` to find a param hidden by nesting.
- More evasions closed — a base64 payload wrapped across whitespace (chunked under the contiguous-run threshold) is now flagged; cross-server steering catches `route`/`delegate`/`forward … to/through` phrasing, not just call/use; and the bundled-archive opener handles the wider ZIP family (`.jar`, `.crx`, `.xpi`, `.nupkg`, `.whl`).
-
Fewer false alarms, plus several new checks
fewer false alarms new detection performanceThe largest update so far, in three parts. First, fewer mistaken warnings: the deep scan's most common false alarm — a server reading an API key from its environment and calling the very service it exists to talk to, which is normal — no longer counts as a serious finding unless something else genuinely backs it up, and two related checks stopped flagging ordinary tools that simply use a secret. Second, new checks: hidden terminal control codes in tool text, look-alike names that impersonate a popular tool using foreign letters shaped like ordinary ones, tools that take in outside content and can also send data out, risky commands hidden behind renamed imports, network servers that listen on every network with no sign of a login check, analytics trackers and clipboard access, disguised code that decodes and runs itself, and instructions smuggled into a tool's hidden default values. Third, speed: the heavy deep scan no longer repeats when a server's code hasn't changed, so the whole catalogue is re-checked faster after an analyzer update.
Technical detail
- Demoted the Semgrep env-secret-to-network finding from high to a low review prompt by default — reading an API key from the environment and calling the vendor's own HTTPS endpoint is the ordinary shape of an API-wrapper server. It re-escalates to high only when a real in-house danger corroborates it (a committed secret, dynamic exec, lethal-trifecta, or tool-poisoning signal on the same server).
- Stopped treating a benign-named tool that merely reads an environment secret as high-signal: purpose-mismatch and tool-shadowing now reserve the high tier for an actual shell capability.
- New detections — ANSI / terminal-escape sequences hidden in tool text (line-jumping); look-alike names built from Cyrillic, Greek, or full-width letters, folded to ASCII before the shadowing and typosquat checks; tools that read a network response body (the lethal trifecta's untrusted-input leg); shell access hidden behind renamed imports; network servers that bind every interface (0.0.0.0) with no detected authentication; analytics/telemetry beacons and clipboard access; base64-decode-then-execute obfuscation and dynamic require of a variable; instructions smuggled into a tool's schema default; and a rug-pull alert when a tool newly gains network plus untrusted-input capability.
- Performance — the heavy taint pass (Semgrep) is skipped on unchanged source via a separate rules-version key, and the in-house scanners share a single file-tokenisation pass, so a version bump re-checks the whole catalogue faster and with less load on the worker.
-
Deep-scan now ignores tests and build scripts
fewer false alarmsThe optional deep code scan flags when a secret read from the environment flows into a network call. It was reporting these everywhere, including test files, examples, and build or release scripts — where using a token to reach an API is the script's own job, not the server's behaviour. It now skips the same non-shipped files the main analyzer already ignores, removing the largest source of mistaken warnings from this check.
Technical detail
- The Semgrep env-secret-to-network taint check now applies the same path exclusions as the in-house baseline — test/example/fixture/doc files, vendored or generated code, and build scripts/ — because a token reaching an API inside a smoke test or seed script is the script's job, not the server's runtime behaviour. Was the single largest Semgrep false-positive class.
-
Fewer false alarms for logged secrets
fewer false alarmsThe analyzer warns when code looks like it writes a secret into a log, where it could leak. This release stops most of the mistaken warnings. It now considers only what is actually being logged, and it understands the common harmless cases, such as recording whether a key is present rather than the key itself, or printing a masked or shortened version.
Technical detail
- The credential-in-log check now scopes to the log call's own arguments — a credential in a preceding guard such as if (token) log('msg') is no longer treated as logged.
- It recognises non-value shapes as safe: presence tests (Boolean(token), !!token), predicate branches (token.startsWith('sk-') ? …), derived members (apiKey.keyId, accessTokenExpiry), and masking/truncation (api_key[:8]).
-
Smarter .env and private-key checks
fewer false alarmsTwo checks for committed secrets were firing too easily. An .env file is now flagged only when it actually holds real values, because an example file full of blanks or placeholders gives nothing away. A private-key warning now requires a complete key to be present, not just a line that mentions one, which security tools and tests routinely include on purpose.
Technical detail
- A committed .env is a leak only when an assignment carries a populated value — blank or placeholder-only assignments are de-facto templates.
- A private-key finding now requires a full PEM block with real base64 material, not a bare -----BEGIN PRIVATE KEY----- header, which appears in detector patterns, redaction fixtures, and key-shape tests on purpose.
-
Allowing a server's own service
fewer false alarmsMany servers exist to talk to one particular service, so a Telegram bot contacting Telegram, or a Discord bot posting to Discord, is simply doing its job. The analyzer now recognises when a built-in address matches what the server is for, and no longer treats it as suspicious.
Technical detail
- Purpose-aware endpoint filtering: a hardcoded call-home host that IS the server's declared vendor (a Telegram server hitting api.telegram.org, a Discord server posting to its own webhook API) is the package's purpose, not a covert sink. Filtered at evidence-persist time so it never reaches the grade.
-
Fewer false alarms after a full review
fewer false alarmsA broad review of earlier findings removed several recurring false alarms. The analyzer now ignores code that merely ships inside a project but was written by others, such as bundled dependencies and test fixtures, and it does a better job of telling a genuinely risky pattern apart from ordinary code that happens to resemble one.
Technical detail
- Permission and tool scans now skip vendored (node_modules) and test/example files — a committed dependency tree and test harnesses were inflating the permission mask.
- JS exec( / eval( require a bare call (RegExp.prototype.exec was about a third of all shell evidence; $$eval and .eval() are library APIs); dynamic-exec skips string literals, JSDoc, the dynamic-import shim, and literal __import__('x').
- Secret scan skips harness / playground / fixture-generator files, SAST rulesets, throwaway-stem tokens, and .env.test / .env.ci; hidden-unicode exempts emoji-sequence ZWJ and RTL marks.
-
Ignoring keys that are meant to be there
fewer false alarmsStopped reporting keys that appear where they are expected and harmless, such as documentation, example files, and the rule files that other security scanners ship. It also stopped treating a public Firebase web key as a secret, since that kind of key is designed to be shared.
Technical detail
- Secret scanning now skips generated .d.ts / .map files, benches/ and corpus/ dirs, documentation prose, and secret-pattern libraries (gitleaks rulesets, redaction / PII-recognizer modules), and drops public Firebase web API keys (project ids, not credentials).
-
First big noise cleanup
fewer false alarmsThe first major pass at reducing noise. The analyzer began skipping machine-generated and bundled code, ignoring well-known example and placeholder keys, and reporting risky shell or eval patterns only when they genuinely do something dangerous rather than something routine.
Technical detail
- Began skipping machine-generated and bundled code (node_modules, *.min.js, *.bundle.js), ignoring AWS-docs example keys and placeholder secret lines, and gating shell / eval findings on a genuinely dangerous payload rather than a routine call.
-
Looking inside bundled archives
capability modelSome packages ship a zip or editor-extension archive inside them. The analyzer now opens those archives and examines what they contain, so anything tucked away in a bundled file gets the same scrutiny as the rest of the source.
Technical detail
- Bundled .vsix / .zip archives in the tree are opened (pure-JS fflate) and their entry source is folded into analysis — env-to-exfil, secrets, dynamic-exec, and extension manifests inside the archive get the same scrutiny as the outer source, not just the outer manifest.
-
Tracking untrusted input
capability model new detectionAdded a new dimension to how a server's permissions are described: whether it takes in untrusted input. This lets the analyzer recognise the dangerous combination of reading untrusted data, holding secrets, and reaching the network, which is the classic recipe for data theft.
Technical detail
- Added the sixth permission bit, untrusted (the injection-entry axis), so the lethal-trifecta detector can see all three legs at once: untrusted-content ingestion + private-data access + network reach.
-
Scanning bundled editor extensions
new detectionDetects editor-extension files, such as VS Code manifests and recommended-extension lists, that are bundled into a package, so their behaviour is reviewed rather than overlooked.
Technical detail
- Detects bundled editor-extension files — VS Code manifests and .vscode/extensions.json recommendation lists — so their behaviour is reviewed rather than overlooked.
-
Reading the listing text for hidden instructions
new detectionAn assistant often reads a project's README or description before using it, which makes that text a place to hide instructions aimed at the assistant. This release scans the listing text for those hidden prompts.
Technical detail
- Scans the README and package.json description an assistant reads before using a server for hidden-channel injection markers — indirect prompt injection in the listing text rather than the tool schema.
-
Flagging risky bundled scripts
new detectionFlags an executable script bundled alongside a skill when it does something risky, such as downloading and running code from the internet, since an assistant could be instructed to run it.
Technical detail
- Flags a suspicious executable bundled inside a skill directory — remote-exec or pipe-to-shell patterns an assistant could be instructed to run.
-
Logged secrets and broad permissions
new detectionAdded two checks: one for code that writes a secret into a log, and one for requests for unusually broad access, such as full account or full drive permissions.
Technical detail
- Added the credential-in-log check (a secret value written into a log or print sink) and the over-broad OAuth scope check (a full-account or full-drive scope request).
-
Spotting data exfiltration in skills
new detectionFlags a skill whose instructions both reference a secret and point to somewhere data could be sent, the pattern behind skills designed to quietly leak information.
Technical detail
- Flags a skill whose prose both references a credential and names an outbound sink — the ToxicSkills exfil pattern in plain skill-file text.
-
Smarter call-home detection
fewer false alarms new detectionImproved the check for code that contacts an outside address. It now ignores local and private addresses that cannot reach the wider internet, and pays special attention to cloud metadata addresses, a known target for credential theft. It also stopped scanning test and example files, which are full of harmless sample addresses.
Technical detail
- Endpoint detection now drops non-routable addresses (loopback / private / reserved / TEST-NET), promotes cloud-metadata addresses (a known credential-theft target), and stops scanning test / example / fixture / doc files full of sample addresses; the hidden-prompt scan ignores a leading BOM.
-
Foundational analyzers
capability model new detectionThe first versions of the scanner. They read a server's published source to work out what it can do (touch the filesystem, run shell commands, reach the network, use secrets or databases), list its tools and dependencies, work out how it communicates, and raise the first danger signals, including committed secrets, dynamically executed code, and suspicious outbound addresses.
Technical detail
- The first analyzers read a server's published source — never executing it — to infer capabilities (filesystem, shell, network, secrets, database), enumerate tools and dependencies, classify transport (stdio / http / SSE / streamable-http), and raise the first danger signals: committed secrets, dynamically executed code, and suspicious outbound addresses.