Skip to content

Commit 6926315

Browse files
authored
fix: harden execution and archive handling (#1469)
* fix: harden execution and archive handling * style: sort security fix imports * fix: contain workspace file operations * docs: document execution security boundaries * fix: enforce JSON grading output contract * fix: close security hardening regressions
1 parent 6cfa296 commit 6926315

13 files changed

Lines changed: 660 additions & 78 deletions

File tree

docs/development.rst

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,56 @@ File Naming Convention
8383
- The configuration for the module, app, and project.
8484

8585
.. <!-- TODO: renaming files -->
86+
87+
88+
Security boundaries for generated code and artifacts
89+
====================================================
90+
91+
RD-Agent executes generated code and processes files produced by external
92+
tools. Treat filenames, archive members, subprocess output, competition names,
93+
and environment names as untrusted input even when the surrounding workflow is
94+
started by a trusted operator.
95+
96+
Workspace file operations
97+
-------------------------
98+
99+
Use :meth:`FBWorkspace.inject_files <rdagent.core.experiment.FBWorkspace.inject_files>`
100+
and :meth:`FBWorkspace.remove_files <rdagent.core.experiment.FBWorkspace.remove_files>`
101+
for files owned by an experiment workspace. These methods accept relative paths
102+
inside ``workspace_path``. Parent traversal, absolute paths, and paths that
103+
escape through a symbolic link are rejected before a file is written or
104+
deleted.
105+
106+
Do not construct an unchecked path from a generated filename and then call
107+
``write_text()``, ``unlink()``, or another filesystem operation directly. New
108+
workspace APIs should apply the same resolved-path containment rule to every
109+
write and delete branch.
110+
111+
Archive extraction
112+
------------------
113+
114+
Use ``rdagent.utils.archive.safe_extract_zip`` and
115+
``rdagent.utils.archive.safe_extract_tar`` for archives that may contain
116+
externally supplied entries. The helpers reject:
117+
118+
* absolute and parent-traversal member paths;
119+
* symbolic links and hard links;
120+
* device nodes and other special file types; and
121+
* archives containing more than 10,000 members by default.
122+
123+
Avoid ``ZipFile.extractall()`` and ``TarFile.extractall()`` in these paths. If a
124+
workflow legitimately needs links, special files, or a larger archive, handle
125+
that input in a separately reviewed trusted-data path rather than weakening the
126+
shared helpers.
127+
128+
Commands and parsed process output
129+
----------------------------------
130+
131+
Pass subprocess arguments as a list with ``shell=False`` whenever any argument
132+
can vary. Conda environment names are limited to letters, digits, ``_``, ``-``,
133+
and ``.``, and Python versions must be numeric dotted versions. Kaggle
134+
competition identifiers use the corresponding competition-slug validator.
135+
136+
Never use ``eval()`` to parse subprocess or container output. Score output must
137+
be finite numeric JSON. Legacy Python dictionary-shaped training metrics may be
138+
parsed with ``ast.literal_eval()`` only when JSON cannot be used.

rdagent/components/coder/finetune/unified_validator.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
2. Micro-batch testing - Runtime validation with small dataset
77
"""
88

9+
import ast
910
import json
1011
import re
1112
import time
@@ -226,10 +227,10 @@ def _parse_execution_log(self, stdout: str, exit_code: int, failed_stage: str =
226227
result["num_epochs"] = int(num_epochs.group(1).replace(",", ""))
227228

228229
# Extract final metrics (JSON format from trainer output)
229-
final_metrics = re.search(r"\{'train_runtime':[^}]+\}", stdout)
230+
final_metrics = re.search(r"\{[\"']train_runtime[\"']:[^}]+\}", stdout)
230231
if final_metrics:
231232
try:
232-
metrics = eval(final_metrics.group(0)) # Safe: only numbers and strings
233+
metrics = ast.literal_eval(final_metrics.group(0))
233234
result["final_metrics"] = {
234235
"train_loss": metrics.get("train_loss"),
235236
"train_runtime": metrics.get("train_runtime"),

rdagent/core/experiment.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,27 @@ def link_all_files_in_folder_to_workspace(data_path: Path, workspace_path: Path)
220220

221221
DEL_KEY = "__DEL__"
222222

223+
def _resolve_workspace_path(self, file_name: str, *, follow_leaf_symlink: bool = True) -> Path:
224+
"""Resolve a caller-provided file name without allowing workspace escape."""
225+
relative_path = Path(file_name)
226+
if relative_path.is_absolute() or ".." in relative_path.parts:
227+
message = f"File path must be relative and contained in the workspace: {file_name}"
228+
raise ValueError(message)
229+
230+
workspace_root = self.workspace_path.resolve()
231+
target_file_path = workspace_root / relative_path
232+
resolved_path = (
233+
target_file_path.resolve()
234+
if follow_leaf_symlink
235+
else target_file_path.parent.resolve() / target_file_path.name
236+
)
237+
try:
238+
resolved_path.relative_to(workspace_root)
239+
except ValueError as exc:
240+
message = f"File path escapes workspace: {file_name}"
241+
raise ValueError(message) from exc
242+
return resolved_path if follow_leaf_symlink else target_file_path
243+
223244
def inject_files(self, **files: str) -> None:
224245
"""
225246
Inject the code into the folder.
@@ -232,12 +253,13 @@ def inject_files(self, **files: str) -> None:
232253
"""
233254
self.prepare()
234255
for k, v in files.items():
235-
target_file_path = self.workspace_path / k # Define target_file_path before using it
236256
if v == self.DEL_KEY: # Use self.DEL_KEY to access the class variable
237-
if target_file_path.exists():
257+
target_file_path = self._resolve_workspace_path(k, follow_leaf_symlink=False)
258+
if target_file_path.exists() or target_file_path.is_symlink():
238259
target_file_path.unlink() # Unlink the file if it exists
239260
self.file_dict.pop(k, None) # Safely remove the key from file_dict
240261
else:
262+
target_file_path = self._resolve_workspace_path(k)
241263
self.file_dict[k] = v
242264
target_file_path.parent.mkdir(parents=True, exist_ok=True)
243265
target_file_path.write_text(v)
@@ -249,8 +271,8 @@ def remove_files(self, file_names: str | list[str]) -> None:
249271
if isinstance(file_names, str):
250272
file_names = [file_names]
251273
for file_name in file_names:
252-
target_file_path = self.workspace_path / file_name
253-
if target_file_path.exists():
274+
target_file_path = self._resolve_workspace_path(file_name, follow_leaf_symlink=False)
275+
if target_file_path.exists() or target_file_path.is_symlink():
254276
target_file_path.unlink() # Unlink the file if it exists
255277
self.file_dict.pop(file_name, None) # Safely remove the key from file_dict
256278

rdagent/scenarios/data_science/proposal/exp_gen/select/prompts.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,10 @@ grade:
107107
Write a Python script named `grade.py` to evaluate `submission.csv` produced by a model.
108108
Input files (relative to current working directory):
109109
- `{{ input_folder }}/label.csv` and `submission.csv`
110-
Output format: `{'score': float, 'metric': str}`
110+
Output exactly one valid JSON object on one line, for example:
111+
`{"score": 0.75, "metric": "auc"}`
112+
Use `json.dumps(...)` to produce the output. Do not print a Python dictionary representation.
113+
`score` must be a finite JSON number, not a string, boolean, NaN, or Infinity.
111114
{% if error %}
112115
{{ error }}
113116
{% endif %}

rdagent/scenarios/data_science/proposal/exp_gen/select/submit.py

Lines changed: 69 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import json
2+
import math
23
import os
34
import pickle
45
import re
56
import shutil
6-
import tarfile
77
import time
88
from pathlib import Path
99
from typing import Any, Dict, List, Optional, Tuple
@@ -26,6 +26,7 @@
2626
from rdagent.scenarios.data_science.experiment.experiment import DSExperiment
2727
from rdagent.utils.agent.ret import PythonAgentOut
2828
from rdagent.utils.agent.tpl import T
29+
from rdagent.utils.archive import safe_extract_tar
2930
from rdagent.utils.fmt import shrink_text
3031
from rdagent.utils.workflow import wait_retry
3132

@@ -350,6 +351,34 @@ def print_code(self, data_py_code: str, grade_py_code: str):
350351
print(grade_py_code)
351352
print("======== code end ========")
352353

354+
def _validate_grade_script(self, grade_py_code: str, reference_exp: DSExperiment, mock_folder: str) -> None:
355+
"""Run a reusable grade script and verify its output contract."""
356+
input_folder = T("scenarios.data_science.share:scen.input_path").r()
357+
submission_path = Path(mock_folder) / "submission.csv"
358+
if not submission_path.exists():
359+
message = f"Cannot validate grade.py because {submission_path} does not exist."
360+
raise RuntimeError(message)
361+
362+
ws = FBWorkspace()
363+
ws.inject_code_from_file_dict(reference_exp.experiment_workspace)
364+
ws.inject_files(**{"grade.py": grade_py_code})
365+
shutil.copy(str(submission_path), str(ws.workspace_path / "submission.csv"))
366+
env = get_ds_env(extra_volumes={str(Path(mock_folder) / input_folder): {"bind": input_folder, "mode": "rw"}})
367+
result = ws.run(env=env, entry=f"python grade.py --cache-buster={time.time()}")
368+
stdout = re.sub(r"^chmod:.*\n?", "", result.stdout, flags=re.MULTILINE)
369+
370+
if result.exit_code != 0:
371+
output = shrink_text(stdout, context_lines=20, line_len=500)
372+
message = f"grade.py validation failed with exit code {result.exit_code}: {output}"
373+
raise RuntimeError(message)
374+
if _parsing_score(stdout) is None:
375+
output = shrink_text(stdout, context_lines=20, line_len=500)
376+
message = (
377+
"grade.py must print a valid JSON object whose 'score' is a finite numeric value; "
378+
f"received stdout: {output}"
379+
)
380+
raise RuntimeError(message)
381+
353382
def _prepare_validation_scripts(
354383
self, reference_exp: DSExperiment, competition: str, mock_folder: str
355384
) -> Tuple[str, str]:
@@ -361,6 +390,7 @@ def _prepare_validation_scripts(
361390
data_py_path = Path(mock_folder) / "data.py"
362391
grade_py_path = Path(mock_folder) / "grade.py"
363392
label_path = Path(mock_folder) / "workspace_input/label.csv"
393+
submission_path = Path(mock_folder) / "submission.csv"
364394
reference_code = reference_exp.experiment_workspace.file_dict.get("main.py", "")
365395
if not reference_code:
366396
raise RuntimeError("ValidationSelector: No code found in the reference experiment.")
@@ -370,7 +400,7 @@ def _prepare_validation_scripts(
370400
shutil.copy(self.sample_code_path / competition / "grade.py", grade_py_path)
371401
data_py_code = data_py_path.read_text()
372402
grade_py_code = grade_py_path.read_text()
373-
if not label_path.exists():
403+
if not label_path.exists() or not submission_path.exists():
374404
ws = FBWorkspace()
375405
if self.sample_rate != 0.8:
376406
data_py_code = data_py_code.replace("0.8", str(self.sample_rate)).replace(
@@ -390,10 +420,11 @@ def _prepare_validation_scripts(
390420
) # Do not cache the result
391421
if result.exit_code == 0:
392422
self.print_code(data_py_code, grade_py_code)
423+
self._validate_grade_script(grade_py_code, reference_exp, mock_folder)
393424
return data_py_code, grade_py_code
394425

395426
# --- Generate data.py if needed ---
396-
if not data_py_path.exists() or not label_path.exists():
427+
if not data_py_path.exists() or not label_path.exists() or not submission_path.exists():
397428
logger.info(f"Generating synthetic data script: {data_py_path}")
398429
data_py_code = self._generate_and_run_script(
399430
script_type="data",
@@ -408,23 +439,31 @@ def _prepare_validation_scripts(
408439
data_py_code = data_py_path.read_text()
409440

410441
# --- Generate grade.py if needed ---
411-
if not grade_py_path.exists():
412-
logger.info(f"Generating grading script: {grade_py_path}")
413-
grade_py_code = self._generate_and_run_script(
414-
script_type="grade",
415-
prompt_template_key="grade",
416-
reference_exp=reference_exp,
417-
competition=competition,
418-
mock_folder=mock_folder,
419-
prompt_kwargs={
420-
"reference_code": reference_code,
421-
"sample_code": data_py_code,
422-
"input_folder": input_folder,
423-
},
424-
)
425-
grade_py_path.write_text(grade_py_code)
426-
self.print_code(data_py_code, grade_py_code)
427-
return data_py_code, grade_py_path.read_text()
442+
if grade_py_path.exists():
443+
grade_py_code = grade_py_path.read_text()
444+
try:
445+
self._validate_grade_script(grade_py_code, reference_exp, mock_folder)
446+
except RuntimeError as exc:
447+
logger.warning(f"Cached grade.py is incompatible and will be regenerated: {exc}")
448+
else:
449+
return data_py_code, grade_py_code
450+
451+
logger.info(f"Generating grading script: {grade_py_path}")
452+
grade_py_code = self._generate_and_run_script(
453+
script_type="grade",
454+
prompt_template_key="grade",
455+
reference_exp=reference_exp,
456+
competition=competition,
457+
mock_folder=mock_folder,
458+
prompt_kwargs={
459+
"reference_code": reference_code,
460+
"sample_code": data_py_code,
461+
"input_folder": input_folder,
462+
},
463+
)
464+
grade_py_path.write_text(grade_py_code)
465+
self.print_code(data_py_code, grade_py_code)
466+
return data_py_code, grade_py_code
428467

429468
def _generate_and_run_script(
430469
self,
@@ -582,19 +621,14 @@ def _parsing_score(grade_stdout: str) -> Optional[float]:
582621
continue
583622
json_str = m.group(0)
584623
try:
585-
# Priority 1: JSON parsing
586-
return float(json.loads(json_str)["score"])
587-
except:
588-
pass
589-
try:
590-
# Priority 2: Eval dict
591-
return float(eval(json_str)["score"])
592-
except:
593-
pass
594-
try:
595-
# Priority 3: Regex for the last number in the string
596-
return float(re.findall(r"[-+]?\d*\.\d+|\d+", json_str)[-1])
597-
except:
624+
score = json.loads(json_str)["score"]
625+
if isinstance(score, bool) or not isinstance(score, (int, float)):
626+
continue
627+
score = float(score)
628+
if not math.isfinite(score):
629+
continue
630+
return score
631+
except (KeyError, TypeError, ValueError):
598632
pass
599633
return None
600634

@@ -626,9 +660,8 @@ def try_get_loop_id(trace: Trace, exp: DSExperiment):
626660
return index
627661

628662

629-
def extract_tar(tar_path: str, to_dir: str = "log") -> str:
630-
with tarfile.open(tar_path, mode="r:*") as tar:
631-
tar.extractall(path=to_dir)
663+
def extract_tar(tar_path: str, to_dir: str = "log") -> None:
664+
safe_extract_tar(tar_path, to_dir)
632665

633666

634667
# ==============================================================================

0 commit comments

Comments
 (0)