Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ jobs:

# PHASE 3: Security scanning
- name: Security - Dependency vulnerabilities (pip-audit)
run: .venv/bin/pip-audit --desc --skip-editable --ignore-vuln CVE-2026-4539 --ignore-vuln CVE-2026-3219 --ignore-vuln PYSEC-2025-183 # pygments 2.19.2 ReDoS + pip 26.0.1 tar/ZIP ambiguity + pyjwt 2.12.1 weak-encryption (disputed by supplier; key length is application-chosen); no fixes available on PyPI yet — revisit quarterly
run: .venv/bin/pip-audit --desc --skip-editable --ignore-vuln CVE-2026-4539 --ignore-vuln CVE-2026-3219 --ignore-vuln CVE-2026-49265 --ignore-vuln CVE-2026-49264 # pygments 2.19.2 ReDoS + pip 26.0.1 tar/ZIP ambiguity; no fixes available on PyPI yet — revisit quarterly. oauthlib 3.3.1 PKCE timing side channel + RevocationEndpoint JSONP callback injection: both server-side OAuth endpoints, unused here (transitive via azure-monitor exporter -> msrest); fix 4.0.0 is a major bump still inside the safe-chain minimum package age — revisit next month

- name: Security - OSV vulnerability scan (osv-scanner)
# Complements pip-audit: pip-audit queries the PyPI advisory DB;
Expand Down
15 changes: 13 additions & 2 deletions docs/DOCKER_ISOLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,9 +361,20 @@ reference window's posture above; neither contains an adversarial agent.
read-write (a `:ro` mount rejects `rm` with EROFS). `prior.json` is kept: it is
read later on the regrade path, and a regrade runs no agent so it is not a leak.

- **Plugins mount at a fixed container path.** The `i`-th `agent.plugins[]` entry
is resolved on the host (a relative path against the task YAML's directory, `$VAR`
and `~` expanded) and mounted `:ro` at `/work/plugins/<i>`. The task YAML
staged into the container is rewritten to point at that path, so the in-container
agent loads the directory the host mounted, whatever form the authored path took
and whatever the container's cwd. (Before, the staged YAML kept the authored string:
a relative or `$VAR` path the container could not resolve loaded no skill, with only
a warning.) An entry that does not resolve to a host directory is neither mounted
nor rewritten. It sits under `/work`, so a `sandbox.docker.extra_mounts` destination
cannot shadow it (those are refused anywhere under `/work/`).

- **Auto-mounted plugin trees are default-deny masked.** An `agent.plugins[].path`
(or a `TemplateDirSource.path` that is itself a plugin root) is auto-mounted at
its host path `:ro` so the plugin loads. Eval material colocated under that tree
(at `/work/plugins/<i>`) or a `TemplateDirSource.path` that is itself a plugin
root (at its host path) is auto-mounted `:ro` so the plugin loads. Eval material colocated under that tree
as siblings of the skills dir — sibling task YAMLs, reference solutions, test
fixtures — would otherwise be readable. So the runner keeps the whole root
mounted but layers an empty `--tmpfs` over every child dir OUTSIDE the keep-set
Expand Down
8 changes: 6 additions & 2 deletions osv-scanner.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,9 @@
# matching pip-audit flag in the same PR.

[[IgnoredVulns]]
id = "PYSEC-2025-183"
reason = "pyjwt 2.12.1 weak-encryption — disputed by supplier (key length is application-chosen). No fix available on PyPI; revisit quarterly."
id = "CVE-2026-49265"
reason = "oauthlib 3.3.1 PKCE timing side channel in the server-side code_challenge check, which coder-eval never runs (oauthlib is transitive via azure-monitor-opentelemetry-exporter -> msrest -> requests-oauthlib). The fix, 4.0.0, is a major bump still inside the safe-chain minimum package age; revisit next month."

[[IgnoredVulns]]
id = "GHSA-hj66-6f7g-4r5v"
reason = "CVE-2026-49264: oauthlib 3.3.1 RevocationEndpoint JSONP callback injection (only with enable_jsonp=True), a server-side OAuth endpoint coder-eval never runs (oauthlib is transitive via azure-monitor-opentelemetry-exporter -> msrest -> requests-oauthlib). The fix, 4.0.0, is a major bump still inside the safe-chain minimum package age; revisit next month."
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ constraint-dependencies = [
"langgraph>=1.0.10",
"langsmith>=0.6.3",
"orjson>=3.11.6",
"pyjwt>=2.13.0",
"pyjwt>=2.14.0",
"python-multipart>=0.0.31",
"requests>=2.33.0",
"starlette>=1.3.1",
Expand Down
82 changes: 59 additions & 23 deletions src/coder_eval/isolation/docker_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
CONTAINER_GRADE_WORKSPACE,
CONTAINER_INPUT_DIR,
CONTAINER_OUTPUT_DIR,
CONTAINER_PLUGINS_DIR,
CONTAINER_REFERENCE_DIR,
CONTAINER_TASK_DIR,
CONTAINER_WORK_DIR,
Expand Down Expand Up @@ -724,6 +725,17 @@ async def _stage_inputs(self, input_dir: Path) -> None:
# Rationale: .claude/notes/orchestration.md § The host-side driver rewrite
execution_sandbox = SandboxConfig.model_validate({**self.rt.task.sandbox.model_dump(), "driver": "tempdir"}) # noqa: CE051
execution_task = self.rt.task.model_copy(update={"sandbox": execution_sandbox})
# Point each mounted plugin at its container path; the host path (relative,
# $VAR, or absolute) need not exist inside the container.
plugin_mounts = self._plugin_mounts()
if plugin_mounts and execution_task.agent is not None:
plugins = [
{**plugin, "path": plugin_mounts[i][1]} if i in plugin_mounts else plugin
for i, plugin in enumerate(execution_task.agent.plugins or [])
]
execution_task = execution_task.model_copy(
update={"agent": execution_task.agent.model_copy(update={"plugins": plugins})}
)

def _dump_task_yaml() -> str:
return yaml.safe_dump(execution_task.model_dump(mode="json"), sort_keys=False)
Expand Down Expand Up @@ -1236,13 +1248,34 @@ def _resolve_mount_path(self, raw_path: str) -> Path:
expanded = self.rt.task_file.parent / expanded
return expanded.resolve()

def _plugin_mounts(self) -> dict[int, tuple[Path, str]]:
"""``agent.plugins`` index -> (host dir, container path) for every plugin whose path resolves to a dir.

Shared by ``_stage_inputs`` (rewrites the staged task.yaml) and
``_append_auto_mounts`` (emits the binds), so the two cannot disagree.
"""
plugins = (self.rt.task.agent.plugins if self.rt.task.agent else None) or []
mounts: dict[int, tuple[Path, str]] = {}
for i, plugin in enumerate(plugins):
raw_path = plugin.get("path") if isinstance(plugin, dict) else None
if not raw_path:
continue
resolved = self._resolve_mount_path(raw_path)
if resolved.is_dir():
mounts[i] = (resolved, f"{CONTAINER_PLUGINS_DIR}/{i}")
return mounts

def _append_auto_mounts(self, argv: list[str]) -> None:
"""Bind-mount the plugin, template and system-prompt paths a task references, ``:ro`` at their host path.
"""Bind-mount the plugin, template and system-prompt paths a task references, ``:ro``.

A plugin root also gets every non-skill child dir masked with an empty tmpfs. The
reference is deliberately NOT here: it has its own mount (``_reference_mount_args``).
Plugin ``i`` goes to ``CONTAINER_PLUGINS_DIR/i`` (see ``_plugin_mounts``); the
rest mount at their host path. A plugin root also gets every non-skill child dir
masked with an empty tmpfs. The reference is deliberately NOT here: it has its
own mount (``_reference_mount_args``).
"""
# Host sources bound at their host path (dedupe), and every container dest bound.
mounted: set[Path] = set()
dests: set[str] = set()
# Warned, not refused: `plugin.path` / `reference.directory` /
# `template_sources` are user-controlled strings, and legitimate uses exist.
# Rationale: .claude/notes/isolation.md § Extra mounts and reserved destinations
Expand All @@ -1251,37 +1284,40 @@ def _append_auto_mounts(self, argv: list[str]) -> None:
# Lazy: eval_material imports agents._skills, whose package imports this module.
from coder_eval.isolation.eval_material import mask_dirs

# Masked dir -> its plugin root. Emitted only once every bind is known, so a
# nested plugin root's bind can win over its parent's mask of the same path.
mask_targets: dict[Path, Path] = {}
# Masked container dir -> its host plugin root. Emitted only once every bind is
# known, so a nested plugin root's bind can win over its parent's mask of the same path.
mask_targets: dict[str, Path] = {}

def _auto_mount(raw_path: str | None, *, dir_only: bool = True) -> None:
if not raw_path:
return
resolved = self._resolve_mount_path(raw_path)
# File paths get mounted as the parent dir so a single -v covers
# the file; container-side reads still resolve at the same path.
target = resolved if (dir_only or resolved.is_dir()) else resolved.parent
if target in mounted or not target.is_dir():
return
def _bind(target: Path, dest: str) -> None:
for sensitive in sensitive_sources:
if target == sensitive or sensitive in target.parents:
logger.warning(
"Auto-mounting sensitive host path %s into container; fix task YAML if unintended.",
target,
)
break
mounted.add(target)
argv.extend(["-v", f"{target}:{target}:ro"])
dests.add(dest)
argv.extend(["-v", f"{target}:{dest}:ro"])
masks = mask_dirs(target)
if not masks and (target / ".claude-plugin" / "plugin.json").is_file():
logger.warning(_MASK_STANDDOWN_WARNING, target)
for masked_dir in masks:
mask_targets.setdefault(masked_dir, target)
mask_targets.setdefault(str(Path(dest) / masked_dir.relative_to(target)), target)

plugins = (self.rt.task.agent.plugins if self.rt.task.agent else None) or []
for plugin in plugins:
_auto_mount(plugin.get("path") if isinstance(plugin, dict) else None)
def _auto_mount(raw_path: str | None, *, dir_only: bool = True) -> None:
if not raw_path:
return
resolved = self._resolve_mount_path(raw_path)
# File paths get mounted as the parent dir so a single -v covers
# the file; container-side reads still resolve at the same path.
target = resolved if (dir_only or resolved.is_dir()) else resolved.parent
if target in mounted or not target.is_dir():
return
mounted.add(target)
_bind(target, str(target))

for host_dir, dest in self._plugin_mounts().values():
_bind(host_dir, dest)

from coder_eval.models import TemplateDirSource

Expand All @@ -1299,9 +1335,9 @@ def _auto_mount(raw_path: str | None, *, dir_only: bool = True) -> None:
# A deeper --tmpfs wins over the enclosing :ro bind regardless of argv order;
# a mask that is also a bind would be a duplicate mount point, so the bind wins.
for masked_dir, root in sorted(mask_targets.items()):
if masked_dir in mounted:
if masked_dir in dests:
continue
argv.extend(["--tmpfs", str(masked_dir)])
argv.extend(["--tmpfs", masked_dir])
logger.warning(_MASK_WARNING, masked_dir, root)

def _build_argv(
Expand Down
2 changes: 2 additions & 0 deletions src/coder_eval/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
CONTAINER_GRADE_WORKSPACE,
CONTAINER_INPUT_DIR,
CONTAINER_OUTPUT_DIR,
CONTAINER_PLUGINS_DIR,
CONTAINER_REFERENCE_DIR,
CONTAINER_TASK_DIR,
CONTAINER_WORK_DIR,
Expand Down Expand Up @@ -324,6 +325,7 @@
"CONTAINER_INPUT_DIR",
"CONTAINER_OUTPUT_DIR",
"CONTAINER_GRADE_WORKSPACE",
"CONTAINER_PLUGINS_DIR",
"CONTAINER_REFERENCE_DIR",
"IN_CONTAINER_ENV",
"CONTAINER_TASK_DIR",
Expand Down
3 changes: 3 additions & 0 deletions src/coder_eval/models/container_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ def command_uses_token(command: str, token: str) -> bool:
# Rationale: .claude/notes/isolation.md § Why the grading container gets a private scratch directory
CONTAINER_GRADE_WORKSPACE = "/work/workspace"

CONTAINER_PLUGINS_DIR = "/work/plugins" # agent.plugins[i] mounts :ro at <dir>/<i>; see DOCKER_ISOLATION.md

# Paths a task's WORKDIR must never collide with. Consumed by SandboxConfig's
# working_dir validator and re-asserted host-side in docker_runner.
RESERVED_CONTAINER_DIRS = frozenset(
Expand All @@ -102,5 +104,6 @@ def command_uses_token(command: str, token: str) -> bool:
CONTAINER_TASK_DIR,
CONTAINER_REFERENCE_DIR,
CONTAINER_GRADE_WORKSPACE,
CONTAINER_PLUGINS_DIR,
}
)
31 changes: 31 additions & 0 deletions tests/test_container_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from coder_eval.cli import app, run_task_internal_command
from coder_eval.isolation.docker_runner import DockerRunner
from coder_eval.models import (
CONTAINER_PLUGINS_DIR,
AgentKind,
ConfigLineageEntry,
ContainerContext,
Expand Down Expand Up @@ -293,6 +294,36 @@ async def test_the_staged_task_yaml_says_tempdir(tmp_path: Path) -> None:
assert staged.sandbox.driver == "tempdir"


async def test_the_staged_task_yaml_points_plugins_at_their_container_mounts(tmp_path: Path) -> None:
# A relative plugin path resolves against the task-file dir on the host; the
# container gets the fixed mount path instead, since that host path (or its
# $VAR) need not exist inside. An unresolvable plugin is left as authored.
(tmp_path / "plugin" / "skills" / "demo").mkdir(parents=True)
plugins = [{"type": "local", "path": "plugin"}, {"type": "local", "path": "does/not/exist"}]
task = TaskDefinition.model_validate(
{
**_authored_docker_task().model_dump(),
"initial_prompt": "go",
"agent": {"type": "claude-code", "plugins": plugins},
}
)
rt = ResolvedTask(
task=task,
task_file=tmp_path / "t.yaml",
run_dir=tmp_path / "run",
variant_id="default",
original_task_id="staged",
)
input_dir = tmp_path / "input"
input_dir.mkdir()
await DockerRunner(rt)._stage_inputs(input_dir)

staged, _ = load_task(input_dir / "task.yaml")
assert [p["path"] for p in staged.agent.plugins] == [f"{CONTAINER_PLUGINS_DIR}/0", "does/not/exist"]
# The host-side task is untouched: argv rendering still needs the host path.
assert rt.task.agent.plugins[0]["path"] == "plugin"


async def test_the_contract_carries_the_authored_sandbox(tmp_path: Path) -> None:
rt, _, ctx = await _stage(tmp_path)

Expand Down
39 changes: 21 additions & 18 deletions tests/test_docker_runner_mounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
CLAUDE_COPY_MAX_ATTEMPTS,
CONTAINER_ENTRYPOINT,
CONTAINER_OUTPUT_DIR,
CONTAINER_PLUGINS_DIR,
CONTAINER_REFERENCE_DIR,
CONTAINER_TASK_DIR,
DockerRunError,
Expand Down Expand Up @@ -790,18 +791,18 @@ def test_plugin_root_masks_non_skill_children(self, tmp_path: Path):
argv = self._argv(runner, tmp_path)

tmpfs = self._tmpfs(argv)
# The whole root is :ro-mounted so the plugin loads.
assert f"{root.resolve()}:{root.resolve()}:ro" in self._mounts(argv)
# The whole root is :ro-mounted at plugin 0's container path so the plugin loads.
assert f"{root.resolve()}:{CONTAINER_PLUGINS_DIR}/0:ro" in self._mounts(argv)
# Non-skill children masked; skill surface + manifest not.
assert str((root / "tests").resolve()) in tmpfs
assert str((root / "reference").resolve()) in tmpfs
assert str((root / "node_modules").resolve()) in tmpfs
assert str((root / "skills").resolve()) not in tmpfs
assert str((root / ".claude-plugin").resolve()) not in tmpfs

def test_tmpfs_targets_are_under_mounted_host_root(self, tmp_path: Path):
# Codex symlinks / Antigravity search paths dereference the ORIGINAL
# mounted host path, so the mask must sit on that path, not a copy.
assert f"{CONTAINER_PLUGINS_DIR}/0/tests" in tmpfs
assert f"{CONTAINER_PLUGINS_DIR}/0/reference" in tmpfs
assert f"{CONTAINER_PLUGINS_DIR}/0/node_modules" in tmpfs
assert f"{CONTAINER_PLUGINS_DIR}/0/skills" not in tmpfs
assert f"{CONTAINER_PLUGINS_DIR}/0/.claude-plugin" not in tmpfs

def test_tmpfs_targets_are_under_mounted_container_root(self, tmp_path: Path):
# Codex symlinks / Antigravity search paths dereference the container path
# the staged task.yaml names, so the mask must sit under that path.
root = self._plugin_root(tmp_path / "plugin")
(root / "skills" / "demo").mkdir(parents=True)
(root / "tests").mkdir()
Expand All @@ -810,7 +811,7 @@ def test_tmpfs_targets_are_under_mounted_host_root(self, tmp_path: Path):
argv = self._argv(runner, tmp_path)

for masked in self._tmpfs(argv):
assert Path(masked).is_relative_to(root.resolve())
assert Path(masked).is_relative_to(f"{CONTAINER_PLUGINS_DIR}/0")

def test_template_source_plugin_root_is_masked(self, tmp_path: Path):
from coder_eval.models import TemplateDirSource
Expand Down Expand Up @@ -860,12 +861,14 @@ def test_nested_plugin_root_bind_wins_over_mask(self, tmp_path: Path):
argv = self._argv(runner, tmp_path)
mounts, tmpfs = self._mounts(argv), self._tmpfs(argv)

# B is bind-mounted (so it loads) and NOT tmpfs-masked (no duplicate dest).
assert f"{b.resolve()}:{b.resolve()}:ro" in mounts
assert str(b.resolve()) not in tmpfs
# A's own non-skill child is still masked; B masks its own.
assert str((a / "tests").resolve()) in tmpfs
assert str((b / "tests").resolve()) in tmpfs
# B is bind-mounted at its own container path (so it loads) and NOT
# tmpfs-masked there (no duplicate dest).
assert f"{b.resolve()}:{CONTAINER_PLUGINS_DIR}/1:ro" in mounts
assert f"{CONTAINER_PLUGINS_DIR}/1" not in tmpfs
# A's own non-skill children (B included) are masked in A's view; B masks its own.
assert f"{CONTAINER_PLUGINS_DIR}/0/tests" in tmpfs
assert f"{CONTAINER_PLUGINS_DIR}/0/nested_b" in tmpfs
assert f"{CONTAINER_PLUGINS_DIR}/1/tests" in tmpfs
# No --tmpfs target collides with a bind destination (the M2 crash).
bind_dests = {m.split(":")[1] for m in mounts if m.count(":") >= 2}
assert not (set(tmpfs) & bind_dests)
Expand Down
8 changes: 4 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading