Skip to content

cubeops: run the container image as an unprivileged user - #1447

Open
dwin-gharibi wants to merge 1 commit into
TencentCloud:masterfrom
dwin-gharibi:cubeops-image-runs-as-root
Open

cubeops: run the container image as an unprivileged user#1447
dwin-gharibi wants to merge 1 commit into
TencentCloud:masterfrom
dwin-gharibi:cubeops-image-runs-as-root

Conversation

@dwin-gharibi

Copy link
Copy Markdown
Contributor

Closes #1446.

Motivation

CubeOps/Dockerfile had no USER, so the service ran as uid 0. It holds the JWT signing secret and the
master encryption key, and it needs no host privileges — and both sibling control-plane images already run
unprivileged (CubeAPI with a cube user, cube-lifecycle-manager on distroless nonroot). CubeOps was
the outlier.

What this changes

CubeOps/Dockerfile runtime stage, following the pattern CubeAPI/Dockerfile:73-81 already establishes:

RUN addgroup -S cube && adduser -S -G cube cube \
    && mkdir -p /data/log/CubeOps \
    && chown -R cube:cube /data/log/CubeOps
...
USER cube

The log directory is created and chowned because config.LogDir defaults to /data/log/CubeOps
(internal/config/config.go:98-99) and a non-root user cannot create it at runtime. Operators who point
CUBE_OPS_LOG_DIR elsewhere must ensure that path is writable by uid 100 — or mount it with the right
ownership.

Nothing else changes: same base image, same packages, same entrypoint and port.

Why CubeOps can drop root

I checked rather than assuming:

  • No Go code invokes docker — grep -rn "exec.Command" CubeOps --include='*.go' finds only rsync
    (internal/service/openclaw.go:1307). The docker-cli package in the image appears unused.
  • The Helm chart mounts no docker socket for CubeOps (grep -rn "docker.sock" deploy/kubernetes/chart/
    returns nothing).
  • CubeOps binds :3010, well above 1024, so no privileged port is involved.

Testing

The full image build needs network access to fetch modules and hit the same
proxy.golang.org … 403 Forbidden that blocks go mod download locally, so I verified the runtime stage
directly by building it with a stub binary in place of the COPY --from=builder:

$ docker run --rm --entrypoint sh cubeops-runtime-probe -c 'id; ls -ld /data/log/CubeOps; touch /data/log/CubeOps/probe'
uid=100(cube) gid=101(cube) groups=101(cube),101(cube)
drwxr-xr-x 1 cube cube 0 /data/log/CubeOps
writable? YES

So the entrypoint runs unprivileged and the default log directory is writable by that user. The build-stage
half of the Dockerfile is untouched by this change.

CI: build-check builds the image in the builder environment, which has the network access this needs.

Risk / rollout

Ownership of a mounted log volume matters after this. If a deployment bind-mounts or PVC-mounts
/data/log/CubeOps from the host, that directory must be writable by uid 100 or CubeOps will fail to open
its log file on start. The chart should either set an fsGroup or pre-chown the path — worth checking
alongside this merge.

No other behaviour change.

Not fixed here

  • CubeMaster/docker/Dockerfile also lacks a USER. CubeMaster does host-level work and may legitimately
    need root; that needs its own audit rather than a copy of this patch.
  • The unused docker-cli package and the missing rsync (used at openclaw.go:1307) are separate issues
    I noticed while checking privileges.

Signed-off-by: Dwin Gharibi <dwin.gharibi@email.kntu.ac.ir>
Copilot AI lite review requested due to automatic review settings August 21, 2026 11:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread CubeOps/Dockerfile
Comment on lines +43 to +45
&& mkdir -p /data/log/CubeOps \
&& chown -R cube:cube /data/log/CubeOps

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This pre-creates and chowns only the log directory, but CubeOps writes to another tree at runtime that this image does not set up for uid 100.

CubeOps/internal/service/openclaw.go:1250-1251 hardcodes two host state roots — /data/shared/agenthub/openclaw and /data/shared/agenthub/openclaw-snapshots — and PrepareOpenclawStateDir (os.MkdirAll) is called unconditionally on every agenthub instance create/clone when persistence_mode=shared_files (CubeOps/internal/service/agenthub.go:492-498, 1241); CopyOpenclawStateDir and os.RemoveAll also touch those trees. Neither root exists in the Alpine base image, so MkdirAll("/data/shared/agenthub/openclaw/<id>") walks up and tries to create /data — writable by root but not by uid 100 → EACCES.

Today (uid 0) those calls succeed, silently creating useless ephemeral dirs in the container layer; after this change the same agenthub calls start returning 500s in the k8s image whenever the paths aren't host-mounted. That is a behavior change this PR's risk section doesn't cover. Please either:

  • create + chown cube:cube the two roots here alongside the log dir (preserves the prior ephemeral behavior), and/or
  • document that shared_files agenthub persistence requires /data/shared/agenthub mounted writable by uid 100.

(Related but pre-existing and out of scope: CopyOpenclawStateDir at openclaw.go:1307 invokes rsync, which this image never installs — the docker-cli apk is unused while the actually-needed rsync is missing.)

@cubesandboxbot

Copy link
Copy Markdown

Review: cubeops: run the container image as an unprivileged user (PR #1447)

AI-generated review — verified against the base branch workspace. Not a human approval.

Verdict: Approve with one substantive gap to address or document. The change is minimal, well-reasoned, matches the existing CubeAPI/Dockerfile pattern, and is a genuine security improvement (CubeOps holds the JWT signing secret and master encryption key and needs no host privileges). The runtime-stage probe the author ran is a good validation of the default path.

What's good

  • USER cube (uid 100) is dropped after COPY, so the binary is installed as root and stays readable/executable — correct layer ordering.
  • /data/log/CubeOps is created and chowned, matching the LogDir default (internal/config/config.go:112-114) and the CubeAPI precedent.
  • The runtime failure mode for an unwritable log dir is non-fatal: logging.Init (internal/logging/logging.go:50-55) catches the MkdirAll error and falls back to stdout, so the app won't crash on start.

Main finding (inline on CubeOps/Dockerfile:43-45)

CubeOps writes to a second tree at runtime that this image does not prepare for uid 100: /data/shared/agenthub/openclaw and /data/shared/agenthub/openclaw-snapshots (internal/service/openclaw.go:1250-1251). PrepareOpenclawStateDir runs on every agenthub create/clone with persistence_mode=shared_files (internal/service/agenthub.go:492-498, 1241); CopyOpenclawStateDir and os.RemoveAll also touch those roots. Because /data does not exist in the base image and / is root-owned, os.MkdirAll under /data/shared now returns EACCES as uid 100 — where it previously "succeeded" as root (creating ephemeral, useless dirs in the container layer). Concretely: agenthub create/clone that used to return 201 will start returning 500 in the k8s image when those paths aren't host-mounted. Recommend pre-creating/chowning those roots (mirroring the log-dir handling) and/or documenting that shared_files agenthub persistence requires /data/shared/agenthub mounted writable by uid 100. This is the one runtime write path the PR's risk section misses.

Secondary notes (non-blocking)

  • Silent log fallback. If an operator points CUBE_OPS_LOG_DIR at a volume not writable by uid 100, file logging is dropped to stdout with only a log-line warning — easy to miss. The PR mentions the ownership requirement, but a chart-level fsGroup/volume ownership story would make this robust; the chart currently has no volume mounts or securityContext for CubeOps.
  • Sibling image still runs as root. deploy/one-click/CubeOps/Dockerfile (used by the one-click/terraform path) has no USER. If the goal is "CubeOps runs unprivileged", that image is now the inconsistent outlier and deserves the same treatment in a follow-up.
  • Pre-existing, out of scope (author already flagged): the image installs docker-cli but nothing invokes docker, while CopyOpenclawStateDir (openclaw.go:1307) calls rsync, which is never installed — so the shared-files state-copy path errors with "executable file not found" regardless of this change.

Risk on rollout

The PR's own warning is accurate: any deployment that bind/PVC-mounts the log path must make it writable by uid 100 (or set an fsGroup). The chart change is not included here, so it should land alongside or before this merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug Report] The CubeOps container image runs as root

2 participants