A pre-registered, blind read of stacklok/toolhive, a runtime that launches MCP servers in containers and puts one authentication, authorization and egress layer in front of them. Free and unsolicited. Verdict: four of eight claims held with the mechanism cited, one could not be settled from the code, and one security finding, reported privately, fixed in the next release and published by the maintainers as an advisory with the reporter credited.
ToolHive's README promises a small default permission profile, one authentication middleware on every transport, an authorizer that gates tool calls rather than only listings, secrets encrypted at rest with the key held outside the store, fail-closed network isolation, bounded OAuth for remote servers, an audit trail that names the subject, and an operator that keeps namespace boundaries. The repository is Go, with a real suite. So the read went to the seams where a secret changes form: where a reference becomes a value, and where a value gets written back to disk.
| result | |
|---|---|
| pre-registered claims reached a cited verdict | 7 / 8 (one left unclear, stated below) |
| held | 4 (one middleware chain with per-request token validation on every transport; the authorizer applied to tools/call, resources/read and prompts/get with evaluator errors denying; the encrypted secrets provider refusing to start without the OS keyring rather than falling back to a key file; egress through an allowlisting proxy that aborts container creation when the proxy fails; audit rows taking the subject from the validated token) |
| security findings on this page | 1, high as pre-committed; rated CVSS 5.5 medium by the maintainers, local access required |
| maintainer response | fixed in 0.50.0; advisory GHSA-pm52-m6xg-c3g9 published with credit five days after the report |
What held is most of the runtime. Every MCP request on every transport passes the same per-request token check. Authorization is applied where a tool is called, not only where tools are listed, and an evaluator error denies. The encrypted secrets provider will not start without the operating system keyring; it does not quietly fall back to a key file. Network isolation goes through a proxy with a whole-host allowlist, and a proxy that fails to come up aborts the container rather than letting it run open. Audit rows carry the subject the token validated, not one the request claimed.
The one claim the read could not settle is the default permission profile. The README calls it minimal; the record could not confirm from the code alone that container translation never widens it, so it says unclear rather than guessing either way.
The finding was in the order of two operations. When a remote MCP server is configured with a pre-registered OAuth client secret, the run configuration holds that secret as a reference into the secret manager, and the first save to disk is deliberately ordered before the reference is resolved. At runtime the resolver replaced the reference with the plaintext value in place, on a field that serializes. Two later callbacks, one on token refresh and one on dynamic client registration, copied that resolved configuration, added their own new references, and saved the whole struct again. The writer has no field-level redaction, so the client secret and any bearer token went into the local run-state file in clear. The state file is created 0600 under a 0750 directory, which is why the maintainers rate it medium: it takes same-user or root access, a backup, or a support bundle. The record keeps the pre-committed high, because the README's promise was that resolved values never touch the store; both ratings and their reasons stand side by side.
The project takes reports through GitHub's private vulnerability reporting, so the report went in as a private advisory on the sixteenth, with what held stated first and the finding as file and line. On the twenty-first the maintainers published GHSA-pm52-m6xg-c3g9 with Trenyx credited as reporter, a fix in 0.50.0, an affected range back to 0.8.1, workarounds for deployments that have not upgraded, and a migrate-on-load path that moves a plaintext value found in an old state file back into the secret manager.
I re-checked 0.50.0 the same day against the lines I cited, before reading their notes. The resolved client secret and bearer token now live in unexported, runtime-only fields; the serialized fields keep their references; the two callbacks still save the configuration, and now write references only. Fixed as claimed. The team was asked nothing.
A blind read is a static pass at one pinned commit: the plan is hashed first, then the code is read against it, and every verdict cites a line. The read is partial by design, and the record lists which packages were opened and which were not. It does not build or run the project, and it does not plant anything in the project's suite. The sealed engagement is the other kind of entry on this site: catalogue defects planted one at a time into a clean checkout, the project's own tests run against each, and the escapes counted. The analitiq-engine entry is what that looks like, with its kill count.
The plan for this read was written from the project's README alone, hashed, and timestamped to the Bitcoin blockchain before the code was cloned. The plan stays private while the fault catalogue it draws on does. The findings, the report and the maintainer's response are the receipts on this page.
Engagement repo: private Read the advisory
Every engagement ships the pre-registration, the findings with their citations, and the maintainer's response, disclosed to you first, privately, with the same receipts you see here.