Skip to content

refactor(isolation): run registered agents in an unprivileged worker - #97

Open
dmorosanu wants to merge 1 commit into
codex/uid-gid-agent-isolationfrom
codex/generic-agent-worker
Open

refactor(isolation): run registered agents in an unprivileged worker#97
dmorosanu wants to merge 1 commit into
codex/uid-gid-agent-isolationfrom
codex/generic-agent-worker

Conversation

@dmorosanu

@dmorosanu dmorosanu commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Stack

This is PR 2, intentionally stacked on #87. Its base is codex/uid-gid-agent-isolation, not main.

Please review this PR as the PR 2 delta, then merge it into #87's branch. Once this PR is merged, PR 3 will be created from the updated #87 stack.

Problem

#87 enforces the UID boundary through three SDK-specific launch seams and an allowlist of Claude, Codex, and Antigravity. That cannot cover an arbitrary AgentRegistry plugin: the harness has no generic way to find and wrap whatever subprocess a plugin may create.

What changed

  • Add one stateful, unprivileged worker that loads the same AgentRegistry, constructs the selected agent, and owns its full start / communicate / stop lifecycle.
  • Bridge stream events, typed results, pending turns, SDK metadata, cooperative stop, and failures over a bounded nonce-framed protocol.
  • Verify at startup that all real/effective/saved/filesystem UIDs and GIDs are 2000, supplementary groups are empty, all Linux capability sets are zero, and NoNewPrivs is set.
  • Remove the hard-coded agent-kind allowlist and the three SDK-specific privilege-drop paths.
  • Keep the root orchestrator responsible for trusted workspace preparation, grading, and final teardown of every UID-2000 process.
  • Preserve the inherited credentials an agent needs while removing harness-only paths and evaluator-only Bedrock credentials from the worker environment.

This covers built-ins and third-party registry plugins installed in the image without adding per-agent isolation code.

How it works now

  1. The host starts the evaluation container. The root-side DockerRunner verifies that the image declares the UID/GID isolation capability and, for now, rejects dynamic grader types that are not yet safe.
  2. The trusted root orchestrator stages the generated workspace and grants only that tree to agent:agent (UID/GID 2000). Hidden task data, grader inputs, and result paths remain root-only.
  3. The orchestrator resolves registry metadata needed by the harness, but it does not instantiate the selected agent in the root process. It sends the registry key, validated config, route, and JSON-safe constructor arguments to an IsolatedAgentProxy.
  4. The proxy starts one Python worker through the common setpriv launcher. The worker checks its kernel identity before accepting work: all UID/GID slots must be 2000, supplementary groups must be empty, all capability sets must be zero, and NoNewPrivs must be 1.
  5. Inside that unprivileged process, the worker loads the normal plugin entry points and AgentRegistry, constructs whichever registered agent was requested, and keeps that same instance alive for the complete evaluation lifecycle.
  6. start, communicate, discard_pending_turn, and stop cross the process boundary through a nonce-framed JSON protocol. Stream events flow back immediately; turn records, state, pending partial turns, SDK options, and environment metadata are synchronized in responses.
  7. Every SDK, CLI, shell, and candidate-code process created by the agent inherits the worker's UID/GID, empty capabilities, and no-new-privileges restriction. No agent-specific wrapper is needed.
  8. Before trusted finalization, the harness stops the worker, kills its process group if necessary, scans for every remaining UID-2000 process, and fails closed if any cannot be removed.
flowchart LR
    Host["Host / DockerRunner"] --> Root["Root orchestrator<br/>(trusted)"]
    Root -->|"stage + chown generated tree"| Workspace["/work/agent<br/>(agent-writable)"]
    Root -->|"spawn via setpriv"| Proxy["IsolatedAgentProxy"]

    subgraph AgentDomain["Unprivileged security domain — UID/GID 2000"]
        Worker["Stateful AgentWorker"]
        Registry["Plugin loading + AgentRegistry"]
        Agent["Selected Agent implementation"]
        Children["SDK / CLI / shell / candidate-code descendants"]

        Worker --> Registry
        Registry --> Agent
        Agent --> Children
        Agent <--> Workspace
    end

    Proxy <-->|"nonce-framed RPC<br/>events, results, state"| Worker
    Protected["/opt/coder-eval/grader<br/>root-only task + grader data"] -. "filesystem access denied" .-> AgentDomain
    Root -->|"trusted grading<br/>(static criteria only in this PR)"| Protected
    Root -->|"stop process group + scan UID 2000"| Cleanup["Fail-closed teardown"]
Loading

The important change is the boundary location: isolation now wraps the generic Agent interface and registry construction, rather than trying to recognize and wrap a subprocess inside each built-in SDK.

Out of scope

Dynamic graders (agent_judge, run_command, and uipath_eval) remain rejected while isolation is enabled. Moving those graders out of the agent-written directory and into their own trusted execution boundary is PR 3.

Validation

  • ruff check src tests: passed
  • pyright: 0 errors; 1 pre-existing Antigravity warning
  • Focused worker/isolation/orchestrator suite: 82 passed; the Linux-only stop-directory assertion is skipped on Windows
  • Custom lint suite: 24 passed
  • Broader lifecycle suite: 320 passed
  • Native WSL smoke test through the real setpriv launcher: complete NoOp lifecycle succeeded with UID/GID 2000 in all four identity slots, no supplementary groups, all capability sets zero, and NoNewPrivs=1
  • Full Windows suite: 4092 passed, 113 skipped, 17 failures. The failures were environment/pre-existing only: user-local untracked-doc navigation checks, live Claude settings/model checks, Windows symlink privilege, and tests that require Unix shell commands; no generic-worker regression surfaced.

@dmorosanu
dmorosanu force-pushed the codex/uid-gid-agent-isolation branch from 1dd19ec to 7a2c59a Compare August 10, 2026 14:16
@dmorosanu
dmorosanu force-pushed the codex/generic-agent-worker branch from 1ce9fb1 to ce92037 Compare August 10, 2026 14:17
@dmorosanu dmorosanu changed the title Run registered agents in an unprivileged worker refactor(isolation): run registered agents in an unprivileged worker Aug 10, 2026
@dmorosanu
dmorosanu marked this pull request as ready for review August 10, 2026 14:17
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

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


Code Review in Progress

Todo List:

  • Read .github/code_review.md for review guidelines
  • Read CLAUDE.md for project conventions
  • Examine the PR diff against origin/codex/uid-gid-agent-isolation
  • Read key changed files in full context
  • Analyze the isolation architecture and security boundaries
  • Check for cross-file consistency
  • Perform "what's missing" analysis
  • Provide comprehensive review feedback

Starting review now...

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.

1 participant