Repository navigation
skillsbench-3d-scan-calc: verifier recomputes the answer from the agent-writable scan_data.stl, so overwriting it and reporting mass 0 scores 1.0 #233
Description
What happened?
The skillsbench-3d-scan-calc sample task gives full reward to an agent that overwrites its input file and reports a mass of 0.
The verifier has no stored answer. _get_ground_truth() re-reads /root/scan_data.stl at grading time and computes the expected mass and material ID from whatever that file contains. test_values_correct then compares the agent's answer with that value.
That file is the agent's own input, and the agent can write to it:
- It is baked into the image at
/root/scan_data.stl, and the prompt gives that path. The agent hasBashandWrite(L32). - The container runs as root (DOCKER_ISOLATION.md). Dropping
DAC_OVERRIDEdoes not stop root from writing a root-owned file. The task's ownpre_runwrites into/rootthe same way. - On a normal
coder-eval run, the success criterion runstest.shin the same container after the agent finishes, so the verifier sees the agent's version of the file.
So the agent can replace scan_data.stl with a single all-zero triangle whose attribute (material ID) is 42, and write {"main_part_mass": 0, "material_id": 42}. The verifier then computes an expected mass of 0.0 and ID 42, and math.isclose(0, 0.0, rel_tol=0.001) passes. No geometry work is needed. The same answer scores 0 when the input is left alone.
Expected: the expected mass and ID should not depend on a file the agent can edit.
(The separate grading container used by evaluate/run --resume starts from a fresh image, so the overwrite would not carry over there. That path already scores this task 0 for a different reason: pre_run is not re-run there, so the /root/mass_report.json symlink is missing.)
Impact
This task is all-or-nothing, so the shortcut gets the same 1.0 as a correct answer. A run using this shortcut would count as a pass without demonstrating STL parsing or mass calculation. Upstream SkillsBench's verifier appears to have the same _get_ground_truth(), but I only tested the copy in this repo.
Possible fix
Keep the expected answer or a pristine STL in a verifier the agent cannot modify, then grade the submitted report against it. $TASK_DIR is mounted read-write, so moving the reference file or a stored answer there alone would not protect it.
Steps to reproduce
The script below runs the task's real verifier/test_outputs.py with pytest 8.4.1 and scores it the way verifier/test.sh does: reward 1 if pytest exits 0. It does not use Docker. The only change is that the verifier's /root/... paths point to a temp dir, which stands in for the container's /root. It makes no network or model calls. I ran it under unshare -n.
python repro.py <path to coder_eval checkout>
It scores four cases:
- No-op: the answer is an empty JSON object.
- An honest solution: largest connected component, signed-volume sum, density lookup.
- Mass 0 and ID 42, with the input left unchanged.
- Mass 0 and ID 42, after overwriting
scan_data.stlwith one zero-volume triangle.
Observed: 0, 1, 0, 1. Expected: case 4 should score 0.
repro.py
# Usage: python repro.py <path to coder_eval checkout> (needs pytest==8.4.1, as in test.sh)
#
# Runs the task's real verifier/test_outputs.py with pytest and scores it the way
# verifier/test.sh does (reward 1 if pytest exits 0, else 0). The only change: the
# verifier's absolute "/root/..." paths are pointed at a temp dir that stands in for
# the container's /root. In the container the agent runs as root and can write there.
import json
import shutil
import struct
import subprocess
import sys
import tempfile
from collections import defaultdict
from pathlib import Path
TASK = Path(sys.argv[1]) / "tasks/samples/skillsbench/3d-scan-calc"
DENSITY = {1: 0.10, 10: 7.85, 25: 2.70, 42: 5.55, 99: 11.34} # material_density_table.md
def solve(stl):
"""Honest solution: largest connected component, signed-volume sum, density lookup."""
data = stl.read_bytes()
(n,) = struct.unpack_from("<I", data, 80)
tris = []
for k in range(n):
f = struct.unpack_from("<12f", data, 84 + 50 * k)
(attr,) = struct.unpack_from("<H", data, 84 + 50 * k + 48)
tris.append((f[3:6], f[6:9], f[9:12], attr))
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
seen = {}
for i, t in enumerate(tris):
for v in t[:3]:
key = tuple(round(c, 4) for c in v)
if key in seen:
parent[find(i)] = find(seen[key])
else:
seen[key] = i
comps = defaultdict(list)
for i in range(n):
comps[find(i)].append(i)
best = (-1.0, None)
for members in comps.values():
vol = 0.0
for i in members:
(ax, ay, az), (bx, by, bz), (cx, cy, cz), _ = tris[i]
vol += ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx)
best = max(best, (abs(vol) / 6.0, tris[members[0]][3]))
vol, mid = best
return {"main_part_mass": vol * DENSITY[mid], "material_id": mid}
def reward(name, answer_fn, new_stl=None):
root = Path(tempfile.mkdtemp()) # stands in for the container's /root
shutil.copy(TASK / "environment/scan_data.stl", root / "scan_data.stl") # as the Dockerfile does
if new_stl is not None:
(root / "scan_data.stl").write_bytes(new_stl) # agent overwrites its input
answer = answer_fn(root)
(root / "mass_report.json").write_text(json.dumps(answer))
src = (TASK / "verifier/test_outputs.py").read_text()
(root / "test_outputs.py").write_text(src.replace('"/root/', f'"{root}/'))
rc = subprocess.run(
[sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", str(root / "test_outputs.py")],
capture_output=True, text=True,
).returncode
shutil.rmtree(root)
print(f"{name:<34} answer={json.dumps(answer)} reward={1 if rc == 0 else 0}")
# One triangle with all-zero coordinates and attribute (material ID) 42: volume 0.
ZERO_STL = b"\0" * 80 + struct.pack("<I", 1) + struct.pack("<12f", *[0.0] * 12) + struct.pack("<H", 42)
CHEAT = {"main_part_mass": 0, "material_id": 42}
reward("no-op (empty answer)", lambda r: {})
reward("honest solution", lambda r: solve(r / "scan_data.stl"))
reward("mass 0, input untouched", lambda r: CHEAT)
reward("mass 0, scan_data.stl overwritten", lambda r: CHEAT, new_stl=ZERO_STL)Environment
coder_eval main @ 8d85394 (0.12.16), Python 3.13.17 with pytest 8.4.1 (the version test.sh pins), Ubuntu 24.04 LTS (Linux 6.8). Driver: none. The verifier is run directly, without Docker.
Logs / output
no-op (empty answer) answer={} reward=0
honest solution answer={"main_part_mass": 34648.04123490179, "material_id": 42} reward=1
mass 0, input untouched answer={"main_part_mass": 0, "material_id": 42} reward=0
mass 0, scan_data.stl overwritten answer={"main_part_mass": 0, "material_id": 42} reward=1Happy to help test a fix. Found while auditing public RL environments.
Activity