Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,5 @@ ref/
# pddlstream writes its FastDownward scratch files into the working directory.
temp/
statistics/

.apptainer-env-cache/
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ python experiments/run_experiment.py approach=agentic \
replicate_seed=0 eval_seed="$EVAL_SEED"
```

With login-file authentication, Docker and Apptainer copy only `auth.json` into a throwaway Codex home. Host `config.toml`, `AGENTS.md`, skills, and session history are not mounted. Each fresh experiment starts with an empty sandbox-local session directory; only an automatic retry of that same experiment can resume it.
With login-file authentication, Docker copies only `auth.json` into a throwaway Codex home. Apptainer keeps authentication on the host in its inference broker and mounts no real credentials. Neither mounts host `config.toml`, `AGENTS.md`, skills, or session history. Each fresh experiment starts with an empty sandbox-local session directory; only an automatic retry of that same experiment can resume it.

#### OpenCode (multi-provider)

Expand Down Expand Up @@ -379,7 +379,7 @@ The agent runs inside a Docker container (`robocode-sandbox`) that provides full
| Network | `init-firewall.sh` whitelists API endpoints for the configured provider (Anthropic, OpenAI, Google, etc.), GitHub IPs, and telemetry; blocks everything else via iptables. Extra domains are passed via `ROBOCODE_FIREWALL_EXTRA_DOMAINS`. |
| Write hook | Claude backend: `PreToolUse` hook in `.claude/settings.json` double-checks Write/Edit paths stay inside `/sandbox`. Codex and OpenCode rely on the enclosing Docker filesystem boundary. |

The Apptainer backend (`container_backend=apptainer`, for HPC clusters with no Docker daemon) keeps the same filesystem isolation but has **no network firewall**: unprivileged Apptainer cannot grant `CAP_NET_ADMIN`, so `init-firewall.sh` is skipped and generated code runs with unrestricted network egress. Use Docker where the iptables allowlist matters.
The Apptainer backend (`container_backend=apptainer`, for HPC clusters without Docker) now runs Codex and Claude in a disconnected network namespace (`--userns --net --network none`). A host broker permits validated model inference, and a separate relay reaches only the experiment environment server. Agent processes cannot use general internet access, and provider credentials stay outside the container. See [setup and isolation boundaries](docs/apptainer-network-isolation.md). Unsupported Apptainer backends and GenPlan fail closed.

### What the agent sees

Expand Down
6 changes: 3 additions & 3 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ if [ "$(id -u)" -eq 0 ]; then
HOME=/home/node USER=node LOGNAME=node \
"${run_as_node[@]}" uv sync --frozen --python python3.11 "${uv_extra_args[@]}"
else
# Unprivileged Apptainer runs preserve the host UID.
# Preserve non-root invocation behavior when firewall setup is explicitly skipped.
uv sync --frozen --python python3.11 "${uv_extra_args[@]}"
fi
cd /sandbox

# Skipped under unprivileged Apptainer, which cannot grant CAP_NET_ADMIN;
# ROBOCODE_SKIP_FIREWALL=1 is set by apptainer_sandbox.py.
# Docker firewall setup. Preserve the existing explicit skip override.
# Apptainer does not invoke this entrypoint; it uses a disconnected namespace.
if [ "${ROBOCODE_SKIP_FIREWALL:-0}" = "1" ]; then
echo "entrypoint: ROBOCODE_SKIP_FIREWALL=1, skipping firewall init" >&2
else
Expand Down
6 changes: 3 additions & 3 deletions docker/strict-blackbox-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
set -euo pipefail
IFS=$'\n\t'

# Skipped under unprivileged Apptainer, which cannot grant CAP_NET_ADMIN;
# ROBOCODE_SKIP_FIREWALL=1 is set by apptainer_sandbox.py.
# Docker firewall setup. Preserve the existing explicit skip override.
# Apptainer does not invoke this entrypoint; it uses a disconnected namespace.
if [ "${ROBOCODE_SKIP_FIREWALL:-0}" = "1" ]; then
echo "entrypoint: ROBOCODE_SKIP_FIREWALL=1, skipping firewall init" >&2
else
Expand All @@ -34,5 +34,5 @@ if [ "$(id -u)" -eq 0 ]; then
-- "$@"
fi

# Unprivileged Apptainer runs preserve the host UID.
# Preserve non-root invocation behavior when firewall setup is explicitly skipped.
exec "$@"
83 changes: 83 additions & 0 deletions docs/apptainer-network-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Apptainer agent isolation

The Apptainer backend runs Codex and Claude in a disconnected network namespace.
It requires an installation that permits unprivileged user/network namespaces and
`--network none`. The launcher checks isolation before starting the agent and
aborts if the required boundary cannot be established.

## Setup

Build the image used by your experiment:

```sh
bash docker/build_sif.sh
# For strict blackbox experiments:
bash docker/build_strict_blackbox_sif.sh
```

Configure `approach.container_backend=apptainer` and authenticate the selected
backend on the host. Credentials must be valid when a run starts; refresh/login
happens on the host. OpenCode, custom model endpoints, and the separate
GenPlan/Best-of-K Apptainer path are unsupported and fail closed.

Use an updated launcher and rebuild strict images when their Dockerfile or copied
render sources change. Updating only the image does not update launcher code in
another checkout. The launcher rejects the legacy strict MCP environment.

## Permitted connections

The agent has only loopback networking. Two mounted Unix sockets provide the
connections needed for experiments:

- The **model broker** holds real credentials on the host and forwards validated
inference requests to fixed provider endpoints. It rejects arbitrary destinations,
CONNECT, redirects, hosted web/MCP tools, remote content references, and unsupported
operations. Client-side tool definitions and inline raster images are supported.
Logs contain endpoint/status/rejection information, not request bodies or tokens.
- The **environment relay** reaches one host port selected by trusted launcher
configuration (`env_server_port`). It supports the existing reset/step/render
protocol. Agent-writable `env_spaces.json` cannot select a new host destination.

Inference is an intentional external connection. Agents can submit permitted
inference requests themselves; the broker is not a spending or rate-limit boundary.

## Runtime boundaries

The supervisor requires a non-root UID, loopback-only interfaces, no IPv4 routes,
zero capabilities, and no-new-privileges. Filesystem/PID isolation and a clean
process environment prevent default host-home mounts and inherited credentials.
There is no fallback to host networking.

Regular dependencies are installed in a trusted preparation phase with network
access, before agent execution and without agent files, sessions, or credentials.
The completed environment is mounted read-only. Runtime offline environment
variables supplement the network namespace; they do not enforce isolation alone.

Strict images contain stdlib, NumPy/SciPy, and generic protocol helpers, without
project or MCP framework packages. Both MCP render tools use the strict interpreter;
agent-written policies execute inside the container. A separate virtualenv would
not stop agents from importing its readable packages via another `sys.path`.
Filtered source mounts also exclude compiled bytecode for withheld modules.

The trusted host, kernel, Apptainer, broker, dependency preparation, and environment
server remain part of the boundary. Final scoring runs separately on the host;
its import allowlist is a methodological guardrail, not hostile-code containment.

## Code and Docker behavior

| Module | Responsibility |
| --- | --- |
| `apptainer_sandbox.py` | Agent launch, CLI configuration, broker/relay lifetime |
| `model_broker.py` | Credentials, fixed provider endpoints, request validation |
| `isolated_transport.py` | Namespace checks and fixed-destination relays |
| `apptainer_environment.py` | Dependency preparation and clean child environment |

Provider hostname constants are shared with Docker's registry. Docker retains its
firewall and credential transport; `ROBOCODE_FIREWALL_EXTRA_DOMAINS` configures Docker,
not the Apptainer broker. Strict Docker images use the same dependency-clean render
server and must also be rebuilt after render-image changes.

Audit the actual node, runtime, and images before relying on isolation, and repeat
after relevant changes. See [red-teaming instructions](apptainer-red-teaming.md)
for controlled network checks and agent probes. Missing tools, unavailable positive
controls, provider refusals, and incomplete reports are not successful isolation tests.
63 changes: 38 additions & 25 deletions docs/blackbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ The approach, in `train()`:
`{host, port, token, observation_space, action_space, max_steps}`.
`host` is `host.docker.internal` for Docker (mapped via
`--add-host host.docker.internal:host-gateway`) or `127.0.0.1` for the
apptainer and local backends.
Apptainer and local backends. Apptainer then rewrites the port to a private
loopback relay pinned to the separately configured host `env_server_port`.
4. Mounts a filtered copy of the repo that strips `environments/`, the kinder
`envs/` and `demos/`, plus the always-excluded `oracles/`, `primitives/`,
`tests/`, and `docs/`.
Expand Down Expand Up @@ -230,10 +231,10 @@ makes blackbox meaningful differs:
many `apptainer.conf` setups still bind the host `/home`, so the agent could
read the real source straight off `/home/<user>/.../environments`.
`--containall` is what fixes this, dropping all default binds (home, tmp, cwd)
so the stripped source is the only source present. It reaches the env server
over `127.0.0.1` (apptainer shares the host network namespace, so no
`--add-host`/firewall is needed; note this also means apptainer does not apply
the default-deny network firewall). Run
so the stripped source is the only source present. Its network namespace has
only loopback. A Unix-socket relay reaches one pinned host env-server port, and
a separate broker permits validated model inference. See
[network isolation](apptainer-network-isolation.md). Run
`python integration_tests/red_team_sandbox.py --apptainer-blackbox` (needs
`robocode-sandbox.sif` built) to verify env source stays unreachable.
- **local**: best-effort ONLY, isolation is NOT enforced. The OS-level sandbox
Expand All @@ -251,15 +252,16 @@ makes blackbox meaningful differs:
only for quick local iteration, not for results that depend on the agent not
having read the source. Use `docker` or `apptainer` for enforced isolation.

Strict mode runs under Docker or Apptainer during synthesis. The strict image gets
one writable mount (`/sandbox`). Under Docker its firewall allows the model provider
plus only the exact host TCP port of the environment server; GitHub, SSH, package
registries, and other host ports are not allowed. Under Apptainer the same image runs
as `robocode-strict-blackbox.sif` with the sandbox as its only mount, but, as in
legacy blackbox, unprivileged Apptainer cannot install the firewall, so that network
restriction is not enforced there: the strict ablation then rests on the
dependency-clean image, the strict env server, and the scoring-time import allowlist.
Scoring needs no container: the import allowlist check
Strict mode runs under Docker or Apptainer during synthesis. Under Docker its
firewall allows the model provider plus only the exact host TCP port of the
environment server; GitHub, SSH, package registries, and other host ports are not
allowed. Under Apptainer the dependency-clean `robocode-strict-blackbox.sif` runs
in a disconnected namespace with the sandbox, session directory, and read-only
broker socket directory mounted. Only validated model inference and the pinned
environment-server relay cross that network boundary.

Final scoring currently runs on the host. Its import allowlist check is a
methodological guardrail, not a network or hostile-code sandbox. The check
runs before the program is loaded, so an approach that imports `pybullet_helpers`,
`tomsgeoms2d`, `robocode`, `kinder`, or any other undeclared dependency fails the
run with a message naming the import instead of silently succeeding from the host
Expand Down Expand Up @@ -324,7 +326,7 @@ state snapshots.
| | sandbox_dir/mcp_renders/*.png
+-------------------+---------------------------------------^---------+
| host.docker.internal:port (docker) | bind mount
| 127.0.0.1:port (apptainer/local) | (rw)
| 127.0.0.1:port (local only) | (rw)
====================+======= container boundary (firewall: | =========
| default-DROP + allow host /24) |
| |
Expand All @@ -351,6 +353,11 @@ state snapshots.
Withheld: environments/ , kinder envs+demos , oracles/ , primitives/ , tests/ , docs/
```

Apptainer uses the same JSON protocol through a different transport: private
container loopback → mounted Unix socket → one pinned host environment-server
port. Its namespace has no external interface. The diagram's Docker firewall
and direct local connection do not describe Apptainer's network boundary.

## Key takeaways

- **One protocol, two consumers inside the sandbox.** The agent's own test
Expand All @@ -361,7 +368,8 @@ state snapshots.
host into the shared `mcp_renders/` mount; only relative paths travel back over
the socket.
- **Isolation is layered.** Withheld source mounts, JSON-only codec, per-run
token, per-connection fresh env, and a default-deny firewall. The `local`
token, per-connection fresh env, and Docker's firewall or Apptainer's
disconnected namespace with restricted relays. The `local`
backend is best-effort only.

## Red-teaming strict blackbox
Expand All @@ -371,21 +379,21 @@ Build the strict image, then run its dedicated live adversarial suite:
```bash
bash docker/build_strict_blackbox.sh
python integration_tests/red_team_sandbox.py --strict-blackbox
# Apptainer: the same suite minus the network probe (no firewall there).
# Apptainer: the same suite, including network and package-install probes.
bash docker/build_strict_blackbox_sif.sh
python integration_tests/red_team_sandbox.py --apptainer-strict-blackbox
```

The suite first proves the allowed surface works: the generated-program
interpreter can import NumPy and SciPy and can reset and step the environment, and
the separate MCP interpreter renders a state through the host.
the same dependency-clean interpreter renders a state through the host.
It then asks an agent to attack the same configuration used in production and
fails if any of these boundaries break:

- the generated-program interpreter imports RoboCode, KinDER, MCP, Gymnasium,
geometry, or simulator packages, or installs a package from the network;
- the separate MCP interpreter exposes environment, primitive, simulator, or
robotics modules;
- changing interpreters/package paths, or executing a policy through MCP,
exposes withheld project, framework, environment, or simulator packages;
- environment source, the host-side canary, or arbitrary Internet content is
reachable through filesystem or network probing;
- withheld client methods or raw protocol commands such as `get_state`,
Expand All @@ -406,14 +414,19 @@ after scoring begins.
Blackbox mode is a *methodological* constraint first: it stops the agent from
reading environment source so it must discover the dynamics empirically. The
isolation behind it (withheld mounts, JSON-only codec, per-run token,
per-connection env, default-deny firewall) is real. The host never executes
agent code: the only things it runs are env stepping and `render_state`, both
trusted. `render_policy` deliberately runs in the container, so an agent that
per-connection env, and backend-specific network restrictions) applies during
agent execution. The environment server runs trusted env stepping and
`render_state`, never agent code. `render_policy` deliberately runs in the container, so an agent that
writes a malicious `approach.py` cannot reach the env source through rendering
(there is no env source in the container, and the host never execs the file).
(there is no env source in the container, and the environment server never
executes the file).
The `blackbox_render_*` red-team tests exercise exactly this path.

One limit is worth stating plainly:
Final policy scoring is a separate host-side execution path and is not contained
by these agent-runtime protections. Full experiment network isolation requires
addressing that path too.

Another limit:

- **The env server listens on all interfaces.** It binds
`0.0.0.0:<ephemeral>` and the container firewall opens the host's `/24`, so
Expand Down
7 changes: 3 additions & 4 deletions experiments/conf/approach/best_of_k.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ max_budget_usd: 20.0 # dollar cap; only bounds cost-reporting backends (null ->
# true: each candidate repeats GenPlan's summary -> strategy -> code flow, no debug loop.
chain_of_thought: false
# The per-episode validation timeout is the shared top-level eval_timeout.
# Run the whole loop inside one sandbox container (like the agentic approach),
# so generated code never executes on the host. local runs in-process.
container_backend: docker # docker | apptainer | local
# Run candidate generation and validation in Docker. Final scoring still loads
# the policy on the host; local also runs generation in-process.
container_backend: docker # docker | local; Apptainer transport is unsupported
docker_image: robocode-sandbox
sif_path: null # null -> <repo_root>/robocode-sandbox.sif
7 changes: 3 additions & 4 deletions experiments/conf/approach/llm_genplan.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ max_debug_attempts: 4 # step cap: 1 initial attempt + this many debug attempts
max_budget_usd: 20.0 # dollar cap; only bounds cost-reporting backends (null -> step cap only)
chain_of_thought: true # summary -> strategy -> code; false: single prompt -> code
# The per-episode validation timeout is the shared top-level eval_timeout.
# Run the whole genplan loop inside one sandbox container (like the agentic
# approach), so generated code never executes on the host. local runs in-process.
container_backend: docker # docker | apptainer | local
# Run generation and debugging in Docker. Final scoring still loads the policy
# on the host; local also runs generation in-process.
container_backend: docker # docker | local; Apptainer transport is unsupported
docker_image: robocode-sandbox
sif_path: null # null -> <repo_root>/robocode-sandbox.sif
Loading
Loading