coder-eval 0.8.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. coder_eval/.gitattributes +2 -0
  2. coder_eval/__init__.py +3 -0
  3. coder_eval/agent.py +302 -0
  4. coder_eval/agents/__init__.py +41 -0
  5. coder_eval/agents/_logging.py +89 -0
  6. coder_eval/agents/antigravity_agent.py +811 -0
  7. coder_eval/agents/claude_code_agent.py +1701 -0
  8. coder_eval/agents/codex_agent.py +2055 -0
  9. coder_eval/agents/noop_agent.py +100 -0
  10. coder_eval/agents/registry.py +163 -0
  11. coder_eval/agents/watchdog.py +116 -0
  12. coder_eval/analysis.py +88 -0
  13. coder_eval/cli/__init__.py +87 -0
  14. coder_eval/cli/aggregate_command.py +153 -0
  15. coder_eval/cli/console.py +7 -0
  16. coder_eval/cli/evaluate_command.py +164 -0
  17. coder_eval/cli/plan_command.py +152 -0
  18. coder_eval/cli/report_command.py +121 -0
  19. coder_eval/cli/run_command.py +686 -0
  20. coder_eval/cli/run_helpers.py +137 -0
  21. coder_eval/cli/run_task_internal_command.py +210 -0
  22. coder_eval/cli/utils.py +37 -0
  23. coder_eval/config.py +212 -0
  24. coder_eval/criteria/__init__.py +163 -0
  25. coder_eval/criteria/_classification_aggregate.py +106 -0
  26. coder_eval/criteria/agent_judge.py +425 -0
  27. coder_eval/criteria/base.py +248 -0
  28. coder_eval/criteria/classification_match.py +107 -0
  29. coder_eval/criteria/command_executed.py +181 -0
  30. coder_eval/criteria/commands_efficiency.py +73 -0
  31. coder_eval/criteria/file_check.py +119 -0
  32. coder_eval/criteria/file_contains.py +85 -0
  33. coder_eval/criteria/file_exists.py +45 -0
  34. coder_eval/criteria/file_matches_regex.py +86 -0
  35. coder_eval/criteria/json_check.py +184 -0
  36. coder_eval/criteria/llm_judge.py +260 -0
  37. coder_eval/criteria/reference_comparison.py +130 -0
  38. coder_eval/criteria/run_command.py +220 -0
  39. coder_eval/criteria/skill_triggered.py +111 -0
  40. coder_eval/criteria/uipath_eval.py +223 -0
  41. coder_eval/errors/__init__.py +26 -0
  42. coder_eval/errors/agent.py +26 -0
  43. coder_eval/errors/budget.py +28 -0
  44. coder_eval/errors/categories.py +205 -0
  45. coder_eval/errors/categorization.py +240 -0
  46. coder_eval/errors/executor.py +124 -0
  47. coder_eval/errors/judge.py +12 -0
  48. coder_eval/errors/retry.py +211 -0
  49. coder_eval/errors/timeout.py +63 -0
  50. coder_eval/evaluation/__init__.py +10 -0
  51. coder_eval/evaluation/checker.py +260 -0
  52. coder_eval/evaluation/judge_anthropic.py +55 -0
  53. coder_eval/evaluation/judge_bedrock.py +114 -0
  54. coder_eval/evaluation/judge_context.py +497 -0
  55. coder_eval/evaluation/judge_models.py +53 -0
  56. coder_eval/evaluation/judge_persistence.py +291 -0
  57. coder_eval/evaluation/judge_usage.py +50 -0
  58. coder_eval/evaluation/sub_agent.py +231 -0
  59. coder_eval/evaluation/summaries.py +48 -0
  60. coder_eval/evaluation/verdict_tool.py +213 -0
  61. coder_eval/formatting.py +191 -0
  62. coder_eval/isolation/__init__.py +15 -0
  63. coder_eval/isolation/docker_runner.py +1253 -0
  64. coder_eval/logging_config.py +399 -0
  65. coder_eval/models/__init__.py +337 -0
  66. coder_eval/models/agent_config.py +373 -0
  67. coder_eval/models/container_paths.py +26 -0
  68. coder_eval/models/criteria.py +942 -0
  69. coder_eval/models/enums.py +121 -0
  70. coder_eval/models/experiment.py +314 -0
  71. coder_eval/models/judge.py +90 -0
  72. coder_eval/models/judge_defaults.py +11 -0
  73. coder_eval/models/limits.py +89 -0
  74. coder_eval/models/merge_strategy.py +132 -0
  75. coder_eval/models/mutations.py +95 -0
  76. coder_eval/models/results.py +787 -0
  77. coder_eval/models/routing.py +160 -0
  78. coder_eval/models/sandbox.py +371 -0
  79. coder_eval/models/tasks.py +631 -0
  80. coder_eval/models/telemetry.py +454 -0
  81. coder_eval/models/templates.py +108 -0
  82. coder_eval/orchestration/__init__.py +13 -0
  83. coder_eval/orchestration/batch.py +690 -0
  84. coder_eval/orchestration/config.py +111 -0
  85. coder_eval/orchestration/config_merge.py +431 -0
  86. coder_eval/orchestration/evaluation.py +94 -0
  87. coder_eval/orchestration/experiment.py +837 -0
  88. coder_eval/orchestration/overrides.py +206 -0
  89. coder_eval/orchestration/task_loader.py +437 -0
  90. coder_eval/orchestrator.py +2078 -0
  91. coder_eval/path_utils.py +79 -0
  92. coder_eval/plugins.py +81 -0
  93. coder_eval/pricing.py +169 -0
  94. coder_eval/py.typed +0 -0
  95. coder_eval/reports.py +1066 -0
  96. coder_eval/reports_experiment.py +761 -0
  97. coder_eval/reports_html.py +1659 -0
  98. coder_eval/reports_stats.py +250 -0
  99. coder_eval/resources/__init__.py +137 -0
  100. coder_eval/resources/default_experiment.yaml +85 -0
  101. coder_eval/resources/default_ignore_patterns.yaml +60 -0
  102. coder_eval/resources/tags.yaml +55 -0
  103. coder_eval/sandbox.py +1127 -0
  104. coder_eval/scoring/__init__.py +11 -0
  105. coder_eval/scoring/ast_similarity.py +38 -0
  106. coder_eval/scoring/complexity.py +115 -0
  107. coder_eval/scoring/quality.py +154 -0
  108. coder_eval/scoring/signature_similarity.py +57 -0
  109. coder_eval/scoring/similarity.py +103 -0
  110. coder_eval/scoring/token_similarity.py +44 -0
  111. coder_eval/simulation/__init__.py +27 -0
  112. coder_eval/simulation/termination.py +88 -0
  113. coder_eval/simulation/user_simulator.py +324 -0
  114. coder_eval/streaming/__init__.py +44 -0
  115. coder_eval/streaming/callbacks.py +57 -0
  116. coder_eval/streaming/collector.py +193 -0
  117. coder_eval/streaming/events.py +198 -0
  118. coder_eval/streaming/renderers.py +248 -0
  119. coder_eval/streaming/wire.py +115 -0
  120. coder_eval/telemetry.py +407 -0
  121. coder_eval/utils.py +517 -0
  122. coder_eval-0.8.2.dist-info/METADATA +242 -0
  123. coder_eval-0.8.2.dist-info/RECORD +127 -0
  124. coder_eval-0.8.2.dist-info/WHEEL +4 -0
  125. coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
  126. coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
  127. coder_eval-0.8.2.dist-info/licenses/NOTICE +18 -0
coder_eval/sandbox.py ADDED
@@ -0,0 +1,1127 @@
1
+ """Sandbox manager for isolated execution environments."""
2
+
3
+ import fnmatch
4
+ import json
5
+ import logging
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ from .models import (
13
+ RepoSource,
14
+ SandboxConfig,
15
+ StarterFilesSource,
16
+ TemplateDirSource,
17
+ )
18
+ from .resources import get_ignore_patterns, should_ignore_path
19
+
20
+
21
+ # Module logger (inherits from coder_eval logger)
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # Top-level entries excluded from Sandbox.capture_to (docker WORKDIR-alignment).
26
+ # The WORKDIR can be HOME or overlap framework mounts, so skip credentials and
27
+ # sandbox-created bulk. `.claude` is the RW lean copy of ~/.claude (carries
28
+ # .credentials.json). The rest are sandbox infra the standard artifacts path
29
+ # also drops. Matched by basename at every level (shutil.ignore_patterns).
30
+ _WORKSPACE_CAPTURE_IGNORE = (".claude", ".venv", ".npm-prefix", "node_modules")
31
+
32
+
33
+ def _grant_read_traverse(root: Path) -> None:
34
+ """Recursively apply ``chmod a+rX`` semantics under ``root``.
35
+
36
+ Preserved artifacts produced inside a root-owned ``driver:docker`` container
37
+ land on the host bind-mount owned by root, with mkdtemp's 0700 sandbox root.
38
+ The host user (a different uid) can't traverse that, so the blob upload and
39
+ any ``ls`` see an empty dir. This grants group+other read everywhere and
40
+ group+other execute only where the owner already has it (dirs, exec files),
41
+ matching ``a+rX``. Symlinks are skipped: their mode is ignored on Linux and
42
+ ``chmod`` would alter the target instead.
43
+ """
44
+
45
+ def _add_bits(path: str) -> None:
46
+ try:
47
+ if os.path.islink(path):
48
+ return
49
+ mode = os.stat(path).st_mode
50
+ new = mode | 0o044 # r for group + other
51
+ if mode & 0o100: # owner-executable -> dir or exec file: add g+x, o+x
52
+ new |= 0o011
53
+ if new != mode:
54
+ os.chmod(path, new)
55
+ except OSError as e:
56
+ logger.debug("grant_read_traverse: could not chmod %s: %s", path, e)
57
+
58
+ _add_bits(str(root))
59
+ for dirpath, dirnames, filenames in os.walk(root):
60
+ for name in (*dirnames, *filenames):
61
+ _add_bits(os.path.join(dirpath, name))
62
+
63
+
64
+ class Sandbox:
65
+ """Manages sandboxed execution environments for agent tasks.
66
+
67
+ Supports multiple drivers (tempdir, docker) and provides isolated
68
+ environments with virtual environments and resource limits.
69
+ """
70
+
71
+ REMEDIATE_HOME_PLUGINS_ENV = "CODER_EVAL_REMEDIATE_HOME_PLUGINS"
72
+ """Env-var flag gating destructive ``$HOME/node_modules/@uipath`` cleanup."""
73
+
74
+ def __init__(self, config: SandboxConfig, task_id: str, task_dir: Path | None = None):
75
+ """Initialize the sandbox.
76
+
77
+ Args:
78
+ config: Sandbox configuration
79
+ task_id: Unique identifier for this task (used in paths)
80
+ task_dir: Directory containing the task YAML file (exposed as TASK_DIR env var in run_command)
81
+ """
82
+ self.config = config
83
+ self.task_id = task_id
84
+ self.task_dir = task_dir
85
+ self.sandbox_dir: Path | None = None
86
+ self.venv_dir: Path | None = None
87
+ self._cleanup_on_exit = True
88
+ self.installed_tool_versions: dict[str, str] = {}
89
+ self._command_base_path: str | None = None
90
+ # Cached canonical `node_modules/@uipath`; pins UiPath CLI plugin discovery
91
+ # via PLUGIN_TOOLS_DIR to bypass CWD-walk contamination.
92
+ self._plugin_tools_dir: str | None = None
93
+
94
+ @property
95
+ def _venv_scripts_dir(self) -> Path | None:
96
+ """Return the platform-appropriate scripts directory inside the venv."""
97
+ if self.venv_dir is None:
98
+ return None
99
+ return self.venv_dir / ("Scripts" if os.name == "nt" else "bin")
100
+
101
+ @property
102
+ def is_persistent(self) -> bool:
103
+ """Whether this sandbox was created with a persistent target directory.
104
+
105
+ When True, the sandbox was created in a specific target directory (typically
106
+ the artifacts directory) and will not be deleted on cleanup().
107
+ """
108
+ return not self._cleanup_on_exit
109
+
110
+ def setup(self, target_dir: Path | None = None) -> Path:
111
+ """Set up the sandbox environment.
112
+
113
+ The sandbox is a plain temporary directory on the host -- there is no
114
+ container or cgroup isolation. Only command-level timeouts are enforced;
115
+ memory and disk limits in :class:`ResourceLimits` are not enforced.
116
+
117
+ Args:
118
+ target_dir: If provided, use this directory instead of creating a temp dir.
119
+ The directory will NOT be deleted on cleanup (persistent mode).
120
+
121
+ Returns:
122
+ Path to the sandbox directory
123
+
124
+ Raises:
125
+ ValueError: If driver is not supported
126
+ RuntimeError: If setup fails
127
+ """
128
+ if self.config.driver == "tempdir":
129
+ return self._setup_tempdir(target_dir=target_dir)
130
+ if self.config.driver == "docker":
131
+ # Docker isolation is dispatched at the orchestrator-entry boundary
132
+ # (coder_eval.isolation.docker_runner). Inside the container, the
133
+ # task is re-run with driver=tempdir, so this branch is never
134
+ # reached on a correctly routed call.
135
+ raise RuntimeError(
136
+ "Sandbox.setup() called with driver='docker' -- Docker tasks must be "
137
+ + "dispatched via DockerRunner from the host. This indicates a routing bug."
138
+ )
139
+ raise ValueError(f"Unsupported sandbox driver: {self.config.driver}")
140
+
141
+ def _setup_tempdir(self, target_dir: Path | None = None) -> Path:
142
+ """Set up a sandbox directory.
143
+
144
+ Args:
145
+ target_dir: If provided, use this directory instead of creating a temp dir.
146
+ Sets _cleanup_on_exit=False so cleanup() preserves the directory.
147
+
148
+ Returns:
149
+ Path to the sandbox directory
150
+ """
151
+ if target_dir is not None:
152
+ # Persistent mode: work directly in the target directory
153
+ target_dir.mkdir(parents=True, exist_ok=True)
154
+ self.sandbox_dir = target_dir
155
+ self._cleanup_on_exit = False
156
+ else:
157
+ # Default: create a temporary directory. Dataset row tasks have IDs like
158
+ # "parent/row" -- flatten path separators so they don't become subdirectories
159
+ # under /tmp (mkdtemp does not auto-create parent dirs).
160
+ safe_task_id = self.task_id.replace("/", "_").replace("\\", "_")
161
+ # On Windows root off the home dir, not the user temp tree: the agent's Git Bash
162
+ # mounts /tmp onto the base temp dir while Python's mkdtemp honors %TEMP% (a CI-set
163
+ # subdir), so a temp-rooted sandbox gets a divergent /tmp twin the grader never reads.
164
+ # POSIX has one namespace (dir=None keeps the system temp, unchanged for driver:docker).
165
+ self.sandbox_dir = Path(
166
+ tempfile.mkdtemp(prefix=f"coder_eval_{safe_task_id}_", dir=Path.home() if os.name == "nt" else None)
167
+ )
168
+
169
+ try:
170
+ # Setup template content (repo, directory, or inline files)
171
+ self._setup_template()
172
+
173
+ # Mark mock binaries executable so the agent's PATH can shadow real CLIs
174
+ self._prepare_mock_path_dirs()
175
+
176
+ # Set up Python virtual environment (only if python config is provided)
177
+ if self.config.python:
178
+ self._setup_virtualenv()
179
+
180
+ # Install required packages
181
+ if self.config.python.env_packages:
182
+ self._install_packages()
183
+
184
+ # Install Node.js packages
185
+ if self.config.node and self.config.node.env_packages:
186
+ self._install_node_packages()
187
+
188
+ # MST-9674: report (without remediating) parent-dir node_modules
189
+ # contamination that could perturb Node module resolution.
190
+ self._check_parent_node_modules_contamination()
191
+ # Opt-in destructive cleanup of $HOME/node_modules/@uipath (eval-host-only).
192
+ self._maybe_remediate_home_plugins_pollution()
193
+ # Cache canonical @uipath dir for PLUGIN_TOOLS_DIR pin; no-op if `uip` absent.
194
+ self._refresh_plugin_tools_dir()
195
+ except Exception:
196
+ # Clean up on failure -- but ONLY a temp dir we created ourselves.
197
+ # For a caller-supplied target_dir (DIRECT_WRITE persistent mode) we
198
+ # must not rmtree it: it may be a pre-existing artifacts dir, and the
199
+ # mode's contract is to never clear it. A self-created tempdir always
200
+ # has _cleanup_on_exit=True at this point; target_dir flips it False.
201
+ if self._cleanup_on_exit:
202
+ shutil.rmtree(self.sandbox_dir, ignore_errors=True)
203
+ self.sandbox_dir = None
204
+ raise
205
+
206
+ return self.sandbox_dir
207
+
208
+ def _apply_repo_source(self, source: RepoSource) -> None:
209
+ """Clone a git repository into the sandbox.
210
+
211
+ Args:
212
+ source: Repository source configuration
213
+
214
+ Raises:
215
+ RuntimeError: If git clone or checkout fails
216
+
217
+ Note:
218
+ Repos are cloned into sandbox_dir/repo/ subdirectory.
219
+ RepoSource should always be first (git clone requires empty target).
220
+ """
221
+ assert self.sandbox_dir is not None, "Sandbox directory not initialized"
222
+
223
+ repo_dir = self.sandbox_dir / "repo"
224
+ cmd = ["git", "clone", source.url, str(repo_dir)]
225
+
226
+ try:
227
+ subprocess.run(cmd, check=True, capture_output=True, text=True, encoding="utf-8", timeout=60)
228
+
229
+ # Checkout specific commit if specified
230
+ if source.commit:
231
+ subprocess.run(
232
+ ["git", "checkout", source.commit],
233
+ cwd=repo_dir,
234
+ check=True,
235
+ capture_output=True,
236
+ text=True,
237
+ encoding="utf-8",
238
+ timeout=30,
239
+ )
240
+ except subprocess.CalledProcessError as e:
241
+ raise RuntimeError(f"Failed to clone repository: {e.stderr}") from e
242
+
243
+ def _resolve_within_sandbox(self, rel: str, *, field: str) -> Path:
244
+ """Join ``rel`` with the sandbox root, resolve, and reject if it escapes.
245
+
246
+ Used by every code path that consumes a sandbox-relative path supplied by
247
+ a task author (``template_dir.mount_point``, ``starter_files`` paths,
248
+ ``mock_path_dirs`` entries). The result is allowed to equal the sandbox
249
+ root (e.g. an empty ``mount_point``); anything that resolves outside
250
+ raises ``RuntimeError`` with the originating ``field`` named so the task
251
+ author can locate the bad entry in their YAML.
252
+
253
+ Args:
254
+ rel: The user-supplied path. May be a relative path, an absolute
255
+ path (joining discards the sandbox prefix), or a string with
256
+ ``..`` segments.
257
+ field: Human-readable label of the YAML field being checked,
258
+ surfaced in the error message.
259
+
260
+ Returns:
261
+ Absolute, resolved path that is guaranteed to be inside the sandbox.
262
+ """
263
+ assert self.sandbox_dir is not None, "Sandbox directory not initialized"
264
+ sandbox_root = self.sandbox_dir.resolve()
265
+ candidate = (self.sandbox_dir / rel).resolve()
266
+ if candidate != sandbox_root and sandbox_root not in candidate.parents:
267
+ raise RuntimeError(f"{field} escapes sandbox: {rel!r} -> {candidate}")
268
+ return candidate
269
+
270
+ def _setup_template(self) -> None:
271
+ """Setup template files/directory in sandbox.
272
+
273
+ Applies template sources sequentially in order. Later sources can overwrite
274
+ files from earlier sources (last-wins conflict resolution).
275
+ """
276
+ sources = self.config.template_sources or []
277
+
278
+ for source in sources:
279
+ if isinstance(source, RepoSource):
280
+ self._apply_repo_source(source)
281
+ elif isinstance(source, TemplateDirSource):
282
+ self._apply_template_dir_source(source)
283
+ elif isinstance(source, StarterFilesSource):
284
+ self._apply_starter_files_source(source)
285
+
286
+ def _apply_template_dir_source(self, source: TemplateDirSource) -> None:
287
+ """Copy template directory contents to sandbox with overwrite tracking.
288
+
289
+ Args:
290
+ source: Template directory source configuration
291
+
292
+ Raises:
293
+ RuntimeError: If template directory doesn't exist or is not a directory
294
+ """
295
+ assert self.sandbox_dir is not None, "Sandbox directory not initialized"
296
+
297
+ template_path = Path(source.path)
298
+ logger = logging.getLogger(__name__)
299
+
300
+ if not template_path.exists():
301
+ raise RuntimeError(f"Template directory not found: {template_path}")
302
+
303
+ if not template_path.is_dir():
304
+ raise RuntimeError(f"Template path is not a directory: {template_path}")
305
+
306
+ mount_root = self._resolve_within_sandbox(source.mount_point, field="Template mount_point")
307
+ mount_root.mkdir(parents=True, exist_ok=True)
308
+
309
+ # Track overwrites for logging
310
+ overwrites: set[str] = set()
311
+
312
+ # Copy contents with ignore patterns
313
+ for item in template_path.rglob("*"):
314
+ # Calculate relative path
315
+ rel_path = item.relative_to(template_path)
316
+ # Match ignore patterns against the template-relative path only —
317
+ # checking the absolute path would let an ancestor directory named
318
+ # `dist`, `build`, `env`, `venv`, or `node_modules` filter out the
319
+ # entire template (e.g. if the repo is cloned under ~/build/…).
320
+ if self._should_ignore_template_file(rel_path) and not self._matches_template_include_pattern(
321
+ rel_path, source.include_patterns
322
+ ):
323
+ continue
324
+
325
+ dest_path = mount_root / rel_path
326
+
327
+ # is_symlink() must come first — is_dir() / is_file() follow
328
+ # symlinks, so a `tools/node_modules/fil-compiler -> ../fil`
329
+ # link would look like a directory and we'd create an empty
330
+ # dir at the destination, breaking npm workspace resolution.
331
+ if item.is_symlink():
332
+ # `is_symlink()` before `exists()` because `exists()` follows
333
+ # the link; a *broken* symlink at dest is still an overwrite
334
+ # we need to clear.
335
+ if dest_path.is_symlink() or dest_path.exists():
336
+ # Only a real directory needs rmtree; symlinks-to-dir,
337
+ # symlinks-to-file, and regular files all clear with
338
+ # unlink() (which removes the link, not its target).
339
+ if dest_path.is_dir() and not dest_path.is_symlink():
340
+ shutil.rmtree(dest_path)
341
+ else:
342
+ dest_path.unlink()
343
+ overwrites.add(str(rel_path))
344
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
345
+ # `item.is_dir()` follows the symlink, so it tells us
346
+ # whether the target is a directory. On Windows
347
+ # `os.symlink` needs `target_is_directory=True` for
348
+ # directory targets — without it Windows creates a
349
+ # file-symlink that can't be traversed. POSIX ignores
350
+ # the flag.
351
+ #
352
+ # We preserve `os.readlink(item)` verbatim — both
353
+ # relative (npm workspaces, e.g. `node_modules/foo
354
+ # -> ../foo`) and absolute targets. Absolute targets
355
+ # remain live links into the host filesystem inside
356
+ # the sandbox; template authors are trusted infra
357
+ # (see `templates/` in this repo), so this is the
358
+ # intended behavior, not a defense boundary.
359
+ os.symlink(
360
+ os.readlink(item),
361
+ dest_path,
362
+ target_is_directory=item.is_dir(),
363
+ )
364
+ elif item.is_dir():
365
+ dest_path.mkdir(parents=True, exist_ok=True)
366
+ elif item.is_file():
367
+ # Track overwrites
368
+ if dest_path.exists():
369
+ overwrites.add(str(rel_path))
370
+
371
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
372
+ shutil.copy2(item, dest_path)
373
+
374
+ # Enhanced logging based on overwrite count
375
+ if overwrites:
376
+ if len(overwrites) <= 5:
377
+ logger.debug(f"Overwrote {len(overwrites)} files from {source.path}: {', '.join(sorted(overwrites))}")
378
+ else:
379
+ logger.debug(f"Overwrote {len(overwrites)} files from {source.path}")
380
+
381
+ def _prepare_mock_path_dirs(self) -> None:
382
+ """Apply +x to plain files in each ``mock_path_dirs`` entry.
383
+
384
+ Resolves each configured directory against the sandbox root and, for every
385
+ plain file directly under it, ORs in the user/group/other execute bits.
386
+ Required on NTFS and after copies that drop the +x bit; a no-op when the
387
+ bit is already set. Missing entries and non-files (e.g. fixture
388
+ subdirectories) are skipped silently. PATH wiring happens in the agent --
389
+ this method only owns the filesystem side.
390
+ """
391
+ assert self.sandbox_dir is not None, "Sandbox directory not initialized"
392
+
393
+ for dir_path in self.resolved_mock_path_dirs:
394
+ for entry in dir_path.iterdir():
395
+ if entry.is_file():
396
+ entry.chmod(entry.stat().st_mode | 0o111)
397
+
398
+ @property
399
+ def resolved_mock_path_dirs(self) -> list[Path]:
400
+ """Absolute paths of configured mock dirs that exist on disk.
401
+
402
+ Returned in the order they appear in ``SandboxConfig.mock_path_dirs``;
403
+ non-existent and non-directory entries are filtered out so the caller
404
+ can pass the result straight to PATH-prepend logic.
405
+
406
+ Entries that resolve outside the sandbox root are rejected with a
407
+ RuntimeError -- a typo like ``"../mocks"`` would otherwise let
408
+ ``_prepare_mock_path_dirs`` chmod +x files on the host filesystem.
409
+ Mirrors the ``mount_point`` containment check in
410
+ :meth:`_apply_template_dir_source`.
411
+ """
412
+ if self.sandbox_dir is None or not self.config.mock_path_dirs:
413
+ return []
414
+ resolved: list[Path] = []
415
+ for rel in self.config.mock_path_dirs:
416
+ candidate = self._resolve_within_sandbox(rel, field="mock_path_dirs entry")
417
+ if candidate.is_dir():
418
+ resolved.append(candidate)
419
+ return resolved
420
+
421
+ def _apply_starter_files_source(self, source: StarterFilesSource) -> None:
422
+ """Create inline starter files in sandbox with overwrite tracking.
423
+
424
+ Args:
425
+ source: Starter files source configuration
426
+
427
+ Raises:
428
+ RuntimeError: If file path escapes sandbox (path traversal)
429
+ """
430
+ assert self.sandbox_dir is not None, "Sandbox directory not initialized"
431
+
432
+ logger = logging.getLogger(__name__)
433
+ overwrites: set[str] = set()
434
+
435
+ for starter_file in source.files:
436
+ # Reject path traversal before any filesystem write; the helper allows
437
+ # the resolved path to equal sandbox_root, which is harmless for files
438
+ # because subsequent mkdir/write_text would fail on an empty path anyway.
439
+ file_path = self._resolve_within_sandbox(starter_file.path, field="starter_files path")
440
+
441
+ # Track overwrites
442
+ if file_path.exists():
443
+ overwrites.add(starter_file.path)
444
+
445
+ # Create parent directories
446
+ file_path.parent.mkdir(parents=True, exist_ok=True)
447
+
448
+ # Write file content
449
+ file_path.write_text(starter_file.content, encoding="utf-8")
450
+
451
+ # Enhanced logging based on overwrite count
452
+ if overwrites:
453
+ if len(overwrites) <= 5:
454
+ logger.debug(f"Overwrote {len(overwrites)} files from starter_files: {', '.join(sorted(overwrites))}")
455
+ else:
456
+ logger.debug(f"Overwrote {len(overwrites)} files from starter_files")
457
+
458
+ def _should_ignore_template_file(self, path: Path) -> bool:
459
+ """Check if template file/directory should be ignored."""
460
+ patterns = get_ignore_patterns(self.config.ignore_patterns)
461
+ return should_ignore_path(path, patterns)
462
+
463
+ def _matches_template_include_pattern(self, rel_path: Path, include_patterns: list[str]) -> bool:
464
+ """Return whether a template-relative path is explicitly re-included.
465
+
466
+ Patterns are matched with :func:`fnmatch.fnmatchcase` against the
467
+ forward-slash form of ``rel_path``. Note that ``*`` does NOT stop at
468
+ ``/`` (unlike gitignore), so ``tools/*/dist`` will also match
469
+ ``tools/a/b/dist``. This is permissive by design: include patterns can
470
+ only re-include paths the default ignore list rejected, so widening is
471
+ safer than narrowing. A leading ``./`` on the pattern is stripped.
472
+ """
473
+ if not include_patterns:
474
+ return False
475
+ normalized_path = rel_path.as_posix()
476
+ for pattern in include_patterns:
477
+ normalized_pattern = pattern.replace("\\", "/").removeprefix("./")
478
+ if fnmatch.fnmatchcase(normalized_path, normalized_pattern):
479
+ return True
480
+ return False
481
+
482
+ def _setup_virtualenv(self) -> None:
483
+ """Create a Python virtual environment in the sandbox."""
484
+ if not self.sandbox_dir:
485
+ raise RuntimeError("Sandbox directory not initialized")
486
+
487
+ self.venv_dir = self.sandbox_dir / ".venv"
488
+
489
+ # Use uv to create virtual environment (faster than venv)
490
+ try:
491
+ # Check if uv is available
492
+ subprocess.run(["uv", "--version"], check=True, capture_output=True, timeout=5)
493
+ # Use uv to create venv
494
+ cmd = ["uv", "venv", str(self.venv_dir)]
495
+ subprocess.run(cmd, check=True, capture_output=True, text=True, encoding="utf-8", timeout=60)
496
+ except (subprocess.CalledProcessError, FileNotFoundError):
497
+ # Fallback to standard venv if uv is not available
498
+ import venv
499
+
500
+ venv.create(self.venv_dir, with_pip=True)
501
+
502
+ def _install_packages(self) -> None:
503
+ """Install required Python packages in the virtual environment."""
504
+ if not self.config.python or not self.config.python.env_packages or not self.venv_dir:
505
+ return
506
+
507
+ # Get path to pip in the virtual environment
508
+ scripts_dir = self._venv_scripts_dir
509
+ assert scripts_dir is not None # guaranteed by venv_dir guard above
510
+ pip_path = scripts_dir / "pip"
511
+
512
+ # Try uv first, fall back to pip
513
+ try:
514
+ subprocess.run(["uv", "--version"], check=True, capture_output=True, timeout=5)
515
+ # Use uv pip for faster installation
516
+ cmd = ["uv", "pip", "install", *self.config.python.env_packages]
517
+ env = os.environ.copy()
518
+ env["VIRTUAL_ENV"] = str(self.venv_dir)
519
+ env["PATH"] = f"{scripts_dir}{os.pathsep}{env['PATH']}"
520
+ except (subprocess.CalledProcessError, FileNotFoundError):
521
+ # Fallback to regular pip
522
+ cmd = [str(pip_path), "install", *self.config.python.env_packages]
523
+ env = None
524
+
525
+ try:
526
+ subprocess.run(cmd, check=True, capture_output=True, text=True, encoding="utf-8", timeout=300, env=env)
527
+ except subprocess.CalledProcessError as e:
528
+ raise RuntimeError(f"Failed to install packages: {e.stderr}") from e
529
+
530
+ def _install_node_packages(self) -> None:
531
+ """Install npm packages locally in the sandbox directory."""
532
+ if not self.config.node or not self.config.node.env_packages or not self.sandbox_dir:
533
+ return
534
+
535
+ packages = self.config.node.env_packages
536
+
537
+ # Try bun first, fall back to npm
538
+ try:
539
+ subprocess.run(["bun", "--version"], check=True, capture_output=True, timeout=5)
540
+ cmd = ["bun", "add", *packages]
541
+ except (subprocess.CalledProcessError, FileNotFoundError):
542
+ cmd = ["npm", "install", *packages]
543
+
544
+ try:
545
+ subprocess.run(
546
+ cmd,
547
+ check=True,
548
+ capture_output=True,
549
+ text=True,
550
+ encoding="utf-8",
551
+ timeout=300,
552
+ cwd=self.sandbox_dir,
553
+ )
554
+ except subprocess.CalledProcessError as e:
555
+ raise RuntimeError(f"Failed to install node packages: {e.stderr}") from e
556
+
557
+ # Capture installed versions
558
+ self._capture_node_tool_versions()
559
+
560
+ def _capture_node_tool_versions(self) -> None:
561
+ """Capture installed versions for explicitly requested npm packages only."""
562
+ if not self.sandbox_dir or not self.config.node:
563
+ return
564
+
565
+ node_modules = self.sandbox_dir / "node_modules"
566
+ if not node_modules.exists():
567
+ return
568
+
569
+ # Extract package names from specifiers (strip version: "@uipath/cli@0.1.5" -> "@uipath/cli")
570
+ requested_names: set[str] = set()
571
+ for spec in self.config.node.env_packages:
572
+ if spec.startswith("@"):
573
+ # Scoped: "@scope/pkg@version" -> "@scope/pkg"
574
+ requested_names.add("@" + spec[1:].split("@", 1)[0])
575
+ else:
576
+ # Unscoped: "pkg@version" -> "pkg"
577
+ requested_names.add(spec.split("@", 1)[0])
578
+
579
+ # Read package.json for each requested package
580
+ for name in requested_names:
581
+ if name.startswith("@") and "/" in name:
582
+ # Scoped: @scope/pkg -> node_modules/@scope/pkg
583
+ scope, pkg = name.split("/", 1)
584
+ pkg_dir = node_modules / scope / pkg
585
+ else:
586
+ pkg_dir = node_modules / name
587
+
588
+ pkg_json = pkg_dir / "package.json"
589
+ if pkg_json.exists():
590
+ try:
591
+ data = json.loads(pkg_json.read_text(encoding="utf-8"))
592
+ version = data.get("version", "unknown")
593
+ self.installed_tool_versions[name] = version
594
+ except (json.JSONDecodeError, OSError) as exc:
595
+ logger.debug(
596
+ "Failed to read or parse package.json for %s at %s: %s",
597
+ name,
598
+ pkg_json,
599
+ exc,
600
+ )
601
+
602
+ def set_command_base_path(self, path: str | None) -> None:
603
+ """Set the parent PATH used by sandbox command checks.
604
+
605
+ The orchestrator uses this to align success-criteria commands with the
606
+ PATH passed to the agent SDK. Sandbox-local venv and node bin entries
607
+ are still prepended by ``run_command``.
608
+
609
+ Also re-derives the canonical ``PLUGIN_TOOLS_DIR`` (MST-9795): the
610
+ resolved ``uip`` binary depends on PATH, and the path-aligned criterion
611
+ is the canonical lookup. Failures are swallowed — the env var simply
612
+ stays unset and the CLI falls back to its walk-based discovery.
613
+
614
+ Passing ``None`` clears the agent-aligned PATH prefix and re-derives
615
+ ``PLUGIN_TOOLS_DIR`` from ``os.environ['PATH']`` alone. The new pin
616
+ may differ from the previous one if the parent PATH resolves ``uip``
617
+ to a different install — by design, since dropping the agent
618
+ alignment means the criterion subprocess should now match the parent
619
+ environment.
620
+ """
621
+ self._command_base_path = path or None
622
+ self._refresh_plugin_tools_dir()
623
+
624
+ @property
625
+ def command_base_path(self) -> str | None:
626
+ """Read-only view of the configured base PATH (or ``None`` when unset).
627
+
628
+ Tests can observe orchestrator-set overrides without touching the
629
+ underlying private slot. Mutate via :meth:`set_command_base_path`.
630
+ """
631
+ return self._command_base_path
632
+
633
+ @property
634
+ def plugin_tools_dir(self) -> str | None:
635
+ """Canonical ``node_modules/@uipath`` derived from the resolved ``uip``.
636
+
637
+ Populated by :meth:`_refresh_plugin_tools_dir` after the agent's PATH
638
+ is captured. When non-None, ``_build_run_command_env`` exports it as
639
+ ``PLUGIN_TOOLS_DIR`` so the UiPath CLI pins plugin discovery instead
640
+ of walking up from CWD — eliminating MST-9795's host-pollution
641
+ asymmetry between authoring-time and criterion-time validation.
642
+
643
+ Returns ``None`` when ``uip`` is not on PATH or the resolved binary
644
+ does not live inside a recognizable ``node_modules/@uipath`` tree
645
+ (e.g. development monorepo runs).
646
+ """
647
+ return self._plugin_tools_dir
648
+
649
+ @property
650
+ def uip_search_path(self) -> str:
651
+ """The PATH used to resolve ``uip`` — agent-aligned prefix + process PATH.
652
+
653
+ The same PATH ``run_command`` subprocesses and the agent SDK env see,
654
+ so a binary resolved against it is the one task commands actually
655
+ executed.
656
+ """
657
+ search_path = os.environ.get("PATH", "")
658
+ if self._command_base_path:
659
+ search_path = f"{self._command_base_path}{os.pathsep}{search_path}"
660
+ return search_path
661
+
662
+ def refresh_plugin_tools_dir(self) -> None:
663
+ """Re-derive :attr:`plugin_tools_dir` for the current PATH.
664
+
665
+ Public hook for callers that need post-task state: the UiPath CLI
666
+ auto-installs/upgrades its tool plugins on first use, so a pin derived
667
+ at setup time can be stale (or ``None``) by the time the task ends.
668
+ """
669
+ self._refresh_plugin_tools_dir()
670
+
671
+ def _refresh_plugin_tools_dir(self) -> None:
672
+ """Resolve the canonical ``node_modules/@uipath`` for the current PATH.
673
+
674
+ Delegates to :func:`coder_eval.utils.resolve_uipath_plugin_dir` against
675
+ ``uip_search_path`` (``command_base_path + os.environ['PATH']`` — the
676
+ same PATH ``run_command`` and the agent SDK will see), then stores the
677
+ result as a string on ``self._plugin_tools_dir`` (or ``None`` if no
678
+ usable ``uip`` is on PATH). Idempotent across calls; safe to call from
679
+ both ``setup`` (initial value when no command_base_path yet) and
680
+ ``set_command_base_path`` (re-derive after PATH alignment).
681
+ """
682
+ from .utils import resolve_uipath_plugin_dir
683
+
684
+ resolved = resolve_uipath_plugin_dir(self.uip_search_path)
685
+ self._plugin_tools_dir = str(resolved) if resolved is not None else None
686
+
687
+ def _maybe_remediate_home_plugins_pollution(self) -> Path | None:
688
+ """Optionally delete ``$HOME/node_modules/@uipath`` before the task runs.
689
+
690
+ Gated on the ``CODER_EVAL_REMEDIATE_HOME_PLUGINS`` env var being a
691
+ truthy string (``"1"``/``"true"``/``"yes"``, case-insensitive). Off
692
+ by default — silent deletion of a user-owned directory is
693
+ destructive and a generic eval framework should not own that
694
+ decision. Operators of dedicated eval runners (Azure) flip the flag
695
+ on at host-bring-up time because every task there is poisoned by
696
+ sibling tasks leaking installs into ``$HOME`` (MST-9674 / MST-9795).
697
+
698
+ Returns the deleted directory on success, ``None`` when no action
699
+ was taken (flag off, dir absent, or under-test ``$HOME`` mismatch).
700
+
701
+ TOCTOU: there is a small window between ``Path.resolve(strict=True)``
702
+ and ``shutil.rmtree`` during which the target could be replaced.
703
+ Combined defenses: (a) the resolved-anchor check rejects HOME=/,
704
+ (b) ``resolved_home in resolved_target.parents`` confines deletion
705
+ under HOME, (c) the operator opt-in gates the entire path. Failures
706
+ in ``rmtree`` are silenced (``ignore_errors=True``) and surfaced via
707
+ a residual-presence warning rather than a raise.
708
+ """
709
+ flag = os.environ.get(self.REMEDIATE_HOME_PLUGINS_ENV, "").strip().lower()
710
+ if flag not in {"1", "true", "yes"}:
711
+ return None
712
+ home = os.environ.get("HOME") or os.environ.get("USERPROFILE")
713
+ if not home:
714
+ return None
715
+ target = Path(home) / "node_modules" / "@uipath"
716
+ if not target.is_dir():
717
+ return None
718
+ # Refuse to touch anything outside the configured HOME — if HOME
719
+ # somehow points at root or a system dir, bail out loudly rather
720
+ # than rm-rf'ing it. The check is belt-and-suspenders: the path
721
+ # construction above already anchors at $HOME.
722
+ try:
723
+ resolved_target = target.resolve(strict=True)
724
+ resolved_home = Path(home).resolve(strict=True)
725
+ except (OSError, RuntimeError) as exc:
726
+ logger.warning(
727
+ "Cannot resolve %s for remediation: %s",
728
+ target,
729
+ exc,
730
+ )
731
+ return None
732
+ if resolved_home == Path(resolved_home.anchor):
733
+ logger.warning(
734
+ "Refusing to remediate %s: HOME=%s resolves to the filesystem root",
735
+ target,
736
+ resolved_home,
737
+ )
738
+ return None
739
+ if resolved_home not in resolved_target.parents:
740
+ logger.warning(
741
+ "Refusing to remediate %s: resolved target %s is not under HOME %s",
742
+ target,
743
+ resolved_target,
744
+ resolved_home,
745
+ )
746
+ return None
747
+ logger.warning(
748
+ "MST-9795 remediation: removing host-pollution dir %s (gated on %s; sibling tasks leaked installs)",
749
+ resolved_target,
750
+ self.REMEDIATE_HOME_PLUGINS_ENV,
751
+ )
752
+ shutil.rmtree(resolved_target, ignore_errors=True)
753
+ if resolved_target.exists():
754
+ logger.warning(
755
+ "MST-9795 remediation: %s still present after rmtree (partial delete; check fs busy/locked files)",
756
+ resolved_target,
757
+ )
758
+ return resolved_target
759
+
760
+ def _build_run_command_env(self) -> dict[str, str]:
761
+ """Build the environment for ``run_command``.
762
+
763
+ Each layer is independent — none breaks if another is absent:
764
+
765
+ 1. Inherit parent env (so agent tools / credentials remain reachable).
766
+ 2. (MST-9265) If the orchestrator has captured the agent's SDK PATH
767
+ via :meth:`set_command_base_path`, **prepend** it ahead of the
768
+ host PATH (not replace) — the agent's PATH only needs to win
769
+ the lookup race for its bundled toolchain, but system binaries
770
+ (``python``, ``node``, ``/usr/bin/*``) must remain reachable to
771
+ criteria. Prepend semantics also stay symmetric with the venv /
772
+ node_bin prepends below.
773
+ 3. Activate the sandbox virtualenv (if present). First-hit-wins:
774
+ if the agent's PATH already contains the venv scripts dir
775
+ (likely, since the agent inherits this process's env), this
776
+ prepend duplicates the entry. Harmless on every OS we target;
777
+ left explicit so the order stays independent of what the agent
778
+ SDK happens to inject.
779
+ 4. Prepend ``<sandbox>/node_modules/.bin`` to PATH (if present).
780
+ 5. (MST-9674) Pin ``NODE_PATH=""`` so Node's fallback search paths
781
+ cannot pick up contaminated parent-dir installs. Note: this
782
+ does NOT disable parent-walking from cwd — that is hard-wired
783
+ in Node — but it eliminates ``NODE_PATH``-mediated leaks.
784
+ 6. (MST-9674) Pin ``NPM_CONFIG_PREFIX`` to a sandbox-scoped
785
+ directory so any ``npm install`` / ``bun add`` from inside the
786
+ sandbox writes into the sandbox, not into
787
+ ``$HOME/node_modules`` where concurrent sandboxes would shadow
788
+ each other.
789
+ 7. Expose ``TASK_DIR`` for criterion scripts.
790
+ """
791
+ assert self.sandbox_dir is not None
792
+ env = os.environ.copy()
793
+ if self._command_base_path:
794
+ env["PATH"] = f"{self._command_base_path}{os.pathsep}{env['PATH']}"
795
+ if self.venv_dir:
796
+ env["VIRTUAL_ENV"] = str(self.venv_dir)
797
+ env["PATH"] = f"{self._venv_scripts_dir}{os.pathsep}{env['PATH']}"
798
+ node_bin = self.sandbox_dir / "node_modules" / ".bin"
799
+ if node_bin.exists():
800
+ env["PATH"] = f"{node_bin}{os.pathsep}{env['PATH']}"
801
+ # MST-9674: keep Node and npm resolution sandbox-local so concurrent
802
+ # tasks cannot poison each other through shared parent-dir node_modules.
803
+ env["NODE_PATH"] = ""
804
+ env["NPM_CONFIG_PREFIX"] = str(self.sandbox_dir / ".npm-prefix")
805
+ # Pin UiPath CLI plugin discovery so the criterion subprocess uses the
806
+ # same @uipath tools the agent authored against (defers to an external pin).
807
+ if self._plugin_tools_dir and "PLUGIN_TOOLS_DIR" not in env:
808
+ env["PLUGIN_TOOLS_DIR"] = self._plugin_tools_dir
809
+ if self.task_dir:
810
+ env["TASK_DIR"] = str(self.task_dir)
811
+ return env
812
+
813
+ def _check_parent_node_modules_contamination(self) -> list[Path]:
814
+ """Walk up from ``sandbox_dir`` and report any ancestor that has a
815
+ populated ``node_modules/`` directory.
816
+
817
+ Concurrent tasks (or anything else on the host that runs
818
+ ``cd <ancestor> && npm install ... --save``) drop packages into
819
+ shared parent dirs. Node's parent-walking module resolver finds
820
+ those before the sandbox-local install, which is the proximate
821
+ cause of MST-9674's ``unknown command 'run'`` failure — but the
822
+ failure mode is generic to Node module resolution, not specific
823
+ to any one npm scope. The check therefore stays
824
+ scope-agnostic: ``coder_eval`` is a generic evaluation framework
825
+ and should not single out one ecosystem's namespace. Operators
826
+ read the logged entry list to decide whether the contamination
827
+ actually matters for their agent's toolchain.
828
+
829
+ This is a *detection-only* helper. It returns the list of
830
+ ancestor ``node_modules`` dirs found and logs a single warning
831
+ per dir. Auto-remediation is intentionally avoided — those dirs
832
+ may legitimately belong to the user and silently deleting them
833
+ would be destructive.
834
+ """
835
+ if self.sandbox_dir is None:
836
+ return []
837
+ offenders: list[Path] = []
838
+ seen: set[Path] = set()
839
+ # Walk strictly upward; do not include the sandbox itself (its
840
+ # node_modules is intentional).
841
+ for parent in self.sandbox_dir.resolve().parents:
842
+ if parent in seen:
843
+ continue
844
+ seen.add(parent)
845
+ node_modules_dir = parent / "node_modules"
846
+ if not node_modules_dir.is_dir():
847
+ continue
848
+ try:
849
+ # Skip dot-entries (``.bin``, ``.cache``, …) — they are
850
+ # package-manager bookkeeping, not installed packages
851
+ # that would shadow a sandbox-local install.
852
+ entries = sorted(p.name for p in node_modules_dir.iterdir() if not p.name.startswith("."))
853
+ except OSError:
854
+ # Permission denied / race-with-delete — skip silently.
855
+ continue
856
+ if not entries:
857
+ continue
858
+ offenders.append(node_modules_dir)
859
+ sample = ", ".join(entries[:5])
860
+ more = f" (+{len(entries) - 5} more)" if len(entries) > 5 else ""
861
+ logger.warning(
862
+ "Parent-dir node_modules contamination detected at %s — "
863
+ + "Node's parent-walking resolver may pick this up before the "
864
+ + "sandbox-local install (see MST-9674). Contents: %s%s",
865
+ node_modules_dir,
866
+ sample,
867
+ more,
868
+ )
869
+ return offenders
870
+
871
+ def run_command(self, command: str, timeout: float | int | None = None) -> tuple[int, str, str]:
872
+ """Run a command in the sandbox environment.
873
+
874
+ Args:
875
+ command: Command to execute
876
+ timeout: Timeout in seconds (uses config default if not specified)
877
+
878
+ Returns:
879
+ Tuple of (exit_code, stdout, stderr)
880
+
881
+ Raises:
882
+ RuntimeError: If sandbox is not set up
883
+ subprocess.TimeoutExpired: If command times out
884
+ """
885
+ if not self.sandbox_dir:
886
+ raise RuntimeError("Sandbox not set up. Call setup() first.")
887
+
888
+ # Use timeout from argument or config
889
+ if timeout is None:
890
+ timeout = self.config.limits.timeout
891
+
892
+ env = self._build_run_command_env()
893
+
894
+ try:
895
+ # Shell execution is intentional for sandbox - allows pipes, redirects, and complex commands.
896
+ # Decode stdout/stderr as UTF-8 with replacement on bad bytes so an agent that emits
897
+ # non-UTF-8 output (e.g. raw binary, locale-encoded compiler errors on Windows) does not
898
+ # kill the run with UnicodeDecodeError. Downstream callers (e.g. json_check) only need
899
+ # JSON-parseable strings; a replacement char is preferable to a crash.
900
+ result = subprocess.run(
901
+ command,
902
+ shell=True, # nosec B602 - Required for sandbox command execution
903
+ cwd=self.sandbox_dir,
904
+ env=env,
905
+ capture_output=True,
906
+ text=True,
907
+ encoding="utf-8",
908
+ errors="replace",
909
+ timeout=timeout,
910
+ )
911
+
912
+ # Log command completion
913
+ logger.debug(f"Command '{command}' exited with code {result.returncode}")
914
+
915
+ # Log stdout if non-empty (pre-strip for cleaner code)
916
+ stdout_content = result.stdout.strip()
917
+ if stdout_content:
918
+ logger.debug(f"STDOUT:\n---\n{stdout_content}\n---")
919
+
920
+ # Log stderr if non-empty
921
+ stderr_content = result.stderr.strip()
922
+ if stderr_content:
923
+ logger.debug(f"STDERR:\n---\n{stderr_content}\n---")
924
+
925
+ return result.returncode, result.stdout, result.stderr
926
+
927
+ except subprocess.TimeoutExpired:
928
+ error_msg = f"Command '{command}' timed out after {timeout} seconds"
929
+ logger.warning(error_msg)
930
+ return -1, "", error_msg
931
+
932
+ # NOTE: get_file_content, file_exists, and list_files intentionally do NOT validate
933
+ # path traversal. The sandbox is a trusted execution environment where the agent
934
+ # needs filesystem access beyond the sandbox root (e.g., reading installed packages,
935
+ # system headers). Path traversal protection is handled at the agent permission level.
936
+
937
+ def get_file_content(self, path: str) -> str:
938
+ """Read the content of a file in the sandbox.
939
+
940
+ Args:
941
+ path: Relative path to the file
942
+
943
+ Returns:
944
+ File content as string
945
+
946
+ Raises:
947
+ RuntimeError: If sandbox is not set up
948
+ FileNotFoundError: If file doesn't exist
949
+ """
950
+ if not self.sandbox_dir:
951
+ raise RuntimeError("Sandbox not set up")
952
+
953
+ file_path = self.sandbox_dir / path
954
+ return file_path.read_text(encoding="utf-8")
955
+
956
+ def file_exists(self, path: str) -> bool:
957
+ """Check if a file exists in the sandbox.
958
+
959
+ Args:
960
+ path: Relative path to the file
961
+
962
+ Returns:
963
+ True if file exists, False otherwise
964
+ """
965
+ if not self.sandbox_dir:
966
+ return False
967
+
968
+ return (self.sandbox_dir / path).exists()
969
+
970
+ def list_files(self, path: str = ".") -> list[str]:
971
+ """List files in a directory within the sandbox.
972
+
973
+ Args:
974
+ path: Relative path to directory (default: root)
975
+
976
+ Returns:
977
+ List of file paths relative to sandbox root
978
+ """
979
+ if not self.sandbox_dir:
980
+ return []
981
+
982
+ target_dir = self.sandbox_dir / path
983
+ if not target_dir.exists() or not target_dir.is_dir():
984
+ return []
985
+
986
+ files = []
987
+ for item in target_dir.rglob("*"):
988
+ if item.is_file():
989
+ rel_path = item.relative_to(self.sandbox_dir)
990
+ files.append(str(rel_path))
991
+
992
+ return sorted(files)
993
+
994
+ def grant_read_access(self) -> None:
995
+ """Apply ``chmod a+rX`` across the sandbox tree (in place).
996
+
997
+ For DIRECT_WRITE preservation the sandbox already lives in the
998
+ artifacts dir, so ``preserve_to`` (which would otherwise grant this)
999
+ never runs. A root-owned docker container leaves the tree at 0700, so
1000
+ the host user can't traverse it; granting group+other read/traverse
1001
+ keeps the artifacts visible across the uid boundary. No-op-ish on the
1002
+ host path, where the sandbox is already owner-readable.
1003
+ """
1004
+ if self.sandbox_dir is not None and self.sandbox_dir.exists():
1005
+ _grant_read_traverse(self.sandbox_dir)
1006
+
1007
+ def preserve_to(self, artifact_dir: Path) -> Path:
1008
+ """Preserve sandbox contents to an artifact directory.
1009
+
1010
+ Uses ``shutil.move``: an atomic rename when source and destination
1011
+ share a filesystem, copy+remove otherwise. Either way the source side
1012
+ is gone the moment this method returns (no second-pass rmtree).
1013
+
1014
+ Args:
1015
+ artifact_dir: Directory to move sandbox contents into.
1016
+
1017
+ Returns:
1018
+ Path to the preserved sandbox.
1019
+
1020
+ Raises:
1021
+ RuntimeError: If sandbox is not set up.
1022
+ """
1023
+ if not self.sandbox_dir:
1024
+ raise RuntimeError("Sandbox not set up")
1025
+
1026
+ # task_id may contain "/" (dataset row tasks); ensure the parent exists.
1027
+ preserve_path = artifact_dir / self.task_id
1028
+ preserve_path.parent.mkdir(parents=True, exist_ok=True)
1029
+
1030
+ # Guard against self-referential move (sandbox already at target).
1031
+ if self.sandbox_dir.resolve() == preserve_path.resolve():
1032
+ return preserve_path
1033
+
1034
+ if preserve_path.exists():
1035
+ shutil.rmtree(preserve_path)
1036
+
1037
+ old_sandbox_dir = self.sandbox_dir
1038
+ shutil.move(str(old_sandbox_dir), str(preserve_path))
1039
+
1040
+ # mkdtemp creates the sandbox root at 0700. Under driver:docker the
1041
+ # container runs as root, so the preserved tree lands on the host
1042
+ # bind-mount owned by root with that 0700 top dir -- the host user
1043
+ # (a different uid) then can't traverse it, so the blob upload and any
1044
+ # `ls` see an empty dir and silently skip the artifacts. Grant a+rX on
1045
+ # the preserved tree so artifacts are readable across the uid boundary.
1046
+ # No-op-ish on the host path, where the sandbox is already owner-readable.
1047
+ _grant_read_traverse(preserve_path)
1048
+
1049
+ # Sandbox now lives at the artifact path -- redirect pointers so that a
1050
+ # subsequent cleanup() is a no-op. Venv absolute paths inside the venv
1051
+ # are not rewritten (same behaviour as the prior copy-based code).
1052
+ self.sandbox_dir = preserve_path
1053
+ if self.venv_dir is not None:
1054
+ try:
1055
+ rel = self.venv_dir.relative_to(old_sandbox_dir)
1056
+ self.venv_dir = preserve_path / rel
1057
+ except ValueError:
1058
+ # Defensive: venv_dir is currently always created under
1059
+ # sandbox_dir (see _setup_virtualenv), so relative_to should
1060
+ # always succeed. If a future code path places it elsewhere,
1061
+ # leave the pointer untouched -- the move did not relocate it.
1062
+ pass
1063
+ self._cleanup_on_exit = False
1064
+ return preserve_path
1065
+
1066
+ def capture_to(self, artifact_dir: Path) -> Path:
1067
+ """Copy an in-place workspace out to ``artifact_dir/<task_id>`` (docker WORKDIR mode).
1068
+
1069
+ Sibling to :meth:`preserve_to`, but COPIES instead of ``shutil.move``: the
1070
+ sandbox here is the container's own WORKDIR (e.g. ``/root``), which is
1071
+ discarded with ``--rm``, and the orchestrator's own cwd may sit under it --
1072
+ so a copy is safe and non-destructive. ``symlinks=True`` +
1073
+ ``ignore_dangling_symlinks=True`` makes a dangling symlink a no-op rather
1074
+ than a failure (the exact breakage the old ``cp -a "$PWD/." "/root/"``
1075
+ reconciliation prelude hit). Grants cross-uid read on the COPY, since that
1076
+ is the artifact the host reads (mirrors preserve_to's grant on its dest).
1077
+
1078
+ Because the WORKDIR can be HOME (``/root``) or otherwise overlap
1079
+ framework mounts, we exclude framework/sensitive entries via
1080
+ :data:`_WORKSPACE_CAPTURE_IGNORE` -- most importantly ``.claude`` (the
1081
+ RW lean copy of the host ``~/.claude`` carries ``.credentials.json``;
1082
+ without this a ``/root`` WORKDIR would leak it into artifacts), plus
1083
+ ``.venv``/``node_modules``/``.npm-prefix`` (sandbox-created bulk, already
1084
+ stripped by the standard artifacts path's post-run cleanup).
1085
+
1086
+ Returns the destination path; unlike preserve_to it does NOT repoint
1087
+ ``self.sandbox_dir`` -- the workspace persists in-container and is reaped
1088
+ with the container, and ``_cleanup_on_exit`` is already False (run-in-place).
1089
+ """
1090
+ if not self.sandbox_dir:
1091
+ raise RuntimeError("Sandbox not set up")
1092
+
1093
+ # task_id may contain "/" (dataset row tasks); ensure the parent exists.
1094
+ preserve_path = artifact_dir / self.task_id
1095
+ preserve_path.parent.mkdir(parents=True, exist_ok=True)
1096
+
1097
+ # Guard against a self-referential copy (workspace already at target).
1098
+ if self.sandbox_dir.resolve() == preserve_path.resolve():
1099
+ _grant_read_traverse(preserve_path)
1100
+ return preserve_path
1101
+
1102
+ if preserve_path.exists():
1103
+ shutil.rmtree(preserve_path)
1104
+ shutil.copytree(
1105
+ self.sandbox_dir,
1106
+ preserve_path,
1107
+ symlinks=True,
1108
+ ignore_dangling_symlinks=True,
1109
+ ignore=shutil.ignore_patterns(*_WORKSPACE_CAPTURE_IGNORE),
1110
+ )
1111
+ _grant_read_traverse(preserve_path)
1112
+ return preserve_path
1113
+
1114
+ def cleanup(self, preserve: bool = False) -> None:
1115
+ """Clean up the sandbox environment.
1116
+
1117
+ Args:
1118
+ preserve: If True, skip cleanup (caller should use preserve_to() explicitly)
1119
+
1120
+ Note:
1121
+ If you want to preserve the sandbox, call preserve_to() before cleanup().
1122
+ The preserve parameter just skips deletion for manual inspection.
1123
+ """
1124
+ if self.sandbox_dir and self.sandbox_dir.exists() and self._cleanup_on_exit and not preserve:
1125
+ shutil.rmtree(self.sandbox_dir)
1126
+ self.sandbox_dir = None
1127
+ self.venv_dir = None