From 132a318aa5aea22e290bb611b46d269eea9c33c6 Mon Sep 17 00:00:00 2001 From: fismif Date: Mon, 28 Sep 2026 12:24:53 +0200 Subject: [PATCH] fix: refuse to run as root unless --allow-root is given 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 #106. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/reusable-rpm-build.yml | 7 +- Dockerfile | 18 +- README.md | 4 + docs/ARCHITECTURE.md | 11 +- docs/DEV.md | 12 ++ docs/cli-reference.md | 7 + docs/configuration.md | 4 + docs/security/README.md | 5 + docs/windows.md | 6 + internal/app/app.go | 15 +- internal/app/build.go | 2 +- internal/app/build_identity.go | 14 +- internal/app/command_user.go | 7 +- internal/app/command_user_test.go | 47 +++++- internal/app/root_guard.go | 69 ++++++++ internal/app/root_guard_test.go | 159 ++++++++++++++++++ internal/app/testdata/config_rows.golden | 1 + internal/app/testmain_test.go | 7 + internal/cli/parse_test.go | 37 ++++ internal/config/options_cli_gen.go | 6 + internal/config/options_def.go | 21 +++ internal/config/options_registry_gen.go | 4 + internal/model/build_identity.go | 24 +++ internal/model/env.go | 1 + internal/model/option_sources_gen.go | 9 +- internal/model/option_sources_test.go | 3 +- internal/model/types.go | 1 + internal/runtime/runtime.go | 15 ++ internal/runtime/runtime_devcontainer_test.go | 79 +++++++++ runtime-assets/microvm/alpine/build-bundle.sh | 10 +- 30 files changed, 575 insertions(+), 30 deletions(-) create mode 100644 internal/app/root_guard.go create mode 100644 internal/app/root_guard_test.go create mode 100644 internal/model/build_identity.go diff --git a/.github/workflows/reusable-rpm-build.yml b/.github/workflows/reusable-rpm-build.yml index 1ba7d10d..54f0f33b 100644 --- a/.github/workflows/reusable-rpm-build.yml +++ b/.github/workflows/reusable-rpm-build.yml @@ -118,7 +118,12 @@ jobs: rpm --query enclave docker --version docker buildx version - enclave tools >/dev/null + # The job container runs as root, which enclave refuses without an opt-in. + if enclave tools >/dev/null 2>&1; then + echo "enclave ran as root without ENCLAVE_ALLOW_ROOT" >&2 + exit 1 + fi + ENCLAVE_ALLOW_ROOT=1 enclave tools >/dev/null dnf remove -y enclave test ! -e /usr/bin/enclave test ! -e /usr/share/enclave diff --git a/Dockerfile b/Dockerfile index deb51ff5..faf86bcf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -88,7 +88,23 @@ ARG USER_ID=1000 ARG GROUP_ID=1000 ARG USERNAME=agent -RUN if id -u ${USER_ID} >/dev/null 2>&1; then \ +RUN if [ "${USER_ID}" -eq 0 ]; then \ + # UID 0 (root host with --allow-root, or --build-uid 0): usermod cannot + # rename root while the build runs as root, so add the agent as a + # second name for UID 0 instead. + if [ "${GROUP_ID}" -eq 0 ]; then \ + groupadd -o -g 0 ${USERNAME}; \ + elif getent group ${GROUP_ID} >/dev/null 2>&1; then \ + groupmod -n ${USERNAME} $(getent group ${GROUP_ID} | cut -d: -f1); \ + else \ + groupadd -g ${GROUP_ID} ${USERNAME}; \ + fi && \ + useradd -o -m -u 0 -g ${GROUP_ID} -d /home/${USERNAME} -s /bin/bash ${USERNAME} && \ + # RUN steps take HOME from the first passwd entry for their UID, which + # is root's, so /root must lead to the agent's home as well. + rm -rf /root && \ + ln -s /home/${USERNAME} /root; \ + elif id -u ${USER_ID} >/dev/null 2>&1; then \ # UID exists (e.g. Ubuntu's "ubuntu" at 1000) - rename to our username EXISTING_USER=$(id -nu ${USER_ID}); \ EXISTING_HOME=$(getent passwd "$EXISTING_USER" | cut -d: -f6); \ diff --git a/README.md b/README.md index 63e32dbe..2df3481d 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,10 @@ sudo gpasswd -a "$USER" docker See Docker's [Linux post-install instructions](https://docs.docker.com/engine/install/linux-postinstall/) and account for the group's root-equivalent privileges. +Run enclave as that regular user, not through `sudo`: enclave refuses to run as +root unless you pass `--allow-root` or set `ENCLAVE_ALLOW_ROOT=1` (see +[Running as root](docs/cli-reference.md#running-as-root)). + On macOS, install Docker Desktop and the source-build dependencies above. ## Installation diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3eea32cf..dceddf90 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -98,6 +98,7 @@ The restricted network request flow has a separate ### Orchestration (`internal/app/`) - [`internal/app/app.go`](../internal/app/app.go) wires parsing, defaults merging, and command dispatch. +- [`internal/app/root_guard.go`](../internal/app/root_guard.go) refuses to run as root before any state is written, unless `--allow-root` or `ENCLAVE_ALLOW_ROOT` opts in. - [`internal/app/commands.go`](../internal/app/commands.go) routes commands to handlers (run/continue/resume/exec/shell/cleanup/tools/etc). - [`internal/app/command_run.go`](../internal/app/command_run.go) drives the run/continue/resume/exec/shell flow and runtime creation. - [`internal/app/build.go`](../internal/app/build.go) manages Docker image build/rebuild detection plus prebuild agent update planning and post-build stamp commits. @@ -345,13 +346,21 @@ must come from global config (`~/.config/enclave/config.json`) or explicit CLI f `--cache-from`). Some build controls are intentionally CLI-only and not read from config files, including `--rebuild`, `--no-rebuild`, `--force-base-image`, `--build-uid`, `--build-gid`, `--runtime-uid-remap`, and -the buildx cache flags. +the buildx cache flags. `--allow-root` is likewise never read from config; its +only alternative is the `ENCLAVE_ALLOW_ROOT` environment variable. Runtime image hashes include the effective build UID/GID. Explicit `--build-uid` / `--build-gid` values are used when provided; otherwise the host UID/GID is included after host resolution. This prevents a loaded shared image from being accepted as current when it was built for a different numeric user. +For UID 0 (a root host with `--allow-root`, or `--build-uid 0`), the Dockerfile +adds `agent` as a second name for UID 0 instead of renaming `root`, which +`usermod` refuses while the build runs as root; the QEMU bundle build does the +same. Lookups by UID return root's passwd entry, which comes first, so the +Dockerfile also replaces `/root` with a link to `/home/agent` for `RUN` steps, +and the runtime sets `HOME` and `USER` for the agent in sessions. + `features` can be set from config or CLI (`--features`). In devcontainer mode, the unset default is no enclave features; pass `--features` (or configure `features`) to opt in. `--features none` selects zero features explicitly. diff --git a/docs/DEV.md b/docs/DEV.md index e417b9ed..64f6e260 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -295,6 +295,18 @@ project scope so project defaults cannot weaken a stricter global setting. linked-worktree gitdir/commondir mounts: project config may strengthen the inherited mode (`follow < readonly < none`), but cannot weaken it. +Root guard note: `--allow-root` is CLI-only and has no config key, so no config +file can grant it; `ENCLAVE_ALLOW_ROOT=1` is its only alternative. +`checkRootGuard` in `internal/app/root_guard.go` runs early in `app.Run`, +before any state is written; tests pin the root check through the +`runningAsRoot` seam. A root host builds the image for UID 0: the Dockerfile +and the QEMU bundle build add `agent` as a second name for UID 0, and +`applyUIDZeroAgentEnv` in `internal/runtime` sets `HOME` and `USER` for the +agent, because lookups by UID return root's passwd entry. To test that path as +a regular user, pass `--build-uid 0 --build-gid 0` with `XDG_CONFIG_HOME`, +`XDG_STATE_HOME`, and `XDG_CACHE_HOME` pointing at a scratch directory: the UID 0 +agent leaves root-owned files behind. + Config additive note: feature additive directives (`+`/`-`) are applied against the implicit default-enabled feature set when `features` is unset. For example, `["-node-dev"]` removes that default feature from the implicit set. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 8571a161..2641dea3 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -224,6 +224,7 @@ store that holds memory; the other `--keep` kinds do not apply there. See | `--skills-validation ` | Validate shared skill frontmatter strictly (default) or leave metadata interpretation to the agent; values are case-insensitive | | `--session-monitor` | Run the agent under the managed tmux session (enables `status` snapshots) | | `--verbose` | Verbose logging | +| `--allow-root` | Run as root anyway (accepted by every command; CLI-only). See [Running as root](#running-as-root) | | `--playwright-mcp` | Enable Playwright MCP server for browser automation (Claude only) | ### Image & Build @@ -300,6 +301,12 @@ The menu lists every installed agent. The IDE profiles (`theia`, `theia-next`) a `--tool` overrides the saved choice for a single run, and a configured `tool` disables the question. Set `"tool": "auto"` to be asked again. +## Running as root + +enclave refuses to run as root, including through `sudo`. The agent's container user takes the host UID, so as root the agent runs as UID 0 (named `agent`, a second name for root), which is host root on bind-mounted directories under rootful Docker. Files enclave and the agent write, both in the project and in the stores under the config, state, and cache roots, become root-owned, and later runs as the regular user fail on them (with `sudo -E`, those roots are in the regular user's home). Run enclave as a regular user with access to the Docker socket (see the [requirements](../README.md#requirements)), or use rootless podman with `--backend podman`. + +To run as root anyway, for example in a CI job container, pass `--allow-root` or set `ENCLAVE_ALLOW_ROOT=1`; each such run prints a warning; sessions then run with the agent as UID 0, or as the UID given with `--build-uid`. The opt-in has no config key, so neither global nor project config can grant it. Help and version output work without it. + ## Backend detection The default backend is `auto`: enclave uses docker when its CLI is on `PATH`, otherwise podman. A `docker` command that is really the `podman-docker` shim counts as podman, and an explicit `docker`, from `--backend` or the `backend` key, that turns out to be the shim is driven as podman as well, with a notice. When both engines are installed, a command that uses an engine asks once which one to use and saves the answer as `"backend"` in `~/.config/enclave/config.json`. The question is only asked when stdin, stdout, and stderr are terminals and no `--json` or `--yes` was given; otherwise (scripts, captured output, JSON consumers) docker is used and a notice points at that key. Commands that never touch an engine (`tools`, `features`, `config`, `review-target`, `network print`, `network diff`, `devcontainer generate`) neither detect nor ask. When neither engine is found, the engine check reports it. An explicit `--backend` or a configured `backend` disables detection. diff --git a/docs/configuration.md b/docs/configuration.md index e646e11d..01e5c2c4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -310,6 +310,7 @@ Merge semantics: | `ENCLAVE_LOG_LEVEL` | Log level: `info` (default) or `debug` | | `ENCLAVE_AGENT_UPDATE_INTERVAL_HOURS` | Minimum hours after a tool's last successful automatic update before `check-update.sh` is eligible to probe again (`0` = always) | | `ENCLAVE_DEVCONTAINER_REWRITE_VARS` | Comma-separated extra env var names for devcontainer home-path normalization | +| `ENCLAVE_ALLOW_ROOT` | Set to `1` to run as root (same as `--allow-root`); see [Running as root](cli-reference.md#running-as-root) | These are read by the Windows launcher on the Windows side only, and are not forwarded into the WSL2 distribution. See [windows.md](windows.md). @@ -324,6 +325,9 @@ Buildx cache and canonical build UID/GID controls are CLI-only. Use `--buildx-cache-dir`, `--build-uid`, `--build-gid`, and `--runtime-uid-remap` for event/offline runs. +`--allow-root` is CLI-only as well; `ENCLAVE_ALLOW_ROOT=1` is its only +alternative, so no config file can let enclave run as root. + The experimental `qemu` backend only runs unrestricted, slim/no-feature bundles, so selecting it implies `allow_all_network=true` and `slim=true` automatically (with a per-run notice). Requesting features or an allowlist (`--allow-domain`) is rejected because the backend cannot honor them. ## Inspecting Resolved Config diff --git a/docs/security/README.md b/docs/security/README.md index fe018590..f3b4fea4 100644 --- a/docs/security/README.md +++ b/docs/security/README.md @@ -7,6 +7,11 @@ workflow. Rootless Docker is [not supported](rootless.md); rootless podman is, t ## Host filesystem +- Enclave refuses to run as root: the agent would then run as UID 0, which is + host root on bind-mounted directories under rootful Docker, and files it + writes would become root-owned. `--allow-root` or `ENCLAVE_ALLOW_ROOT=1` + overrides this; no config file can. [userns-remap](host-hardening.md) limits + what container UID 0 maps to on the host. - The project is a host bind mount and is writable by default. Agent changes are real host changes. - `--project-mount readonly` makes the project/worktree read-only and clamps diff --git a/docs/windows.md b/docs/windows.md index 756d3529..d6b72b4b 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -40,6 +40,12 @@ Linux container runtime and Linux path semantics; the launcher exists so the Steps 3 and 4 are both required. Installing only the launcher gives you a command that reports that enclave is not installed in the distribution. +A distribution created with `wsl --import` logs in as root by default, and +enclave [refuses to run as root](cli-reference.md#running-as-root). Create a +regular user and make it the default (`[user] default=` in the +distribution's `/etc/wsl.conf`). `ENCLAVE_ALLOW_ROOT` set on Windows is forwarded +into the distribution like any other `ENCLAVE_` variable. + `winget` is not supported: it needs a pull request into `microsoft/winget-pkgs` per release, which does not fit a rolling release. diff --git a/internal/app/app.go b/internal/app/app.go index 71856f67..47f535dc 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -57,6 +57,19 @@ func Run(args []string) int { return 0 } + // The guard runs before anything writes state (the tool question, asset + // extraction, stores), so a refused root run leaves no root-owned files. + // Folding the env opt-in into the options lets validation and per-tool + // re-resolution see one value. + parsed.Options.AllowRoot = rootAllowed(parsed.Options.AllowRoot) + if err := checkRootGuard(parsed.Options.AllowRoot); err != nil { + if parsed.Action == cli.ActionExtensionManage && parsed.ExtRequest != nil { + return reportExtensionResults(*parsed.ExtRequest, nil, err) + } + logx.Errorf("%v", err) + return 1 + } + projectDir, err := resolveProjectDir() if err != nil { logx.Errorf("%v", err) @@ -84,7 +97,7 @@ func Run(args []string) int { if parsed.Options.Verbose { logx.SetLevel("debug") } - return runUserHostCommand(*parsed.UserCommand, parsed.UserCommandArgs, projectDir, home) + return runUserHostCommand(*parsed.UserCommand, parsed.UserCommandArgs, projectDir, home, parsed.Options.AllowRoot) case usercmd.TargetSession: // Session commands run through the standard run pipeline as a // shell-style execution; fall through with a rewritten action. diff --git a/internal/app/build.go b/internal/app/build.go index 915a66ba..536d1f23 100644 --- a/internal/app/build.go +++ b/internal/app/build.go @@ -573,7 +573,7 @@ func buildImage(ctx context.Context, paths model.Paths, host model.Host, combine username = ru } } - buildUID, buildGID := effectiveBuildIdentity(host, opts) + buildUID, buildGID := model.EffectiveBuildIdentity(host, opts) buildArgs := map[string]string{ "USER_ID": buildUID, "GROUP_ID": buildGID, diff --git a/internal/app/build_identity.go b/internal/app/build_identity.go index 99cd2e14..823c99d8 100644 --- a/internal/app/build_identity.go +++ b/internal/app/build_identity.go @@ -16,22 +16,10 @@ import ( "enclave/internal/util" ) -func effectiveBuildIdentity(host model.Host, opts model.BuildOptions) (uid string, gid string) { - uid = strings.TrimSpace(opts.BuildUID) - if uid == "" { - uid = host.UID - } - gid = strings.TrimSpace(opts.BuildGID) - if gid == "" { - gid = host.GID - } - return uid, gid -} - func appendEffectiveBuildIdentityHashSuffix(suffix string, host model.Host, opts model.BuildOptions) string { // This runs after host resolution, so it captures the actual UID/GID baked // into the image even when --build-uid/--build-gid were not explicit. - uid, gid := effectiveBuildIdentity(host, opts) + uid, gid := model.EffectiveBuildIdentity(host, opts) if strings.TrimSpace(uid) != "" { suffix += "-effective-build-uid-" + util.HashString(uid) } diff --git a/internal/app/command_user.go b/internal/app/command_user.go index 496084d9..90d15ef6 100644 --- a/internal/app/command_user.go +++ b/internal/app/command_user.go @@ -23,7 +23,7 @@ import ( // arguments, and exit code through untouched. The inherited environment is // augmented with enclave context so scripts can re-invoke the binary and // locate the project and config directories. -func runUserHostCommand(cmd usercmd.Command, args []string, projectDir, home string) int { +func runUserHostCommand(cmd usercmd.Command, args []string, projectDir, home string, allowRoot bool) int { bin, err := os.Executable() if err != nil { logx.Warnf("could not resolve enclave binary path; ENCLAVE_BIN will be empty: %v", err) @@ -41,6 +41,11 @@ func runUserHostCommand(cmd usercmd.Command, args []string, projectDir, home str model.EnvProjectRoot+"="+projectDir, model.EnvConfigDir+"="+config.HostConfigRootDir(home), ) + // A script re-invoking $ENCLAVE_BIN inherits the root opt-in, which may + // have come from --allow-root rather than the environment. + if allowRoot { + c.Env = append(c.Env, model.EnvAllowRoot+"=1") + } if err := c.Run(); err != nil { var exitErr *exec.ExitError diff --git a/internal/app/command_user_test.go b/internal/app/command_user_test.go index 2725153a..3033b443 100644 --- a/internal/app/command_user_test.go +++ b/internal/app/command_user_test.go @@ -31,9 +31,9 @@ func writeUserScript(t *testing.T, dir, name, body string) string { return path } -// runHostCommandCaptured swaps os.Stdout/os.Stderr around the executor so the -// script output (and any logx error) can be asserted. -func runHostCommandCaptured(t *testing.T, cmd usercmd.Command, args []string, projectDir, home string) (stdout, stderr string, code int) { +// captureOutput swaps os.Stdout/os.Stderr around fn so its output (and any +// logx message) can be asserted. +func captureOutput(t *testing.T, fn func()) (stdout, stderr string) { t.Helper() origOut, origErr := os.Stdout, os.Stderr outR, outW, err := os.Pipe() @@ -45,7 +45,7 @@ func runHostCommandCaptured(t *testing.T, cmd usercmd.Command, args []string, pr t.Fatalf("pipe: %v", err) } os.Stdout, os.Stderr = outW, errW - code = runUserHostCommand(cmd, args, projectDir, home) + fn() _ = outW.Close() _ = errW.Close() os.Stdout, os.Stderr = origOut, origErr @@ -58,7 +58,15 @@ func runHostCommandCaptured(t *testing.T, cmd usercmd.Command, args []string, pr if err != nil { t.Fatalf("read stderr: %v", err) } - return string(outBytes), string(errBytes), code + return string(outBytes), string(errBytes) +} + +func runHostCommandCaptured(t *testing.T, cmd usercmd.Command, args []string, projectDir, home string, allowRoot bool) (stdout, stderr string, code int) { + t.Helper() + stdout, stderr = captureOutput(t, func() { + code = runUserHostCommand(cmd, args, projectDir, home, allowRoot) + }) + return stdout, stderr, code } func TestRunUserHostCommand(t *testing.T) { @@ -75,7 +83,7 @@ func TestRunUserHostCommand(t *testing.T) { "exit 7\n") cmd := usercmd.Command{Name: "deploy", Path: script, Target: usercmd.TargetHost} - stdout, _, code := runHostCommandCaptured(t, cmd, []string{"--env", "prod"}, "/tmp/project", "/home/user") + stdout, _, code := runHostCommandCaptured(t, cmd, []string{"--env", "prod"}, "/tmp/project", "/home/user", false) if code != 7 { t.Fatalf("expected exit code 7, got %d", code) @@ -96,7 +104,7 @@ func TestRunUserHostCommandStartFailure(t *testing.T) { missing := filepath.Join(t.TempDir(), "does-not-exist") cmd := usercmd.Command{Name: "ghost", Path: missing, Target: usercmd.TargetHost} - _, stderr, code := runHostCommandCaptured(t, cmd, nil, "/tmp/project", "/home/user") + _, stderr, code := runHostCommandCaptured(t, cmd, nil, "/tmp/project", "/home/user", false) if code != 1 { t.Fatalf("expected exit code 1 for start failure, got %d", code) @@ -106,6 +114,31 @@ func TestRunUserHostCommandStartFailure(t *testing.T) { } } +func TestRunUserHostCommandForwardsRootOptIn(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell script fixtures require a POSIX shell") + } + t.Setenv(model.EnvAllowRoot, "") + script := writeUserScript(t, t.TempDir(), "deploy", "#!/bin/sh\necho \"allow:$"+model.EnvAllowRoot+"\"\n") + cmd := usercmd.Command{Name: "deploy", Path: script, Target: usercmd.TargetHost} + + for _, tc := range []struct { + allowRoot bool + want string + }{ + {allowRoot: true, want: "allow:1\n"}, + {allowRoot: false, want: "allow:\n"}, + } { + stdout, _, code := runHostCommandCaptured(t, cmd, nil, "/tmp/project", "/home/user", tc.allowRoot) + if code != 0 { + t.Fatalf("allowRoot=%v: expected exit code 0, got %d", tc.allowRoot, code) + } + if stdout != tc.want { + t.Fatalf("allowRoot=%v: expected %q, got %q", tc.allowRoot, tc.want, stdout) + } + } +} + func TestPrepareUserSessionCommand(t *testing.T) { home := "/home/user" uc := usercmd.Command{Name: "triage", Path: "/p/triage", Target: usercmd.TargetSession} diff --git a/internal/app/root_guard.go b/internal/app/root_guard.go new file mode 100644 index 00000000..844d7740 --- /dev/null +++ b/internal/app/root_guard.go @@ -0,0 +1,69 @@ +// Copyright (C) 2026 EclipseSource GmbH and others. +// +// This program and the accompanying materials are made available under the +// terms of the MIT License, which is available in the project root. +// +// SPDX-License-Identifier: MIT + +package app + +import ( + "fmt" + "os" + "strings" + + "enclave/internal/logx" + "enclave/internal/model" +) + +// runningAsRoot checks both IDs: the image bakes the real UID (user.Current), +// and under sudo both are 0. +var runningAsRoot = func() bool { + return os.Getuid() == 0 || os.Geteuid() == 0 +} + +// rootAllowed reports whether the user opted in to running as root, through +// --allow-root or ENCLAVE_ALLOW_ROOT. There is deliberately no config key, so +// neither global nor project config can grant it. +func rootAllowed(flag bool) bool { + return flag || envTruthy(os.LookupEnv(model.EnvAllowRoot)) +} + +// checkRootGuard refuses to run as root unless allowed. As root the image is +// built for UID 0 (the agent becomes a second name for root), so the agent is +// host root on bind mounts under rootful Docker, and every file Enclave writes +// becomes root-owned, which breaks later runs as the regular user. +func checkRootGuard(allowed bool) error { + if !runningAsRoot() { + return nil + } + if allowed { + logx.Warnf("running as root: the agent runs as UID 0 unless --build-uid picks another UID, which is host root on bind-mounted directories under rootful Docker, and files Enclave writes are owned by root") + return nil + } + return rootRefusal() +} + +// rootRefusal stays on one line: under --json it becomes the result +// envelope's error field. +func rootRefusal() error { + who := "Run enclave as a regular user." + if sudoUser := strings.TrimSpace(os.Getenv("SUDO_USER")); sudoUser != "" && sudoUser != "root" { + who = fmt.Sprintf("enclave was started through sudo; run it as %s without sudo.", sudoUser) + } + return fmt.Errorf("refusing to run as root: the agent would run as UID 0, which is host root on bind-mounted directories under rootful Docker, and files Enclave writes would be owned by root. %s "+ + "To use Docker without sudo, add your user to the docker group and sign in again (see https://docs.docker.com/engine/install/linux-postinstall/), or use rootless podman with --backend podman. "+ + "Pass --allow-root or set %s=1 to run as root anyway", who, model.EnvAllowRoot) +} + +func envTruthy(value string, set bool) bool { + if !set { + return false + } + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} diff --git a/internal/app/root_guard_test.go b/internal/app/root_guard_test.go new file mode 100644 index 00000000..6e5b642d --- /dev/null +++ b/internal/app/root_guard_test.go @@ -0,0 +1,159 @@ +// Copyright (C) 2026 EclipseSource GmbH and others. +// +// This program and the accompanying materials are made available under the +// terms of the MIT License, which is available in the project root. +// +// SPDX-License-Identifier: MIT + +package app + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "enclave/internal/extinstall" + "enclave/internal/model" +) + +// setRunningAsRoot pins the root check. Tests using it must not run in +// parallel: the seam is package state. +func setRunningAsRoot(t *testing.T, root bool) { + t.Helper() + previous := runningAsRoot + runningAsRoot = func() bool { return root } + t.Cleanup(func() { runningAsRoot = previous }) +} + +func TestCheckRootGuardIgnoresNonRoot(t *testing.T) { + setRunningAsRoot(t, false) + if err := checkRootGuard(false); err != nil { + t.Fatalf("non-root run refused: %v", err) + } +} + +func TestCheckRootGuardRefusesRoot(t *testing.T) { + setRunningAsRoot(t, true) + t.Setenv("SUDO_USER", "") + err := checkRootGuard(false) + if err == nil { + t.Fatal("expected root run to be refused") + } + msg := err.Error() + for _, want := range []string{"--allow-root", model.EnvAllowRoot + "=1", "docker group", "https://docs.docker.com/engine/install/linux-postinstall/", "--backend podman", "Run enclave as a regular user."} { + if !strings.Contains(msg, want) { + t.Errorf("refusal %q does not mention %q", msg, want) + } + } + if strings.Contains(msg, "\n") { + t.Errorf("refusal must stay on one line for the JSON error field: %q", msg) + } +} + +func TestCheckRootGuardAllowsRootWithWarning(t *testing.T) { + setRunningAsRoot(t, true) + var err error + stdout, stderr := captureOutput(t, func() { err = checkRootGuard(true) }) + if err != nil { + t.Fatalf("allowed root run refused: %v", err) + } + if stdout != "" { + t.Errorf("warning must not reach stdout, got %q", stdout) + } + if !strings.Contains(stderr, "running as root") { + t.Errorf("expected a root warning on stderr, got %q", stderr) + } +} + +func TestRootRefusalNamesSudoUser(t *testing.T) { + t.Setenv("SUDO_USER", "alice") + if msg := rootRefusal().Error(); !strings.Contains(msg, "run it as alice without sudo") { + t.Errorf("expected a sudo hint naming alice, got %q", msg) + } + t.Setenv("SUDO_USER", "root") + if msg := rootRefusal().Error(); strings.Contains(msg, "sudo;") { + t.Errorf("SUDO_USER=root must not produce a sudo hint, got %q", msg) + } +} + +func TestRootAllowed(t *testing.T) { + if !rootAllowed(true) { + t.Error("--allow-root must opt in") + } + if err := os.Unsetenv(model.EnvAllowRoot); err != nil { + t.Fatal(err) + } + if rootAllowed(false) { + t.Errorf("unset %s must not opt in", model.EnvAllowRoot) + } + for _, value := range []string{"1", "true", "TRUE", "yes", "on", " 1 "} { + t.Setenv(model.EnvAllowRoot, value) + if !rootAllowed(false) { + t.Errorf("%s=%q should opt in", model.EnvAllowRoot, value) + } + } + for _, value := range []string{"", "0", "false", "no", "maybe"} { + t.Setenv(model.EnvAllowRoot, value) + if rootAllowed(false) { + t.Errorf("%s=%q should not opt in", model.EnvAllowRoot, value) + } + } +} + +func TestRunRefusesRootBeforeWritingState(t *testing.T) { + setRunningAsRoot(t, true) + home := t.TempDir() + t.Setenv("HOME", home) + + var code int + stdout, stderr := captureOutput(t, func() { code = Run([]string{"ps"}) }) + if code != 1 { + t.Fatalf("expected exit code 1, got %d", code) + } + if stdout != "" { + t.Errorf("refusal must not reach stdout, got %q", stdout) + } + if !strings.Contains(stderr, "refusing to run as root") { + t.Errorf("expected the refusal on stderr, got %q", stderr) + } + entries, err := os.ReadDir(home) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Errorf("refused run wrote into HOME: %v", entries) + } +} + +func TestRunAllowsHelpAndVersionAsRoot(t *testing.T) { + setRunningAsRoot(t, true) + for _, args := range [][]string{{"version"}, {"--version"}, {"--help"}, {"--allow-root", "version"}} { + var code int + captureOutput(t, func() { code = Run(args) }) + if code != 0 { + t.Errorf("Run(%q) as root returned %d", args, code) + } + } +} + +func TestRunRootRefusalKeepsExtensionJSONEnvelope(t *testing.T) { + setRunningAsRoot(t, true) + t.Setenv("HOME", t.TempDir()) + + var code int + stdout, _ := captureOutput(t, func() { code = Run([]string{"tools", "add", "owner/repo", "--yes", "--json"}) }) + if code == 0 { + t.Fatal("expected a non-zero exit code") + } + var envelope struct { + SchemaVersion string `json:"schemaVersion"` + Results []extinstall.ActionResult `json:"results"` + } + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("stdout is not a result envelope: %v\n%s", err, stdout) + } + if len(envelope.Results) != 1 || envelope.Results[0].Action != extinstall.ActionFailed || !strings.Contains(envelope.Results[0].Error, "refusing to run as root") { + t.Fatalf("expected one failed result carrying the refusal, got %+v", envelope.Results) + } +} diff --git a/internal/app/testdata/config_rows.golden b/internal/app/testdata/config_rows.golden index 553df2dc..1e5e4c1c 100644 --- a/internal/app/testdata/config_rows.golden +++ b/internal/app/testdata/config_rows.golden @@ -38,6 +38,7 @@ buildx_cache_to | default=""/false global=""/false project=""/false toolOverride progress | default=""/true global=""/false project=""/false toolOverride=""/false cli=""/false | source=0 effective="" network_log | default=""/true global=""/false project=""/false toolOverride=""/false cli=""/false | source=0 effective="" verbose | default="false"/true global=""/false project=""/false toolOverride=""/false cli=""/false | source=0 effective="false" +allow_root | default="false"/true global=""/false project=""/false toolOverride=""/false cli=""/false | source=0 effective="false" ports | default=""/false global=""/false project=""/false toolOverride=""/false cli=""/false | source=0 effective="" session_name | default=""/false global=""/false project=""/false toolOverride=""/false cli=""/false | source=0 effective="" add_dirs | default=""/false global=""/false project=""/false toolOverride=""/false cli=""/false | source=0 effective="" diff --git a/internal/app/testmain_test.go b/internal/app/testmain_test.go index be6f1b70..1ad73193 100644 --- a/internal/app/testmain_test.go +++ b/internal/app/testmain_test.go @@ -10,6 +10,8 @@ package app import ( "os" "testing" + + "enclave/internal/model" ) func TestMain(m *testing.M) { @@ -21,5 +23,10 @@ func TestMain(m *testing.M) { panic(err) } } + // A root opt-in in the developer's shell would otherwise flip root-guard + // and --build-uid 0 results. + if err := os.Unsetenv(model.EnvAllowRoot); err != nil { + panic(err) + } os.Exit(m.Run()) } diff --git a/internal/cli/parse_test.go b/internal/cli/parse_test.go index 5e1b90ae..87a065ee 100644 --- a/internal/cli/parse_test.go +++ b/internal/cli/parse_test.go @@ -1304,6 +1304,43 @@ func TestParseUserCommandGlobalFlagBeforeName(t *testing.T) { } } +// --allow-root is a global flag: every command, including host user commands, +// must accept it on either side of the command name. +func TestParseAllowRootIsGlobal(t *testing.T) { + cmds := []usercmd.Command{{Name: "deploy", Path: "/p/deploy", Target: usercmd.TargetHost}} + for _, tc := range []struct { + args []string + action string + }{ + {args: []string{"--allow-root", "ps"}, action: "ps"}, + {args: []string{"ps", "--allow-root"}, action: "ps"}, + {args: []string{"--allow-root", "--tool", "codex"}, action: "run"}, + {args: []string{"tools", "list", "--allow-root"}, action: "tools"}, + {args: []string{"--allow-root", "deploy", "x"}, action: "user-command"}, + // Bool flags ignore their value (as --verbose does), so =false still opts in. + {args: []string{"--allow-root=false", "ps"}, action: "ps"}, + } { + res, err := Parse(tc.args, config.DefaultOptions(), cmds...) + if err != nil { + t.Fatalf("Parse(%q) failed: %v", tc.args, err) + } + if res.Action != tc.action { + t.Errorf("Parse(%q): action %q, want %q", tc.args, res.Action, tc.action) + } + if !res.Options.AllowRoot || res.Sources.AllowRoot != model.SourceCLI { + t.Errorf("Parse(%q): AllowRoot=%v source=%v, want true from the CLI", tc.args, res.Options.AllowRoot, res.Sources.AllowRoot) + } + } + + res, err := Parse([]string{"ps"}, config.DefaultOptions()) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if res.Options.AllowRoot { + t.Error("AllowRoot must default to false") + } +} + func TestParseUserCommandHostRejectsSessionFlag(t *testing.T) { defaults := config.DefaultOptions() cmds := []usercmd.Command{{Name: "deploy", Path: "/p/deploy", Target: usercmd.TargetHost}} diff --git a/internal/config/options_cli_gen.go b/internal/config/options_cli_gen.go index 79e63a6c..99447a53 100644 --- a/internal/config/options_cli_gen.go +++ b/internal/config/options_cli_gen.go @@ -282,6 +282,12 @@ func optionCLIFlags() map[string][]CLIFlag { sources.Verbose = model.SourceCLI }), }, + "allow_root": { + boolFlag("--allow-root", "Allow running enclave as root (unsafe)", func(opts *model.Options, sources *model.OptionSources) { + opts.AllowRoot = true + sources.AllowRoot = model.SourceCLI + }), + }, "ports": { valueFlag("-p", "Publish a container port to the host (e.g. 5391, 8080:80, or 0:5391 for an auto-assigned host port, Docker only)", "-p requires a value", func(opts *model.Options, sources *model.OptionSources, value string) error { opts.Ports = append(opts.Ports, value) diff --git a/internal/config/options_def.go b/internal/config/options_def.go index 1af8bd51..700d56c9 100644 --- a/internal/config/options_def.go +++ b/internal/config/options_def.go @@ -944,6 +944,27 @@ func OptionDefs() []OptionDef { }, }, }, + { + Name: "allow_root", + Group: OptionGroupGlobal, + Kind: OptionKindBool, + OptionField: "AllowRoot", + SourceField: "AllowRoot", + Apply: ApplyNone, + CLIFlags: []CLIFlagDef{ + { + Name: "--allow-root", + Usage: "Allow running enclave as root (unsafe)", + ValueKind: CLIValueNone, + Action: CLIAction{ + Kind: CLIActionSetBool, + OptionField: "AllowRoot", + SourceField: "AllowRoot", + BoolValue: true, + }, + }, + }, + }, { Name: "ports", Group: OptionGroupRun, diff --git a/internal/config/options_registry_gen.go b/internal/config/options_registry_gen.go index b8ba64c5..440dd5d3 100644 --- a/internal/config/options_registry_gen.go +++ b/internal/config/options_registry_gen.go @@ -364,6 +364,10 @@ func OptionSpecs() []OptionSpec { } }, }, + { + Name: "allow_root", + Group: OptionGroupGlobal, + }, { Name: "ports", Group: OptionGroupRun, diff --git a/internal/model/build_identity.go b/internal/model/build_identity.go new file mode 100644 index 00000000..1381128e --- /dev/null +++ b/internal/model/build_identity.go @@ -0,0 +1,24 @@ +// Copyright (C) 2026 EclipseSource GmbH and others. +// +// This program and the accompanying materials are made available under the +// terms of the MIT License, which is available in the project root. +// +// SPDX-License-Identifier: MIT + +package model + +import "strings" + +// EffectiveBuildIdentity returns the UID and GID baked into the runtime image: +// --build-uid and --build-gid when given, otherwise the host's. +func EffectiveBuildIdentity(host Host, opts BuildOptions) (uid string, gid string) { + uid = strings.TrimSpace(opts.BuildUID) + if uid == "" { + uid = host.UID + } + gid = strings.TrimSpace(opts.BuildGID) + if gid == "" { + gid = host.GID + } + return uid, gid +} diff --git a/internal/model/env.go b/internal/model/env.go index 8cdbac8b..baa65949 100644 --- a/internal/model/env.go +++ b/internal/model/env.go @@ -11,6 +11,7 @@ const ( EnvPrefix = "ENCLAVE_" EnvHome = EnvPrefix + "HOME" + EnvAllowRoot = EnvPrefix + "ALLOW_ROOT" EnvLogLevel = EnvPrefix + "LOG_LEVEL" EnvColor = EnvPrefix + "COLOR" EnvAgentUpdateIntervalHours = EnvPrefix + "AGENT_UPDATE_INTERVAL_HOURS" diff --git a/internal/model/option_sources_gen.go b/internal/model/option_sources_gen.go index 0b37824e..ac0dd629 100644 --- a/internal/model/option_sources_gen.go +++ b/internal/model/option_sources_gen.go @@ -18,7 +18,8 @@ type OptionSources struct { } type GlobalOptionSources struct { - Verbose OptionSource + AllowRoot OptionSource + Verbose OptionSource } type RunOptionSources struct { @@ -83,7 +84,8 @@ func (s OptionSources) RunSources() RunOptionSources { return s.RunOptionSources func DefaultOptionSources() OptionSources { return OptionSources{ GlobalOptionSources: GlobalOptionSources{ - Verbose: SourceDefault, + AllowRoot: SourceDefault, + Verbose: SourceDefault, }, RunOptionSources: RunOptionSources{ AddDirs: SourceDefault, @@ -155,6 +157,9 @@ func MergeOptionSources(base OptionSources, override OptionSources) OptionSource if override.AllowDomains != SourceUnset { base.AllowDomains = override.AllowDomains } + if override.AllowRoot != SourceUnset { + base.AllowRoot = override.AllowRoot + } if override.AuthName != SourceUnset { base.AuthName = override.AuthName } diff --git a/internal/model/option_sources_test.go b/internal/model/option_sources_test.go index 2ca3ec0c..0ffb6ac1 100644 --- a/internal/model/option_sources_test.go +++ b/internal/model/option_sources_test.go @@ -19,7 +19,8 @@ func TestMergeOptionSourcesCoversAllFields(t *testing.T) { // Create an override with every field set to SourceCLI (non-default value) override := OptionSources{ GlobalOptionSources: GlobalOptionSources{ - Verbose: SourceCLI, + AllowRoot: SourceCLI, + Verbose: SourceCLI, }, RunOptionSources: RunOptionSources{ Tool: SourceCLI, diff --git a/internal/model/types.go b/internal/model/types.go index 9f5dfc92..ead05f18 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -41,6 +41,7 @@ type RunOptions struct { NoCache bool NetworkLog string Verbose bool + AllowRoot bool Background bool SessionName string PlaywrightMCP bool diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 3b9ada9b..ceab40e9 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -823,6 +823,7 @@ func (r *Runtime) backendRequest(ctx *ExecutionContext, detached bool, interacti user := "" r.applyDevcontainerUserIntent(&user, &env) r.applyRuntimeUIDRemapIntent(&user, &env) + r.applyUIDZeroAgentEnv(user, &env) req := backend.Request{ Session: backend.SessionMeta{ @@ -1434,6 +1435,20 @@ func (r *Runtime) applyRuntimeUIDRemapIntent(user *string, env *[]string) { ) } +// applyUIDZeroAgentEnv sets HOME and USER for the agent in an image built for +// UID 0. There the agent is a second passwd entry for UID 0, and lookups by UID +// return root's entry, which comes first. Admin sessions keep root's values. +func (r *Runtime) applyUIDZeroAgentEnv(user string, env *[]string) { + if user != "" || r.run.Admin { + return + } + uid, _ := model.EffectiveBuildIdentity(r.host, r.build) + if n, err := strconv.Atoi(uid); err != nil || n != 0 { + return + } + r.appendUserHomeEnvEntries(env, r.containerUser, r.containerHome) +} + func (r *Runtime) envVarSet(key string) bool { if key == "" { return false diff --git a/internal/runtime/runtime_devcontainer_test.go b/internal/runtime/runtime_devcontainer_test.go index b48b0670..003dd784 100644 --- a/internal/runtime/runtime_devcontainer_test.go +++ b/internal/runtime/runtime_devcontainer_test.go @@ -170,6 +170,85 @@ func TestApplyRuntimeUIDRemapIntentSkipsWhenDisabled(t *testing.T) { } } +func TestBackendRequestNamesUIDZeroAgent(t *testing.T) { + for _, tc := range []struct { + name string + host model.Host + build model.BuildOptions + }{ + {name: "root host", host: model.Host{UID: "0", GID: "0"}}, + {name: "build uid 0", host: model.Host{UID: "1000", GID: "1000"}, build: model.BuildOptions{BuildUID: "0"}}, + } { + t.Run(tc.name, func(t *testing.T) { + r := &Runtime{ + profile: model.Profile{Name: "claude"}, + project: model.Project{Hash: "abc123abc123", Dir: "/work/project"}, + host: tc.host, + build: tc.build, + containerUser: model.ContainerUser, + containerHome: model.ContainerHome, + } + req := r.backendRequest(&ExecutionContext{ContainerName: "session"}, false, false) + + if got := backendEnvValue(req.Env, "USER"); got != model.ContainerUser { + t.Errorf("USER = %q, want %q", got, model.ContainerUser) + } + if got := backendEnvValue(req.Env, "HOME"); got != model.ContainerHome { + t.Errorf("HOME = %q, want %q", got, model.ContainerHome) + } + }) + } +} + +func TestApplyUIDZeroAgentEnvLeavesOtherSessionsAlone(t *testing.T) { + root := model.Host{UID: "0", GID: "0"} + for _, tc := range []struct { + name string + host model.Host + build model.BuildOptions + run model.RunOptions + user string + }{ + {name: "non-root host", host: model.Host{UID: "1000", GID: "1000"}}, + {name: "root host with build uid 1000", host: root, build: model.BuildOptions{BuildUID: "1000"}}, + {name: "admin session", host: root, run: model.RunOptions{Admin: true}}, + {name: "user already chosen", host: root, user: "root"}, + } { + t.Run(tc.name, func(t *testing.T) { + r := &Runtime{ + host: tc.host, + build: tc.build, + run: tc.run, + containerUser: model.ContainerUser, + containerHome: model.ContainerHome, + } + var env []string + r.applyUIDZeroAgentEnv(tc.user, &env) + + if len(env) != 0 { + t.Fatalf("expected no env changes, got %v", env) + } + }) + } +} + +func TestApplyUIDZeroAgentEnvKeepsExplicitHome(t *testing.T) { + r := &Runtime{ + host: model.Host{UID: "0", GID: "0"}, + containerUser: model.ContainerUser, + containerHome: model.ContainerHome, + } + env := []string{"HOME=/custom"} + r.applyUIDZeroAgentEnv("", &env) + + if envSliceContainsKV(env, "HOME", model.ContainerHome) { + t.Fatalf("explicit HOME was overridden: %v", env) + } + if !envSliceContainsKV(env, "USER", model.ContainerUser) { + t.Fatalf("expected USER for the agent, got %v", env) + } +} + func TestBaseMountsProjectMountReadonly(t *testing.T) { projectDir := t.TempDir() r := New(model.RuntimeConfig{ diff --git a/runtime-assets/microvm/alpine/build-bundle.sh b/runtime-assets/microvm/alpine/build-bundle.sh index edd44457..889204e3 100755 --- a/runtime-assets/microvm/alpine/build-bundle.sh +++ b/runtime-assets/microvm/alpine/build-bundle.sh @@ -90,7 +90,15 @@ chroot "$root" /bin/sh -eu -c ' fi group_name=$(getent group "$gid" | cut -d: -f1) if ! id -u agent >/dev/null 2>&1; then - adduser -D -h /home/agent -s /bin/bash -u "$uid" -G "$group_name" agent + if [ "$uid" -eq 0 ]; then + # busybox adduser refuses a UID in use: add the agent as a second name for root. + echo "agent:x:0:$gid::/home/agent:/bin/bash" >> /etc/passwd + echo "agent:!:::::::" >> /etc/shadow + mkdir -p /home/agent + chown "0:$gid" /home/agent + else + adduser -D -h /home/agent -s /bin/bash -u "$uid" -G "$group_name" agent + fi fi mkdir -p /etc/sudoers.d echo "agent ALL=(root) NOPASSWD:/sbin/apk,/usr/bin/apk" > /etc/sudoers.d/agent