Skip to content

fix(sandbox): remap out-of-range host ids instead of failing to start - #18

Open
geojaz wants to merge 11 commits into
feat/sandbox-all-harnessesfrom
fix/sandbox-uid-remap
Open

geojaz wants to merge 11 commits into
feat/sandbox-all-harnessesfrom
fix/sandbox-uid-remap

Conversation

@geojaz

@geojaz geojaz commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Problem

docker run --user rejects uids/gids above the int32 max (2147483647):

docker: Error response from daemon: uids and gids must be
in range 0-2147483647

The sandbox unconditionally passed os.getuid()/os.getgid() on Linux. Identities minted by an external identity provider (Cloud Identity, Workspace external users, most LDAP setups) routinely exceed that limit (3998470835 is an example of an out-of-range id). On such a host the sandbox cannot start at all: docker emits create, attach and destroy with no start event, exits 125, and the agent never runs.

Why not just drop --user

Dropping --user makes the container start, but it hands the untrusted agent root inside the boundary this module exists to enforce. That trades a loud failure for a silent containment downgrade, which doesn't fit the rest of the module: it already refuses to fall back to an unsandboxed run rather than quietly weaken the boundary. This was tried first, and it also left files in the operator's home that teardown could not remove without root.

Fix

Remap rather than degrade. When either id is out of range, the container runs as 1000:1000, the unprivileged node user in the node:22-slim base these images build on, so the agent stays unprivileged.

  • A throwaway root container from the same image chowns the workspace and every fixture mount to 1000:1000 before the run.
  • After the run, in a finally, another throwaway root container chowns everything back to the real host ids so the operator can still read and delete the artifacts without root.
  • Fixtures are covered, not just the workspace: they're mounted read-write by design and live outside the workspace, so chowning the workspace alone would strand them.
  • The pre-run chown is fatal on failure: handing an unprivileged agent a workspace it can't write would produce a misleading result rather than an honest refusal.
  • The post-run handback is best-effort and never masks a real agent result, but logs the exact repair command if it fails.
  • The whole path is inert when the caller's ids are already in range, including a caller running as root, which takes the existing path unchanged.

Costs

  • Two extra short-lived container runs, only on affected hosts (in-range ids take the old path unchanged).
  • The workspace is transiently owned by the remap id during the run, so reading artifacts mid-run needs root.
  • If the run is SIGKILLed, no finally fires, and ownership is left at the remap id rather than handed back.

Test plan

  • uv run pytest tests/unit/agents/test_sandbox.py -q — 76 passed

pradeepvrd and others added 11 commits September 8, 2026 00:16
Only gemini_cli was migrated, so a sandboxed matrix could not actually run the
other three arms: AgentHarness.run refuses a sandboxed harness that has not
been migrated, rather than silently running it on the host. The published
antigravity and claude_code runs WERE sandboxed, but by an uncommitted patch and
an untracked tree on the bastion -- neither in any PR, neither reviewed, and
neither present in this lineage.

Each harness now routes its agent turn through run_agent_cmd and declares
supports_sandbox. Fixture mounts need no per-harness work: the eval harness
already builds them centrally into the spec.

openclaw needed more than a flag. Its state dir and config path cross the
boundary as env VALUES, and the host spelling means nothing inside the
container, so the agent turn now gets container-translated paths while the
post-run 'oc sessions' / export-trajectory calls keep the host spelling. Both
read the same bytes: the state dir lives under the workspace, which is the bind
mount itself. The container also has its own oc on PATH, so the host binary path
is not passed through. container_path() is lifted out of
SandboxExecutor.map_host_path so the cwd mapping and these value translations
cannot drift apart.

secret-rotation opts out via a new Task.requires_unsandboxed. The task IS
credential work -- it rotates a Secret Manager version through ADC, and ADC is
exactly what the boundary strips, so sandboxing it would not harden it, it would
make it impossible. The harness skips the sandbox for that task only and logs a
warning, so a sandboxed matrix shows which task did not get a boundary. The flag
clears config.sandbox rather than just skipping spec completion: the agent's own
gate reads that field, and a leftover skeletal spec would refuse the run.
… all

Provider deduction keys off the stack DIRECTORY NAME and only exactly
'kind'/'vcluster' deduce. The directory is 'cp-recovery-kind', which does not,
so the run died at provisioning with 'stack requires an explicit provider'
before the agent ever started. Verified: _select_provider raises for this task
and for no other -- every b-00xx task already declares provider: kind, and this
was the only ported kind task that did not.

Infrastructure selection, not grading: nothing about the prompt, the
verification spec or the expected output changes, so the task remains
comparable with the rows already scored against it.
…cs-server on kind

The stack configured provider "kubernetes" from module.cluster.endpoint,
whose value falls back to the vcluster submodule. That submodule's own
resources are served by the same provider, so tofu refused the graph with
"Cycle: module.cluster.module.vcluster..." before planning anything -- for
every infra_provider, which left the task unable to provision at all.

Add a vcluster-free managed_endpoint output and read that instead: the
ordering edge on the cluster survives, the cycle does not.

Also install metrics-server when infra_provider=kind. The HPA objective grades
ScalingActive=True, which needs a live metrics pipeline that a stock kind
cluster lacks; without it the objective fails for provider reasons rather than
for anything the agent did.
The task was pinned to gcp for one reason: the HPA objective grades
ScalingActive=True and a stock kind cluster ships no metrics pipeline, so the
objective failed for provider reasons rather than for anything the agent did.
The stack now installs metrics-server under infra_provider=kind, so that
reason is gone and the objective is decided by the agent again.

Verified on kind end to end: the cluster provisions, the seeded workload rolls
out, and kubectl top pods returns live CPU for the target deployment.

Only the infrastructure provider changes. The prompt, expected_output and
verification_spec are untouched, so the task grades exactly what it did
before. INFRA_PROVIDER=gcp still selects GKE.
It was gcp before: the k-sigs migration (kubernetes-sigs#143) declared provider: gcp
explicitly, and that is the definition every published run of this task was
graded against. Switching the provider changes what the task measures, so it
should not move as a side effect of making kind viable.

The metrics-server install and the vcluster cycle fix stay. They were the real
bugs: the cycle blocked provisioning on EVERY provider, and without
metrics-server the HPA objective failed on kind for provider reasons rather
than agent ones. kind is now a genuinely working alternative for local
iteration.

What still argues for gcp is the load path, not metrics: on GKE the Service is
a LoadBalancer the chaos generator reaches directly, while on kind it is a
ClusterIP behind the harness port-forward. The planned spike failed to inject
in 8 of 8 published runs, so the fewer moving parts in that path the better
until a spike demonstrably lands.

Either way every runner must use the same provider for this task -- the Service
type and load path differ, so a kind arm and a GKE arm are not comparable.
Same defect optimize-scale had: the kubernetes and helm providers were
configured from module.cluster.endpoint, whose value falls back to the vcluster
submodule, and that submodule's resources are served by those same providers.
Tofu rejected the graph with "Cycle: ...module.vcluster..." before planning
anything, so the task could not provision on any provider -- it failed on both
arms in the 2026-09-08 rerun campaign.

Read managed_endpoint instead, forwarded through this stack's local cluster
wrapper. tofu plan now succeeds: 18 to add, no cycles.
b-0059 and b-0061 grade NetworkPolicy enforcement, and kindnet does not
enforce policy, so both stacks pass disable_default_cni = true to the cluster
module. The module never declared that variable: tofu failed at init with
"An argument named disable_default_cni is not expected here", so neither task
has ever provisioned. Their setup scripts already document the intent -- Calico
is expected to be installed by the cluster module before they run.

Implement it: swap kindnet for Calico v3.27.3 behind the variable. Two details
that are not obvious.

Nodes stay NotReady until a CNI is running, so kind_cluster.wait_for_ready has
to be off in that case or the apply blocks until timeout on exactly the
clusters that need Calico. null_resource.calico does the waiting instead, and
duplicate_context now waits on it too.

pod_subnet is set to Calico's own default pool (192.168.0.0/16) rather than
kind's 10.244.0.0/16; left mismatched, Calico allocates outside the subnet the
node spec and kube-proxy expect.

The variable defaults to false and the networking block is emitted only when it
is true, so every existing kind task plans exactly as before (verified on
b-0024: no Calico resource, wait_for_ready still true, same 3 resources).

Verified end to end on a real kind cluster: node Ready, calico-node 1/1,
podCIDR 192.168.0.0/24, and a default-deny ingress policy actually enforced --
curl returns 200 before the policy and fails after it. Under kindnet the same
probe would keep returning 200.
_build_agent_config_snapshot rebuilds AgentConfig field by field, and
extra_flags was not among the fields it copied. AGENT_EXTRA_FLAGS parsed
correctly into AgentConfig.from_env and was then dropped on the floor before
the agent ever saw it, with nothing logged.

The visible symptom is agy keeping its 5m default --print-timeout on every
benchmark run: a task whose agent turn runs longer dies with "timeout waiting
for response", an errored run with a truncated trajectory. b-0060 hit it twice
at 313s and 308s while every run that finished did so under 250s. The
documented mitigation for that flake is to pass a larger --print-timeout, which
was impossible through the harness.

Verified: the snapshot now carries (\x27--print-timeout=24h\x27) from the env, and
the 249 agent/evalharness unit tests still pass.
The judge and the chaos driver resolve models differently: the judge reaches
Vertex, where gemini-3.1-pro exists, while the chaos driver goes through the
API key, whose endpoint only publishes gemini-3.1-pro-preview and 404s the
plain id. The existing note covers the judge, so a working judge reads as
evidence the whole config is fine.

It cost a run to find. optimize-scale came back chaos_invalidated with the
spike never injected, which looks like a fault-injection problem rather than
the driver being unable to call a model at all.
docker run --user rejects ids above int32 max:

    docker: Error response from daemon: uids and gids must be
    in range 0-2147483647

wrap_argv passed os.getuid()/os.getgid() unconditionally on Linux.
Identities minted by an external identity provider (Cloud Identity,
Workspace external users, most LDAP setups) routinely exceed that
limit, and on such a host the sandbox cannot start at all: docker
emits create, attach and destroy with no start event, exits 125, and
the agent never runs.

Dropping --user would make it run, but by giving the untrusted agent
root inside the boundary this module exists to enforce. That trades a
loud failure for a silent containment downgrade, which is out of
keeping with the rest of the module: it already refuses to fall back
to an unsandboxed run rather than quietly weaken the boundary. It was
also tried first, and it left files in the operator's home that
teardown could not remove without root.

So remap rather than degrade. When either id is out of range the
container runs as 1000:1000, the unprivileged node user in the
node:22-slim base these images build on, and the agent stays
unprivileged. A throwaway root container from the same image chowns
the workspace and every fixture mount to that id beforehand, and
chowns them back to the real host ids afterwards in a finally, so the
operator can still read and delete the artifacts without root.
Fixtures are covered too, not just the workspace: they are mounted
read-write by design and live outside the workspace, so chowning the
workspace alone would strand them.

The pre-run chown is fatal on failure, since an unprivileged agent
handed a workspace it cannot write would produce a misleading result
rather than an honest refusal. The handback is best-effort and never
masks a real agent result, but logs the exact repair command when it
fails.

The whole path is inert when the caller's ids are already in range,
including a caller running as root, which takes the existing path
unchanged.
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.

2 participants