Skip to content

Add protected mock service and plugin bundle projection - #95

Open
dmorosanu wants to merge 14 commits into
codex/uid-gid-agent-isolationfrom
feat/protected-mockd
Open

Add protected mock service and plugin bundle projection#95
dmorosanu wants to merge 14 commits into
codex/uid-gid-agent-isolationfrom
feat/protected-mockd

Conversation

@dmorosanu

Copy link
Copy Markdown
Contributor

This PR carries the protected mock service and the plugin bundle projection, split out of #87 so that PR stays scoped to the UID/GID architecture; it stacks on codex/uid-gid-agent-isolation because both features build on the agent/grader identity boundary (mockd authorizes callers by the agent UID and needs the image users and groups).

Fixture matching behavior is unchanged from the previously merged sub-PR #93.

Protected mock service (mockd)

How it works. Fixtures live in /opt/coder-eval/mock/fixtures (mode 0500, owned by the mockd user), so they are not readable by the agent identity. The agent instead gets thin client wrappers named as the mocked CLI (for example uip) on PATH, generated by Sandbox._generate_protected_mock_clients. The wrappers talk to mockd over a Unix domain socket, so fixture bytes never enter the agent-readable filesystem.

How it starts.

  1. docker_runner stages fixtures and writes mock-config.json into the private grader tree.
  2. In-container, run_task_internal wraps the turn in running_mock_server(...).
  3. That launches coder_eval_mockd.sh, which execs setpriv --reuid=mockd --regid=mockd --groups=uip-rpc (UID/GID 2100) with all capabilities dropped and --no-new-privs.
  4. The server loads and validates the fixtures, binds /run/coder-eval/uip.sock, chowns it to the uip-rpc group and chmods it 0660.

The agent user is a member of uip-rpc only when protected_mocks is configured (CODER_EVAL_AGENT_ALLOW_RPC). Startup failures abort loudly, carrying the child's exit code and a tail of its stderr, rather than binding a socket over a partially loaded fixture set.

Algorithm (one request).

  1. The client sends argv (size-capped at MAX_REQUEST_BYTES; oversized requests are refused before any connection is attempted).
  2. The server authorizes the peer UID via SO_PEERCRED - root or the agent UID only.
  3. Lookup: exact command map first, then normalized matching (flag form and order ignored, never subset or substring), then the explicit opt-in match_mode: subset rules in fixture-file order (unchanged from feat(protected-mock): add subset fixture matching and harden mockd startup #93).
  4. On a hit, replay the fixture stdout and exit code.
  5. On a miss whose prefix is on the passthrough allowlist (for example [docsai, ask]), run the real command with no shell, bounded execution time and output size, and an in-memory response cache.
  6. Otherwise reject.

Every call is appended to calls.jsonl.

flowchart LR
A["agent<br/>UID 2000"] --> W["CLI wrapper<br/>(uip)"]
W --> S["unix socket<br/>/run/coder-eval/uip.sock<br/>0660, group uip-rpc"]
S --> M["mockd<br/>UID 2100"]
M --> F["fixtures<br/>0500, mockd-only"]
M -.->|"typed passthrough<br/>(allowlisted prefixes)"| R["real CLI"]
A -->|denied| F
Loading

Plugin bundle projection

How it works. Local plugins are projected into sanitized read-only copies at /opt/coder-eval/agent-skills/plugin-N instead of raw host mounts. agent.plugins[].path in the task payload is rewritten to point at the projection.

How it starts. During _prepare_isolated_sources, each local plugin source is staged via stage_bundle before the container starts. The bundle directory is mounted read-only, and the manifest is kept beside the bundle rather than inside it.

Algorithm.

  • build_manifest walks only the allowed top-level subtrees (skills, commands, agents, .claude-plugin, hooks), sha256-hashes every file, and validates that symlinks resolve inside the allowed subtrees (absolute links are rejected, as are broken or looping ones).
  • It fails if hidden grading material patterns (resolution.md, check_*.py) appear inside an agent-visible subtree.
  • stage_bundle requires an empty destination, copies exactly the manifest-listed files, and re-verifies the digest after copying.
flowchart LR
H["host plugin checkout"] --> B["build_manifest<br/>allowlist + hashes<br/>+ symlink validation"]
B --> S["stage_bundle<br/>copy + digest re-verify"]
S --> P["/opt/coder-eval/agent-skills/plugin-N<br/>read-only mount"]
P --> A["agent"]
H -->|"no raw mount"| A
Loading

Validation

Ran the full make verify sequence locally on Windows (invoking the underlying commands directly):

StepResult
ruff format --check src/ tests/ .github/scripts/369 files already formatted
ruff check src/ tests/ .github/scripts/All checks passed
pyright0 errors, 1 warning (pre-existing, antigravity_agent.py:394)
pytest tests/test_custom_lint.py (CE001+)170 passed, 1 failed - environmental, see below
pytest tests/ -n auto -m "not live and not lint" --cov-fail-under=803961 passed, 2 failed, 105 skipped; coverage 90.38% (gate 80%)

Known-acceptable results:

  • 2 failures in tests/test_sandbox.py (test_build_run_command_env_preserves_external_plugin_tools_dir, test_capture_to_copies_and_tolerates_dangling_symlink) fail with OSError: [WinError 1314] A required privilege is not held by the client. These are pre-existing environmental failures - Windows symlink creation needs admin or Developer Mode - and are unrelated to this change.
  • 105 skips are POSIX-only tests that do not apply on Windows (docker driver, POSIX symlinks, shebang/exec-bit paths, mode bits).
  • 1 custom-lint failure, TestCE028DocIndexParity::test_every_published_doc_is_in_the_nav, is caused entirely by five untracked local scratch files in my working tree that are not part of this branch. The only docs/ pages this commit touches - DOCKER_ISOLATION.md and TASK_DEFINITION_GUIDE.md - are both already in the mkdocs nav, so CE028 is clean for this change and will pass in CI.

The diff of this branch against the pre-split base (dcc73456) is exactly the added test coverage in tests/test_protected_mock.py and nothing else, confirming the split restored the stripped content byte for byte.

@github-actions

github-actionsBot commented Aug 10, 2026

Copy link
Copy Markdown

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


Code Review in Progress

Review Checklist:

  • Read .github/code_review.md for review guidelines
  • Read CLAUDE.md for project conventions
  • Get full diff from correct base branch (origin/codex/uid-gid-agent-isolation)
  • Review each changed file in full context
  • Perform cross-file consistency checks
  • Analyze "what's missing"
  • Provide design-level scrutiny
  • Format final review per code_review.md specifications

Starting comprehensive review now...

@dmorosanu
dmorosanuforce-pushed the codex/uid-gid-agent-isolation branch from 1dd19ec to 7a2c59aCompareAugust 10, 2026 14:16
Sign up for freeto 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

@dmorosanu