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 exactly 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}` };
} 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 === "medium") return { decision: "ask", why: scan.risk_summary };
return { decision: "allow", why: scan.risk_summary };
}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']}"
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"] == "medium":
return "ask", scan["risk_summary"]
return "allow", scan["risk_summary"]Only 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 9 tools, 5 of them free with no key.
Connect from whichever stack your agent runs on. For desktop and IDE clients, see the agents 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)# 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
Every non-error scan returns an attestation: a compact JWS signed with Ed25519 over the verdict, the risk,
the rules version and sub, which is the SHA-256 of exactly 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" });
// Bind it to the bytes you are about to run (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 | How bad and what kind. Gate on this. |
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 | Binds the verdict to the bytes, reproducible by consumers. 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.