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