Skip to content

[Bug]: theoremqa evaluates model output with bare eval(); the sandbox is escapable, and closing it is a _fixed-shaped change #77

Description

@ethan-scitix

Your current environment

🐛 Describe the bug

theoremqa_kshot_base_gen evaluates model output with bare eval() at three sites — :266, :313, :366 — guarded by _EVAL_GLOBALS, which sets "__builtins__": {}.

That guard is not a sandbox. Clearing __builtins__ does not prevent attribute traversal, and the standard catch_warnings route reaches a live open:

from sieval.tasks.theoremqa_kshot_base_gen import _EVAL_GLOBALS

payload = (
    "[c for c in ().__class__.__base__.__subclasses__() "
    "if c.__name__=='catch_warnings'][0]()._module.__builtins__['open']"
    "('/tmp/pwned','w')"
)
eval(payload, _EVAL_GLOBALS)

Observed: /tmp/pwned is created. The three call sites reach this from an extracted model answer, so any model being evaluated can write files as the eval process.

Two smaller notes on the same guard:

  • factorial is exposed, so eval("factorial(2000000)", _EVAL_GLOBALS) runs for 16 s on this machine (measured) with no upper bound. Grading is synchronous on the shared event loop — MultiTaskRunner.arun starts every TaskRunner in one anyio.run — so that stalls every other task in the session, not just this one.
  • The task also still grades inline. feat(tasks): task-name variants, UGMathBench as _fixed, and grading off the shared event loop #68 moved every math-verify grader into a worker process and the pure-sympy ones onto anyio.to_thread.run_sync; theoremqa was left alone because its fix is not mechanical (see below).

This is not just a hardening change — it changes grading semantics

This is the part worth deciding before writing any code. The module already says so at :78:

# Upstream uses bare eval(num). SiEval keeps eval sandboxed for task runtime
# safety, so builtins such as abs/round/pow are intentionally unavailable.

So a model answering abs(-5) or round(1.6) scores differently here than under upstream's evaluator. That divergence is real, deliberate, and unquantified — and reference_impl.notes currently claims the opposite:

"answer_clean and numeric matching mirror official utils.py/number_utils.py."

Meanwhile the task ships under the unqualified name theoremqa_kshot_base_gen.

Under the variant convention introduced in #68 (.claude/rules/tasks.md, sieval/tasks/CLAUDE.md), the unqualified name means "what upstream measures, bugs included", and a local correction takes a _fixed variant owing (a) every divergence enumerated in reference_impl.notes and (b) a quantified score impact. By that rule:

  1. the existing sandboxing is already a convention violation — an unquantified, score-affecting divergence under an unqualified name, with notes asserting fidelity;
  2. and closing the RCE properly makes the divergence larger, so it lands in the same place: a _fixed variant, not a patch to the unqualified task.

Put plainly: hardening the evaluator is itself a _fixed. That is why this is an issue and not a quick PR.

Proposed sequencing

  1. Now, cheap and semantics-neutral: correct reference_impl.notes so it stops claiming to mirror upstream, and record the known divergence (missing builtins) as text. Regenerate sieval/meta/index.json.
  2. Gate on feat(tasks): task-name variants, UGMathBench as _fixed, and grading off the shared event loop #68 landing and the _fixed pattern being exercisedugmathbench_0shot_gen_fixed is its first user and hasn't merged yet. Migrating a second benchmark onto a convention that has not yet been proven end-to-end is the wrong order.
  3. Then: theoremqa_kshot_base_gen_fixed with a real evaluator (a tokenising parser rather than eval, the way sieval/community/ugmathbench.py::_parse_sympy_source does it — it was tested against these same payloads and does not execute them, because parse_expr rewrites name tokens into symbols), plus the offload, plus the quantified delta against the current task on a stored run.

Open decision: the convention needs an explicit safety carve-out

This outlives the theoremqa fix — do not close it with the RCE.

Step 3 above leaves the unqualified theoremqa_kshot_base_gen in an awkward place. The convention says the unqualified name tracks upstream including its bugs, and upstream's bug here is an unsandboxed eval on model output. Read literally, the rule asks us to ship a remote-code-execution path under the plain name.

That cannot be the intent, but right now nothing says so. The rule is silent on the case, which means the next person to hit it has to re-derive the answer — and the two readings ("be faithful" vs "don't ship an RCE") point opposite ways.

Proposed: state the exception in .claude/rules/tasks.md and sieval/tasks/CLAUDE.md, next to the existing "the unqualified name tracks upstream" paragraph. Rough wording:

Fidelity stops at execution safety. Tracking upstream never extends to reproducing a path that executes model output, writes outside the run directory, or cannot be bounded. Where upstream's own harness does that, the unqualified task carries the hardened behaviour, and the divergence is recorded in reference_impl.notes with its score impact quantified like any other. This is the one divergence that does not require a _fixed variant — a variant exists so two readings can be compared, and "the unsafe reading" is not one we intend to run.

Note this cuts against the rest of the convention on purpose, so it should be argued rather than slipped in — hence flagging it here instead of folding it into #68. Two things to settle:

  1. does the carve-out cover only execution safety (eval/exec/subprocess), or also unbounded computation (the factorial(2000000) case above, and the power towers guarded in feat(tasks): task-name variants, UGMathBench as _fixed, and grading off the shared event loop #68)?
  2. if a task takes the hardened behaviour under its unqualified name, does the score impact still have to be quantified before it may ship as stable? (I would say yes — it is the same obligation _fixed carries, and it is the only thing keeping "hardened" from becoming an excuse.)

Known sites this would already apply to: theoremqa_kshot_base_gen (this issue), sieval/community/deepseek_math.py:317, and — already hardened under #68sieval/community/ugmathbench.py.

Related

Before submitting a new issue...

  • Make sure you already searched for relevant issues and documentation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions