Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/reusable-rpm-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +121 to +126

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This updates the later installation smoke test, but the earlier build-rpm-fedora job also runs as root. Its 'Verify packaged runtime assets' step calls scripts/verify-package-assets.sh, which invokes Enclave twice without an override. CI currently fails at that verification step, so test-fedora is skipped. Please pass --allow-root explicitly to both Enclave invocations in the verifier and use the flag for the allowed invocation here too. The verifier's negative case must get past the guard and still fail because embedded assets are unavailable; a root-refusal error would test the wrong behavior. Please retain the separate assertion here that running without the override is refused.

An out-of scope follow-up issue would be to fix the ci job for fedora. Currently, the fedora ci job runs in a fedora:44 job container and apparently the default user is root there. The follow-up should try setting a non-privileged user in the container.

dnf remove -y enclave
test ! -e /usr/bin/enclave
test ! -e /usr/share/enclave
Expand Down
18 changes: 17 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +91 to +94

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy for this PR to address only the root-guard part of #106. This alone is a great UX improvement! I would suggest to move the new UID-0 image and runtime support into a separate PR: the Dockerfile and QEMU account changes, the UID-0 HOME/USER handling, and the build-identity extraction needed for that handling.

Those changes introduce separate compatibility questions, including deleting existing /root content and remapping a UID-0 agent. We don't need to solve those here, as the behavior on main is broken anyway in these cases. For this PR, --allow-root or the respective env variable can bypass the new guard without promising to repair previously unsupported root-image workflows; please make that scope clear in the docs. Keep the guard, focused tests/docs, generated flag support, and necessary CI adjustments.

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); \
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Comment on lines +357 to +362

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would defer this to a follow-up PR and only do the guard and the --allow-root bypass in this PR without fixing the UID 0 build issue.

Building with uid 0 is broken on main anyway and triggering the guard and an understandable error message is a much nicer UX than the current behavior. If someone then uses --allow-root, they will still get the current broken state, but we can introduce that fix in a separate PR.


`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.
Expand Down
12 changes: 12 additions & 0 deletions docs/DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +302 to +308

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my comment in ARCHITECTURE.md


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.
Expand Down
7 changes: 7 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ store that holds memory; the other `--keep` kinds do not apply there. See
| `--skills-validation <strict\|agent>` | 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
Expand Down Expand Up @@ -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.
Comment on lines +306 to +308

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my comment in ARCHITECTURE.md


## 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.
Expand Down
4 changes: 4 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/security/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/windows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<name>` 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.

Expand Down
15 changes: 14 additions & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment on lines +60 to +65

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guard is still reached after writing paths. Run calls discoverUserCommands before parsing, and its ResolveHostHome call creates and deletes a temporary file. More importantly, dynamic completion runs inside cli.Parse: __complete run --tool "" can extract embedded assets, then return before this guard. That leaves a path to root-owned cache files without an opt-in. Please make discovery and the exempt help/version/completion paths read-only, with writing initialization behind the guard. The existing test only checks whether HOME is empty afterward, so it misses temporary writes; please cover completion asset extraction as well. Both behaviors were reproduced with the existing root-check test seam, without running an actual root container.

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)
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion internal/app/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 1 addition & 13 deletions internal/app/build_identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
7 changes: 6 additions & 1 deletion internal/app/command_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
47 changes: 40 additions & 7 deletions internal/app/command_user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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}
Expand Down
Loading
Loading