Skip to content

feat(isolation): add UID and GID isolation for evaluated agents - #87

Open
dmorosanu wants to merge 1 commit into
mainfrom
codex/uid-gid-agent-isolation
Open

feat(isolation): add UID and GID isolation for evaluated agents#87
dmorosanu wants to merge 1 commit into
mainfrom
codex/uid-gid-agent-isolation

Conversation

@dmorosanu

@dmorosanu dmorosanu commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What changed

  • run evaluated Claude, Codex, and Antigravity processes under a dedicated unprivileged Linux UID/GID with cleared groups, cleared capabilities, and no_new_privs
  • keep task, grader, reference, template, and output mounts below a root-only parent that the agent identity cannot traverse
  • scrub harness-only environment variables from the agent's SDK subprocess environment
  • bound container process counts, then reap residual agent processes and verify their removal before trusted finalization
  • fail closed at preflight for images without the isolation capability label, and for docker.working_dir, docker.extra_mounts, and dynamic privileged criteria (run_command, uipath_eval, agent_judge)
  • document the Docker identity boundary

Protected mock service and plugin bundle projection moved to follow-up PRs stacked on this branch.

Why

The evaluated agent shares the container with the harness that grades it. Without a separate identity, the agent process can read the task sources, references, and expected outputs that decide its own score. The boundary must keep grading material out of reach of the agent identity while leaving the agent's own workspace fully writable.

How it works

Two in-container identities:

  • grader (root) owns /opt/coder-eval/grader/** at mode 0700. Task sources, references, templates, staged inputs, and the run output directory all live below it.
  • agent (UID/GID 2000) owns only /work/agent and /home/agent.

The agent's environment is scrubbed through scrub_agent_env_overrides - evaluator credentials, including the Bedrock token, are not passed to the agent, and HOME is set to /home/agent rather than the host's value.

How it starts

DockerRunner.run()
  -> _validate_agent_isolation_compatibility()   agent-type allowlist; run_command / uipath_eval /
                                                 agent_judge, working_dir, extra_mounts fail closed
  -> _preflight_agent_isolation_image()          requires org.coder-eval.agent-isolation=uid-gid-v1
  -> _prepare_isolated_sources()                 stages the private mount map
  -> docker run --init --pids-limit 512 --env CODER_EVAL_AGENT_ISOLATION=1
       -> run_task_internal (root)               grants the agent its workspace
         -> coder_eval_claude_agent.sh / coder_eval_drop_privilege.sh
              setpriv --reuid 2000 --regid 2000 --clear-groups + no_new_privs

Algorithm (one grading turn)

  1. The agent works as UID 2000 inside /work/agent.
  2. The turn ends.
  3. Orchestrator._stop_isolated_agent_processes reaps residual UID-2000 processes and verifies the UID is empty.
  4. The grader (root) reads the outputs and runs the criteria.

_rewrite_task_paths makes the task YAML's host paths resolve to their private container locations, so criteria keep working against the relocated mounts.

Diagram

flowchart TB
    host["host runner<br/>DockerRunner"] --> container

    subgraph container["eval container"]
        direction TB
        subgraph grader["grader (root)"]
            gdir["/opt/coder-eval/grader<br/>private mounts: task_dir, input,<br/>output, references, templates"]
            checker["criteria checker"]
        end
        subgraph agentzone["agent (UID 2000)"]
            work["/work/agent"]
            home["/home/agent"]
        end
        grader -- "coder_eval_drop_privilege.sh<br/>setpriv --reuid/--regid 2000" --> agentzone
        agentzone -. "denied (0700 root-only)" .-> gdir
    end
Loading

Impact and compatibility

  • Docker agent isolation is on by default (sandbox.docker.agent_isolation: true).
  • The run image must declare org.coder-eval.agent-isolation=uid-gid-v1. Images derived with FROM coder-eval-agent:<version> inherit it; runtime-kit injection into an unrelated base does not yet provide the required users and setpriv launchers.
  • docker.working_dir, docker.extra_mounts, and the dynamic privileged criteria run_command, uipath_eval, and agent_judge fail closed until a separate minimal-input grader sandbox exists.
  • Migrate troubleshoot fixtures to protected mocks skills#2503 is the dependent migration. Note that the skills nightly experiment currently declares extra_mounts (~/.uipath), so it fails closed under the isolation default until that migration lands.
  • protected_mocks configuration is not part of this PR - it moves to the follow-up PR along with the mock service itself.
  • Native-Linux container validation is still pending because the supplied test VM was unreachable from the implementation environment.

Validation

make verify run on Windows against this branch:

  • ruff format --check and ruff check: pass (361 files)
  • pyright: 0 errors, 1 pre-existing Antigravity warning
  • custom architectural lint (CE001+): 171 passed
  • test suite: 3,930 passed, 102 skipped (POSIX-only tests skip on Windows as expected), coverage 89.05% against the 80% gate
  • 2 remaining failures in tests/test_sandbox.py are pre-existing Windows symlink-privilege (WinError 1314) environment failures in a file this branch does not touch

@dmorosanu

Copy link
Copy Markdown
Contributor Author

Real-container validation: claude and codex arms

Ran this branch on a native-Linux Docker host (Azure VM) against tasks drawn from runs where the deployed harness had read grading material. Both agent backends, same tasks, same probes. Evidence below is from live containers, not code reading.

The launch seams both drop privileges

docker top during live runs, identical across every container:

UID    COMMAND
0      coder-eval        <- harness
2000   claude            <- evaluated agent (claude arm)
2000   codex app-server  <- evaluated agent (codex arm)
2000   codex-code-mode   <- and its child

This matters because the two backends reach the barrier by different routes: claude via cli_path -> coder_eval_claude_agent.sh, codex via launch_args_override -> coder_eval_drop_privilege.sh. Both were unexercised with a live model before this. Direct check: coder_eval_drop_privilege.sh /usr/bin/id -> uid=2000(agent) gid=2000(agent) groups=2000(agent), capability sets all zero, NoNewPrivs: 1, supplementary groups cleared.

Grading material is unreachable

Probed inside each live container as the agent identity (setpriv --reuid=2000 --regid=2000 --clear-groups). Identical result in every container, both arms:

Path Result
/opt/coder-eval/grader (ls) Permission denied
grader/input/task.yaml (staged criteria) Permission denied
grader/input/context.json Permission denied
grader/task_dir/RESOLUTION.md Permission denied
grader/task_dir/check_*.py Permission denied
grader/plugins/plugin-0/tests/tasks (whole skills checkout) Permission denied
grader/templates Permission denied
/work/input/... (the old path) No such file or directory

A find / -xdev as UID 2000 for grading-artifact names returns nothing under /opt/coder-eval. An adversarial probe task also failed on /proc/self/root symlink traversal, the root harness's /proc/<pid>/fd, and /proc/1/environ, and a canary planted in the answer key appeared nowhere in the workspace, transcript, or artifacts.

The two agents behaved very differently, and both results are informative

Claude did go looking, and was stopped. It ran ls /opt/coder-eval/mock/ and find /opt/coder-eval/mock and got Permission denied both times; it could see only the directory names agent-skills grader mock. Every attempt at protected material was attempted-and-denied.

Codex never tried at all. Across 7 tasks and 99 tool calls, zero commands touched any grading-material path. It read /opt/coder-eval/agent-skills/... - the intentional skill projection - and stopped there. The plausible reading is that it had no reason to go hunting: the skills are where it expects them, so nothing prompts a search. That is worth noting because codex is the backend whose earlier runs produced these reads in the first place.

Neither result alone is proof. Claude's denials show the barrier holds under pressure; codex's silence shows the layout removes the motive. The deterministic probes above are what actually establish reachability, independent of what any model chose to do.

What this does not cover

Content staged into the agent's own workspace via mock_path_dirs (m/.store, m/.log, m/.uip.bin, mocks/calls.log) stays readable - it is chowned to the agent by construction, so no UID barrier can hide it. Claude read m/.store on one task and recovered the fixture manifest while still scoring 1.00; codex did not touch it. Closing that is the protected-mock work, not this boundary.

Also: run_command / agent_judge / uipath_eval criteria are rejected under isolation, which currently excludes 591 of 1156 suite tasks (51%). A grader sandbox for run_command is the gating item before this can be turned on broadly.

Fixes pushed while validating

  • 02ca02cd - pin npm's global prefix so the Claude CLI lands at the path the isolation wrapper execs. Without it the image does not build.
  • 62cfccd0 - exempt the isolation-managed /work/agent workspace from the reserved-path assertion. Isolation assigns that path and the next check rejected it, so every isolated run aborted before starting a container.
  • 0502c1b0 - the build-failure fixture needs a concrete agent type now that agent_isolation defaults to true.
  • 7f2b135f - stop the evaluated agent inheriting AWS_BEARER_TOKEN_BEDROCK. The UID barrier cannot hide a process's own environment, and no backend needs to inherit it (Claude sets it explicitly from a resolved Bedrock route; codex uses CODEX_API_KEY). This also prevents an inherited token silently steering a direct-route run onto Bedrock.
  • 7d4a41a7 - isolate the codex env assertions from ambient scrubbed credentials.

Suite on Linux after these: 4214 passed, 8 skipped. Remaining failures are environmental on that host (no python alias, POSIX-only symlink cases) or live tests needing endpoint config, and reproduce with the change reverted.

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @dmorosanu's task in 1m 31s —— View job


Code Review in Progress

Todo List:

  • Read .github/code_review.md for review guidelines
  • Review CLAUDE.md for project conventions
  • Get full diff with git diff origin/main...HEAD
  • Review each changed file with full context
  • Perform cross-file consistency checks
  • Analyze "what's missing" aspects
  • Check design-level decisions
  • Format and post final review

Starting review now...

@MarinRzv
MarinRzv requested review from CarlesUIPath and a lite review from Copilot August 10, 2026 07: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.

Pull request overview

Introduces a hardened Docker execution boundary for evaluated agents by running them under a dedicated unprivileged UID/GID, projecting plugins through manifest-verified bundles, and adding a protected Unix-socket mock service whose fixtures are not readable by the agent.

Changes:

  • Add Linux UID/GID isolation primitives (drop-privilege launchers, workspace ownership transfer, residual process reaping) and enforce compatible images via a Docker label preflight.
  • Add protected mock subsystem (fixture-only RPC server + thin client wrappers) and wire it through DockerRunner/Sandbox + task loading.
  • Add plugin bundle staging with manifest verification to prevent mounting raw plugin repositories into agent-readable paths.

Reviewed changes

Copilot reviewed 33 out of 33 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/test_protected_mock.py Covers protected mock fixture loading, match modes, passthrough caching, and Docker staging behavior.
tests/test_plugin_bundle.py Verifies plugin bundle sanitization, symlink hardening, and DockerRunner path rewriting/mount behavior.
tests/test_docker_runner_mounts.py Updates mount/layout assertions for protected grader roots and agent home mounts.
tests/test_docker_identity_isolation.py Drift guards for UID/GID constants, labels, capability-clearing launchers, and env scrubbing.
tests/test_docker_build_failure.py Adjusts build-failure test to pass isolation compatibility gating.
tests/test_codex_agent.py Stabilizes Codex env assertions under new env-scrubbing behavior.
src/coder_eval/utils.py Adds agent env scrub allow/deny lists and a helper to generate safe env overrides.
src/coder_eval/sandbox.py Generates protected-mock client wrappers and ensures generated mock dir is added to PATH precedence.
src/coder_eval/protected_mock/server.py Implements mockd server with exact/normalized/subset match modes, budgets, and limited passthrough.
src/coder_eval/protected_mock/runtime.py Adds lifecycle management for mockd subprocess startup/teardown with stderr tailing.
src/coder_eval/protected_mock/protocol.py Defines shared protocol constants and size/time limits.
src/coder_eval/protected_mock/client.py Implements thin agent-visible client that logs invocations and enforces protocol limits.
src/coder_eval/protected_mock/init.py Adds package marker for protected mock subsystem.
src/coder_eval/plugin_bundle.py Adds manifest-verified staging of agent-visible plugin projections with symlink validation.
src/coder_eval/orchestrator.py Re-grants workspace ownership post pre-run and terminates isolated agent processes before finalization.
src/coder_eval/orchestration/task_loader.py Resolves protected mock fixture paths relative to task YAML/experiment directories.
src/coder_eval/orchestration/experiment.py Ensures protected mock fixtures are resolved for experiment-driven tasks.
src/coder_eval/models/sandbox.py Adds agent_isolation default-on and the ProtectedMockConfig model + validations.
src/coder_eval/models/container_paths.py Introduces protected grader root layout, agent/mock identities, and reserved container directories.
src/coder_eval/models/init.py Re-exports new container path and identity constants via coder_eval.models.
src/coder_eval/isolation/docker_runner.py Enforces image capability labels, stages sanitized sources/bundles, and updates mounts/argv for protected layout.
src/coder_eval/isolation/agent_identity.py Adds runtime checks, workspace chowning, and UID-based process termination verification.
src/coder_eval/cli/run_task_internal_command.py Validates protected grader root, grants Claude state to agent UID, and runs mockd during in-container orchestration.
src/coder_eval/agents/codex_agent.py Routes Codex app-server through drop-privilege shim and adjusts HOME/CODEX_HOME behavior under isolation.
src/coder_eval/agents/claude_code_agent.py Scrubs harness-only env vars and routes Claude CLI execution via the isolation shim.
src/coder_eval/agents/antigravity_agent.py Stages a localharness wrapper under drop-privilege policy and scrubs env during serialized spawn.
docs/TASK_DEFINITION_GUIDE.md Documents protected_mocks schema, fixture format, match modes, and passthrough constraints.
docs/DOCKER_ISOLATION.md Documents the UID/GID boundary, protected mount layout, compatibility limits, and runtime-kit constraints.
docker/Dockerfile Adds identities/groups, protected directory layout, setpriv dependency, launch scripts, and capability label.
docker/coder_eval_mockd.sh Launches mockd under the mockd identity with cleared caps and no_new_privs.
docker/coder_eval_mock_client Provides agent-visible client executable entrypoint.
docker/coder_eval_drop_privilege.sh Drops evaluated agent processes to the agent identity with cleared caps/no_new_privs and optional RPC group.
docker/coder_eval_claude_agent.sh Wraps Claude CLI execution through the generic drop-privilege launcher.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +865 to +876
replacements = sorted(self._host_to_private_paths.items(), key=lambda item: len(item[0]), reverse=True)

def rewrite(value: object) -> object:
if isinstance(value, str):
for source, target in replacements:
value = value.replace(source, target)
return value
if isinstance(value, list):
return [rewrite(item) for item in value]
if isinstance(value, dict):
return {key: rewrite(item) for key, item in value.items()}
return value
Comment thread src/coder_eval/sandbox.py Outdated
Comment on lines +564 to +590
def _generate_protected_mock_clients(self) -> None:
"""Generate data-free wrappers for fixture-backed mockd tools."""

if not self.config.protected_mocks:
return
assert self.sandbox_dir is not None, "Sandbox directory not initialized"

from coder_eval.protected_mock.protocol import CLIENT_EXECUTABLE

client_dir = self._resolve_within_sandbox(RECORD_CLI_DIR, field="protected mock client directory")
client_dir.mkdir(parents=True, exist_ok=True)
log_path = self.sandbox_dir / RECORD_CLI_LOG
log_path.touch(exist_ok=True)

for spec in self.config.protected_mocks:
wrapper = client_dir / spec.tool
if wrapper.exists():
raise RuntimeError(
f"protected mock client would overwrite {wrapper}; remove the colliding record_cli or mock"
)
wrapper.write_text(
"#!/bin/sh\n"
+ f"CODER_EVAL_MOCK_CALL_LOG={shlex.quote(str(log_path))} "
+ f'exec {shlex.quote(CLIENT_EXECUTABLE)} {shlex.quote(spec.tool)} "$@"\n',
encoding="utf-8",
newline="\n",
)
Comment on lines 1448 to +1455
# Mount the original task dir at the SAME host path so the
# in-container Orchestrator can set TASK_DIR (used by run_command
# criteria via `$TASK_DIR/foo.json`) to a path that resolves
# identically inside and outside the container.
host_task_dir: Path | None = None
if self.rt.task_file:
host_task_dir = self.rt.task_file.parent.resolve()
argv += ["-v", f"{host_task_dir}:{host_task_dir}:ro"]
argv += ["-v", f"{host_task_dir}:{CONTAINER_TASK_DIR}:ro"]
@CarlesUIPath

Copy link
Copy Markdown
Contributor

Thanks for the PR. Moving the protected-mock service and plugin-bundle projection to follow-ups makes the UID/GID core much easier to review, and the fail-closed discipline (refuse rather than silently run unprotected) is the right instinct.

Before we push this, I'd like to sync, because this PR and #88 close the same leak and I want to make sure we don't lock ourselves out of the harness×model leaderboard.

You already document most of the compatibility limits in the body (criteria / extra_mounts / working_dir fail-closed, the skills#2503 migration, Linux validation pending). My one structural concern isn't in the body, but the following:

Isolation is gated on a closed, per-SDK allowlist, which conflicts with the open Agent SPI. In docker_runner.py:

supported_agents = {AgentKind.CLAUDE_CODE.value, AgentKind.CODEX.value, AgentKind.ANTIGRAVITY.value, AgentKind.NONE.value}
if agent_type not in supported_agents:
    raise DockerRunError("… no verified UID-drop launch seam for agent type …")

CLAUDE.md:230–237 states the opposite:

"there is no closed AgentKind enum or Orchestrator._create_agent dispatch to edit. agent.type is an open string validated against AgentRegistry… The coder_eval_uipath Delegate SDK agent is the first real out-of-tree worked example of this SPI."

With agent_isolation defaulting to true, that means:

  • Delegate SDK (coder_eval_uipath) — our flagship out-of-tree agent, and the one CLAUDE.md cites as the SPI's worked example — fails closed today; it can only run docker with agent_isolation: false (no protection at all).
  • More generally, isolation doesn't come through the SPI: each harness needs bespoke per-SDK glue (Claude cli_path, Codex argv override, Antigravity localharness PATH-shim), so any harness we add later has to hand-write its own seam — and not all have a clean one. (For example, if we add OpenHands for the leaderboard down the line, its runtime sandbox would conflict with the drop the same way Codex's Landlock does — which this PR already works around by forcing Codex full-access.)

Why I'd like to bring in #88: grade-outside closes the same leak by absence — graders/criteria/reference never enter the container; grading runs host-side over the copied-out workspace. That's harness- and plugin-agnostic — the Delegate SDK works today with zero per-SDK wiring, and any harness we add later inherits leak-closure for free — and it keeps run_command/uipath_eval/agent_judge + extra_mounts working — i.e. it removes the exact fail-closed cases you list (and would unblock the skills nightly without waiting on skills#2503).

Could we grab 30 min to align the two before either merges? Happy to walk through the grade-outside side.

@dmorosanu
dmorosanu force-pushed the codex/uid-gid-agent-isolation branch from 1dd19ec to 7a2c59a Compare August 10, 2026 14:16
@dmorosanu dmorosanu changed the title Add UID/GID isolation for evaluated agents feat(isolation): isolate evaluated agents by UID and GID Aug 10, 2026
@dmorosanu dmorosanu changed the title feat(isolation): isolate evaluated agents by UID and GID feat(isolation): add UID and GID isolation for evaluated agents Aug 10, 2026
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.

3 participants