镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

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

Closed

Description

@jimmy-holdout

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 has Bash and Write (L32).
  • The container runs as root (DOCKER_ISOLATION.md). Dropping DAC_OVERRIDE does not stop root from writing a root-owned file. The task's own pre_run writes into /root the same way.
  • On a normal coder-eval run, the success criterion runs test.sh in 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:

  1. No-op: the answer is an empty JSON object.
  2. An honest solution: largest connected component, signed-volume sum, density lookup.
  3. Mass 0 and ID 42, with the input left unchanged.
  4. Mass 0 and ID 42, after overwriting scan_data.stl with 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=1

Happy to help test a fix. Found while auditing public RL environments.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions