Skip to content

fix: refuse to run as root unless --allow-root is given - #116

Open
fismif wants to merge 1 commit into
eclipse-enclave:mainfrom
fismif:fix/root-guard
Open

fismif wants to merge 1 commit into
eclipse-enclave:mainfrom
fismif:fix/root-guard

Conversation

@fismif

@fismif fismif commented Sep 28, 2026 •

Copy link
Copy Markdown

What it does

Part of #106 (1 of 2); the sensitive-mount guard follows in a separate PR.

Root guard. Running enclave as root, directly or through sudo, leaves root-owned files in the project and, under sudo -E, in the regular user's config, state, and cache roots, and later runs as the regular user fail on them. Under rootful Docker the agent is also host root on every bind mount. Enclave now refuses to run as root:

  • The check (internal/app/root_guard.go) runs early in app.Run, before anything writes state. Help, version, and shell completion still work.
  • --allow-root or ENCLAVE_ALLOW_ROOT=1 opts in, and each allowed run prints a warning. There is deliberately no config key, so neither global nor project config can grant it.
  • The refusal is a single line, so for tools|features add|update|remove --json it lands in the result envelope's error field. It names the sudo user when there is one and points to the docker group and rootless podman.
  • Host user commands pass the opt-in on to scripts that re-invoke $ENCLAVE_BIN.
  • The RPM smoke test runs as root in its job container, so it now asserts the refusal and then runs with ENCLAVE_ALLOW_ROOT=1.

Root sessions work. Getting past the guard was not enough: no image could be built for UID 0 (a root host, or --build-uid 0).

  • Dockerfile: the user setup renamed the existing user with the build UID to agent, and usermod cannot rename root while the build runs as root. A new UID 0 branch adds agent as a second passwd and group name for UID 0 and leaves root alone. The existing branches, which non-root builds take, are unchanged.
  • QEMU bundle (build-bundle.sh): the same approach, because busybox adduser refuses a UID in use.
  • Lookups by UID return root's entry, which comes first. RUN steps therefore got HOME=/root, and the build helpers landed off the agent's PATH (enclave-install-tool: not found). The UID 0 branch replaces /root with a link to /home/agent.
  • Sessions got USER=root and HOME=/root the same way, so the default git identity was root@enclave. The runtime now exports USER=agent and HOME=/home/agent for UID 0 images, as the QEMU backend already did. Admin sessions keep root's values, and HOME/USER from the project .env still win.
  • effectiveBuildIdentity moves to model.EffectiveBuildIdentity, so the image build and the runtime share the "--build-uid, else the host UID" rule.

Worth a close look: in UID 0 images /root becomes a symlink to /home/agent. Root's passwd entry is untouched. On the Debian and Ubuntu bases /root only holds .bashrc and .profile, which the agent's home gets from skel; a custom base image with more in /root would lose it. Non-root images keep a real /root.

Docs: a new "Running as root" section in docs/cli-reference.md, plus configuration.md, security/README.md, windows.md, README.md, ARCHITECTURE.md, and DEV.md.

How to test

make test covers the guard (internal/app/root_guard_test.go), flag parsing (internal/cli/parse_test.go), and the UID 0 session environment (internal/runtime/runtime_devcontainer_test.go).

As root, from a throwaway directory (the UID 0 agent can write root-owned files into the project):

sudo ./bin/enclave ps              # refused on one line, exit 1
sudo ./bin/enclave --version       # works
sudo ./bin/enclave --allow-root --tool claude --backend docker \
  --image-name enclave-uid0-test:latest shell -- -lc 'id -u; echo "$USER $HOME"'
                                   # 0, then: agent /home/agent
sudo ./bin/enclave --allow-root --tool claude --backend qemu \
  shell -- -lc 'id -u'             # 0 (needs KVM)

Without sudo, --build-uid 0 --build-gid 0 takes the same build path. Point the XDG roots at a scratch directory, because the UID 0 agent leaves root-owned files behind:

E=$PWD/bin/enclave S=$(mktemp -d); mkdir -p "$S/project"; cd "$S/project"
export XDG_CONFIG_HOME=$S/config XDG_STATE_HOME=$S/state XDG_CACHE_HOME=$S/cache
"$E" --tool claude --backend docker --build-uid 0 --build-gid 0 \
  --image-name enclave-uid0-test:latest shell -- -lc 'id -u; echo "$USER $HOME"'
                                   # 0, then: agent /home/agent
# cleanup: the files are root-owned, so remove them through a container
docker run --rm --user 0 --entrypoint rm -v "$S:/s" enclave-uid0-test:latest \
  -rf /s/project /s/config /s/state /s/cache
docker rmi enclave-uid0-test:latest

Leave out --slim for now (see Follow-ups).

What I verified:

  • make build, make lint, and make test. The only failure is in internal/wslshim, which fails on any host with /usr/bin/enclave installed (see Follow-ups).
  • As root: Docker and QEMU sessions run with the agent as UID 0. Without the opt-in the run is refused, and nothing is written.
  • As a regular user with --build-uid 0: USER=agent, HOME=/home/agent, default git identity agent@enclave, a writable home, and working default features. The admin shell keeps USER=root.
  • Non-root sessions are unchanged: the host UID, named agent, with a real /root.
  • The Dockerfile user setup in isolation for UID/GID 0/0, 0/1000, 1000/1000, and 00/00, the bundle user setup, and a full UID 0 QEMU bundle build.

Follow-ups

Related to this change:

  • --allow-root=false still opts in, because bool flags ignore their value (as --verbose does). That needs a general parser fix, left out of this PR.
  • Shell completion runs before the guard, and its completers resolve paths, which can extract the embedded assets into the cache. Completing as root can therefore leave root-owned cache files.
  • Git refuses the project ("detected dubious ownership") when the agent's UID differs from the project owner, for example sudo enclave --allow-root in a regular user's checkout. That is git's own protection, and enclave sets no safe.directory. Should we document it or handle it?
  • Not covered: podman as root, and devcontainer remoteUser: root (left out on purpose). QEMU UID 0 bundles don't get the /root link; their build doesn't need it, but tools that ignore $HOME, such as ssh, use /root in the VM.

Pre-existing, found while testing:

  • --slim builds fail for every UID with "/extensions/features": not found. prepareBuildContext stages only the selected features, but the Dockerfile copies extensions/features unconditionally.
  • The IDE bridge mount (internal/runtime/ide_bridge.go) is nested in the Claude config store, and the container runtime creates its ide mount point there as root on the host. fix: pre-create the tool skills directory in the config store #93 pre-created skills and memory, but not ide.
  • TestProbeScriptExitsNotFoundWhenNothingIsInstalled in internal/wslshim fails on hosts with /usr/bin/enclave installed, which is one of the probe's fallback paths.

Breaking changes

  • This PR introduces breaking changes and has been coordinated with maintainers.

Anyone who runs enclave as root today is now refused until they pass --allow-root or set ENCLAVE_ALLOW_ROOT=1. That includes CI job containers, sudo, and WSL distributions created with wsl --import, which log in as root. The policy (refuse by default, a real opt-in, no config key) was agreed with the project lead. Separately, the Dockerfile change alters the image hash, so every image rebuilds once.

Review checklist

When run as root, directly or through sudo, enclave and its agent leave
root-owned files in the project, and under sudo -E also in the regular
user's config, state, and cache roots, so later runs as the regular user
fail on them. Under rootful Docker the agent is also host root on every
bind-mounted directory. Enclave now refuses to run as root before it
writes any state. --allow-root or ENCLAVE_ALLOW_ROOT=1 opts in; there is
deliberately no config key, so neither global nor project config can
grant it. Help and version output work without the opt-in, the refusal
stays on one line so it fits the --json result envelope, and user host
commands pass the opt-in on to scripts that re-invoke $ENCLAVE_BIN. The
RPM smoke test runs as root in its job container, so it now checks the
refusal and then runs with ENCLAVE_ALLOW_ROOT=1.

The opt-in has to lead to a working session, but an image for UID 0
could not be built: the Dockerfile renamed the user that already had the
build UID to agent, and usermod cannot rename root while the build runs
as root. The QEMU bundle build failed too, because busybox adduser
refuses a UID in use. Both now add agent as a second passwd name for
UID 0 and leave the root entry alone.

Lookups by UID still return root's entry, which comes first. RUN steps
therefore got HOME=/root, which put the build helpers in
/root/.local/bin, off the agent's PATH, so the UID 0 branch replaces
/root with a link to /home/agent. Sessions got USER=root and HOME=/root
the same way, which made the default git identity root@enclave, so the
runtime now exports USER=agent and HOME=/home/agent for UID 0 images, as
the QEMU backend already did. Admin sessions keep root's values. The
build UID rule moves to model.EffectiveBuildIdentity so the image build
and the runtime share it.

Non-root builds take the same path as before, but the Dockerfile change
alters the image hash, so every image rebuilds once.

Tested as root with Docker and QEMU sessions and the refusal, and as a
regular user with --build-uid 0. Podman as root and devcontainer
remoteUser: root are not covered.

Part of eclipse-enclave#106.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@fismif
fismif requested a review from xai September 28, 2026 10:40
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