← News
Security research

claude-mem runs an unpinned dependency with your full credentials

17 April 2026 · 11 min read

An adoption-risk analysis of thedotmack/claude-mem, the Claude Code memory plugin, focused on its Chroma-MCP subsystem.

Executive summary

claude-mem is a widely used Claude Code plugin that gives the agent persistent memory across sessions. We pointed Oplane at it and focused on the Chroma-MCP subsystem, the vector-search pipeline behind its semantic recall. The plugin’s own code is reasonable and, in places, clearly careful. The risk is not in what claude-mem ships. It is in what it assembles and runs on your machine at startup.

On first use, claude-mem launches its vector backend by shelling out to uvx and pulling chroma-mcp straight from PyPI:

uvx --python 3.13 chroma-mcp --client-type persistent --data-dir ~/.claude-mem/chroma/
  • No version pin. chroma-mcp is passed to uvx with no version constraint, so it resolves to whatever the latest release on PyPI happens to be that day.
  • No integrity check. There is no checksum, signature, or hash validation on the downloaded package before it executes.
  • Full environment inheritance. The subprocess receives the parent environment minus CLAUDECODE_*. In practice that still includes AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, NPM_TOKEN, SSH_AUTH_SOCK, and anything else living in your shell.

Net effect: every developer who installs the plugin runs an unpinned, unverified package from a public registry with their full credential set, on every session. And because the dependency is never written to a manifest, every software-composition scanner you own reports a clean tree. The interesting part is not the bug. It is that the standard tooling is structurally unable to see it.


Background: why we looked at claude-mem

claude-mem (thedotmack/claude-mem) is a memory layer for Claude Code. It installs a set of lifecycle hooks and a small local daemon (an Express worker on localhost:37777, run under Bun) that captures what happens in your sessions: the prompts you send, the tool calls the agent makes, the summaries it writes at the end. It stores that in SQLite for keyword lookup and embeds it into a Chroma vector store for semantic recall, so a later session can ask “what did we decide about auth last week” and get a useful answer.

That is a genuinely useful tool, and it is also, by construction, one of the most sensitive things you can install. A memory plugin sees everything: every repository you open, every command the agent runs, every secret that scrolls past in a tool result. It runs locally, on the same machine that holds your cloud credentials and your code-host tokens. When we assess a tool with that shape, we are not looking for a clever bug. We are following the data and the execution: what gets captured, where it is written, and what else runs as a side effect of running it.

The plugin is popular and visibly active, the kind of project that scores well on every signal people use as a proxy for safety: lots of stars, a busy commit history, a clean repository. None of those signals describe what the tool actually executes on your laptop, which is the gap this writeup is about.

How we found it: threat modeling with Oplane

We did not grep for this or wait for a scanner to flag it. We threat-modeled the Chroma-MCP subsystem in Oplane and let the data-flow tell us where to look.

Oplane is an agentic threat modeling system. You point it at a scope (a feature, a subsystem, a pull request) and it produces a data-flow diagram of the components involved, a ranked list of security requirements that scope ought to satisfy, and an implementation-status tracker for each. It runs inside agent-style coding tools over the Model Context Protocol, so the model is something you generate inline while reading the code, not an artifact you maintain on the side.

The scope we modeled was the vector-search pipeline: the Claude Code hooks, the worker API on localhost:37777, the ChromaSync formatter, the ChromaMcpManager that owns the subprocess, and the on-disk store at ~/.claude-mem/chroma/. The data flow Oplane drew is short and entirely local, with one exception that matters:

Claude Code hooks
  -> Worker API (localhost:37777)
    -> ChromaSync
      -> ChromaMcpManager
        -> [stdio / MCP JSON-RPC] -> chroma-mcp subprocess  (uvx, from PyPI)
                                       -> ~/.claude-mem/chroma/

Against that scope Oplane raised a set of requirements. The two that define this writeup both concern the same line of code, and Oplane flagged them separately:

  1. Pin and integrity-check the chroma-mcp subprocess before it runs

    Critical

  2. Scope the subprocess environment to an allowlist, not the full credential set

    High

  3. Add a retention and deletion path for the on-disk vector store

    Medium

  4. Stop passing the remote Chroma API key as a command-line argument

    Low

The separation is the useful part. Pinning the package and scoping the environment are two different controls protecting against two different failures, and shipping one does not fix the other. A pinned package run with your full credential set is still over-privileged; a scoped environment running an unpinned package is still a supply-chain gamble. You need both, and a single “harden the subprocess” ticket tends to deliver neither cleanly.


What actually runs at startup

The subprocess is owned by a singleton, ChromaMcpManager in src/services/sync/ChromaMcpManager.ts. It lazy-connects on the first tool call and keeps one persistent chroma-mcpprocess alive for the worker’s lifetime, talking to it over stdio with MCP JSON-RPC. The command line is assembled in buildCommandArgs(), and the relevant argument is a bare package name. Three small decisions stack up there:

  1. chroma-mcp is passed to uvx with no version constraint.

    ChromaMcpManager.ts, buildCommandArgs()

    It resolves to whatever PyPI serves at spawn time. You do not decide what runs; the registry does.

  2. There is no checksum, signature, or hash validation on the package.

    The same uvx invocation; no lockfile for this dependency.

    uvx caches locally, but a cache miss or a cleared cache triggers a fresh, unchecked download from PyPI.

  3. The subprocess inherits the full parent environment.

    sanitizeEnv() in src/supervisor/env-sanitizer.ts

    The filter only strips CLAUDECODE_* / CLAUDE_CODE_* prefixes. Every other credential in your shell is passed straight through.

None of these is unusual on its own. Calling uvx to run a tool is idiomatic; inheriting the environment is the default for child processes; not pinning a developer-tooling dependency is a thing most projects do somewhere. What makes the combination matter is the position it runs in: first-party, at startup, on every install, with the keys to your cloud and your code host already in scope.


Why your scanner never sees it

Here is the part that makes this a blind spot rather than a bug. chroma-mcp is never written down. It does not appear in a package.json, a requirements.txt, or any lockfile. It is a bare string handed to uvx at runtime. Software-composition analysis works by reading manifests and lockfiles and matching the resolved tree against an advisory database. A dependency that exists only as an argument at process-spawn time has nothing for that machinery to read.

ToolWhy it misses chroma-mcp
DependabotReads manifests and lockfiles committed to the repo. chroma-mcp is in neither, so there is no version to alert on.
Snyk Open SourceReads the resolved dependency tree. Nothing resolves chroma-mcp, so there is no node to match against an advisory.
SocketWatches package-install events in npm and PyPI. The install is a uvx subprocess at app startup, not a tracked package install.
npm auditReads the npm dependency tree. chroma-mcp is a Python package, outside npm entirely.

You can run a clean audit of the repository, get a green check from every one of these, and still have an unverified package executing on your laptop with your credentials. The audit is not wrong. It is answering a different question than the one that matters here.


The dependency you never pinned is also abandoned

Unpinned would be a smaller problem if the thing on the other end were healthy. It is not. We ran osv-scanner against the locked dependency tree that chroma-mcp pulls in, and it surfaced 49 known vulnerabilities across 15 packages (2 Critical, 16 High, 20 Medium, 11 Low), all with fixes already available upstream. A sample of the worst:

Package (resolved version)Known CVEs
mcp 1.6.0 (hard-pinned)Three, worst CVSS 8.7 (transport and session crashes). The hard pin blocks the fix.
aiohttp 3.11.1619, including one Critical (9.1).
h11 0.14.0One Critical (9.1).
urllib3 2.3.0Five, three of them at CVSS 8.9.
protobuf 5.29.4Two High (8.2).
pillow 11.1.0Two High (8.7 and 8.9).

Two honest caveats. First, most of these are HTTP and network-stack vulnerabilities, and claude-mem talks to chroma-mcp over stdio, not HTTP, so they are not directly reachable in the default local configuration. They do become reachable the moment you enable remote Chroma mode or an external embedding provider, both of which send traffic out through the vulnerable aiohttp / urllib3 stack. Second, the mcp==1.6.0 pin is upstream’s, not yours, and it prevents the transitive fixes from being applied without a fork.

The bigger signal is what the volume implies. chroma-mcpis Chroma’s own MCP server, yet at the time of writing it is effectively abandoned: no release since 0.2.6 in August 2025, no commits since September, and an unpatched SQL-injection report that has sat open and unanswered since April. That is what sharpens the risk. A pin would at least freeze you on a known version; pulling an abandoned package unpinned means uvx chroma-mcpresolves “latest” from a tree nobody is maintaining, and the SQLi already sitting in it is never going to be patched. claude-mem’s own input sanitization happens to blunt that specific vector, which is to its credit, but it is one app papering over a dependency with nobody left to fix it.


The blast radius

On a normal day none of this fires. The subprocess runs as you, on your machine, and does its job. The problem is the shape of the exposure: an unpinned, unverified package, fetched fresh from a public registry, run with your full credential set, on every developer who installs the plugin.

The environment filter is narrow by design. sanitizeEnv() strips the CLAUDECODE_* and CLAUDE_CODE_*variables (it deliberately preserves a couple, like the OAuth token) and passes everything else through. “Everything else” is the interesting set:

  • AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
  • GITHUB_TOKEN, NPM_TOKEN, and similar registry / code-host tokens
  • SSH_AUTH_SOCK (a live handle to your running SSH agent)
  • DATABASE_URL and whatever else your shell exports

This is the textbook runtime supply-chain path, and it is open by default. If the resolved package is ever compromised (a typosquat, a maintainer account takeover, one bad release on the day your cache misses), it runs with everything your shell can reach. The pin and the environment scope are the two cheap controls that take that from “everything” down to almost nothing.


What Oplane flagged

Across the Chroma-MCP subsystem and the wider system model, the requirements that bear on this writeup were:

  1. Chroma-MCP subprocess supply-chain integrity verification (pin + verify)

    Critical

  2. Integrity verification for install scripts and dynamic runtime downloads

    Critical

  3. Sensitive environment-variable filtering for the chroma-mcp subprocess

    High

  4. Supply-chain scanning for dynamic dependency downloads (no SBOM, no CI audit)

    High

  5. Data retention and deletion policy for the on-disk vector store

    Medium

  6. API-key handling in remote Chroma mode (passed on the command line)

    Low


The fix is small

Two changes cover the critical path, and neither touches behavior. Pin the version so you decide what runs instead of PyPI deciding for you, and narrow the environment filter to an allowlist so the subprocess gets what it needs and nothing else.

// before: latest from PyPI, full environment
const args = ['--python', '3.13', 'chroma-mcp', /* ... */];
const env = sanitizeEnv(process.env); // strips CLAUDECODE_* only

// after: a version you chose, and a minimal environment
const args = ['--python', '3.13', 'chroma-mcp==0.2.6', /* ... */];
const env = pick(process.env, ['PATH', 'HOME', 'SSL_CERT_FILE']);

Pinning to a known-good release (rather than a moving latest) means a compromised future release does not silently land on every developer the next time the cache misses. The allowlist takes the blast radius from “every credential in the shell” down to the handful of variables the vector store actually needs. Both are a few lines. Both are the difference between a modest local convenience and a standing credential-exposure path.


Disclosure and response

We shared these findings with the maintainer. In fairness, they disputed parts of it, and they are right about the most important part: claude-mem’s own code is not malicious, and on a single-user machine with full-disk encryption the day-to-day risk is modest. The subprocess runs as you, doing work you asked for.

We are not claiming this one plugin is dangerous. The point was never that. It is that “popular, audited, healthy-looking” tells you nothing about what a tool assembles and runs at startup, and that the controls which would close the gap (a version pin, an environment allowlist) are cheap enough that there is no good reason to leave them out. We would still encourage the project to pin and scope the subprocess; it costs almost nothing and removes the entire class.


Closing thoughts

Stars, commit cadence, and a clean repository scan all looked fine here. Every signal people reach for was green. The risk lived one layer down, in the execution path: a bare package name, resolved at runtime, run with your whole environment, invisible to the tools built to catch exactly this.

The lesson generalizes past this one plugin. The supply chain you can scan is the one written into your manifests. The supply chain that actually runs is the one assembled at startup, and you only see it if you follow the data flow instead of the manifest.

Find the dependencies your scanner can't see

Oplane threat-models your architecture at the data-flow level and catches the runtime supply chain that manifest-based scanners miss.

We value your privacy

We use cookies to make the site work better for you and to analyze traffic. You can accept all cookies, customize your settings, or reject non-essential cookies.