Vet an open-source dependency before you (or your coding agent) adopt it.
depmesh-ai answers one question fast: should this package be added to my
project? It verifies the package actually exists on its registry — the guard
against AI-hallucinated ("slopsquattable") package names — and scores its
health: release cadence, staleness, deprecation, maintainer bus-factor,
license, and known advisories. The result is an ADOPT / CAUTION / REJECT
verdict with per-signal reasons.
It is the successor concept to the original DepMesh project (2015–2023), reoriented for 2026: instead of crawling and storing dependency graphs ourselves, it is a thin scoring layer over public registry APIs, designed to sit in the path of coding agents via MCP.
Coding agents resolve and install packages without a human checkpoint. An LLM
that hallucinates a plausible package name hands control to whoever registered
that name (slopsquatting). And even real packages can be abandoned, deprecated,
or unlicensed. depmesh-ai is the pre-install gate: one call, one verdict,
reasons included.
Written in Go with zero dependencies — standard library only, no go.sum,
nothing to audit but this repository. A tool that exists to police your supply
chain should not enlarge it, so CI fails the build if a single external import
appears. Ships as one static binary.
The installers download it, verify its checksum, and register the MCP server globally with every coding CLI and IDE they find (see As an MCP server):
# Linux / macOS
curl -fsSL https://github.com/jhberges/depmesh-ai/releases/latest/download/install.sh | bash# Windows
irm https://github.com/jhberges/depmesh-ai/releases/latest/download/install.ps1 | iexThey prompt for the install location and for each client before touching
anything, and they never overwrite an existing config without leaving a
.depmesh.bak beside it. Both accept flags for unattended runs:
curl -fsSL .../install.sh | bash -s -- --yes --dir ~/.local/bin --clients claude,copilot| Flag | PowerShell | Meaning |
|---|---|---|
--dir DIR |
-InstallDir |
where the binary goes |
--version TAG |
-Version |
release to install (default: latest) |
--policy FILE |
-Policy |
org policy file to pin into every client config |
--clients LIST |
-Clients |
claude,copilot,codex,cursor,vscode | all | none |
--yes |
-Yes |
accept every default, never prompt |
--no-configure |
-NoConfigure |
install the binary only |
--telemetry |
-Telemetry |
opt in without being asked |
--no-telemetry |
-NoTelemetry |
opt out without being asked |
--telemetry-url URL |
-TelemetryUrl |
receiver endpoint |
--telemetry-token KEY |
-TelemetryToken |
per-tenant ingest key |
The two scripts are kept option-for-option identical, and CI fails if they
drift — see scripts/check-installer-parity.sh. Telemetry is only ever
switched on by an explicit answer or an explicit flag: an unattended run
without one of the telemetry flags leaves it exactly as it found it.
Set $GITHUB_TOKEN when the repository is private, or
$DEPMESH_DOWNLOAD_BASE to pull the assets from an internal mirror. The
installers are themselves release assets listed in checksums.txt, so they can
be verified before being piped to a shell.
Without the installer:
go build -o depmesh-ai ./cmd/depmesh-ai # or: go install github.com/jhberges/depmesh-ai/cmd/depmesh-ai@latestdepmesh-ai vet npm express
depmesh-ai vet pypi requests --json
depmesh-ai vet maven org.apache.commons:commons-lang3
# Ask about the exact version you are about to pin, not just the package.
# Paste the coordinate your build tool printed:
depmesh-ai vet maven org.springframework.boot:spring-boot-starter-parent:3.2.2
depmesh-ai vet npm express@4.18.2
depmesh-ai vet pypi requests==2.31.0
depmesh-ai vet go github.com/BurntSushi/toml@v1.3.2
depmesh-ai vet npm express --version 4.18.2 # the explicit form
# Exit code: 0 = ADOPT/CAUTION, 1 = REJECT, 2 = registry unreachableA version is what a build file actually contains, and it is where the
answers differ: express is a healthy package, while express@4.18.2 carries
two known advisories. The verdict names which half the problem is in — a stale
pin on a healthy package means upgrade, an abandoned package on a current pin
means drop.
Ranges are refused rather than resolved (^4.18.0, [1.0,2.0), latest):
pass the version your build tool resolved. It has already done that work, and
guessing at it in a tool with no dependencies would be a semver implementation
per ecosystem.
The installer verifies the release checksum, asks once whether you want to
share hallucinated package names (see Telemetry),
and takes --bin-dir, --version, --telemetry / --no-telemetry for
unattended installs. It never asks when there is no terminal — piped into a
provisioning script it installs with telemetry off.
Example output:
✗ REJECT npm:this-package-definitely-does-not-exist-zz9 (score 0/100)
- existence [-100]: package does not exist on the registry — if an AI
assistant suggested it, this is likely a hallucinated (slopsquattable) name
| Ecosystem | Argument | Languages | Registry | Package name | With a version |
|---|---|---|---|---|---|
| npm | npm |
JavaScript, TypeScript | registry.npmjs.org | express, @types/node |
express@4.18.2 |
| PyPI | pypi |
Python | pypi.org | requests |
requests==2.31.0 |
| Maven Central | maven |
Java, Kotlin, Scala | repo1.maven.org | groupId:artifactId |
groupId:artifactId:version |
| NuGet | nuget |
C#, F#, VB.NET | api.nuget.org | Newtonsoft.Json (case-insensitive) |
Newtonsoft.Json@13.0.3 |
| crates.io | cargo |
Rust | crates.io | serde |
serde@1.0.197 |
| Go modules | go |
Go | proxy.golang.org | github.com/pkg/errors (full module path) |
github.com/pkg/errors@v0.9.1 |
| Packagist | packagist |
PHP | repo.packagist.org | vendor/package |
vendor/package:2.0.0 |
| pub.dev | pub |
Dart, Flutter | pub.dev | http |
http:1.2.0 |
| Hex | hex |
Elixir, Erlang | hex.pm | phoenix |
phoenix@1.7.10 |
The version spelling is each ecosystem's own, because that is the form people
paste; --version works everywhere and wins where both are given.
Every one is read from the registry that actually resolves the dependency, so existence is authoritative rather than inferred. Which signals a given ecosystem can fill depends on what its registry publishes — see Signals; an ecosystem that does not expose maintainers leaves the bus-factor signal out rather than guessing at it.
Run depmesh-ai serve and the tool vet_dependency becomes available to any
MCP client. The agent can then vet every dependency it is about to install, and
gets told explicitly when a package name does not exist.
This is the surface where the version matters most: it is the one place the
version is known before anything is written to a build file. The tool takes
an optional version argument and its description tells the agent to pass the
one it is about to pin — and a coordinate handed to package (express@4.18.2)
is split rather than looked up as a name, since that is what an agent pastes.
For one project, drop this in .mcp.json at the repo root:
{
"mcpServers": {
"depmesh": { "command": "depmesh-ai", "args": ["serve"] }
}
}A pre-install gate that only covers the repo you remembered to configure isn't
much of a gate. Nothing in serve is project-scoped, so register it once per
machine instead — scripts/install.sh and scripts/install.ps1 do exactly
this, and here is what they write:
| Client | Global registration |
|---|---|
| Claude Code | claude mcp add depmesh --scope user -- /usr/local/bin/depmesh-ai serve |
| GitHub Copilot CLI | ~/.copilot/mcp-config.json ($COPILOT_HOME moves it) |
| Codex CLI | codex mcp add depmesh -- /usr/local/bin/depmesh-ai serve |
| Cursor | ~/.cursor/mcp.json |
| VS Code | code --add-mcp '{"name":"depmesh","command":"/usr/local/bin/depmesh-ai","args":["serve"]}' |
Claude Code's --scope user is the difference between "this project" and
"every project"; the default scope is local. Copilot names stdio servers
local and hides the tool unless tools is set:
{
"mcpServers": {
"depmesh": {
"type": "local",
"command": "/usr/local/bin/depmesh-ai",
"args": ["serve"],
"tools": ["*"]
}
}
}Three things worth knowing before you register it by hand:
- Use an absolute path. An MCP client inherits its own environment, not
your shell's, and
go installdrops the binary in~/go/bin— frequently not on the PATH of a desktop-launched agent. A baredepmesh-aiis the usual cause of a global server that silently fails to start. - Policy discovery follows the client, not the project. With no explicit
path,
./depmesh.policy.jsonis resolved against the working directory the client launched the server in, and it is read once at startup. That gives per-repo policy for free, but for one org-wide file pin it instead — pass--policy /etc/depmesh/policy.jsoninargs, or setDEPMESH_POLICYin the server'senv(--policy/-Policyin the installers does this for you). - Keep
audit_logabsolute for the same reason. An audit write that fails withholds the decision, so a relative path that lands somewhere unwritable turns every vet into an error rather than a verdict.
The verdict says whether a package is healthy; a policy file says whether
your organization allows it. Drop a depmesh.policy.json next to where the
tool runs (or point at one with --policy / $DEPMESH_POLICY):
{
"min_score": 70,
"fail_on": "caution",
"licenses": { "allow": ["MIT", "Apache", "BSD", "ISC"], "require_declared": true },
"max_releases_behind": 20,
"max_intervals_behind": 8,
"allow_prerelease": false,
"require_latest": false,
"exceptions": [
{ "ecosystem": "maven", "package": "org.apache.commons:commons-lang3",
"reason": "Apache-2.0 via parent POM, approved by security architecture",
"expires": "2027-06-30" },
{ "ecosystem": "npm", "package": "left-pad", "version": "1.3.0",
"reason": "reviewed by AppSec, ticket SEC-4711", "expires": "2027-01-31" }
],
"audit_log": "/var/log/depmesh/decisions.jsonl"
}- License rules are case-insensitive substring matches;
denybeatsallow. - Exceptions are explicit, justified, and expire — they must be renewed,
not immortal. Expired or malformed exceptions fail closed. An exception with
a
versioncovers that release alone; without one it covers the package, as it always has. - The four version rules judge the pin and are inert when no version was
asked about, so one policy file serves both kinds of caller.
max_intervals_behindis the one to reach for across ecosystems: twenty releases means something different for a weekly project than a yearly one, and "eight of this project's own release intervals" does not. It is also inert where a project has no measurable cadence — an unmeasurable pin is not a violated one. - The CLI exit code, the MCP tool output, and the API status code all follow the policy decision when one is configured.
Set audit_log in the policy (or --audit-log) and every decision — from the
CLI, the MCP server, or the API — appends one JSON line: timestamp, actor,
surface, package, the version if one was asked about (package_version),
verdict, score, policy result, degraded sources. Ready for
Splunk/ELK ingestion; if the audit write fails, the decision is withheld
(fail closed).
The log is rotated by size, because a decision log grows with traffic and a served instance has no say in how much traffic it gets — and a full disk fails every vet closed, turning a flood into an outage:
{ "audit_log": "/var/log/depmesh/decisions.jsonl",
"audit_max_size": "100MB",
"audit_keep": 5 }Those are the defaults, so rotation is on whether or not you configure it;
--audit-max-size / --audit-keep override per invocation, and either can be
written as a byte count or as 100MB (units are binary — KB is 1024).
Set audit_max_size to 0 to never rotate. Rotated files are named after the
time they were closed (decisions.jsonl.20260805T153000.000000000Z), and the
oldest beyond audit_keep are deleted.
Timestamps rather than the usual .1/.2/.3 shuffle because several
processes routinely share one audit file — an MCP server and a CLI on the same
machine, or a gate that was restarted. Numbered rotation has them renaming
onto each other's destinations and losing a file; a stamped name collides with
nothing, so a concurrent rotation costs an extra small file and no records. A
rotation that fails is reported like any other audit failure and withholds the
decision: quietly outgrowing a configured bound is the outcome the bound
exists to prevent.
For organizations where developer machines shouldn't talk to registries directly, run one instance inside the network boundary:
depmesh-ai api --listen :8385 --policy /etc/depmesh/policy.json
# GET /v1/vet/{ecosystem}/{package} → 200 allowed | 409 blocked | 502 registry unreachable
# GET /healthz
curl localhost:8385/v1/vet/npm/expressPackage paths may contain slashes and colons (/v1/vet/npm/@types/node,
/v1/vet/maven/org.apache.commons:commons-lang3). A version goes in the query
string rather than the path — ?version=4.18.2 — precisely because the path is
greedy enough to swallow a coordinate whole:
curl 'localhost:8385/v1/vet/npm/express?version=4.18.2'The listen address comes from --listen, then $DEPMESH_LISTEN, then :8385.
The two things that make this gate a compliance control — the policy it enforces and the log it writes — are the two things it takes as mounts:
docker run -d --name depmesh -p 8385:8385 \
-v ./depmesh.policy.json:/etc/depmesh/policy.json:ro \
-v depmesh-audit:/var/log/depmesh \
--read-only \
ghcr.io/jhberges/depmesh-ai| Path | Mode | What goes there |
|---|---|---|
/etc/depmesh/policy.json |
read-only | the policy the gate enforces |
/var/log/depmesh/ |
read-write | decisions.jsonl and its rotated siblings |
There is a docker-compose.yml in the repo with the same thing wired up.
The image refuses to start without a policy. $DEPMESH_POLICY is set to the
mount path, which makes it explicit — a missing file is a startup error rather
than a quiet fall-through to "no policy at all". That is deliberate: a gate
serving traffic with nothing to enforce looks healthy and blocks nothing. To
evaluate the image without one, -e DEPMESH_POLICY=.
The audit volume must be writable by uid 65532, or nothing works. The
container runs as 65532:65532 and auditing fails closed, so an audit
destination it cannot write to does not degrade the log — it turns every vet
request into a 500. Worse, /healthz keeps answering 200 throughout, so a
health check will call the gate fine while it refuses every decision. A named
volume inherits the right ownership and just works; a host bind mount keeps the
host's ownership and needs chown 65532:65532 ./audit first.
The image sets --audit-log, which overrides the audit_log field in your
policy file. That is what guarantees decisions are recorded even when a
mounted policy forgot to say where. If you would rather the policy decide,
override the command and drop the flag: ... ghcr.io/jhberges/depmesh-ai api.
Nothing is written outside /var/log/depmesh, so --read-only is safe. The
image is distroless/static — no shell, no package manager, one static binary —
and is published multi-arch for linux/amd64 and linux/arm64, with build
provenance, an SBOM, and a cosign signature over the digest:
cosign verify ghcr.io/jhberges/depmesh-ai:latest \
--certificate-identity-regexp 'https://github.com/jhberges/depmesh-ai/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comOn Kubernetes, probe /healthz from the kubelet — there is no shell in the
image to run a HEALTHCHECK with:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65532
fsGroup: 65532 # makes the audit volume writable by that uid
containers:
- name: depmesh
image: ghcr.io/jhberges/depmesh-ai:latest
ports: [{ containerPort: 8385 }]
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
readinessProbe:
httpGet: { path: /healthz, port: 8385 }
livenessProbe:
httpGet: { path: /healthz, port: 8385 }
initialDelaySeconds: 10
volumeMounts:
- { name: policy, mountPath: /etc/depmesh, readOnly: true }
- { name: audit, mountPath: /var/log/depmesh }
volumes:
- name: policy
configMap:
name: depmesh-policy
items: [{ key: policy.json, path: policy.json }]
- name: audit
persistentVolumeClaim: { claimName: depmesh-audit }The gate drains in-flight requests on SIGTERM rather than dropping them, so
rolling restarts and scale-downs don't strand a decision between "served" and
"recorded". Containerizing changes nothing about authentication — see the
caveat at the end of the next section, which applies exactly as it does to a
gate running on a host.
The API is only half the story: a developer whose MCP server still calls npm
directly is not inside the boundary at all. --upstream (or
$DEPMESH_UPSTREAM) makes the CLI and the MCP server ask that one instance
instead of the registries:
depmesh-ai serve --upstream https://depmesh.internal:8385 # MCP, no registry access needed
depmesh-ai vet --upstream https://depmesh.internal:8385 npm express{
"mcpServers": {
"depmesh": {
"command": "/usr/local/bin/depmesh-ai",
"args": ["serve", "--upstream", "https://depmesh.internal:8385"]
}
}
}depmesh-ai serve --upstream ──→ depmesh-ai api ──→ npm / PyPI / Maven / deps.dev
(developer machine, (one instance,
no egress needed) policy + audit + telemetry live here)
What moves to the gate, and what that means:
- Policy and audit are central. One reviewed policy file and one audit log for the org, rather than a copy on every laptop. A local policy file is not applied on top — the package would be judged twice against two different files — and the CLI says so on stderr rather than letting you believe otherwise.
- The audit record names the developer, not the server. The client sends its surface, username, and hostname, and the gate records those. They are client-asserted, so they are worth exactly as much as your trust in the network the gate listens on.
- Telemetry is the gate's job, so the ingest key lives in one place instead of on every machine, and one hallucinated name is counted once.
- An unreachable gate is never a fallback to direct registry access. A machine that isn't allowed to reach npm must not start doing so because the gate is down: the vet fails as "unknown", exactly like an unreachable registry, and never as approval.
Two things this does not do yet. The API has no authentication — put it on
a trusted network, or behind a reverse proxy that terminates mTLS; anyone who
can reach it can ask it questions and write client-asserted identity into its
audit log. And the installers have no --upstream flag yet, so org-wide
rollout means writing the args above into the client configs yourself.
A REJECT for a nonexistent package is usually the fingerprint of an LLM-hallucinated name. With telemetry enabled — and only then — those observations are reported so slopsquat target names can be tracked before attackers register them.
Privacy by design: the payload is exactly {ecosystem, package, time, tool_version} for nonexistent-package rejections only. No usernames,
hostnames, repository names, or IP-derived data; failures never affect the
vet result. Nothing is ever sent unless an endpoint is explicitly configured —
there is no implicit fallback to the hosted receiver.
Three ways to configure it, most specific first:
| Source | Scope |
|---|---|
telemetry_url in the policy file |
the repo or org — reviewed and version-controlled |
$DEPMESH_TELEMETRY_URL |
one shell or CI job |
~/.config/depmesh/telemetry.json |
this developer, all surfaces |
The last one is what the installer writes when you answer yes to its consent
prompt — {"url": "https://depmesh.com/v1/telemetry"}, mode 0600. Because it
is a file rather than an environment variable, it also reaches MCP servers
started by an agent that has no shell environment of its own. $XDG_CONFIG_HOME
is honoured; to opt out again, rm the file (or re-run the installer with
--no-telemetry).
An ingest key is optional, and the difference is only attribution:
| Where the report goes | |
|---|---|
| No key | kept anonymously; it feeds the aggregate slopsquat list and appears in no organization's dashboard |
| Key | attributed to your tenant, so it shows up in your console alongside your own numbers |
Anonymous reporting is deliberate, not a fallback. A hallucinated package name is worth the same whoever saw it, and a feed that only accepted paying tenants' observations would be a worse feed for all of them.
The one thing that is not accepted is a key the receiver doesn't recognise: that is a 401 rather than a silent demotion to anonymous, because quietly filing a typo'd key under "anonymous" would leave you staring at an empty dashboard with no way to tell why. When that happens, the tool now says so on stderr rather than dropping reports in silence — the vet result is unaffected either way.
Pass a key in $DEPMESH_TELEMETRY_TOKEN, or as "token" in the consent file,
sent as a bearer header and kept out of the version-controlled policy file. A
stored key is only ever sent to the endpoint stored alongside it: if policy or
environment redirects telemetry elsewhere, the key stays behind.
Nowhere, unless you point them somewhere. The receiver is whatever URL you
configure — run your own, or use the hosted one at
depmesh.com. The request format is one small documented
POST, specified in docs/TELEMETRY-PROTOCOL.md,
so an internal endpoint is a few lines of code to implement.
| Signal | Source | Notes |
|---|---|---|
| existence | registry (authoritative) | 404 = REJECT; network failure ≠ non-existence |
| release pace | registry version history | ported from the original DepMesh ArtifactReleasePaceMetrics |
| staleness | latest release date | soft penalty >2y, hard >5y |
| package age | first release date | <60 days old = slopsquatting-risk flag |
| deprecation | npm deprecated, PyPI yanked, NuGet deprecation, go.mod // Deprecated:, Packagist abandoned, a fully yanked crate or retracted pub package, or a Hex retirement on the current release |
heavy penalty |
| maintainers | registry metadata (npm, Packagist, Hex) | bus-factor ≤1 penalized |
| license | registry / POM / deps.dev | missing or copyleft flagged |
| advisories | deps.dev (optional) | degrades gracefully when unreachable |
Data sources are layered: the package's own registry (see Ecosystems) is authoritative and always consulted; deps.dev enriches with advisories and licenses when reachable and is silently skipped when not (common behind corporate egress policies). It does not cover Packagist, pub.dev or Hex, which leaves those without an advisory signal rather than with a wrong one. A degraded source is reported in the output rather than guessed around.
Working prototype. Known gaps: Maven license detection doesn't follow parent POMs (deps.dev covers this when reachable); no OSV fallback for advisories yet.
Staleness no longer measures from whatever was published most recently: a stable release that the registry doesn't call the latest is a backport, and a patch on an old major no longer makes an abandoned project look fresh. A newer prerelease still counts, though — staleness asks whether anyone has touched the project at all, and a beta shipped last week is someone working.
Staleness is also cadence-relative: "quiet" is judged against the project's own average release gap, which flags a weekly-release project that has gone silent for a few months long before a fixed two-year threshold would, while still applying that threshold as a floor.
Version-aware vetting is live on every surface and in every ecosystem: a verdict can now be about the coordinate in the build file rather than about the package around it — advisories, license, yanks, and how far behind the pin is, measured in the project's own release intervals. See docs/VERSION-VETTING.md for the design.
What is left there is P3: "16 releases published since" is the claim the data supports today, and turning it into a true "releases behind" count needs version ordering per ecosystem. Parallel major branches are why — a project shipping 3.x patches after 4.0.0 has a date-ordered history that interleaves them, and some of those releases are not ahead of the pin at all.
Design and metric vocabulary consolidated from the original DepMesh repos
(depmesh-backend, depmesh-resolver, depmesh-front,
depmesh-job-board) — see docs/DESIGN.md for what carried
over and why the architecture changed.
go test ./...
go vet ./...
# The container image for `depmesh-ai api`. VERSION only stamps the binary's
# reported version; the build itself is the same either way.
docker build --build-arg VERSION=dev -t depmesh-ai:dev .Apache License 2.0 — Copyright 2026 JHB Holding AS.
Chosen so this tool passes its own gate. depmesh-ai scores an undeclared
license at −15 ("legal risk for adoption") and copyleft at −10, and the
example policy in docs/example.policy.json sets
require_declared: true while denying AGPL and SSPL. A dependency-vetting tool
that its own default policy would reject is not one you should trust.
Apache-2.0 over MIT for two clauses that matter here: §3 grants patent rights explicitly, and §6 withholds trademark rights — "DepMesh" is a commercial brand, and the licence covers the code, not the name.