Repository navigation
feat(harbor): Docker PTY sessions, verify-phase network policy and a static sandbox policy test (M29) - #4862
Conversation
|
/ok to test 6ac6cfa |
|
🌿 Preview your docs: https://nvidia-preview-pr-4862-c3f95b11fae8.docs.buildwithfern.com/nemo/gym |
6ac6cfa to
b69effd
Compare
|
/ok to test b69effd |
b69effd to
b21a912
Compare
|
/ok to test b21a912 |
|
/ok to test 7110d16 |
ffrujeri
left a comment
There was a problem hiding this comment.
The PTY lifecycle fixes look solid, and each has a regression test that uses the real terminal plumbing. I ran the Docker PTY path against a live daemon to cover the unchecked item in the test plan (python:3.12-slim and alpine:3.20, as root and as nobody): terminal sizing, SIGINT on a foreground job (exit 130), resize, close() ending a background job and removing the PID files, command and pipe-mode exits, the paused-container SandboxPtyError, and session release on container close all behaved as described.
The one thing I'd like settled before merge is the verifier-versus-deployment network policy thread; the fail-closed intersection you proposed sounds right to me. The only CI failure is the non-blocking kilocode conformance job.
|
/ok to test 22fe4e2 |
|
/ok to test 97ebc23 |
|
/ok to test f921994 |
|
/ok to test 4a6f40f |
|
/ok to test 890edb2 |
|
/ok to test b118c1e |
ffrujeri
left a comment
There was a problem hiding this comment.
One remaining case: network_policy: {} is deny-all in the 0.1.15 SDK, but bool(deployment) treats it as absent, so a task allowlist can still widen it. Could this check deployment is not None instead?
|
/ok to test 50d38ef |
|
Fixed in 50d38ef (folded into the M29 commit): |
|
/ok to test ace1a65 |
yuhezhang-ai
left a comment
There was a problem hiding this comment.
The blocking findings from my review are addressed on ace1a658 against stack parent 5529c2f3, including the terminal lifecycle/input issues and the empty deny-all network-policy bypass. All 40 focused network-policy and terminal regression tests pass, and the original empty-policy reproduction now refuses the widening task allowlist before creating a sandbox.
The remaining documentation wording comment is nonblocking. The main unit-test job is still running, so merge should wait for required checks.
|
/review role=automation |
ffrujeri
left a comment
There was a problem hiding this comment.
Thanks for the fix, LGTM.
svcnvidia-nemo-ci
left a comment
There was a problem hiding this comment.
HOLD — one BLOCKER async write hang, two RISK gaps in the static policy checks, and one NOTE about network-probe evidence.
Reviewed all 18 changed files and directly affected contracts. The earlier terminal lifecycle/input fixes and the empty deny-all deployment-policy refusal are present in the current source and regression assertions. The delegated network report was unavailable, so that scope was inspected directly.
This was a source-only review: no tests or live Docker/OpenSandbox checks were executed. The suggested corrections below require author verification.
Note: formal approval is not available for this request, so this review is posted as a comment only.
Review generated by codex. This was a full review. Request a full review with /review reset=true.
|
/ok to test 8342487 |
|
/ok to test 6f35ff3 |
|
/ok to test ae053cb |
…(M26) Every `gym dataset validate` and `gym eval run <task target>` now writes one per-run summary beside its rollouts, `<rollouts stem>_run_summary.json` (`nemo_gym.tasks.harbor.run_summary`): the command, target, taskset and agent; the dataset's folder, source and digests; the `task_source` the run config declares for the taskset (known before dispatch) with the value the result rows carry as a cross-check (`rows_task_source`); the files of the run (rollouts and sidecars, the health check's reports, the rows with the data processor's `_prepare.jsonl` and `_metrics.json`, the collated collector input under `<stem>/preprocessed_datasets/`, run config, skipped.json, images.json, the verifier root), each null when the run did not write it: the aggregate metrics and the health reports, which follow a completed collection, are listed only when at least as new as the rollouts file, so an earlier run's file by the same name (the health reports keep one name per folder whatever the `--output` stem) is not passed off as this run's, and a fresh start (`resume_from_cache` off, the default) records what sits under its rollouts, failures and materialized-inputs names before it dispatches, since the collector clears them only once the servers are up, so a run that dies before that (servers that fail to start) reports those files as `stale_files`, not its own, with its tasks `missing`, while a resumed run's cached rows stay its own; and, per selected task and repeat, the outcome, the verifier's mode, wall time, network and return code, the relative paths of its verifier log folder, test-stdout.txt, trajectory.json, entrypoint-stdout.txt and collected artifacts/ (per turn for multi-step tasks), and the input the row carried (task and dataset digests, the responses_create_params metadata). Paths are relative to the summary's folder; a folder a row names that is gone is listed under `missing_paths`. The summary is written after collection whatever it did, and also when a validate finds no task with a solution/ and dispatches nothing (beside the `--output` the command asked for, resolved through the same parser as a dispatching run); a writer failure is logged without touching the run's outcome. A fresh `gym eval run` reads its selected rows for the summary only after the collector ran, inside that same guard; a resumed run still reads them first for the resume-conflict check, as before. `--verbose` is unchanged (main's `verbose` key reaches every spun-up server); the Harbor environment server and the oracle agent now trace the episode at DEBUG (seeded, test.sh exit and rewards with the log folder, final score; solve.sh budget, user or "the sandbox default user", and exit) and stay quiet below WARNING otherwise. No judge runs on the Harbor path outside the task's own test.sh, so there are no judge trajectories to put in the row; whatever a sandboxed judge writes under /logs/verifier is in the verifier log folder the summary names. Docs: harbor README outputs section, the Harbor Tasks page and the observability index. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Adil Asif <adasif@nvidia.com>
…static sandbox policy test (M29) Docker provider: `create_pty` (SupportsSandboxPty) runs one `docker exec -i -t` client per session under a host pseudo-terminal, sized before start and resized through SIGWINCH; `pty=False` uses pipes with stdout and stderr split. The program starts through a launcher that records its pid in the container (under /tmp, or /dev/shm when /tmp refuses the write, as under `create.read_only: true` without a tmpfs; a session that can record it nowhere is not started and ends at once with a message), which `send_signal` and `close` use (a departed docker client does not end the process it started); the read of the record waits up to two seconds for the launcher, since a call right after `create_pty` reaches the container through a second exec that races the first. The pid file is the session user's, so neither its content nor its owner is trusted: the pid is followed only to a process whose environment carries the session's marker (`NEMO_GYM_PTY_SESSION=<id>`, set by the exec's own `--env`) and whose parent is outside the container (PPid 0, the runtime's execs), so a rewritten, hard-linked or renamed file never aims the root-side kill at a process the session did not start. The record is read only while it is a regular file, through an open pinned on a descriptor and tested there (an O_RDWR open of a FIFO does not wait for a writer), 32 bytes at most, under a 30 s exec timeout of its own, so the session's user can make its record useless but not stall `send_signal()` or `close()` on it. In PTY mode `send_signal` addresses the terminal's foreground process group (from /proc/<pid>/stat), so it reaches the job an interactive shell is running as a key press would; on pipes the process's own group and the process. `close()` hangs the process group up, waits up to a second (bounded with an integer-only sleep too) for a shell to relay it, then sweeps the session. Sessions live in the Gym process (no attach by id), are released with their container and at `aclose()`. `sandbox.pty.create/exec` now work on Docker as on OpenSandbox. Harbor verify phase on OpenSandbox: verified live, a sandbox created without a network policy has no egress sidecar and cannot be restricted later, and a sandbox created with one keeps its defaultAction for life (rules patch within seconds, `*` is no catch-all). So the restricted policy is applied where it can be: the separate verifier's own sandbox is created under `phase_network_policy([verifier].network_mode, allowed_hosts)`, replacing the deployment's policy for that sandbox for its whole life (entrypoint and artifact staging included, a wider window than Harbor's verify() call; documented). The loader admits the verifier phase policy for separate-mode tasks and refuses it on shared-mode tasks naming the fix; other phases stay refused; a provider other than OpenSandbox, a CIDR host, or a spelling Harbor's `normalize_allowed_hosts` refuses (bracketed or zoned IPv6 literal, wildcard over an IP) is a terminal 422 at seed. `verifier_network` reports the mode. `tests/unit_tests/test_sandbox_policy.py` pins the sandbox rules on the syntax trees of the Harbor path: no host process spawning outside the dataset tooling under any import alias (and only git/the build tool/docker there), no dynamic code loading, the verifier wrapper only reaches a sandbox exec, no routes of the server's own, the sandbox descriptor only in the harness session seed and in no response model, no shipped config selecting the host-execution provider; the spawn check is self-tested on snippets it must flag. `build_plan` imports subprocess for its CompletedProcess type under TYPE_CHECKING only. The in-sandbox tool bridge with MCP grants is dropped from this row. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Adil Asif <adasif@nvidia.com>
|
/ok to test c3f95b1 |
|
/review role=automation |
svcnvidia-nemo-ci
left a comment
There was a problem hiding this comment.
SHIP — no unresolved prior findings or qualifying new regressions found.
Verified all four findings from review 5660201709 against the current source:
- PTY writes: write() now serializes the entire operation and checks closure after acquiring the lock. Regression assertions cover complete A+B delivery, abandonment of both writers, and descriptor reuse without leaked input.
- R2 dynamic loading: The production check and self-tests resolve import aliases, reject the reported loaders, and preserve regex compilation and ordinary sandbox/model methods.
- R5 local provider: The check and regression cases now reject null-valued sandbox provider blocks while retaining the intended reference/name boundaries.
- Network-probe wording: The documentation separates the observed raw-IP timeout from its unestablished enforcement layer and retains the deployment-setting caveat.
The earlier empty deny-all deployment-policy refusal also remains present, with assertions covering allowlist rejection before provisioning and successful no-network narrowing.
This completed follow-up covered the reported fixes, preservation cases, and directly affected contracts—not a fresh whole-PR review. The previously reviewed tree was unavailable, limiting exact inter-review comparison. Assessment was source-only; no tests or live Docker/OpenSandbox checks were executed.
Note: formal approval is not available for this request, so this review is posted as a comment only.
Review generated by codex. This was a findings-only review. Request a full review with /review reset=true.
|
🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/40533122604 |
|
🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/40540769119 |
Summary
M29 of the Harbor task-format stack: the remaining sandbox capabilities. Docker gets interactive PTY sessions on a par with OpenSandbox, the Harbor verify phase gets its network policy on OpenSandbox, and a static policy test pins the rule that task code runs only in the sandbox and the sandbox is never exposed outside the harness session. The in-sandbox tool bridge with grants for
[[environment.mcp_servers]]is dropped from this row: host-loop harnesses keep the 422 for tasks with MCP servers.What changes
Docker PTY (
SupportsSandboxPty).nemo_gym/sandbox/providers/docker/pty.pyruns onedocker exec -i -tclient per session under a host pseudo-terminal, so the CLI allocates a terminal in the container and forwards bytes both ways; the session reads and writes the host pty's master.rows/colssize the terminal before the client starts (the CLI applies the size at exec start),resize()sets the host size and sends the clientSIGWINCH;cwd/env/usermap to-w/--env/--user;pty=Falseruns the client on pipes with stdout and stderr split. The program starts through a one-line launcher that records its pid in/tmp/.nemo-gym-pty-<id>.pidinside the container (in/dev/shm/.nemo-gym-pty-<id>.pidwhen/tmprefuses the write, as undercreate.read_only: truewithout a tmpfs on/tmp; a session that can record it nowhere, say--ipc=noneas well, is not started: the launcher says so on stderr and exits 1, so the session ends at once with the message rather than running unsignallable), whichsend_signal()(killthrough a second exec, as root) andclose()use. The read of the record waits up to two seconds for the launcher to write it, becausecreate_pty()returns as soon as the client is spawned and asend_signal()/close()issued right away reaches the container through a seconddocker execthat races the session's own. The read is bounded against the record's owner too: a record that is not a regular file (a FIFO, or a link to a device, put at the path by the session's user) is refused at once rather than opened, the file is opened read-write on a pinned descriptor and tested again there (anO_RDWRopen of a FIFO does not wait for a writer, and the test on/proc/self/fd/3catches whatever was swapped in after the path was tested), at most 32 bytes are read, and the exec that reads it runs underPID_READ_TIMEOUT_S(30 s, or the shorterexec.default_timeout_s) instead of the 180 s default, so the session's user can make its record useless but cannot stallsend_signal()orclose()on it. The file is the session user's, who can rewrite it or replace it by a link to a file it does not own, so neither its content nor its owner is trusted: it is read once, as the session's own user (/proc/<pid>/environis readable by the process's own uid but not by a root withoutCAP_SYS_PTRACE, which Docker's default capability set does not grant, so a root-side read fails for every non-root session; thekilland the cleanup run as root and no added capability is needed), and followed only to a plain pid above 1 whose process carries the session's marker in its environment (NEMO_GYM_PTY_SESSION=<id>, set by the exec's own--env), whose parent is outside the container (PPid0, the runtime's execs) and, in PTY mode, which leads its session; a rewritten, hard-linked or renamed file can never aim the root-sidekillat a process the session did not start. In PTY modesend_signal()addresses the terminal's foreground process group, read from/proc/<pid>/stat, which is what a key press reaches: the program of a command session, or the job an interactive shell is running (a shell with job control keeps each job in a group of its own, so a signal to the shell's group would never reach it); on pipes the process's own group and then the process. A docker client that goes away does not end the process it started, so closing a live session hangs up its process group, gives a shell up to a second (bounded with an integer-onlysleeptoo) to relay the hangup to its jobs, thenSIGKILLs every process left in the recorded process's session, and removes the pid file. Every in-container line is spelled for busyboxshas well as dash and bash (kill -INT -<pgid>), sinceshis busybox in Alpine-based images. Sessions live in the Gym process (noattach()by id;pty.exec(detach=True)runs attached) and are released when the container is closed and at provideraclose().sandbox.pty.create/execand the interactive sandbox shell (python -m nemo_gym.sandbox.shell) now work on Docker exactly as the facade expects, for root and non-root sessions alike (a session program that changes its user orexecs a setuid program loses its signalling; documented). A harness that drives a terminal session, a Terminus-2-style PTY harness say, can build on them; no harness in the repository consumessandbox.ptyyet (the shipped Terminus-2 sandboxed agent drives tmux overexec), so the PTY is exercised by the facade, the shell and the tests, not by a rollout.OpenSandbox network policy at verify. Verified live with the OpenSandbox 0.1.15 SDK against an OpenSandbox deployment (transcript in the report; the probes did not read the control plane's own version): a sandbox created without a
network_policyhas no egress sidecar and cannot be restricted afterwards (the egress endpoints answer 502); a sandbox created with a policy keeps itsdefaultActionfor life, the SDK'spatch_egress_rules/delete_egress_ruleschange rules within seconds but nothing changes the default action, and*is not a catch-all target. So Harbor's_phase_network_policyswitch has no equivalent on the agent's sandbox, and the restricted policy is applied where it can be: the separate verifier's own sandbox is created under it. Concretely:nemo_gym.sandbox.agent_tools.phase_network_policy(provider_name, network_mode, allowed_hosts)turns a Harbor phase'sno-network/allowlistinto the OpenSandbox egress policy (deny by default, the hosts as ordered allow rules: host names,*.domainwildcards and bare IP literals as Harbor spells them; CIDR ranges refused, since the OpenSandbox egress sidecar documents FQDN and wildcard targets only and they were not verified live, a parity gap with Harbor, which admits them; and the spellings Harbor'snormalize_allowed_hostsrefuses, bracketed or zoned IPv6 literals and wildcards over an IP literal, refused as Harbor does), failing closed for any other provider asrestricted_network_policydoes.[verifier].network_mode/allowed_hostsfor separate-mode tasks and refuses them on shared-mode tasks with a message that names the fix (a shared-mode task with artifacts that the server would verify separately underseparate_verifier_for_artifactsis refused too, since the loader cannot see that setting: declareenvironment_mode = "separate");[environment],[agent],[verifier.environment]and step restrictions stay refused.provider_options.network_policy, replacing the deployment'ssandbox_provider_options.network_policyfor that sandbox as Harbor's explicit phase policy replaces the baseline. The window is wider than Harbor's, which wrapsverifier.verify()only: here the verifier sandbox lives its whole life under the policy, so the verifier image'sENTRYPOINT/HEALTHCHECKand the artifact staging run restricted too (documented, with the advice to allowlist hosts a verifier image needs at start).verifier_networkin the result reports the mode. No new config keys or CLI flags.Static sandbox policy test (
tests/unit_tests/test_sandbox_policy.py), rules derived from the code and stated in its docstring: no host process spawning underenvironment_servers/harborunder any import alias (calls are resolved through the module's imports,fromimports of spawn functions and bareimport subprocess|multiprocessing|ptyare refused outside the spawning modules unless type-only; the check is self-tested on snippets it must flag) and onlygit/the configured build tool/dockerin the three dataset-tooling modules undernemo_gym/tasks/harbor(nevershell=True); no dynamic code loading;verifier_command(...)is only ever handed to a sandbox.exec; the server registers no route of its own (/runand/aggregate_metricsonly, no command-shaped request field),sandbox_access=is passed exactly once in the whole server package, inside thePOST /v1/agent_sessionsto the configured agent server,serialize()is called only by_sandbox_access, and no response model carries the descriptor; no shipped Harbor config selects the host-executionlocalprovider, resolved the way the server does (the top-level block anysandbox_provider/gpu_sandbox_providervalue names, under whatever name, plus anysandbox*-named block; self-tested on synthetic configs).Docs: the Docker and OpenSandbox provider pages, the sandbox index (interactive terminals), the Docker provider README and the Harbor environment server README.
Test plan
tests/unit_tests/test_docker_pty.py(54): the real terminal plumbing against a localshunder a host pty (sizing, resize, theSIGWINCHresize()sends to the client, CRLF, exit, EOF, pipe-mode stderr split, close-before-exit,run_detached, the facade'spty.execend to end throughDockerProvider), thedocker execcommand line, signal and cleanup execs against a recorded fake CLI, and real signals:send_signal("SIGINT")on an interactive bash and dash session (under the pty as controlling tty, as runc gives an exec) ends the foregroundsleepwith status 130 and leaves the shell usable, with the in-container lines run by this host'sshand bybusybox sh; the pipe-mode fallback ends a command session with SIGTERM;close()of a live bash session ends its background job (hangup relayed, then the session sweep); a pid file rewritten to1, to another user's pid or to the session's own child, or replaced by a hard link or a symlink to another file, is refused before any kill, bysend_signal()and byclose();close()'s hangup grace stays about a second under asleepthat rejects fractions;check_recorded_pidon 15 crafted records; a failedkillsurfaces asCould not signalunless the process exits within the second;provider.close()abandons a live session with no exec into the container; session release on container close andaclose();close()andsend_signal()issued right aftercreate_pty()with the launcher held back half a second still reach the process (the pid read waits up to two seconds for the record, bounded under an integer-onlysleeptoo); a pid directory that refuses the write (a read-only/tmp) sends the record to the/dev/shmfallback and signals and cleanup work from it, and a session that can record its pid nowhere ends at once with the launcher's message; and a non-root session in a stand-in container (a user+pid+mount namespace: session as uid 1000, root-side execs as uid 0 withoutCAP_SYS_PTRACE, the session's process with a realPPid0 and the pid file at its real path in a private/tmp), where the root-side read of the old code is refused by the kernel and the read as the session's user,send_signalon the foreground job,close()of the shell and its background job and the pipe-modeSIGTERMall work, and the same session under a read-only tmpfs mounted over/tmprecords its pid under/dev/shmand is signalled and closed from there (skipped whereunshare --map-auto/nsenter/setpriv/setsidare unavailable); a pid record replaced by a FIFO, by a symlink to one at the fallback path or by a link to/dev/zerois refused at once bysend_signal()and byclose()(within the two-second wait, nothing killed, the process left to the container, the record removed) undershand busybox, a 1 MB record is read 32 bytes at most, the pinned record open comes back at once for a FIFO with or without a writer attached, a symlink to one, a device and a directory, and the pid read's exec runs underPID_READ_TIMEOUT_S(30 s, or the shorterexec.default_timeout_s) while thekilland the cleanup keep the defaulttests/unit_tests/test_sandbox_agent_tools.py::TestPhaseNetworkPolicy(15), loader tests intest_harbor_tasks.py(18 cases),environment_servers/harbor/tests/test_app.py::TestVerifierNetwork(8)tests/unit_tests/test_sandbox_policy.py(30: the five rules on the tree, 16 self-tests of the spawn check on snippets that must be flagged or accepted, 7 self-tests of the R5 provider-block resolution)ruff check/ruff format --checkclean; pre-commit on the changed files passesphase_network_policy("opensandbox", "no-network")refuseshttps://example.com(exit 6) and a raw IP (exit 28), under theallowlistofexample.comreaches it and refuseswww.nvidia.comgym dataset validateon OpenSandbox with ano-networkand anallowlistseparate-verifier task whosetest.shcurls from the verifier and reads the agent's own curl result (see the report), and a shared-mode task with the same restriction skipped at prepare with the loader's reasondocker execside with fakes🤖 Generated with Claude Code