Files
werkator/docs/prs/2026-10-05-PR#118-pull-request-target-fresh-home.md
miandmhoennig d23f43bf49
werkator build succeeded after 02:08
werkator/deploy build succeeded after 00:07
Pull-request target builds share no writable home, cache or socket (v1.36.1) (#118)
Security fix for pull-request target builds (PR#117): a target build now shares nothing that the repository's other builds can write.

- werkdock: a fresh, empty home per build as `/root`; never `.git/werkator/buildenv/home`, never `werkdock.gradleHome`.
- Docker: no shared Gradle volume, no Docker socket.
- A target definition without a sandbox, or with `docker.socket: true`, is refused at start.

Found in the AI-review trial on mih09: ordinary CI builds run PR code with the shared home read-write and could plant e.g. a `sitecustomize.py` that the next target build, which holds the host's secrets, would execute.
Details: the PR-doc under `docs/prs/`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #118
2026-10-05 09:21:38 +02:00

7.3 KiB

PR#118: Pull-Request Target Builds Share No Home, Cache or Socket

WARNING: This document describes only the change applied in this PR. It may already be outdated once the next PR is merged. Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.

The Problem

PR#117 pinned the definition of a pull-request target build to the host, so a pull request cannot change its command or environment. But the build still shared state with the repository's other builds, and those run the code of every branch.

The werkdock runner bound the repository's toolchain home .git/werkator/buildenv/home as /root into every sandbox, read-write. A pull request's own CI build could plant /root/.local/lib/python3.x/site-packages/sitecustomize.py, a .bashrc or a tool configuration there. The next pull-request target build, which holds the host's secrets in werkdock.env, executed it. The same home carried /root/werkdock.sock, and a werkdock.gradleHome reached the target build as well.

The Docker runner had the same class of issue: the per-repository Gradle cache volume, and the Docker socket where docker.socket is set. A native target build would run in the host user's HOME, shared with everything else.

Non-Goals

  • No change for builds that are not pull-request target builds.
  • No protection of the images: a build with the Docker socket, or with werkdock's Docker-API socket, can still change images in the stores the target build starts from.
  • No cache for target builds; they start empty every time.

The Scenarios

Feature: a pull-request target build shares nothing other builds write

Background

  • A pull-request target build is a build definition with trigger.onPullRequestTarget: true (PR#117).
  • Other builds are all other builds of the same repository; they run the code of the branches they build.

Scenario#118.01: in werkdock, a target build gets a fresh home of its own

So that nothing another build left in the toolchain home runs with the secrets.

  • Given a pull request's CI build planted sitecustomize.py under .git/werkator/buildenv/home
    • and the host configures a shared werkdock.gradleHome
  • When a pull-request target build runs in werkdock
  • Then /root is a fresh, empty directory beside its fresh workspace, without the planted file
    • and no shared Gradle home is mounted, and GRADLE_USER_HOME is not set to it
    • and the home is removed with the workspace
  • And every other build keeps the toolchain home and the shared Gradle home
Verified by
  • WerkdockBuildRunnerUnitTest a pull-request target build binds a home of its own as /root and no shared Gradle home, a file planted in the shared toolchain home is not in the home of a pull-request target build, every other build keeps the repository's toolchain home and the shared Gradle home
  • GitWorktreeWorkspacesIntegrationTest a pull-request target build gets a fresh workspace apart from the branch's, without anything left behind
  • WatcherPullRequestTargetUnitTest the workspace and home of a finished pull-request target build are removed, a queued one's kept

werkdock is not installed on the development host, so the werkdock tests check the assembled werkdock run arguments and the directory bound as /root, not a running sandbox. The two werkdock target tests and the Docker target test fail with the runner change reverted.

Scenario#118.02: in Docker, a target build gets no shared cache and no socket

So that no other build's cache content and no daemon reach the build that holds the secrets.

  • Given a pull-request target build running in Docker
  • When it starts
  • Then the per-repository Gradle volume is neither prepared nor mounted
    • and the Docker socket is not mounted, even where docker.socket says so
  • And every other build keeps the Gradle volume
Verified by
  • DockerBuildRunnerUnitTest a pull-request target build gets neither the shared Gradle volume nor the Docker socket, even when asked, every other build keeps the shared Gradle volume

Scenario#118.03: a target build without a sandbox, or with the socket, is refused at start

So that the configuration says what the build gets, and a host-native target build cannot exist.

  • Given the host's configuration defines a pull-request target build
  • When neither docker.enabled nor werkdock.enabled holds for it, with builds.default applied
    • or it has docker.socket: true, also inherited from builds.default
  • Then the start is refused with a message naming the definition
  • And a fragment or a committed file checked on its own is not refused, since its sandbox may come from the layers it joins
  • And the native runner refuses such a build in any case
Verified by
  • PullRequestTargetConfigUnitTest a pull-request target build without a sandbox is refused at start, naming it, a pull-request target build with the Docker socket is refused, also when it inherits it, a fragment or committed file checked on its own may leave the sandbox to the layers it joins
  • ProcessBuildRunnerUnitTest never starts a pull-request target build on the host, which would share the host user's HOME
  • PullRequestTargetBuildIntegrationTest runs the base, reports on the head, and tells the build which pull request it judges

The Solution

BuildRunner.start takes pullRequestTarget, set by the executor for a build with a pull request. WerkdockBuildRunner then binds GitWorktreeWorkspaces.pullRequestHomeOf(workspace) as /root and drops werkdock.gradleHome. The home lies beside the fresh pull-request workspace (<workspace>.home); prepareFresh deletes it before each build and the watcher removes it with the workspace. DockerBuildRunner mounts no Gradle volume and no socket; Gradle then writes into the container, which --rm removes. ProcessBuildRunner refuses the build.

ConfigLoader.checkPullRequestTargetSandboxes refuses a target without a sandbox and one with docker.socket in the primary configuration, with builds.default applied. Refusing rather than ignoring the socket keeps the configuration honest, as for the other target refusals.

Open Questions

  • The runner sets no DOCKER_HOST; a DOCKER_HOST pointing at /root/werkdock.sock that the host's definition inherits from builds.default still reaches the target build, but the fresh home holds no socket, so it leads nowhere.
  • Images are shared: a build with the Docker socket, or with werkdock's Docker-API socket, may retag or replace the image a target build starts from. A target build on a host where any build has a socket trusts those builds with its image.
  • Target builds start with empty caches; a review tool that downloads much pays for it on every run.