{"openapi":"3.1.0","info":{"title":"Lazaretto: skill & tool verification API","version":"2026.09.26a","summary":"Deterministic verification of agent skills/tools: known-bad matching + behavioral signals.","contact":{"name":"Lazaretto","url":"https://lazaretto.dev"},"license":{"name":"Proprietary"}},"servers":[{"url":"https://lazaretto.dev"}],"paths":{"/v1/scan":{"post":{"summary":"Scan a target and return a verdict bound to its content hash.","description":"Auth: X-API-Key, OR x402 payment (when enabled). Without valid auth the paid path returns 402 with payment requirements. An `error` verdict returns HTTP 422 and is never billed (§9.5).","security":[{"apiKey":[]},{"x402":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScanRequest"}}}},"responses":{"200":{"description":"A verdict (clear/flagged/malicious). x402 only: the signed `attestation` comes back, and is published, only when the payment settled. When the payment facilitator did not answer clearly, the result still comes back, with no `attestation` and a `payment` block {outcome:\"unknown\", detail, contact, time}. Whether that payment settled is unknown (settlement_outcome_unknown). Do not pay again for the scan; if you were charged, nothing more is owed. Email contact@lazaretto.dev with the paying wallet address and the `time` if in doubt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScanReport"}}}},"401":{"description":"Missing or invalid API key."},"402":{"description":"Payment required (x402, DUAL-STACK): the PAYMENT-REQUIRED response header carries the x402 v2 challenge (network eip155:8453, `amount`), the JSON body carries the v1 challenge (network base, `maxAmountRequired`) plus a human-readable `hint` with prices and the free tier. Pay via PAYMENT-SIGNATURE (v2) or X-PAYMENT (v1) headers."},"409":{"description":"payment_in_use: this x402 payment is already in use by another request, or was used for one within the last few minutes. One payment pays for one request, so nothing was run or settled for this one. Sign a new payment."},"422":{"description":"An `error` verdict: analysis could not complete. Never billed."},"429":{"description":"Rate limited."}}}},"/v1/scan/batch":{"post":{"summary":"Behaviorally scan every pinned dependency in a lockfile, billed per package.","description":"The paid counterpart to /v1/lockfile, which only matches identities. Reads the code of each package and screens for credential theft, exfiltration, obfuscation, prompt injection and install-time droppers. Each result carries a verdict, a risk level, the ids of the rules that fired and a risk summary. It does not carry file-and-line evidence: for that, scan the package on its own with POST /v1/scan (scan_artifact over MCP). Requires an X-API-Key holding credits: one credit per package that returns a verdict, NOTHING for a package that errors (its reservation is refunded). Capped at 25 packages per call, in lockfile order, deduplicated, worst verdict first. Sending the same lockfile again rescans the same first 25. To continue, send the rest as {\"packages\":[...]}: the `not_scanned.packages` list from the result (name@version strings) is accepted as it is. Read `complete_coverage` before trusting the result: false means something was capped, errored, only partly readable, or skipped for having no registry identity, so it is not a clean bill of health for the whole tree.","security":[{"apiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"lockfile":{"type":"string"},"packages":{"type":"array","description":"Exactly pinned packages, as {name, version} objects or name@version strings.","items":{"oneOf":[{"type":"object","properties":{"name":{"type":"string"},"version":{"type":"string"}}},{"type":"string","examples":["chalk@5.6.1","@scope/name@1.0.0"]}]}}}}}}},"responses":{"200":{"description":"Per-package verdicts plus coverage and billing detail. `not_scanned` is null or {count, reasons, by_reason: {credits, time, cap}, packages}: by_reason splits count by cause, and packages lists the name@version of every package not scanned, in lockfile order. `refund_failed` is true only when unbilled credits could not be returned automatically."},"400":{"description":"No exactly-pinned versions found."},"401":{"description":"Missing or invalid API key."},"402":{"description":"No credits available. Body quotes the packs and their prices."},"429":{"description":"Rate limited (per key)."},"503":{"description":"The batch could not be completed. No scan credits are billed; `refund_failed` says whether the reserved credits came back. `auto_reload_possible` is present and true when the key has auto-reload switched on: reserving the credits may have started a reload, and if it did, that pack was charged to the saved card and its credits are on the key."}}}},"/check":{"get":{"summary":"Plain-text usage for the zero-install check.","responses":{"200":{"description":"How to run the one-liner."}}},"post":{"summary":"The free lockfile check, rendered for a terminal.","description":"Same check as POST /v1/lockfile, returned as human-readable text instead of JSON, for `curl -s https://lazaretto.dev/check --data-binary @package-lock.json`. No key, no account. Unverified entries are shown as an explicit non-answer.","responses":{"200":{"description":"Plain-text result."},"400":{"description":"Unrecognized lockfile, or no exactly-pinned versions."},"503":{"description":"The feed could not be consulted. Not an all-clear."}}}},"/v1/known-bad/{sha256}":{"get":{"summary":"Free loss-leader: exact known-bad hash lookup.","parameters":[{"name":"sha256","in":"path","required":true,"schema":{"type":"string","pattern":"^(sha256:)?[0-9a-f]{64}$"}}],"responses":{"200":{"description":"Whether the hash matches a known-bad indicator.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KnownBad"}}}},"400":{"description":"Malformed hash."},"429":{"description":"Rate limited (detection-oracle bound)."}}}},"/.well-known/jwks.json":{"get":{"summary":"Public keys for verifying scan attestations (JWKS).","description":"OKP/Ed25519 keys. Verify a scan `attestation` offline against these; `keys` is empty when signing is not configured.","responses":{"200":{"description":"A JWK Set."}}}},"/v1/verify":{"post":{"summary":"Verify a scan attestation (free, anonymous).","description":"Body {attestation:\"<compact JWS>\"}. Returns {valid, claims}, plus `contradicted` when a `clear`/`flagged` verdict's content hash is now a known-bad match (a stale verdict). Offline verification against /.well-known/jwks.json is preferred; this adds the contradiction check.","responses":{"200":{"description":"Verification result (valid true/false; never a signature-based error status)."},"400":{"description":"No attestation in the body."},"429":{"description":"Rate limited."}}}},"/v1/lockfile":{"post":{"summary":"Free: check every pinned version in a lockfile against the malicious-package feed.","description":"No auth, IP rate-limited. Send a package-lock.json, yarn.lock (v1/Berry), or pnpm-lock.yaml (v5/v6/v9), raw or as {\"lockfile\":\"<contents>\"}, or an explicit {\"packages\":[{\"name\",\"version\"}]} list. Only EXACT versions are checked: a range has no definitive answer, because a compromised release usually sits between clean ones. Fail-closed: anything that could not be checked is listed in `unverified`, so an empty `malicious` list is only an all-clear when `unverified` is empty too. `skipped` counts entries with no registry identity (file:/link:/workspace:/git), which are reported rather than silently dropped.","responses":{"200":{"description":"Malicious pinned versions, plus whatever could not be checked."},"400":{"description":"Unrecognized lockfile, or no exactly-pinned versions in it."},"429":{"description":"Rate limited."},"503":{"description":"The feed could not be consulted. Not an all-clear."}}}},"/v1/lockfile/diff":{"post":{"summary":"Free: the exact package versions a lockfile change adds.","description":"No auth, IP rate-limited. Body {\"base\": <lockfile>, \"head\": <lockfile>}, each the raw contents of a package-lock.json, yarn.lock or pnpm-lock.yaml. Returns `added` (the name@version pairs present in head and absent from base, which is the list to scan), `upgraded`, `removed`, an `unchanged` count, and per-side `checked` and `truncated`. A truncated side makes the diff partial, and `note` says so. This compares identities only and checks nothing for malware: send `added` to POST /v1/scan/batch for that.","responses":{"200":{"description":"What the head lockfile adds, upgrades and removes relative to base."},"400":{"description":"A side is missing, or could not be read as a lockfile. An unreadable lockfile is not a statement that nothing changed."},"429":{"description":"Rate limited."}}}},"/v1/watch":{"post":{"summary":"Register a dependency set to re-check later (one credit to create).","description":"Requires an X-API-Key. Creating a watch spends one credit, taken only after the baseline is recorded, so a feed outage costs nothing. Send a lockfile (raw or as {\"lockfile\":\"<contents>\"}) or {\"packages\":[{\"name\",\"version\"}]}: exactly pinned versions only, at most 2000. We store package identities and what we knew at that moment, never lockfile contents or code. Returns a `watch_id` and a `watch_token` shown once. A watch is re-checked when you read it with GET /v1/watch/{id}: nothing runs on a schedule and nothing is pushed to you, so poll it.","security":[{"apiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"lockfile":{"type":"string"},"packages":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"version":{"type":"string"}}}},"webhook_url":{"type":"string","description":"Optional https URL. Accepted and stored, but no deliveries are sent yet, so do not rely on it for alerts."}}}}}},"responses":{"201":{"description":"The watch, its one-time token, and the baseline taken at registration."},"400":{"description":"No exactly-pinned versions, too many packages, or a webhook_url that is not https."},"401":{"description":"Invalid API key."},"402":{"description":"No key, or no credits left. Body carries `ways_to_pay`."},"404":{"description":"Watches are not enabled on this deployment."},"429":{"description":"Rate limited."},"503":{"description":"The baseline could not be taken, or the watch could not be saved. No watch was created; `detail` says whether a spent credit was returned."}}}},"/v1/watch/{id}":{"get":{"summary":"Re-check a watch now (free).","description":"Evaluates the watched identities against the current advisory feed at the time of the request. `newly_malicious` lists packages that were clean when you registered and are listed now; `still_malicious` were already listed then. Reading acknowledges an alert, so it is not reported as new again. An unknown id and a wrong token get the same 404.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"x-watch-token","in":"header","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The re-check result."},"404":{"description":"No such watch, or the wrong token."},"429":{"description":"Rate limited."},"503":{"description":"`degraded: true`: the feed could not be consulted. Not an all-clear, and nothing is acknowledged."}}},"delete":{"summary":"Stop a watch and delete its dependency list.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"x-watch-token","in":"header","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Stopped; the dependency list is deleted."},"404":{"description":"No such watch, or the wrong token."},"429":{"description":"Rate limited."}}}},"/v1/attestations/{subject}":{"get":{"summary":"Free: find an existing signed verdict by content hash or package identity.","description":"No auth, IP rate-limited. `subject` is a `sha256:<hex>` content hash, or the target ref as it was scanned (for example `chalk@5.6.1`). Returns the newest attestation with its compact JWS, so it can be verified offline against /.well-known/jwks.json without trusting this response. A clear or flagged verdict over a content hash is re-checked against the current known-bad set first, and `contradicted` is non-null when it no longer holds. `stale_rules` is true when it was attested under older rules. The findings and evidence are not in an attestation. When NOBODY has attested the subject and it is an npm package identity, the answer is a free identity check against the OSV / OpenSSF malicious-package corpus, carried in `identity_check` with `found` still false and `answer` set to `identity_check`. An identity check is a different and weaker thing than an attestation: it is unsigned, it looks at the package identity rather than its code, and absence from the corpus is not a verdict.","parameters":[{"name":"subject","in":"path","required":true,"schema":{"type":"string","maxLength":300}}],"responses":{"200":{"description":"The attestation, its age, and freshness signals; or, with `found` false, a labelled identity check against the advisory corpus."},"400":{"description":"No subject."},"404":{"description":"Nobody has attested this subject and there is no package identity to check either. Absence of an attestation is not a verdict."},"429":{"description":"Rate limited."}}}},"/v1/checkout":{"post":{"summary":"Buy a credit pack with a card (hosted checkout).","description":"For a person without a crypto wallet; agents pay over x402 instead. Body {\"bundle\":\"starter\"|\"pro\"|\"scale\"}, plus an optional `api_key` to top up an existing key (only its id reaches the payment provider). A JSON caller gets {url, session_id}: open `url` to pay on the hosted page. A form post is redirected there. Card details never reach Lazaretto. The API key is shown once on the confirmation page. The page for people is /buy.","responses":{"200":{"description":"The hosted checkout URL and its session id."},"303":{"description":"Form post: redirect to the hosted checkout."},"400":{"description":"Unknown bundle, or an api_key that does not exist."},"429":{"description":"Rate limited."},"502":{"description":"The checkout could not be started. x402 and the free trial are unaffected."},"503":{"description":"The key to top up could not be verified. Try again shortly."}}}},"/v1/health":{"get":{"summary":"Liveness, latency percentiles, IOC coverage/freshness, and usage.","responses":{"200":{"description":"Health + usage snapshot."}}}},"/v1/rules":{"get":{"summary":"Rule categories + ids (never detection logic).","responses":{"200":{"description":"Catalog."}}}},"/benchmark.json":{"get":{"summary":"Our own noise numbers, with every corpus named in full. No key, no payment.","description":"What the HTML page at /benchmark shows, as data: how many of a list of popular npm packages our rules flagged, where the run disagreed with labels we wrote before it, and how many of our own attack samples were detected. Each figure carries a `measures` field saying exactly what it counts and a `corpus_chosen_by_us` field saying where the list came from, because we picked every list here and none of them is a random sample of the registry or a field measurement. The attack samples are ours: `recall.composition` counts how many follow a published incident report against how many are technique families and evasion variants we wrote, and how many are not npm packages at all. Stamped with the `rules_version` it describes; the build refuses to publish a measurement taken at a different one.","responses":{"200":{"description":"Recall against our own attack samples, one record per surveyed corpus with every member and its verdict, and the caveats that go with them."}}}},"/v1/credits/topup":{"post":{"summary":"Buy a prepaid credit bundle over x402 (one settlement = many scans).","description":"Body {bundle:\"starter\"|\"pro\"|\"scale\"}. Pay the bundle price via x402; returns a new X-API-Key with credits (or tops up a presented key). One credit = one full scan.","responses":{"200":{"description":"Key + credit balance."},"400":{"description":"Unknown bundle (body lists bundles with prices)."},"402":{"description":"Payment required: same dual-stack x402 challenge as /v1/scan (v2 header + v1 body), priced per bundle. x402-fetch v1 clients must raise maxValue above its $0.10 default."},"409":{"description":"payment_in_use: this x402 payment is already in use by another request, or was used for one within the last few minutes. Nothing was settled or issued for this request. Sign a new payment."},"500":{"description":"paid_not_fulfilled: the payment settled but the credits could not be issued. Do not pay again. Email contact@lazaretto.dev with the tx_hash from the body if it is present, otherwise with the paying wallet address and the time of payment, and the credits are issued by hand."},"502":{"description":"settlement_outcome_unknown: the payment facilitator did not answer clearly (an error, a timeout, a dropped connection, or a failure reported after a transfer may have gone out), so whether the payment moved is unknown. No credits are issued. Do not pay again. The body carries `tx_hash` when the facilitator named a transaction. Email contact@lazaretto.dev with the paying wallet address and the `time` from the body; if the payment did settle, the credits are issued by hand."}}}},"/v1/usage":{"get":{"summary":"Remaining credit balance for an X-API-Key.","security":[{"apiKey":[]}],"responses":{"200":{"description":"Balance."}}}},"/v1/trial":{"post":{"summary":"Claim a free developer key (no payment): a daily scan allowance that refills.","description":"Mints a new X-API-Key on the free developer tier: a small per-day scan allowance that refills daily and never expires. IP-rate-limited and one grant per source per window; the key is returned once. Buying a capacity pack (POST /v1/credits/topup) promotes the key to purchased capacity, which never resets.","responses":{"200":{"description":"A new free-tier key with its daily allowance, plus `setup`, ready to paste: a one-line export that puts the key in LAZARETTO_API_KEY, a first scan command that reads it from there, an MCP client config and a GitHub Actions workflow. The key is sent only in the X-API-Key header; the workflow reads it from a repository secret."},"429":{"description":"Already claimed / rate limited."},"503":{"description":"Unavailable."}}}},"/mcp":{"post":{"summary":"Remote MCP server (Streamable HTTP, JSON-RPC). Zero-install for any MCP-capable agent.","description":"Add this URL as an MCP server. Free and anonymous tools: check_lockfile, known_bad_lookup, verify_attestation, find_attestation, get_free_key. Metered tools, against an X-API-Key header holding credits: scan_artifact, scan_mcp_server and check_mcp_tools (one credit per non-error verdict) and scan_lockfile_deep (one credit per package that returns a verdict). JSON-RPC methods: initialize, tools/list, tools/call, ping.","responses":{"200":{"description":"JSON-RPC response."},"202":{"description":"Notification accepted (no body)."},"400":{"description":"Malformed JSON-RPC (parse error, or a batch array)."}}},"get":{"summary":"What this endpoint is, how to connect to it, and its tool list.","description":"The protocol traffic is the POST above. A GET is not part of it, so it answers a description instead: HTML for a browser, JSON for a machine, content-negotiated on Accept. A client that asks for text/event-stream still gets 405, because that request is the protocol asking for a server-initiated stream, which this endpoint does not offer.","responses":{"200":{"description":"A description of the endpoint, how to connect, and every tool with its cost."},"405":{"description":"Asked for text/event-stream. There is no server-initiated stream here."}}}}},"components":{"securitySchemes":{"apiKey":{"type":"apiKey","in":"header","name":"X-API-Key"},"x402":{"type":"http","scheme":"x402","description":"Pay-per-call via the x402 protocol (USDC on Base)."}},"schemas":{"ScanRequest":{"type":"object","required":["target"],"properties":{"target":{"type":"object","required":["type"],"properties":{"type":{"type":"string","enum":["inline","raw_url","npm_package","pypi_package","github_repo","clawhub_skill","mcp_server","mcp_tools"]},"ref":{"type":"string","description":"Identifier for non-inline targets."},"content":{"type":"string","description":"Literal content for type=inline."},"name":{"type":"string","description":"Filename for type=inline, for example SKILL.md or index.js. Which rules run depends on the kind of file, so send it when you have it; without it the kind is inferred from the content."}}},"depth":{"type":"string","enum":["lookup","full"],"default":"full","description":"lookup = hash + IOC match only; full = + behavioral rules."}}},"ScanReport":{"type":"object","properties":{"scan_id":{"type":"string"},"target_hash":{"type":"string","pattern":"^(sha256:[0-9a-f]{64})?$","description":"Hash of exactly what was analyzed. EMPTY STRING when no bytes were read: an npm package that was taken down from the registry is still reported malicious on package IDENTITY, and there is no content to bind that claim to. Check for the empty string before using this in a /report/ or badge URL."},"verdict":{"type":"string","enum":["clear","flagged","malicious","error"]},"confidence":{"type":"string","enum":["high","medium","low"]},"risk":{"type":"string","enum":["critical","high","medium","low","none"],"description":"How serious the concern is, and the field to gate on. `verdict` only says whether anything fired, so a credential stealer and a bundler that uses Function() are both `flagged`. `risk` separates them: reading secrets AND being able to ship them is critical; constructing code at run time is medium."},"risk_summary":{"type":"string","description":"One plain sentence naming the concern."},"known_bad":{"$ref":"#/components/schemas/KnownBad"},"findings":{"type":"array","items":{"$ref":"#/components/schemas/Finding"}},"rules_version":{"type":"string"},"scanned_at":{"type":"string","format":"date-time"},"disclaimer":{"type":"string"},"attestation":{"type":"string","description":"Compact JWS (EdDSA) over the verdict core, present on non-error verdicts. Verify it against /.well-known/jwks.json (offline) or POST /v1/verify. Its `sub` claim binds it to this artifact, so an agent can share the verdict and any recipient can trust it without re-scanning. Left out when the payment outcome is unknown (see `payment`)."},"payment":{"type":"object","description":"x402 only, present only when the payment facilitator did not answer clearly, so whether the payment settled is unknown. Do not pay again for this scan.","properties":{"outcome":{"type":"string","enum":["unknown"]},"detail":{"type":"string"},"contact":{"type":"string"},"time":{"type":"string","format":"date-time","description":"Quote this with the paying wallet address when writing in."}}}}},"KnownBad":{"type":"object","properties":{"matched":{"type":["boolean","null"],"description":"true=known-bad; false=checked clean; null=could not check (fail-closed)."},"match_type":{"type":"string","enum":["exact_hash","fuzzy_hash","embedded_ioc","known_publisher","malicious_package"],"description":"How the known-bad match was made. `malicious_package` means the package identity (name + version) is listed as malware in the OSV/OpenSSF corpus, which is scoped to the affected versions: a project compromised in one release is not condemned in its later clean ones."},"sources":{"type":"array","items":{"type":"string"}}}},"Finding":{"type":"object","properties":{"rule_id":{"type":"string"},"category":{"type":"string"},"severity":{"type":"string","enum":["high","medium","low","info"]},"description":{"type":"string"},"evidence":{"type":"object","properties":{"file":{"type":"string"},"line":{"type":"integer"},"snippet":{"type":"string"}}}}}}}}