Sandboxing pre-commit

pre-commit is a tool that is often a prerequisite for contributing to Python upstream projects. It installs as a git pre-commit hook and runs various checks on commit, using tools installed in a set of virtualenvs that it automatically manages.

In other words, it is a Remote Code Execution engine running with the permissions of your own user, and with read and write access to all your information (including, say, ~/.ssh or ~/.netrc). If you are lucky, and for as long as you're lucky, it will perform useful tasks on your code.

The recent eMudhra certificate situation, where I learnt how Python, as well as several other ecosystems, likes to embed CA certificate bundles in virtualenvs, caused my tolerance for this to lower to a point where I felt the need to limit pre-commit's reach.

However, I didn't want to end up with another containerized OS tree that it's then my responsibility to manage, and I tried to go the bubblewrap way, so that the environment where pre-commit runs will get the same security updates as the rest of my system.

The result is a pre-commit wrapper script that:

Here's a breakdown of the bwrap options I used so far.

Set up usual filesystem expectations:

    --ro-bind /usr /usr \
    --symlink usr/bin /bin \
    --symlink usr/lib64 /lib64 \
    --symlink usr/lib /lib \
    --proc /proc \
    --dev /dev \

Make the working directory and pre-commit's environments storage available and writable:

    --bind ${CURDIR} "$GUEST_DIR" \
    --bind ~/.cache/pre-commit "/home/${USER}/.cache/pre-commit" \
    --chdir "$GUEST_DIR" \

Forward git's configuration:

    --ro-bind /etc/gitconfig /etc/gitconfig \
    --ro-bind ~/.gitconfig /home/${USER}/.gitconfig \

Make .git writable (pre-commit needs to stash, for example), but use a tmpfs overlay so that the writes don't persist:

    --overlay-src "$CURDIR/.git" \
    --tmp-overlay "$GUEST_DIR/.git" \

Remove access to everything except the above. Prevent keystroke injection at the terminal. Propagate ^C.

    --unshare-all \
    --new-session \
    --die-with-parent

If network is needed:

    --share-net \
    --ro-bind /etc/resolv.conf /etc/resolv.conf \
    --ro-bind /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt

To run it, you can set up the wrapper script to run with a higher priority in $PATH, but you need to edit .git/hooks/pre-commit to remove the first branch of the if that, frustratingly, tries to run it as python3 -m pre_commit.

I'm looking forward to a time where it becomes stadard practice to manage user commands this way: why should a desktop solitaire game have access to ~/.ssh? Or network? Why is it still normal, in 2026, to run a Desktop system like that?

Comments

With an account on the Fediverse or Mastodon, you can respond to this post. Since Mastodon is decentralized, you can use your existing account hosted by another Mastodon server or compatible platform if you don't have an account on this one. Known non-private replies are displayed below.

Learn how this is implemented here.