Projects

dockerized-tools

Six command-line tools that run in pinned, throwaway Docker containers behind thin wrappers, so none of them is installed on my machine.

Status: active
Docker
Bash

A command-line tool installed on my laptop runs with my privileges. It can read my SSH keys, my cloud credentials and every token in my environment, and a compromised release gets the same access the next time I update. dockerized-tools is the repository where I stopped installing them. Each tool runs in a throwaway container built from a pinned image, and the only thing on my PATH is a wrapper script.

Each tool is a folder with five files

<tool>/
  Dockerfile   # the image, with the tool installed at a fixed version
  build.sh     # the version pins, passed as build arguments
  <tool>       # the wrapper: docker run with the isolation flags
  README.md    # usage, update policy, security model, removal
  design.md    # why it is built this way

A symlink in ~/bin points at the wrapper. The tool is installed when the image is built and never pulled at run time, so an update only happens when I edit a pin in build.sh and rebuild. Removing a tool is one docker rmi and one deleted symlink.

What a wrapped tool can and cannot touch

Every wrapper starts from the same flags:

-v "$PWD":/work -w /work                          # the current directory, nothing else
--user "$(id -u):$(id -g)"                        # files it writes belong to me, not root
--cap-drop ALL --security-opt no-new-privileges   # no privilege escalation
--read-only --tmpfs /tmp                          # no writes outside the mounted directory
--network none                                    # no network by default

My home directory and the Docker socket are never mounted. The tool sees the directory I ran it from and nothing above it, so an absolute host path does not exist inside the container. The shellcheck wrapper rejects absolute paths with an explanation, because the alternative is a “file not found” that looks like a ShellCheck bug.

The network flag matters most for tools that read everything. A linter opens every file I point it at, which is the access an exfiltration payload wants. With no network, a compromised release can read the source and has nowhere to send it.

When a tool needs more than the baseline, the exact extra mount or flag goes in that tool’s README with the reason.

Nothing is pinned younger than two days

A pin must point at a version that is at least two days old, and the cutoff for transitive dependencies is the build date minus two days (--exclude-newer for Python packages, npm install --before for npm). I check the publish date before bumping, and I reject the latest release if it is too new. Malicious releases are usually detected and pulled within hours, so a two-day delay skips the period when they are still live.

Where upstream publishes a binary, the build downloads it and verifies it against a recorded SHA256. I use that for ShellCheck, OpenTofu and adb, because a Docker tag can be re-pushed over the same name and a checksum cannot. An npm package has no single artifact to checksum, so for that case the base image is fixed by digest and the dependency resolution is bounded by date.

The six tools wrapped today

  • specify (GitHub spec-kit) scaffolds files into a repository and runs fully offline. The two subcommands that need the network are behind an opt-in environment variable.
  • shellcheck runs on the full baseline with no exceptions.
  • rcd (Renovate Config Debugger) has two modes. The command-line mode mounts the current directory. The MCP server mode mounts nothing, because its tools receive file contents over stdio and never take a path. The mode an agent launches unsupervised is the one with the least access.
  • tofu (OpenTofu) and ansible manage my homelab, so they run with the network on and one SSH key mounted read-only. A single Ansible wrapper serves six entry points and picks the one to run from the name of the symlink.
  • adb runs with the network on, because adb over TCP is its whole job, and keeps its own RSA key so the device does not ask for authorization on every call. I built it to inspect an Android TV box I suspected of carrying malware. Here the untrusted party is the device, and the container limits what an exploit against the adb client could reach.

Where the isolation stops

The container covers the tool while it runs. It does not cover what the tool leaves behind or does elsewhere. The scripts that spec-kit generates run later on the host, through my coding agent, with my privileges, so I review their diff before running them. OpenTofu and Ansible act on real machines with real credentials, and no local sandbox limits what they do remotely.

Other limits are inconvenience. Docker Desktop on macOS does not pass USB through, so the adb wrapper only works over the network. Every invocation pays for a container start, tens of milliseconds once the image is warm.

Before I call a new wrapper done I run four checks: the image builds, the tool starts inside the container, a real run produces files owned by my user and not by root, and from inside the container my ~/.ssh and home directory are not visible. A wrapper that mounts too much works exactly like one that mounts the right amount, so the fourth check is the only one that tells them apart.