Repository navigation
fix(docker): harden the docker-* task container like repos containers - #248
Conversation
The task container that docker_solver starts for a docker-* run had no --cap-drop, --security-opt no-new-privileges or --pids-limit, unlike the containers repos/container.py starts. It now gets all three, with CHOWN, DAC_OVERRIDE, FOWNER, SETUID and SETGID added back: the harness's own root steps need them (the agent's tree, apt, setpriv). Smoke runs on python:3.12-slim, with the same bind mount and fork load: drop-ALL alone breaks apt, setpriv/runuser, chown and root writes into agent-owned files; the five added capabilities restore them, and each one is needed by at least one check. KILL, NET_BIND_SERVICE and NET_RAW stay dropped. Refs intel#229 (P2-1a). Changes no numbers. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #248 +/- ##
==========================================
+ Coverage 92.74% 92.83% +0.08%
==========================================
Files 254 262 +8
Lines 32087 33019 +932
Branches 5344 5510 +166
==========================================
+ Hits 29759 30652 +893
- Misses 1560 1571 +11
- Partials 768 796 +28
🚀 New features to boost your workflow:
|
antonwolfy
left a comment
There was a problem hiding this comment.
The change is right: TASK_CONTAINER_HARDENING matches the repos/container.py flags (--cap-drop ALL, no-new-privileges, --pids-limit 32768), _start_container is the only place a task container is started, and no harness step needs a capability that is now dropped. One thing to fix before merge: the new test does not pin where the flags sit in the docker run argv.
What I ran, on Linux at 890089a:
make ci-localwith Docker: 33 passed, 0 failed, 6 skipped (offline-only or not applicable on a PR), including the docker-oracle smoke and changed-line coverage.- Mutation runs of the new test against copies of
docker_solver.pyandcontainer_agent.py: dropping any single cap,--cap-drop ALL,no-new-privilegesor--pids-limitis caught; moving the flags is not (see inline). - The harness's reap step (
kill -9 -1as the agent uid),chown/chmodas root,setprivandsuwith exactly these flags on a small image: all work;pkillof another user's process as root fails, as the PR says.
Checked, no issues
- Flags: the set matches
repos/container.pyapart from the five added capabilities;CapEffinside the container is0xcb, which is CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID. - Growth baseline:
docker_solver.pyis 899 lines, asgrowth_baseline.jsonsays. - Docs: the
#what-the-agent-can-reachanchor exists and the fragment follows thechangelog.dformat.
| The solve and the verifier run in a container without Linux capabilities | ||
| beyond `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `SETUID` and `SETGID`, so a setuid | ||
| binary cannot raise its privileges and the process count is capped at 32768. | ||
| A step that signals another user's process, binds a port below 1024 (on a host | ||
| whose `ip_unprivileged_port_start` is 1024) or opens a raw socket fails; | ||
| `docker-oracle` will show it. See | ||
| [What the agent can reach](coding-harnesses.md#what-the-agent-can-reach). |
There was a problem hiding this comment.
The list of what now fails is not right. Ports below 1024 still work: Docker (20.10+) sets ip_unprivileged_port_start to 0 inside each container network namespace, so the host value does not matter (checked on Docker 29 with the host at 1024: root and nobody both bind port 80 with these flags). On the other side, more steps fail than listed: chroot (SYS_CHROOT), mknod (MKNOD), setcap in a package postinst (SETFCAP), setting setgid bits on files of another group (FSETID). Please drop the NET_BIND_SERVICE case and list the real ones, here, in coding-harnesses.md:296-299 and in the changelog fragment.
| ### Changed | ||
|
|
||
| - **`docker-*` task containers are hardened like `repos` containers** — the | ||
| task container starts with `--cap-drop ALL`, `--security-opt no-new-privileges` | ||
| and `--pids-limit 32768`, and gets back only `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, | ||
| `SETUID` and `SETGID`, which the harness's own root steps (the agent's tree, | ||
| `apt`, `setpriv`) need. A setuid binary in a task image can no longer raise its | ||
| privileges. A task whose solve or verifier needs another capability now fails at | ||
| that step: killing another user's process (`KILL`), binding a port below 1024 on a | ||
| host where `net.ipv4.ip_unprivileged_port_start` is 1024 (`NET_BIND_SERVICE`), or | ||
| raw sockets (`NET_RAW`). Before, the container ran with Docker's default | ||
| capabilities and no process limit. |
There was a problem hiding this comment.
This changes the environment every docker-* cell runs in, so a task that needs a dropped capability goes from pass to reward 0, and nothing in the run artifacts says which setup a run used. Please say in the fragment that docker-* results from before this change may not be comparable for such tasks. Were all catalog tasks' oracles re-run with these flags, or only the #229 smoke task?
| run_args = ["run", "-d", "--name", container, *TASK_CONTAINER_HARDENING, *net_args, | ||
| *env_args, "--cpus", cpus, "--memory", memory, image, "sleep", str(lifetime)] |
There was a problem hiding this comment.
repos/container.py pairs --pids-limit with --init, but here PID 1 is sleep, which never reaps. Every process that outlives its docker exec (nohup ... &, a daemon a test starts) becomes a zombie when it exits and still counts against the new limit (checked: 5 orphans give 5 zombies in pids.current). Please add --init to TASK_CONTAINER_HARDENING too.
| # The task container's hardening flags are the ones repos/container.py sets. Unlike there, root | ||
| # here gets back the few capabilities its own steps need: CHOWN and FOWNER (chown/chmod of the | ||
| # agent's tree), DAC_OVERRIDE (root writing the agent's files), SETUID/SETGID (apt, setpriv). | ||
| # Each was needed in the #229 smoke runs; KILL, NET_BIND_SERVICE and the rest stay dropped. |
There was a problem hiding this comment.
The harness itself does not run apt or setpriv (setpriv is not used anywhere in the repo), and its own docker exec -u needs no capability inside the container. SETUID/SETGID are there for task scripts (apt dropping to _apt, su). Please fix the comment and the changelog line so the next person who tightens this knows what really needs them.
| # agent's tree), DAC_OVERRIDE (root writing the agent's files), SETUID/SETGID (apt, setpriv). | ||
| # Each was needed in the #229 smoke runs; KILL, NET_BIND_SERVICE and the rest stay dropped. | ||
| TASK_CONTAINER_HARDENING = ( | ||
| "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "32768", |
There was a problem hiding this comment.
The comment says these are the flags repos/container.py sets, but 32768 is a second copy of repos.container.PIDS_LIMIT. Consider one shared constant. The test checks >= 4096 while the docs promise 32768; please assert the documented value and that the flags come before the image name.
A low-port bind still works: Docker sets ip_unprivileged_port_start to 0 in the container's network namespace, so dropping NET_BIND_SERVICE never shows. Name what does fail under the new flags instead (KILL, NET_RAW, MKNOD, SYS_CHROOT) and the silent one: FSETID, a setgid bit dropped with exit 0. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
|
antonwolfy
left a comment
There was a problem hiding this comment.
Review at 41355f8. The change since my last review is docs only (changelog fragment and two docs pages), and it fixes the low-port claim. The flags in the code are right. My earlier thread on the test is still open, and one unfixed point from @vlad-perevezentsev blocks.
Still open from my earlier review:
dokimi/tests/test_docker_solver_harness.py:243: the test does not pin where the flags sit in therunargv, and the pids assert is still>= 4096. With the flags moved after"sleep", str(lifetime)the new test alone still passes (1 passed);"99999999999"and"4096"for the limit also pass. Only an unrelated assert,run[-2:] == ["sleep", "1800"]intest_docker_task_md.py, catches the move. A--privilegedorseccomp=unconfinedadded to the tuple also passes. Keep the suggestion from the thread: index checks against the image name and== "32768".
I agree with @vlad-perevezentsev on the missing --init (PID 1 is sleep, which never reaps) and on the apt/setpriv comment at container_agent.py:87-89. New evidence: no harness code calls setpriv, runuser, apt or su (the root steps are mkdir, chown -R, chmod and rm -rf), and the same comment still lists NET_BIND_SERVICE as dropped.
@vlad-perevezentsev's question on whether the catalog tasks' oracles were re-run with these flags, and the note that earlier docker-* results may not be comparable, wait for the author. So do @xaleryb's points on the PR body's Merge Danger list and the ip_unprivileged_port_start=0 sentence.
What I ran, on Linux at 41355f8:
make ci-localwith Docker: 31 passed, 2 failed, 6 skipped; 8,980 tests passed, 2 skipped. Both FAILs are the gitleaks install and scan steps (Install gitleaksexit 22,Scan commitsexit 127), where the proxy returned 504 on the download; a gitleaks run over the PR's two commits found no leaks. mypy, deptry, the schema compatibility check, the changelog check and the docs checks pass.- 19 mutants of
TASK_CONTAINER_HARDENINGand_start_containeragainst the six docker-related test files: dropping or changing any cap,--cap-drop ALL,no-new-privilegesor--pids-limitis caught; flag order after the image name, a huge pids value, and extra flags are not.
Checked, no issues
- Call sites:
_start_containeratdocker_solver.py:356is the onlydocker runfor the task container. - Growth baseline:
docker_solver.pyis 899 lines, asgrowth_baseline.jsonsays. - Docs:
mkdocs build --strictpasses and the#what-the-agent-can-reachanchor exists. - Low ports: the corrected docs and changelog no longer claim a port below 1024 fails.
5057725 to
c690563
Compare
…n in the test - TASK_CONTAINER_HARDENING gets --init, as repos/container.py has it: without it PID 1 (sleep) never reaps orphans. Probe: 3 orphans left 3 zombies and pids.current 6 without --init, 0 zombies and 4 with it. - --pids-limit reads repos.container.PIDS_LIMIT instead of a second 32768. - SETUID/SETGID are for task scripts that drop privileges (apt, su). The harness's own `exec -u 10001` works without them (probed on debian:trixie-slim with the other flags), so the comment says so. - Test: flags must come before the image, pids pinned to 32768. Moving the flags after `sleep` now fails the test. - Changelog: docker-* results from before may not be comparable. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…efore the image The position check passed for a missing flag: all() over no occurrences is True. Deleting --init from TASK_CONTAINER_HARDENING left the test green. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
antonwolfy
left a comment
There was a problem hiding this comment.
LGTM. The two commits since my last review take everything I asked for: --init is in TASK_CONTAINER_HARDENING, the limit comes from repos.container.PIDS_LIMIT, the comment no longer claims the harness runs apt or setpriv, and the test pins the flags' position and the exact limit. One nit on the test.
What I ran, on a Linux review host at c690563:
make ci-localwith Docker: 33 passed, 0 failed, 6 skipped; 8,980 tests passed, 2 skipped; changed-line coverage, mypy, deptry, the schema check, the changelog check,mkdocs buildand the docker-oracle smoke pass.- Mutants of
TASK_CONTAINER_HARDENINGand_start_containeragainst the three docker test files: dropping or changing--init,--cap-drop ALL,no-new-privileges, any cap-add, the pids value, and moving the flags after the image or aftersleepare all caught. An extra--privilegedor--security-opt seccomp=unconfinedis not (see inline).
@vlad-perevezentsev's question on whether the catalog tasks' oracles were re-run with these flags, and @xaleryb's points on the PR body's Merge Danger list and the ip_unprivileged_port_start=0 sentence, wait for the author.
Checked, no issues
- Flags: the tuple matches
repos/container.py(--init,--cap-drop ALL,no-new-privileges,--pids-limit) plus the five cap-adds;_start_containerplaces it before the image. - Docs: the changelog,
coding-harnesses.mdand the contributing page name the same flags and capabilities as the code, and the changelog now says earlierdocker-*results may not be comparable. - Growth baseline:
docker_solver.pyis 899 lines, asgrowth_baseline.jsonsays. The newreposimport creates no cycle.
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Summary
P2-1a of #229. The
docker-*task container (docker_solver._start_container) now starts with the same hardening flagsrepos/container.pysets, plus five named capabilities that root needs for the harness's own steps.Files:
harnesses/container_agent.py:TASK_CONTAINER_HARDENING(the flag tuple, with the reason for each add-back).harnesses/docker_solver.py: theruncall uses it (900 → 899 lines, growth baseline lowered).tests/test_docker_solver_harness.py:test_start_container_drops_capabilities_and_bounds_pids.docs/coding-harnesses.md,docs/contributing-terminal-bench-task.md: a note on the dropped capabilities for task authors.No shared policy builder:
repos/container.pyandmetrics/example_runner.pyare untouched.Evidence
Failing test on the old code, passing on the new (
tests/test_docker_solver_harness.py):Full suite on the pre-rebase tree:
8708 passed, 4 skipped. After the final commit,test_docker_solver_harness.py,test_docker_task_md.pyandtest_container_agent.pypass (207 passed, 1 skipped, then141 passed, 1 skippedafter a comment-only change), andmake ci-local CI_LOCAL_ARGS='--offline --jobs static,typecheck'passes on the rebased HEAD.Smoke runs: a small image,
docker runwith the old flags and with the new ones,--cpus 8,--rm, run-specific names, every container stopped. Each probe runs as the harness does (root steps viaexec -u root, agent steps as uid 10001):no-new-privs, which the old flags do not set.bind-port-80passed in every mode: the test host hasip_unprivileged_port_start=0, so NET_BIND_SERVICE is not exercised here.End to end:
dokimi tasks run --harnesses docker-oracleon a small task passed (1/1, reward 1.0) with the hardeneddocker runin the run log.Merge Danger
Door: two-way. Revert restores the old container.
Runs can now fail where a task needs a dropped capability. This is the behaviour change to check before merge:
KILL: a solve or verifier that signals another user's process (killas root on uid-10001 processes) fails.NET_BIND_SERVICE: binding a port below 1024 fails on hosts wherenet.ipv4.ip_unprivileged_port_startis 1024.NET_RAW: ping, raw sockets fail.no-new-privileges), so a task that relies onsudofails.Blast Radius: every
docker-*task container (docker-oracle,docker-claude*,docker-probe*, SWE-bench rows from #213 once they land). The egress sidecar container (container_agent.py,docker createnear L186) is still unhardened; that is a follow-up, not in this PR.Numbers do not change: this PR does not touch scoring, token or cost accounting.
Refs #229 (P2-1a).
🤖 Generated with Claude Code