Skip to content

Build GitWarren as a container, so the scanners see what ships - #106

Merged
michal-wrzosek merged 2 commits into
mainfrom
worktree-glama-docker
Sep 21, 2026
Merged

michal-wrzosek merged 2 commits into
mainfrom
worktree-glama-docker

Conversation

@michal-wrzosek

Copy link
Copy Markdown
Contributor

Glama builds every MCP server it lists from a Dockerfile, runs the result in a
Firecracker microVM, and watches it at the syscall and network layers. When a
maintainer has not written one, Glama infers one. When the inferred build does
not come out, the profile page survives and the server quietly drops out of
search, category listings and recommendations, with no failing check and no
notification anywhere.

This writes ours, so what gets scanned is what we actually ship.

What the image does

It installs the published npm package at a pinned version, which is the same
artifact npx gitwarren mcp fetches. An inferred Dockerfile would almost
certainly fetch the package at container start, turning every run into
outbound traffic to the registry, which is exactly the shape the scan flags.
Ours downloads nothing at run time.

Three choices in it are the answer to the question a security scan asks:

  • One directory is written. GITWARREN_DATA_DIR is set to /data rather
    than left to the platform default, which here would scatter the one piece of
    state this server keeps into a container layer.
  • No listener, no network. The default command is mcp, without
    --serve. Serving binds the review page on 127.0.0.1:41427, which is the
    right default outside a container and surface nobody asked for inside one.
  • No compiler, no root. better-sqlite3 needs a toolchain only where its
    prebuilds do not reach, and both paths have to work or the image is
    reproducible on some architectures and not others. So the toolchain is
    present, in a build stage that is thrown away. The shipped image runs as the
    base image's node user at uid 1000.

The one concession is safe.directory. A bind-mounted repository carries a uid
from the host, the tooling refuses to read a repository it believes is someone
else's, and the image waives that rather than asking whoever runs it to. That
check defends against a repository's own config running commands as you. Inside
a container holding one server and one mount, where no shell is ever involved,
the trade is worth making, and it is made here and nowhere else.

Measured, not assumed

Check Result
Architectures built and handshaked arm64 and amd64
End-to-end session steps passed 10 of 10
Paths written during a full session /data, /data/gitwarren.db, /data/instance-id
Same session under --network none identical
Image size, arm64 and amd64 360 MB and 339 MB

The session was a real one over stdio against a mounted repository: handshake,
tool listing, agent_identity, add_repository, create_review reading the
diff live from a read-only mount, and add_review_comment. All seventeen tools
came back with a substantive description and behaviour annotations, which is
the seventy percent of Glama's quality score that is about tool definitions.

The write set was captured with docker diff on a run with no volume mounted
for /data, so that every file the server touched had to land in the container
layer where it could be listed.

Keeping it from rotting

scripts/sync-plugin-versions.mjs now covers the pin the way it covers the
plugin and registry manifests. It is the first entry in that list that is not
JSON, so text swaps the parse for the raw string, and the getter reports
every distinct value it finds, so the two build stages drifting apart fails
rather than silently picking one. npm version stages the Dockerfile with the
rest.

CI builds the image and speaks a real handshake to it, because a build that
produces something unable to answer is still a broken listing. That job
resolves the pin against the registry first: npm version commits a bump
before the release workflow publishes it, and a red check for a clock is not
worth having.

Not related to Glama's hosting product

Worth saying explicitly, since the Dockerfile invites the question. Glama's
hosting is a separate paid, opt-in service that runs a server on their
machines. It does not fit this product, because a server on their hardware
cannot see anyone's repositories. This change is only about being built and
scanned correctly as a listing.

🤖 Generated with Claude Code

michal-wrzosek and others added 2 commits September 21, 2026 08:33
Glama builds every MCP server it lists from a Dockerfile, runs the result in a
microVM and watches it at the syscall and network layers. When a maintainer has
not written one, it infers one; when the inferred build does not come out, the
profile page survives and the server drops out of search, category listings and
recommendations. There is no red check anywhere in that - just fewer people
finding it - which is a bad way to learn that a directory guessed wrong about
how we build.

So the guess is removed. The image installs the published npm package at a
pinned version, which is the same artifact `npx gitwarren mcp` fetches, so what
gets scanned is what a user runs rather than a build nobody has.

Three choices in it are the answer to the question a scan is asking, and each
was measured rather than hoped for:

`GITWARREN_DATA_DIR` is set to `/data` instead of left to the platform default.
Outside a container that default is the user's application-data directory,
which here would scatter the one piece of state this server keeps into a
container layer. Named, there is exactly one path the process writes to, and it
is the one to mount. `docker diff` after a real session - add a repository,
open a review, leave a comment - lists `/data/gitwarren.db` and
`/data/instance-id` and nothing else in the filesystem.

The default command is `mcp`, without `--serve`. Serving would also bind the
review page on 127.0.0.1:41427 for the length of the run, which is the right
default outside a container and a listener nobody asked for inside one. The
same session runs unchanged under `--network none`: this server speaks stdio,
reads the working tree and writes SQLite, and the only socket it ever opens is
the best-effort poke to a GUI on loopback, which finds no owner here and
carries on.

The compiler is in a build stage that is thrown away. `better-sqlite3` needs a
toolchain only where its prebuilds do not reach, and both paths have to work or
the image is reproducible on some architectures and not others - so the
toolchain is present, and the image that runs the server has no compiler, no
headers and no package index, running as the base image's `node` user at uid
1000. Built and handshaked on arm64 and amd64.

The one concession is `safe.directory`. A bind-mounted repository carries a
uid from the host, the tooling refuses to read a repository it believes is
someone else's, and the image waives that rather than asking whoever runs it
to. That check defends against a repository's own config running commands as
you; inside a container holding one server and one mount, where no shell is
ever involved, the trade is worth making, and it is made here and nowhere else.

Two things keep it from rotting. `sync-plugin-versions.mjs` now covers the pin
the way it covers the plugin and registry manifests - it is the first entry
that is not JSON, so `text` swaps the parse for the raw string, and the getter
reports every distinct value it finds so that the two build stages drifting
apart fails rather than picking one. And CI builds the image and speaks a real
handshake to it, because a build that produces something unable to answer is
still a broken listing. That job resolves the pin against the registry first:
`npm version` commits a bump before the release workflow publishes it, and a
red check for a clock is not worth having.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The release steps promised that one commit says the version everywhere it
appears, and listed the plugin manifests as the everywhere. The pin in the
Dockerfile joined that set in the previous commit and the sentence did not
notice, which is the exact failure the sentence exists to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@michal-wrzosek
michal-wrzosek merged commit 7c20f89 into main Sep 21, 2026
5 checks passed
@michal-wrzosek
michal-wrzosek deleted the worktree-glama-docker branch September 21, 2026 06:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant