From a3014a4481c8b14fd9e81ffaaad51819c8356243 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 21 Apr 2026 08:27:53 +0300 Subject: [PATCH] fix(docker): add SETUID/SETGID caps so gosu drop in entrypoint succeeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Docker terminal backend runs containers with `--cap-drop ALL` and re-adds only DAC_OVERRIDE, CHOWN, FOWNER. Since commit fee0e0d3 ("run as non-root user, use virtualenv") the image entrypoint drops from root to the `hermes` user via `gosu`, which requires CAP_SETUID and CAP_SETGID. Without them every sandbox container exits immediately with: Dropping root privileges error: failed switching to 'hermes': operation not permitted Breaking every terminal/file tool invocation in `terminal.backend: docker` mode. Fix: add SETUID and SETGID to the cap-add list. The `no-new-privileges` security-opt is kept, so gosu still cannot escalate back to root after the one-way drop — the hardening posture is preserved. Reproduction ------------ With any image whose ENTRYPOINT calls `gosu `, the container exits immediately under the pre-fix cap set. Post-fix, the drop succeeds and the container proceeds normally. docker run --rm \ --cap-drop ALL \ --cap-add DAC_OVERRIDE --cap-add CHOWN --cap-add FOWNER \ --security-opt no-new-privileges \ --entrypoint /usr/local/bin/gosu \ hermes-claude:latest hermes id # -> error: failed switching to 'hermes': operation not permitted # Same command with SETUID+SETGID added: # -> uid=10000(hermes) gid=10000(hermes) groups=10000(hermes) Tests ----- Added `test_security_args_include_setuid_setgid_for_gosu_drop` that asserts both caps are present and the overall hardening posture (cap-drop ALL + no-new-privileges) is preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/tools/test_docker_environment.py | 28 ++++++++++++++++++++++++++ tools/environments/docker.py | 6 ++++++ 2 files changed, 34 insertions(+) diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index e19229a79..62b8b83df 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -382,3 +382,31 @@ def test_normalize_env_dict_rejects_complex_values(): "BAD_DICT": {"nested": True}, }) assert result == {"GOOD": "string"} + + +def test_security_args_include_setuid_setgid_for_gosu_drop(): + """_SECURITY_ARGS must include SETUID and SETGID so the image entrypoint + can drop from root to the non-root `hermes` user via gosu. + + Without these caps gosu exits with + ``error: failed switching to 'hermes': operation not permitted`` + and the container exits immediately (exit 1) before running any work. + + `no-new-privileges` is kept, so gosu still cannot escalate back to root + after the drop — the drop is a one-way transition performed before the + `no_new_privs` bit is enforced on the exec boundary. + """ + args = docker_env._SECURITY_ARGS + + # Flatten to set of added caps for clarity. + added = { + args[i + 1] + for i, flag in enumerate(args[:-1]) + if flag == "--cap-add" + } + assert "SETUID" in added, "SETUID cap missing — gosu drop in entrypoint will fail" + assert "SETGID" in added, "SETGID cap missing — gosu drop in entrypoint will fail" + + # Sanity: the hardening posture is still in place. + assert "--cap-drop" in args and "ALL" in args + assert "--security-opt" in args and "no-new-privileges" in args diff --git a/tools/environments/docker.py b/tools/environments/docker.py index d2ea5c964..65c33b349 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -148,6 +148,10 @@ def find_docker() -> Optional[str]: # We drop all capabilities then add back the minimum needed: # DAC_OVERRIDE - root can write to bind-mounted dirs owned by host user # CHOWN/FOWNER - package managers (pip, npm, apt) need to set file ownership +# SETUID/SETGID - the image entrypoint drops from root to the 'hermes' +# user via `gosu`, which requires these caps. Combined with +# `no-new-privileges`, gosu still cannot escalate back to root after +# the drop, so the security posture is preserved. # Block privilege escalation and limit PIDs. # /tmp is size-limited and nosuid but allows exec (needed by pip/npm builds). _SECURITY_ARGS = [ @@ -155,6 +159,8 @@ _SECURITY_ARGS = [ "--cap-add", "DAC_OVERRIDE", "--cap-add", "CHOWN", "--cap-add", "FOWNER", + "--cap-add", "SETUID", + "--cap-add", "SETGID", "--security-opt", "no-new-privileges", "--pids-limit", "256", "--tmpfs", "/tmp:rw,nosuid,size=512m",