Quickstart
Check a lockfile. No key.
curl -s https://lazaretto.dev/check --data-binary @package-lock.jsonEvery exactly pinned version is matched against published malicious-package advisories. Works with npm, yarn and pnpm lockfiles. Nothing is stored.
Get a free developer key
curl -s -X POST https://lazaretto.dev/v1/trialReturns api_key with 10 full scans a day, plus ready-made setup commands. Or get one in the browser at /start. Keep it out of your shell history:
read -rs LAZARETTO_API_KEY && export LAZARETTO_API_KEYRead the code of one package
curl -s -X POST https://lazaretto.dev/v1/scan -H "X-API-Key: $LAZARETTO_API_KEY" -H 'content-type: application/json' -d '{"target":{"type":"npm_package","ref":"left-pad@1.3.0"},"depth":"full"}'You get a verdict, a risk with a one-line risk_summary, every finding with its file and line, the SHA-256 of what was read, and a signed attestation.
Gate your agent's installs
The pattern that fits most agents: before the tool that runs an install executes, ask whether that exact version is
listed as malware (free, one GET), and when you hold a key, have the code read too. Gate on risk, not on the
verdict alone. This example denies listed malware and high risk, and asks a person on medium and on anything it could not
check; set your own policy.
const LZ = "https://lazaretto.dev";
type Decision = { decision: "allow" | "ask" | "deny"; why: string };
/** Decide whether an agent may install name@version. Only a definite answer allows. */
export async function preInstallCheck(name: string, version: string, apiKey?: string): Promise<Decision> {
// 1. Free, no key: is this exact version listed as malware, or already attested?
const subject = encodeURIComponent(`npm:${name}@${version}`);
const res = await fetch(`${LZ}/v1/attestations/${subject}`);
if (!res.ok) return { decision: "ask", why: `identity check unavailable (${res.status})` };
const id = await res.json();
if (id.found) {
// Someone already paid for a scan of this version: a signed verdict exists.
if (id.contradicted || id.verdict === "malicious") return { decision: "deny", why: "known bad" };
if (id.risk === "critical" || id.risk === "high") return { decision: "deny", why: `attested risk ${id.risk}` };
if (id.advisory_check) return { decision: "ask", why: "advisory recheck gave no answer" }; // never clear
} else {
const listed = id.identity_check?.listed_as_malware;
if (listed === true) return { decision: "deny", why: "listed as malware: " + id.identity_check.advisory_ids.join(", ") };
if (listed !== false) return { decision: "ask", why: "no definite answer" }; // null means unchecked, never clear
}
if (!apiKey) return { decision: "allow", why: "not listed; code not read" };
// 2. With a key: read the code. One credit per verdict, nothing for an error.
const r = await fetch(`${LZ}/v1/scan`, {
method: "POST",
headers: { "content-type": "application/json", "x-api-key": apiKey },
body: JSON.stringify({ target: { type: "npm_package", ref: `${name}@${version}` }, depth: "full" }),
});
if (!r.ok) return { decision: "ask", why: `scan unavailable (${r.status})` };
const scan = await r.json();
if (scan.verdict === "error") return { decision: "ask", why: "scan did not complete" };
if (scan.risk === "critical" || scan.risk === "high") return { decision: "deny", why: scan.risk_summary };
if (scan.risk === "none" || scan.risk === "low") return { decision: "allow", why: scan.risk_summary };
return { decision: "ask", why: scan.risk_summary }; // medium, unknown, or a value this code does not know
}import requests
from urllib.parse import quote
LZ = "https://lazaretto.dev"
def pre_install_check(name: str, version: str, api_key: str | None = None) -> tuple[str, str]:
"""Return ("allow" | "ask" | "deny", why). Only a definite answer allows."""
# 1. Free, no key: is this exact version listed as malware, or already attested?
subject = quote(f"npm:{name}@{version}", "") # encode "/" and "@" too
r = requests.get(f"{LZ}/v1/attestations/{subject}", timeout=10)
if not r.ok:
return "ask", f"identity check unavailable ({r.status_code})"
body = r.json()
if body.get("found"):
# Someone already paid for a scan of this version: a signed verdict exists.
if body.get("contradicted") or body.get("verdict") == "malicious":
return "deny", "known bad"
if body.get("risk") in ("critical", "high"):
return "deny", f"attested risk {body['risk']}"
if body.get("advisory_check"):
return "ask", "advisory recheck gave no answer" # never clear
else:
ident = body.get("identity_check") or {}
listed = ident.get("listed_as_malware")
if listed is True:
return "deny", "listed as malware: " + ", ".join(ident.get("advisory_ids", []))
if listed is not False:
return "ask", "no definite answer" # None means unchecked, never clear
if not api_key:
return "allow", "not listed; code not read"
# 2. With a key: read the code. One credit per verdict, nothing for an error.
r = requests.post(
f"{LZ}/v1/scan",
headers={"x-api-key": api_key},
json={"target": {"type": "npm_package", "ref": f"{name}@{version}"}, "depth": "full"},
timeout=60,
)
if not r.ok:
return "ask", f"scan unavailable ({r.status_code})"
scan = r.json()
if scan["verdict"] == "error":
return "ask", "scan did not complete"
if scan["risk"] in ("critical", "high"):
return "deny", scan["risk_summary"]
if scan["risk"] in ("none", "low"):
return "allow", scan["risk_summary"]
return "ask", scan["risk_summary"] # medium, unknown, or a value this code does not knowOnly a definite answer allows. A failed or rate-limited call returns ask, and so does
listed_as_malware: null, which means the advisory corpus could not be consulted or could not be matched to that
version. For Claude Code there is a ready-made version of the first step as a hook, the lazaretto-guard plugin.
Use the MCP server from code
https://lazaretto.dev/mcp is a stateless Streamable HTTP server with 10 tools, 6 of them free with no key.
Connect from whichever stack your agent runs on. For desktop and IDE clients, see the setup page.
// npm i @modelcontextprotocol/client
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
const client = new Client({ name: "my-agent", version: "1.0.0" });
await client.connect(
new StreamableHTTPClientTransport(new URL("https://lazaretto.dev/mcp"), {
// Omit requestInit for the free tools only.
requestInit: { headers: { "X-API-Key": process.env.LAZARETTO_API_KEY! } },
}),
);
const result = await client.callTool({
name: "find_attestation",
arguments: { subject: "npm:chalk@5.6.1" },
});
await client.close();// npm i @modelcontextprotocol/sdk (v1)
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const client = new Client({ name: "my-agent", version: "1.0.0" });
await client.connect(new StreamableHTTPClientTransport(new URL("https://lazaretto.dev/mcp")));
const result = await client.callTool({
name: "check_lockfile",
arguments: { packages: ["chalk@5.6.1", "express@4.21.2"] },
});# pip install "mcp>=2,<3"
import asyncio
from mcp import Client
async def main() -> None:
async with Client("https://lazaretto.dev/mcp") as client:
result = await client.call_tool("find_attestation", {"subject": "npm:chalk@5.6.1"})
print(result)
asyncio.run(main())
# For the metered tools, pass an httpx2.AsyncClient carrying the X-API-Key header
# to mcp.client.streamable_http.streamable_http_client and hand that to Client.# pip install openai-agents
import asyncio, os
from agents import Agent, Runner
from agents.mcp import MCPServerStreamableHttp
async def main() -> None:
async with MCPServerStreamableHttp(
name="lazaretto",
params={
"url": "https://lazaretto.dev/mcp",
"headers": {"X-API-Key": os.environ["LAZARETTO_API_KEY"]}, # omit for the free tools
},
cache_tools_list=True,
) as lazaretto:
agent = Agent(
name="Installer",
instructions="Before installing any package, check it with the lazaretto tools.",
mcp_servers=[lazaretto],
)
result = await Runner.run(agent, "Is chalk@5.6.1 fine to install?")
print(result.final_output)
asyncio.run(main())from openai import OpenAI
client = OpenAI()
resp = client.responses.create(
model="YOUR_MODEL",
tools=[{
"type": "mcp",
"server_label": "lazaretto",
"server_url": "https://lazaretto.dev/mcp",
"headers": {"X-API-Key": "YOUR_KEY"}, # omit for the free tools
"require_approval": "never",
}],
input="Check chalk@5.6.1 and debug@4.4.2 before I install them.",
)
print(resp.output_text)# From Perplexity's docs. Not run by us.
from perplexity import Perplexity
client = Perplexity()
response = client.responses.create(
model="YOUR_MODEL",
tools=[{
"type": "mcp",
"server_label": "lazaretto",
"server_url": "https://lazaretto.dev/mcp?ref=perplexity",
# "headers": {"X-API-Key": "YOUR_KEY"}, # for the scans that read code
}],
input="Check chalk@5.6.1 and chalk@5.3.0 before I install them.",
)
print(response.output_text)# pip install langchain-mcp-adapters (pins mcp<2)
import asyncio
from langchain_mcp_adapters.client import MultiServerMCPClient
async def main() -> None:
client = MultiServerMCPClient({
"lazaretto": {
"transport": "http",
"url": "https://lazaretto.dev/mcp",
"headers": {"X-API-Key": "YOUR_KEY"}, # omit for the free tools
}
})
tools = await client.get_tools() # hand these to your LangGraph agent
print([t.name for t in tools])
asyncio.run(main())// npm i ai @ai-sdk/mcp
import { createMCPClient } from "@ai-sdk/mcp";
const mcp = await createMCPClient({
transport: {
type: "http",
url: "https://lazaretto.dev/mcp",
headers: { "X-API-Key": process.env.LAZARETTO_API_KEY! }, // omit for the free tools
},
});
try {
const tools = await mcp.tools(); // pass to generateText or streamText
} finally {
await mcp.close();
}CI: scan what each pull request adds
The GitHub Action fails the build when a pinned dependency is known malware, free and with no key. Add a key and it also reads the code of each dependency version a pull request adds, which is where a brand-new malicious release hides before any advisory exists. A pull request that changes no dependencies costs nothing.
name: Lazaretto
on: [pull_request]
permissions:
contents: read
pull-requests: write
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: jamesdfinance-dev/lazaretto-scan-action@v2
with:
api-key: ${{ secrets.LAZARETTO_API_KEY }}Store the key with gh secret set LAZARETTO_API_KEY. Never put it in the workflow file.
The Action on GitHub Marketplace.
Whole dependency trees
| Endpoint | What it answers | Cost |
|---|---|---|
POST /v1/lockfile | Every pinned version in a lockfile, matched against malicious-package advisories. JSON. | free |
POST /check | The same check rendered as plain text for a terminal. | free |
POST /v1/lockfile/diff | Base and head lockfiles in, exactly the versions a change adds out. | free |
POST /v1/scan/batch | Reads the code of every pinned dependency, worst verdict first, up to 25 per call. | one credit per package |
POST /v1/watch | Registers a dependency set; reading it later reports anything listed as malware since. | one credit to create, free to read |
An empty malicious list is an all-clear only when unverified is also empty and
truncated is false. On the batch scan, read complete_coverage before trusting the result.
Checking MCP servers before you connect
An MCP server's tool descriptions are text your model obeys. scan_mcp_server (or POST /v1/scan
with "type":"mcp_server") calls initialize and tools/list on the server, never its
tools, and reads what it advertises for hidden directives, exfiltration parameters and cross-server shadowing. Most servers
run over stdio and have no URL: send the tool list your client already holds with "type":"mcp_tools" or the
check_mcp_tools tool, and nothing is contacted.
curl -s -X POST https://lazaretto.dev/v1/scan -H "X-API-Key: $LAZARETTO_API_KEY" \
-H 'content-type: application/json' \
-d '{"target":{"type":"mcp_server","ref":"https://the-server.example/mcp"}}'Signed attestations
A full scan through POST /v1/scan returns an attestation, unless it ended in an error or its x402
settlement was not confirmed. The free check page does not return one. The connector tools sign nothing, so they return one
only when an earlier scan of the same content already signed it. An attestation is a compact JWS signed with Ed25519 over the verdict, the risk,
the rules version and sub, which is the SHA-256 of what was read (or, for a release the registry has
already pulled, its package identity). Pass it to another agent, put it in a README,
or store it next to a lockfile. Anyone can verify it offline, with no call to us and no need to trust whoever handed it over.
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(new URL("https://lazaretto.dev/.well-known/jwks.json"));
const { payload } = await jwtVerify(attestation, JWKS, { issuer: "https://lazaretto.dev" });
// Check it names the artifact you asked about (sub is a package identity only for a pulled release).
if (payload.sub !== "sha256:" + yourArtifactHash) throw new Error("attestation is for a different artifact");
console.log(payload.verdict, payload.risk, payload.rules_version);Or verify online with POST /v1/verify, which also flags a verdict the current data now contradicts. Look up
whether anyone has attested a package, an MCP endpoint or a hash with GET /v1/attestations/{subject} or the
find_attestation tool, both free. There is no expiry on purpose: a verdict means "clear at scan time under
these rules", never "clear forever".
Verdicts, risk and billing
| Field | Values | Read it as |
|---|---|---|
verdict | malicious, flagged, clear, error | malicious is reserved for indicator-backed matches. Heuristic rules only reach flagged. clear means nothing matched. |
risk | critical, high, medium, low, none, unknown | How bad and what kind. Gate on this. unknown comes only with an error verdict: the scan did not finish, so no level is asserted. It is not a clear result. |
confidence | high, medium, low | Lower when part of the artifact could not be read or a source could not be checked. |
known_bad.matched | true, false, null | null means the check could not run. Never read it as false. |
analysis_partial | true when set | Some of the artifact was unreadable, so "no rule fired" is a weaker statement. |
target_hash | sha256, or empty | The SHA-256 of what was read. Empty for a verdict on a package identity the registry has pulled. |
Billing is one credit per verdict. An error is never billed, over the API, MCP or x402. A scan is
$0.03 per call over x402, or credits from packs: starter $3 for 150 scans, pro $12 for 700 scans, scale $25 for 1,600 scans. Packs never expire and have no daily cap.
See pricing.
Rate limits
Limits are counted per source (your key, or your address without one) in one-minute windows. Most routes share a single counter, and each refuses once that counter passes its own ceiling, so a burst of lookups also counts against a batch scan made in the same minute.
| Route | Ceiling per minute |
|---|---|
| Scans, credit top-ups and the MCP endpoint | 120 |
| Free lockfile check | 60 |
| Free lockfile check of more than 50 packages (its own counter) | 10 |
| Known-bad and attestation lookups | 60 |
| Batch scans | 6 |
A 429 carries Retry-After, and rate-limited routes report X-RateLimit-Remaining.