Skip to content

Commit ddf63e9

Browse files
CarlesUIPathclaudebai-uipath
authored
fix(docker): close two driver:docker channels that leaked grading material to the agent (#179)
* feat(sandbox): 1/3 — delete staged task.yaml in-container after load (anti-cheat) Fix A: make /work/input a writable mount (drop :ro; grant input dir writable) and delete the staged task.yaml immediately after load_task in the in-container entry point, gated on IN_CONTAINER_ENV. The staged file is the post-override TaskDefinition with success_criteria, and the agent runs in the same container, so leaving it readable hands over the grading answer key. It is read exactly once; grading reads criteria from memory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sandbox): 2/3 — allowlist tmpfs-mask of auto-mounted plugin trees Fix B: default-deny mask over an auto-mounted Claude-plugin root. The whole root stays :ro-mounted so the plugin loads, but every child dir outside the keep-set (.claude-plugin + manifest-declared skill dirs) is masked with an empty tmpfs. Colocated eval material — sibling task YAMLs, reference solutions, tests/, node_modules/ — is masked by default so an unknown layout can never leak. New pure helper eval_material.mask_dirs; shared skill-dir resolver manifest_skill_dirs (renamed public in agents/_skills.py) is the SSOT for "what is a skill dir". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(lint): 3/3 — CE065 flags eval material inside a skill dir Fix C: a static lint rule (pytest class, CE055 template) that flags the one residual the Fix B allowlist cannot close — a task_id: YAML or a resolved reference.directory colocated INSIDE a skill dir reached through an in-repo agent.plugins[].path / sandbox.template_sources[].path. Masking a skill dir would hide the skill, so such material stays readable and leaks the grading answer key under driver: docker. Reuses the shared manifest_skill_dirs resolver (SSOT, import-identity asserted) with positive+negative sensors. Documents both docker anti-cheat fixes in docs/DOCKER_ISOLATION.md and the CE065 entry in CLAUDE.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: also delete context.json in-container (source_yaml leaked criteria) Code review found Fix A closed only /work/input/task.yaml but left the identical success_criteria readable in /work/input/context.json via source_yaml (top-level AND inside every config_lineage entry / ConfigLineageEntry.source_yaml) — in a dir this change makes world-readable+writable. The agent could cat context.json to recover its grading answer key. Delete both staged files after they are read into memory (_scrub_staged_task_yaml -> _scrub_staged_inputs); correct the docstring and docs/DOCKER_ISOLATION.md (the 'context.json carries no criteria' claim was false). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(harness): log deferred Low findings from anti-cheat review Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(anti-cheat): apply review Low findings L1/L2/L3 L1 — CE065 now flags ANY task_id: YAML or reference dir left readable under a plugin root (not just inside a skill dir): it reuses the runtime mask_dirs (SSOT), walks the readable surface, and catches a loose task_id: YAML *file* at the plugin root too (a tmpfs masks a dir, not a single file). Renamed the class to TestCE065EvalMaterialReadableUnderPluginRoot; added a loose-root-file sensor. L2 — docker_runner resolves a relative agent.plugins[].path against the task-file dir (new _resolve_mount_path), not CWD, matching reference/template/CE065 resolution so the static rule and the runtime inspect the same tree. L3 — eval_material.mask_dirs no longer descends a kept path, so a degenerate manifest skills: '.' masks nothing (fail-safe) instead of hiding the whole skill surface; added a test. Updated CLAUDE.md CE065 entry and docs. make verify green (the one failure is a pre-existing flaky wall-clock datetime test in test_agent_telemetry, passes on rerun, unrelated to these files). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(anti-cheat): apply multi-model review findings M1/M2/M3 + doc drift Multi-model review (Opus + Fable + Sonnet) found no Critical/High and no leak bypass; addressed the 3 Mediums and the doc drift: M2 — nested plugin roots (plugin B under plugin A) produced a duplicate Docker mount destination: A's mask emitted --tmpfs <A>/B while B's own mount bound <A>/B:ro. Defer mask emission until all binds are known and drop any mask whose path is also a bind (the bind wins so B loads and masks its own children). Extracted the auto-mount block into _append_auto_mounts to stay under the per-method lint limits. Added a nested-plugin test. M1 — added direct tests for _resolve_mount_path (relative->task-file dir, absolute unchanged, task_file None->cwd, env-var expansion); the L2 fix had no coverage. M3 — warn when a plugin root's mask stands down (manifest skills: '.') so the anti-cheat mask never voids silently. Added a test. Doc drift (L-a/L-b/L-c): corrected the now-false 'task.yaml readable at /work/input' / 'hiding criteria is unsolved' claims in orchestrator.py, fs_permissions.py and CLAUDE.md (Fix A deletes task.yaml + context.json after load); widened DOCKER_ISOLATION.md's residual note to include the loose-root-file case; marked the harness-candidates entry RESOLVED (nothing deferred). make verify green (5758 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Bai Li <bai.li@uipath.com>
1 parent ffb9a81 commit ddf63e9

14 files changed

Lines changed: 862 additions & 62 deletions

‎.claude/notes/lint-rules.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,7 @@ shipped. An id is a permanent documentation anchor: a suppression comment carryi
667667
an older branch, review or commit message must never start meaning something new. CE023 is
668668
retired the same way, after the rule was deleted with the package it guarded.
669669

670-
Claim 068 next, and note 065 IS TAKEN without being in `ALL_RULES`: doc-surface and
670+
Claim 069 next, and note 065 IS TAKEN without being in `ALL_RULES`: doc-surface and
671671
whole-tree rules are `@pytest.mark.lint` classes in `tests/test_custom_lint.py` rather than
672672
`BaseRule`s, so `runner.py`'s uniqueness assert cannot see them. Enumerating them in a
673673
comment is how that note fell behind CE044, so grep instead:

‎.claude/notes/permissions.md‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,6 @@ read-write throwaway copy; when it was bind-mounted `:ro` the chmod returned ERO
2929
no tmpfs mask and has not been one since: see
3030
[isolation.md](isolation.md) § Why the framework mounts are writable copies.
3131

32-
What the window does NOT hide is the task DEFINITION. `task.yaml` is also staged at
33-
`/work/input` for the in-container orchestrator and that mount is untouched, so hiding the
34-
criteria from the agent remains a separate, unsolved problem.
35-
3632
Criteria address reference files with the `$REFERENCE_DIR` token (same resolver as
3733
`$TASK_DIR`) and the `REFERENCE_DIR` env var for `run_command`; `reference_comparison` names
3834
one file via `reference_file`.
@@ -64,10 +60,6 @@ criteria and judges run. The task directory is shielded by the same window: unde
6460
it is mounted as a throwaway COPY at a fixed container path, which is what makes it
6561
chmod-able without touching the user's checked-out `tasks/` tree.
6662

67-
It shields grading MATERIAL that happens to live in the task directory (a `reference/`
68-
subdirectory, fixtures), not the task DEFINITION: `task.yaml` is separately staged at
69-
`/work/input`, which the agent can still read.
70-
7163
### Why a stack and not a refcount
7264

7365
Windows nest with *different* modes, so what an exit has to restore is the mode of the

‎docs/DOCKER_ISOLATION.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,45 @@ If the task declares a `reference:` block, a throwaway **copy** of its directory
339339

340340
See [Reference Solutions](TASK_DEFINITION_GUIDE.md#reference-solutions).
341341

342+
### Two more passive-read anti-cheat blocks
343+
344+
These are `driver: docker` only. `driver: tempdir` shares the host uid and has no
345+
filesystem isolation, so neither applies there (nor can — there is nothing to
346+
mask). Both are defense-in-depth passive-read blocks, consistent with the
347+
reference window's posture above; neither contains an adversarial agent.
348+
349+
- **The staged grading inputs are deleted after load.** The host stages the
350+
post-override `TaskDefinition` (with `success_criteria`) at `/work/input/task.yaml`
351+
**and** a `context.json` whose `source_yaml` is the raw task text — criteria
352+
verbatim, both at the top level and inside every `config_lineage` entry — for the
353+
in-container orchestrator to load once at startup. The agent runs in the same
354+
container, so leaving *either* readable would hand it the grading answer key
355+
(deleting only `task.yaml` leaves the identical criteria one file over in
356+
`context.json`). The in-container entry point deletes **both** immediately after
357+
they are consumed — `context.json` is parsed into memory in the command body and
358+
`task.yaml` by `load_task`, both before the delete (gated on
359+
`CODER_EVAL_IN_CONTAINER`). They are read exactly once — grading reads criteria
360+
from the in-memory task, never from disk. The `/work/input` mount is therefore
361+
read-write (a `:ro` mount rejects `rm` with EROFS). `prior.json` is kept: it is
362+
read later on the regrade path, and a regrade runs no agent so it is not a leak.
363+
364+
- **Auto-mounted plugin trees are default-deny masked.** An `agent.plugins[].path`
365+
(or a `TemplateDirSource.path` that is itself a plugin root) is auto-mounted at
366+
its host path `:ro` so the plugin loads. Eval material colocated under that tree
367+
as siblings of the skills dir — sibling task YAMLs, reference solutions, test
368+
fixtures — would otherwise be readable. So the runner keeps the whole root
369+
mounted but layers an empty `--tmpfs` over every child dir OUTSIDE the keep-set
370+
(`.claude-plugin` + the manifest-declared skill dirs). Everything that is not
371+
the plugin surface is masked by default, so an unknown or new eval layout can
372+
never leak; `tests/`, `node_modules/`, and reference solutions are masked for
373+
free. A root agent cannot `umount` a tmpfs (`CAP_SYS_ADMIN` is not in Docker's
374+
default set), so this mask is *stronger* than the mode-000 reference window. Two
375+
residuals the mask cannot cover — an eval def or reference COLOCATED inside a
376+
skill dir (masking it would hide the skill), and a `task_id:` YAML **file** loose
377+
at the plugin root (a tmpfs masks a directory, not a single file) — are caught by
378+
lint rule CE068 (keep eval material out of skill dirs and off the plugin root;
379+
put it under a sibling `tests/`).
380+
342381
Inside the container, the entrypoint invokes `coder-eval _run-task-internal` (hidden subcommand), which loads the staged YAML + context, runs the standard in-process Orchestrator (driver auto-coerced back to `tempdir`), and writes `task.json` to the output mount. Host reads it and feeds the existing aggregation pipeline.
343382

344383
A `result_kind` discriminator on `CriterionResult` ensures `ClassificationCriterionResult` subclasses survive the JSON round-trip — without it, host-side aggregation would silently lose `observed_label`/`expected_label`.

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ external = [
305305
"CE064",
306306
"CE065",
307307
"CE066",
308+
"CE068",
308309
] # custom architectural lint rules (tests/lint/)
309310

310311
[tool.ruff.lint.pylint]

‎src/coder_eval/agents/_skills.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
_SKILL_FILE = "SKILL.md"
2525

2626

27-
def _manifest_skill_dirs(root: Path) -> list[Path]:
27+
def manifest_skill_dirs(root: Path) -> list[Path]:
2828
"""Skill directories a Claude-plugin root declares, in manifest order.
2929
3030
Reads the ``skills`` field of ``<root>/.claude-plugin/plugin.json`` (a string
@@ -81,7 +81,7 @@ def _plugin_skill_dirs(
8181
hint,
8282
)
8383
continue
84-
candidates = [directory for directory in _manifest_skill_dirs(root) if directory.is_dir()]
84+
candidates = [directory for directory in manifest_skill_dirs(root) if directory.is_dir()]
8585
# A path that is ALREADY a bare skills directory has no `skills/` subdir,
8686
# so use it as-is. Deliberately NOT a fallback for a root that HAS one.
8787
# Rationale: .claude/notes/agents.md § Skills, per harness

‎src/coder_eval/cli/run_task_internal_command.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import asyncio
1818
import contextlib
1919
import logging
20+
import os
2021
from pathlib import Path
2122
from typing import Any
2223

@@ -45,6 +46,17 @@
4546
logger = logging.getLogger(__name__)
4647

4748

49+
def _scrub_staged_inputs(task_yaml: Path, context_json: Path) -> None:
50+
"""Delete the staged ``task.yaml`` and ``context.json`` once loaded, inside a container only.
51+
52+
Both carry the task's ``success_criteria``, and the agent runs in this same container.
53+
``prior.json`` is left for the regrade path, which reads it later and runs no agent.
54+
"""
55+
if os.environ.get(IN_CONTAINER_ENV) == "1":
56+
task_yaml.unlink(missing_ok=True)
57+
context_json.unlink(missing_ok=True)
58+
59+
4860
def heartbeat_is_alive(current: str, last_counter: str, current_mtime: float, last_mtime: float) -> bool:
4961
"""True when the heartbeat shows a fresh signal of life.
5062
@@ -176,6 +188,7 @@ def run_task_internal_command(
176188
# `task_file` is then pointed under the task_dir mount, so the `TASK_DIR` the
177189
# Orchestrator exposes to `run_command` criteria resolves there, not /work/input.
178190
task, _ = load_task(task_yaml)
191+
_scrub_staged_inputs(task_yaml, context_json)
179192
# The path below is never re-read; it only seeds Orchestrator's TASK_DIR.
180193
runtime_task_file = task_dir / "task.yaml" if task_dir.is_dir() else task_yaml
181194

‎src/coder_eval/isolation/docker_runner.py‎

Lines changed: 91 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@ def _rewrite_loopback_for_container(url: str) -> str | None:
140140
# Rationale: .claude/notes/isolation.md § The stdout line limit
141141
STDOUT_LINE_LIMIT_BYTES = 64 * 1024 * 1024 # 64 MiB
142142

143+
_MASK_WARNING = "Masking non-skill path %s under plugin root %s (anti-cheat: only skills stay readable)."
144+
_MASK_STANDDOWN_WARNING = (
145+
"Anti-cheat mask stood down for plugin root %s: its whole tree is the declared skill surface "
146+
'(e.g. manifest `skills: "."`), so nothing is masked. Any eval material colocated here is READABLE '
147+
"to the agent — move it outside the plugin root."
148+
)
149+
143150

144151
async def _heartbeat_loop(heartbeat_path: Path) -> None:
145152
"""Write a monotonic counter to ``heartbeat_path`` every interval until cancelled.
@@ -656,8 +663,9 @@ async def run(self) -> EvaluationResult:
656663
await asyncio.to_thread(self._prepare_task_dir_mount, staging)
657664
# AFTER staging, BEFORE the container starts: the DAC caps are dropped, so
658665
# every framework-owned mount must be reachable through its `other` bits.
666+
# Writable so the entry point can delete the staged task.yaml/context.json.
659667
# Rationale: .claude/notes/isolation.md § grant_container_access
660-
await asyncio.to_thread(grant_container_access, input_dir, writable=False)
668+
await asyncio.to_thread(grant_container_access, input_dir, writable=True)
661669
await asyncio.to_thread(grant_container_access, output_dir, writable=True)
662670
if self.grade_workspace is not None:
663671
# The one mount whose files the harness did NOT create, so the owner
@@ -1218,6 +1226,84 @@ def _reference_mount_args(self) -> list[str]:
12181226
# every turn, which a `:ro` mount rejects with EROFS.
12191227
return ["-v", f"{self._reference_mount_src}:{CONTAINER_REFERENCE_DIR}"]
12201228

1229+
def _resolve_mount_path(self, raw_path: str) -> Path:
1230+
"""Resolve an auto-mount source to an absolute host path.
1231+
1232+
A relative path resolves against the task-file dir, as CE068 resolves it, not the CWD.
1233+
"""
1234+
expanded = Path(os.path.expandvars(os.path.expanduser(raw_path)))
1235+
if not expanded.is_absolute() and self.rt.task_file is not None:
1236+
expanded = self.rt.task_file.parent / expanded
1237+
return expanded.resolve()
1238+
1239+
def _append_auto_mounts(self, argv: list[str]) -> None:
1240+
"""Bind-mount the plugin, template and system-prompt paths a task references, ``:ro`` at their host path.
1241+
1242+
A plugin root also gets every non-skill child dir masked with an empty tmpfs. The
1243+
reference is deliberately NOT here: it has its own mount (``_reference_mount_args``).
1244+
"""
1245+
mounted: set[Path] = set()
1246+
# Warned, not refused: `plugin.path` / `reference.directory` /
1247+
# `template_sources` are user-controlled strings, and legitimate uses exist.
1248+
# Rationale: .claude/notes/isolation.md § Extra mounts and reserved destinations
1249+
sensitive_sources = self._sensitive_source_paths()
1250+
1251+
# Lazy: eval_material imports agents._skills, whose package imports this module.
1252+
from coder_eval.isolation.eval_material import mask_dirs
1253+
1254+
# Masked dir -> its plugin root. Emitted only once every bind is known, so a
1255+
# nested plugin root's bind can win over its parent's mask of the same path.
1256+
mask_targets: dict[Path, Path] = {}
1257+
1258+
def _auto_mount(raw_path: str | None, *, dir_only: bool = True) -> None:
1259+
if not raw_path:
1260+
return
1261+
resolved = self._resolve_mount_path(raw_path)
1262+
# File paths get mounted as the parent dir so a single -v covers
1263+
# the file; container-side reads still resolve at the same path.
1264+
target = resolved if (dir_only or resolved.is_dir()) else resolved.parent
1265+
if target in mounted or not target.is_dir():
1266+
return
1267+
for sensitive in sensitive_sources:
1268+
if target == sensitive or sensitive in target.parents:
1269+
logger.warning(
1270+
"Auto-mounting sensitive host path %s into container; fix task YAML if unintended.",
1271+
target,
1272+
)
1273+
break
1274+
mounted.add(target)
1275+
argv.extend(["-v", f"{target}:{target}:ro"])
1276+
masks = mask_dirs(target)
1277+
if not masks and (target / ".claude-plugin" / "plugin.json").is_file():
1278+
logger.warning(_MASK_STANDDOWN_WARNING, target)
1279+
for masked_dir in masks:
1280+
mask_targets.setdefault(masked_dir, target)
1281+
1282+
plugins = (self.rt.task.agent.plugins if self.rt.task.agent else None) or []
1283+
for plugin in plugins:
1284+
_auto_mount(plugin.get("path") if isinstance(plugin, dict) else None)
1285+
1286+
from coder_eval.models import TemplateDirSource
1287+
1288+
sandbox_cfg = self.rt.task.sandbox
1289+
for source in (sandbox_cfg.template_sources or []) if sandbox_cfg else []:
1290+
if isinstance(source, TemplateDirSource):
1291+
_auto_mount(source.path)
1292+
1293+
# Defensive: normally inlined into system_prompt by load_task / experiment
1294+
# resolution, but a variant could inject an absolute path that survives.
1295+
agent_cfg = self.rt.task.agent
1296+
if agent_cfg and agent_cfg.system_prompt_file:
1297+
_auto_mount(agent_cfg.system_prompt_file, dir_only=False)
1298+
1299+
# A deeper --tmpfs wins over the enclosing :ro bind regardless of argv order;
1300+
# a mask that is also a bind would be a duplicate mount point, so the bind wins.
1301+
for masked_dir, root in sorted(mask_targets.items()):
1302+
if masked_dir in mounted:
1303+
continue
1304+
argv.extend(["--tmpfs", str(masked_dir)])
1305+
logger.warning(_MASK_WARNING, masked_dir, root)
1306+
12211307
def _build_argv(
12221308
self, input_dir: Path, output_dir: Path, *, container_name: str, image: str | None = None
12231309
) -> list[str]:
@@ -1300,7 +1386,9 @@ def _build_argv(
13001386
# Rationale: .claude/notes/isolation.md § Environment forwarding
13011387
argv += ["--env", "TELEMETRY_ENABLED=false"]
13021388

1303-
argv += ["-v", f"{input_dir.resolve()}:{CONTAINER_INPUT_DIR}:ro"]
1389+
# Read-WRITE: the entry point deletes the staged task.yaml and context.json
1390+
# after load, and `rm` fails with EROFS on a `:ro` bind mount.
1391+
argv += ["-v", f"{input_dir.resolve()}:{CONTAINER_INPUT_DIR}"]
13041392
# The host run_dir at the container's standard output location, so the
13051393
# in-container Orchestrator writes straight to the host filesystem.
13061394
argv += ["-v", f"{output_dir}:{CONTAINER_OUTPUT_DIR}"]
@@ -1330,51 +1418,7 @@ def _build_argv(
13301418
host_claude_dir = Path.home() / ".claude"
13311419
argv += ["-v", f"{self._claude_mount_src}:{host_claude_dir}"]
13321420

1333-
# Host paths the task references (plugin dirs, resolved template dirs), at
1334-
# the SAME path inside the container. The reference is deliberately NOT
1335-
# here -- it has its own mount and is masked out of the task_dir mount.
1336-
# ``mounted`` dedupes overlapping entries.
1337-
mounted: set[Path] = set()
1338-
# Warned, not refused: `plugin.path` / `reference.directory` /
1339-
# `template_sources` are user-controlled strings, and legitimate uses exist.
1340-
# Rationale: .claude/notes/isolation.md § Extra mounts and reserved destinations
1341-
sensitive_sources = self._sensitive_source_paths()
1342-
1343-
def _auto_mount(raw_path: str | None, *, dir_only: bool = True) -> None:
1344-
if not raw_path:
1345-
return
1346-
resolved = Path(os.path.expandvars(os.path.expanduser(raw_path))).resolve()
1347-
# File paths get mounted as the parent dir so a single -v covers
1348-
# the file; container-side reads still resolve at the same path.
1349-
target = resolved if (dir_only or resolved.is_dir()) else resolved.parent
1350-
if target in mounted or not target.is_dir():
1351-
return
1352-
for sensitive in sensitive_sources:
1353-
if target == sensitive or sensitive in target.parents:
1354-
logger.warning(
1355-
"Auto-mounting sensitive host path %s into container; fix task YAML if unintended.",
1356-
target,
1357-
)
1358-
break
1359-
mounted.add(target)
1360-
argv.extend(["-v", f"{target}:{target}:ro"])
1361-
1362-
plugins = (self.rt.task.agent.plugins if self.rt.task.agent else None) or []
1363-
for plugin in plugins:
1364-
_auto_mount(plugin.get("path") if isinstance(plugin, dict) else None)
1365-
1366-
from coder_eval.models import TemplateDirSource
1367-
1368-
sandbox_cfg = self.rt.task.sandbox
1369-
for source in (sandbox_cfg.template_sources or []) if sandbox_cfg else []:
1370-
if isinstance(source, TemplateDirSource):
1371-
_auto_mount(source.path)
1372-
1373-
# Defensive: normally inlined into system_prompt by load_task / experiment
1374-
# resolution, but a variant could inject an absolute path that survives.
1375-
agent_cfg = self.rt.task.agent
1376-
if agent_cfg and agent_cfg.system_prompt_file:
1377-
_auto_mount(agent_cfg.system_prompt_file, dir_only=False)
1421+
self._append_auto_mounts(argv)
13781422

13791423
# HAZARD: task.reference.directory is deliberately NOT auto-mounted at its
13801424
# host path. That would bind the REAL tree in beside the shielded copy, so
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Allowlist (default-deny) mask for auto-mounted Claude-plugin trees.
2+
3+
``mask_dirs`` names the child dirs of a mounted plugin root to tmpfs-mask so that only the
4+
plugin surface (``.claude-plugin`` + the manifest-declared skill dirs) stays readable.
5+
See docs/DOCKER_ISOLATION.md for the two residuals the mask cannot cover.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from pathlib import Path
11+
12+
from coder_eval.agents._skills import manifest_skill_dirs
13+
14+
15+
_PLUGIN_MANIFEST_RELPATH = (".claude-plugin", "plugin.json")
16+
17+
18+
def mask_dirs(root: Path) -> list[Path]:
19+
"""Directories under a mounted plugin ``root`` to tmpfs-mask, or ``[]`` for a non-plugin root.
20+
21+
A nested skills path keeps its ancestors unmasked but masks their other children;
22+
symlinked children are skipped.
23+
"""
24+
root = root.resolve()
25+
if not (root / Path(*_PLUGIN_MANIFEST_RELPATH)).is_file():
26+
return []
27+
28+
keep = {(root / ".claude-plugin").resolve(), *manifest_skill_dirs(root)}
29+
30+
# A kept path or an ancestor of one: never masked, descended into instead.
31+
protected: set[Path] = set()
32+
for kept in keep:
33+
protected.add(kept)
34+
for ancestor in kept.parents:
35+
if ancestor == root:
36+
break
37+
if root in ancestor.parents:
38+
protected.add(ancestor)
39+
40+
# Never descend into a kept path. Under manifest `skills: "."` the root itself
41+
# is kept, so nothing is masked (the caller warns).
42+
descend = {p for p in ({root} | protected) if p not in keep}
43+
44+
masked: set[Path] = set()
45+
for directory in descend:
46+
if not directory.is_dir() or directory.is_symlink():
47+
continue
48+
for child in directory.iterdir():
49+
if child.is_symlink():
50+
continue
51+
if not child.is_dir():
52+
continue
53+
resolved = child.resolve()
54+
if resolved in protected:
55+
continue
56+
masked.add(resolved)
57+
58+
return sorted(masked)

‎src/coder_eval/orchestrator.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1957,8 +1957,8 @@ async def _communicate_attempt() -> TurnRecord:
19571957
# ANTI-CHEAT WINDOW. The reference and the task dir sit at mode 000 for every
19581958
# communicate attempt, retries included (this wrapper is outside
19591959
# execute_with_retry), and are restored on every exit path. The SANDBOX owns
1960-
# whether a chmod window means anything for its driver. It does NOT hide the
1961-
# task DEFINITION: task.yaml is also staged at /work/input, an unsolved gap.
1960+
# whether a chmod window means anything for its driver. The task DEFINITION
1961+
# staged at /work/input is not windowed; the entry point deletes it after load.
19621962
# Rationale: .claude/notes/permissions.md § Reference solutions and the anti-cheat window
19631963
assert self.sandbox is not None
19641964
async with self.sandbox.set_permissions([self._reference_dir, self.sandbox.task_dir]):

0 commit comments

Comments
 (0)