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

104 lines
7.3 KiB
Markdown

# 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](../../src/test/kotlin/de/hoennig/werkator/build/WerkdockBuildRunnerUnitTest.kt) `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](../../src/test/kotlin/de/hoennig/werkator/build/GitWorktreeWorkspacesIntegrationTest.kt) `a pull-request target build gets a fresh workspace apart from the branch's, without anything left behind`
- [WatcherPullRequestTargetUnitTest](../../src/test/kotlin/de/hoennig/werkator/watcher/WatcherPullRequestTargetUnitTest.kt) `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](../../src/test/kotlin/de/hoennig/werkator/build/DockerBuildRunnerUnitTest.kt) `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](../../src/test/kotlin/de/hoennig/werkator/config/PullRequestTargetConfigUnitTest.kt) `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](../../src/test/kotlin/de/hoennig/werkator/build/ProcessBuildRunnerUnitTest.kt) `never starts a pull-request target build on the host, which would share the host user's HOME`
- [PullRequestTargetBuildIntegrationTest](../../src/test/kotlin/de/hoennig/werkator/build/PullRequestTargetBuildIntegrationTest.kt) `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.