Skip to content

Token on every route, a safer browser tool, a real health check, tests in CI - #37

Merged
mkagenius merged 12 commits into
mainfrom
robust/sandbox-main
Nov 4, 2026
Merged

mkagenius merged 12 commits into
mainfrom
robust/sandbox-main

Conversation

@abhishek-anand

@abhishek-anand abhishek-anand commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Makes the sandbox that is on main today safe to leave running and simpler to trust, without changing what it does.

Includes #26 (resource limits and workspace mounts) as its base; merge that first or this one, either order works. Supersedes #27, whose server changes are already on main; its installer hint is here.

What changes

  • Every MCP and REST route needs Authorization: Bearer <token> (only GET /health is open). The installer generates the token once into ~/.coderunner/api-token, passes it to the container by name, and refuses an image that ignores it. Docker instructions and Compose publish the port on 127.0.0.1 only. Until now anything that could reach port 8222 could run code in the sandbox; with Docker that was the LAN.
  • The browser tool opens only public http(s) URLs, with a timeout, and always closes the browser. From inside the sandbox, loopback and private addresses reach the Jupyter server, the host gateway and the LAN.
  • Code sent to a kernel is never sent a second time by the retry loop; only "could not reach the kernel" is retried.
  • /health answers 503 when the Jupyter server behind the kernels is down, so the installer and the container healthcheck mean something.
  • Skill archives are bounded to 200 MB unpacked and a bad one is set aside instead of failing every list_skills call.
  • Unit tests (tests/) and a Tests workflow (ruff, shellcheck, unittest) on every pull request, installing the same requirements.txt the image uses.
  • README answers Integration with Claude code #13 (when the sandbox helps with Claude Code) and Bug Report: Failed to Enable CodeRunner in Claude Desktop on macOS #7 (Claude Desktop showing the tool as disabled); the Claude Desktop proxy reads the token from the installer's file.
  • Usability: the installer no longer downgrades a newer Apple container to its pin (1.5.0 would have become 1.2.2), refuses to run under sudo (which put everything under /var/root), and prints the claude mcp add command with the token when the sandbox is ready. The README's client snippets carry the header; the Claude Code plugin predates the token and needs an update of its own.

How it was verified

  • Image built from this branch with Apple container 1.5.0 on macOS 26 and run as a throwaway container: /health reports the Jupyter server, an anonymous request gets 401 and the token 200, test-e2e.sh passes 11 of 11 (the wrong-token check is new), the browser tool refuses http://127.0.0.1:8888 and file:///etc/passwd, and test-sessions.py passes.
  • python -m unittest discover -s tests (10 tests), ruff and shellcheck clean.
  • Not run here: the GitHub Actions workflow itself (first run happens on this PR) and a Docker (Linux) build.

Published images predate the token and are refused by the installer; build from this checkout until a new image is published.

abhishek-anand and others added 10 commits August 12, 2026 05:34
macOS ships bash 3.2, where expanding an empty array under 'set -u' is a
fatal 'unbound variable' error. MOUNT_SPECS, NORMALIZED_MOUNTS and
VOLUME_ARGS are all empty when no --mount is passed, which breaks once
this branch is combined with the 'set -u' added in #24.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The server in the sandbox had no authentication: anything that could reach
port 8222 (every process on the Mac; the LAN, when Docker published the port
on all interfaces) could run code in it. Every route except GET /health now
needs Authorization: Bearer <token>; a missing or weak server token fails
closed with 503.

The installer generates the token once into ~/.coderunner/api-token (0600),
or saves the CODERUNNER_API_TOKEN you export, passes it to the container by
name, and checks that anonymous requests are refused and the token is accepted
before reporting the sandbox ready. Docker Compose and the Docker instructions
publish the port on 127.0.0.1 only. The examples, the Claude Desktop proxy and
test-sessions.py send the header.

Verify: tests/test_auth.py; ./install.sh then curl without and with the header.
- The browser tool opens only http and https URLs whose host resolves to a
  public address: from inside the sandbox, loopback, link-local and private
  addresses reach the Jupyter server, the host gateway and the LAN. Pages
  load with a 30 s timeout, the browser is always closed, and the text is
  cut at 200,000 characters.
- Code that was sent to a kernel is never sent again. The retry loop only
  moves to another kernel when the first could not be reached; a connection
  lost during a run, or a run that timed out, is reported as an error, since
  the code may have had side effects.
- /health answers 503 unless the Jupyter server that runs the kernels
  answers too, so the installer and the container healthcheck see a sandbox
  whose kernels cannot start.
- Skill archives are bounded to 200 MB unpacked, and a bad one is set aside
  as .zip.bad instead of failing every list_skills call.
- The shared directory and the Jupyter URL come from the environment, so
  server.py imports outside the image; tests/test_server.py covers the
  request validation, the URL guard, skill archives, session ids and the
  health check, and tests/test_auth.py the token. A Tests workflow installs
  requirements.txt and runs them with ruff and shellcheck on every pull
  request.
- The Claude Desktop proxy and the OpenAI example read the token from
  ~/.coderunner/api-token when CODERUNNER_API_TOKEN is not set; test-e2e.sh
  sends it and checks that a wrong one is refused.
- README: when the sandbox helps with Claude Code (#13), and what to check
  when Claude Desktop shows the tool as disabled (#7).
- The installer replaced any installed Apple container whose version was not
  its pin, which on a Mac with 1.5.0 meant a downgrade to 1.2.2 with sudo.
  It now installs only when the tool is missing or older than the pin.
- Run as root, the installer put the token, the venv and the assets under
  /var/root where the clients run as the user never find them. It refuses to
  run as root; the steps that need it ask for sudo themselves. The README
  and the skills guide no longer say sudo ./install.sh.
- When the sandbox is ready the installer prints the claude mcp add command
  with the token, and the README's Claude Code, Gemini CLI and OpenCode
  snippets carry the header. The Claude Code plugin predates the token and
  the README says so until it is updated.
Copilot AI balanced review requested due to automatic review settings November 4, 2026 11:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical flaws permit private-network browser access, duplicate execution through unsafe retries, and an unauthenticated code-execution service remaining exposed after installer failure.

6 open issues
What changed in this PR

This PR hardens CodeRunner’s sandbox interfaces and installation flow, building on the resource and workspace controls from #26. It also adds automated checks and updates client setup guidance.

Changes:

  • Adds bearer authentication across MCP and REST routes, token-aware clients, and loopback-only Docker port publishing.
  • Adds browser URL checks, safer execution retries, Jupyter-backed health checks, and skill archive limits.
  • Adds unit tests and CI checks, plus installer improvements and Claude integration documentation.
File Description
tests/​test_server.py Tests URL validation, archives, sessions, and health.
tests/​test_auth.py Tests authentication and fail-closed behavior.
test-sessions.py Authenticates session test requests.
test-e2e.sh Adds authenticated requests and wrong-token checks.
SKILLS-README.md Removes sudo from installation instructions.
server.py Integrates authentication and runtime safeguards.
README.md Documents credentials, resource controls, and client setup.
install.sh Manages tokens, resource settings, mounts, and readiness checks.
examples/​openai_agents/​openai_client.py Loads and sends the sandbox token.
examples/​claude_desktop/​mcpproxy.py Adds token authentication to the Desktop proxy.
Dockerfile Includes the authentication module in the image.
docker-compose.yml Requires a token and binds the port to loopback.
DOCKER_SETUP.md Documents authenticated, loopback-bound Docker usage.
cleanup.sh Adds a Bash shebang.
AUTHENTICATION.md Explains credentials and sandbox trust boundaries.
auth.py Implements bearer authentication middleware.
.github/​workflows/​tests.yml Runs lint, shell checks, and unit tests in CI.

Review effort: Balanced

Comment thread install.sh Resolved
Comment thread server.py Resolved
Comment thread server.py

try:
page = await browser.new_page()
await page.goto(url, timeout=NAVIGATE_TIMEOUT_MS, wait_until="domcontentloaded")
Comment thread server.py Resolved
Comment thread server.py Resolved
Comment thread server.py Resolved
…box that ignores the token

- The browser tool checks each request the page makes (redirects, frames,
  subresources) the way it checks the first URL, through a Playwright route;
  the address test uses is_global, which also refuses the shared 100.64/10
  space, and the DNS lookup runs in a thread with a ten-second bound instead
  of on the event loop. A name that changes its address between the check
  and the browser's own resolution is not caught, and the code says so.
- A send that raises after the request may have reached the kernel; it now
  counts as sent, so only failures before the send are retried.
- Any failure to unpack a skill archive sets it aside, including the
  RuntimeError a password-protected zip raises.
- When the running image ignores the token, the installer stops the sandbox
  before reporting the failure instead of leaving it reachable.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Critical bypasses through redirects, DNS rebinding, and unguarded browser traffic still allow access to private services, including unauthenticated local servers.

3 open issues
1 previously missed issue

In code that hasn't changed since last review

Medium severity Existing token files retain insecure permissions

install.sh:29

umask only restricts newly created files; it does not change permissions when this redirection overwrites an existing token file. Running ensure_token with a pre-existing mode-0644 file leaves it at 0644, exposing the code-execution credential to other local accounts when the parent directory is traversable. Enforce mode 0600 before writing, and fail if either securing or saving the credential fails.

5 resolved since last review

Review effort: Balanced

Comment thread server.py
if await checked_url(route.request.url):
await route.abort("blockedbyclient")
else:
await route.continue_()
Comment thread server.py
Comment on lines +773 to +774
page = await browser.new_page()
await page.route("**/*", only_public)
@mkagenius
mkagenius merged commit 24823e1 into main Nov 4, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants