Home / Developers

For developers

Build with Lazaretto

Put a pre-install check into an agent, a CI pipeline or a platform. Start with no key and nothing to install, add one when you want the code read.

Quickstart

1

Check a lockfile. No key.

curl -s https://lazaretto.dev/check --data-binary @package-lock.json

Every exactly pinned version is matched against published malicious-package advisories. Works with npm, yarn and pnpm lockfiles. Nothing is stored.

2

Get a free developer key

curl -s -X POST https://lazaretto.dev/v1/trial

Returns 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:

paste the key at the silent prompt
read -rs LAZARETTO_API_KEY && export LAZARETTO_API_KEY
3

Read 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.

.github/workflows/lazaretto.yml
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

EndpointWhat it answersCost
POST /v1/lockfileEvery pinned version in a lockfile, matched against malicious-package advisories. JSON.free
POST /checkThe same check rendered as plain text for a terminal.free
POST /v1/lockfile/diffBase and head lockfiles in, exactly the versions a change adds out.free
POST /v1/scan/batchReads the code of every pinned dependency, worst verdict first, up to 25 per call.one credit per package
POST /v1/watchRegisters 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.

Verify offline with jose
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

FieldValuesRead it as
verdictmalicious, flagged, clear, errormalicious is reserved for indicator-backed matches. Heuristic rules only reach flagged. clear means nothing matched.
riskcritical, high, medium, low, noneHow bad and what kind. Gate on this.
confidencehigh, medium, lowLower when part of the artifact could not be read or a source could not be checked.
known_bad.matchedtrue, false, nullnull means the check could not run. Never read it as false.
analysis_partialtrue when setSome of the artifact was unreadable, so "no rule fired" is a weaker statement.
target_hashsha256, or emptyBinds 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.

RouteCeiling per minute
Scans, credit top-ups and the MCP endpoint120
Free lockfile check60
Free lockfile check of more than 50 packages (its own counter)10
Known-bad and attestation lookups60
Batch scans6

A 429 carries Retry-After, and rate-limited routes report X-RateLimit-Remaining.

Reference