diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index e19229a795..62b8b83df1 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 d2ea5c964c..65c33b349c 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",