Skip to content

jupyter_env: verification runs in the agent's own kernel, so a rollout can report any reward #1210

Closed

Description

@k21993

Summary

E2BSandbox.run_shell executes shell commands by running a subprocess.run snippet through run_code, which is the same persistent kernel the agent writes to with add_and_execute_code_cell. A single cell that rebinds subprocess.run therefore decides the outcome of every shell command the environment runs afterwards, including the verify commands and the read of the reward file.

The agent does not need to write reward.txt, and it does not need the verify commands to pass. It replaces the function that runs them.

This is separate from #1199. That issue is about the contents of a file the agent can write; this is about the environment asking the agent's own kernel whether the agent succeeded.

Where

On main @ 39fdf5cd:

  • envs/jupyter_env/server/e2b_sandbox.py:91, run_shell builds a subprocess.run cell and sends it to run_code.
  • envs/jupyter_env/server/jupyter_environment.py:385, _run_verify_commands scores passed / total from those results.
  • envs/jupyter_env/server/jupyter_environment.py:444, _read_reward_override reads reward.txt through the same run_shell.

success is execution.error is None, so a cell that raises nothing counts as a passing command.

Reproduction

No E2B account needed. This drives the real E2BSandbox.run_shell and the real JupyterEnvironment against a fake kernel whose only interesting property is the one E2B's has: a namespace that persists between cells.

import contextlib, io, sys, types

from jupyter_env.models import JupyterState
from jupyter_env.server.e2b_sandbox import E2BSandbox
from jupyter_env.server.jupyter_environment import JupyterEnvironment
from openenv.core.env_server.mcp_types import CallToolAction


class FakeKernel:
    """A persistent Python kernel, which is what E2B's run_code gives you."""

    def __init__(self):
        self.ns: dict = {}

    def run_code(self, code: str):
        out, err, error = io.StringIO(), io.StringIO(), None
        try:
            with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
                exec(code, self.ns)
        except BaseException as exc:  # SystemExit included, as in a real kernel
            error = types.SimpleNamespace(name=type(exc).__name__, value=str(exc), traceback="")
        return types.SimpleNamespace(
            logs=types.SimpleNamespace(
                stdout=[out.getvalue()] if out.getvalue() else [],
                stderr=[err.getvalue()] if err.getvalue() else [],
            ),
            error=error,
            results=[],
            execution_count=1,
        )


AGENT_CELL = """\
import subprocess

class _Fake:
    returncode = 0
    stdout = ""
    stderr = ""

subprocess.run = lambda *a, **k: _Fake()
"""

sandbox = object.__new__(E2BSandbox)  # the real wrapper, without an E2B account
sandbox._sbx = FakeKernel()
sandbox.sandbox_id = "fake"

env = JupyterEnvironment()
env._sandbox = sandbox
env._state = JupyterState(
    episode_id="ep-1",
    sandbox_id="fake",
    verify_commands=["exit 1", "exit 1"],  # both fail
)

env.step(CallToolAction(tool_name="add_and_execute_code_cell", arguments={"code": AGENT_CELL}))
env.step(CallToolAction(tool_name="final_answer", arguments={"answer": "done"}))

results = env.state.verify_results
print(f"{sum(1 for r in results if r.success)}/{len(results)} passed, reward={env.state.last_reward}")

Output:

2/2 passed, reward=1.0

Both verify commands exit non-zero. No reward file is ever written.

#1200 does not close this

I applied #1200's run_shell change (raise SystemExit(_result.returncode) when the return code is non-zero) in the same reproduction and got the same result:

2/2 passed, reward=1.0

The check runs inside the kernel, so it reads the return code off the object the agent's function returned, which is 0. The same applies to the new pre-verify rm -f ... && test ! -e ... guard: it is another run_shell, so the agent decides whether it reports success. Setting stdout = "1.0" on the fake result instead of "" also puts the override path back in the agent's hands, within the [0, 1] range #1200 enforces.

None of that is a criticism of #1200. It fixes what it set out to fix, and the author already flagged this case as out of scope for it.

Not the same shape as terminus_env

terminus_env/server/e2b_sandbox.py builds shell commands the same way, but the agent only ever reaches that sandbox through terminal(command=...), which runs as a subprocess rather than as kernel Python. Nothing the agent sends mutates the kernel namespace, so the escape does not apply there. jupyter_env is exposed because add_and_execute_code_cell is kernel-level Python by design, which is the point of the environment.

Suggested direction

Run verification somewhere the agent's code cannot reach, and keep the notebook kernel for the notebook.

The pinned SDK already offers this: e2b_code_interpreter.Sandbox exposes commands (verified against e2b-code-interpreter>=1.0.0, the env's own pin), so self._sbx.commands.run(command) executes as a process with a real exit status that no cell can intercept. create_code_context is an alternative if a separate kernel is preferred, and a second sandbox for verification would also work at higher cost.

The reward-file read wants the same treatment, since reading it through the kernel is equally controllable.

Worth stating as an env-level rule while this is being fixed: nothing the agent can execute should be able to influence how verification runs, or how its result is read.

Scope

Happy to implement this once #1200 lands, since it touches the same two files and I would rather not cut across it.

Activity

  1. surajsharan commented on Sep 21, 2026

    @surajsharan
    Contributor

    Thanks for writing this up so thoroughly, and for checking it against #1200's changes. The end-to-end reproduction is really useful. For context, I ran into the same thing while testing that fix and noted it on #1199 (#1199 (comment)), and #1200 lists it as a follow-up.

    Would you be OK with me taking the implementation, since it sits right next to #1200's changes in the same files? Your reproduction would be the regression test, and I'd add you as co-author on the commit.

    One thing I found while reading the SDK that's worth knowing whoever picks it up: commands.run starts /bin/bash -l -c, so it sources the user's login files, which the agent can also write. The fix needs to cover or at least document that.

    If you'd rather do it yourself, that's completely fine too, and I'm happy to review it.

  2. k21993 commented on Sep 21, 2026

    @k21993
    ContributorAuthor

    Yes, please take the implementation. Using the reproduction as the regression test and adding me as co-author works for me.
    Good catch on the login shell. I agree that commands.run alone is insufficient if the agent can modify the selected user’s startup files. Please extend the regression coverage to poison the relevant login profile and verify that the real command exit status still reaches the verifier.
    A dedicated verifier user with an agent-unwritable home may be sufficient through commands.run(user=...). If that boundary cannot be enforced, a separate sandbox or another execution path may be necessary. Happy to review the PR against that invariant and the final #1200 behavior.

  3. surajsharan commented on Sep 21, 2026

    @surajsharan
    Contributor

    Thanks! Before writing it I checked which user the agent actually runs as, since that decides whether a verifier-user boundary can hold, and in this template it can't:

    So in jupyter_env nothing inside the sandbox is out of the agent's reach, and a dedicated verifier user can't be enforced. A login-profile test would pass only because the fake says so, so I'd rather document that limit than include a test that implies it's covered. What I'd propose:

    1. This PR: move verification (the verify commands, and the reward-file clear and read) off the notebook kernel onto commands.run, as root in the notebook's working directory so verify commands keep the permissions they have today. That closes the path in this issue, and it also stops ordinary notebook state (os.chdir, os.environ, a mocked subprocess) from changing what verification sees, which can bite honest agents too. Tests: your reproduction, the notebook-state cases, and a non-zero exit through CommandExitException. The PR will say plainly that against a root agent this is hardening, not a boundary.
    2. Separately, if you and the maintainers want it: an option to run verification in a fresh sandbox, which is the only boundary that holds here. It's a bigger design (what to copy over, whether setup reruns there), so I'd rather agree on it before building.

    Does that split work for you?

  4. k21993 commented on Sep 22, 2026

    @k21993
    ContributorAuthor

    That split works for me, with one framing condition: the first PR should describe commands.run as removing notebook-kernel coupling and hardening verification, rather than establishing an isolation boundary. The root agent can still influence the same sandbox.

    The reproduction, notebook-state cases, and real non-zero exit coverage sound right. Please also link a follow-up before merge for fresh-sandbox verification, with the invariant that agent-controlled state cannot alter verifier startup, execution, or result collection. Happy to review both the implementation and that boundary.

  5. added a commit that references this issue on Oct 1, 2026
    08bba52
  6. added a commit that references this issue on Oct 20, 2026
    ad8074a
  7. added a commit that references this issue on Oct 27, 2026
    7236b0d
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