Repository navigation
OpenShell jobs cannot reach their model or the network: the non-dumpable runner vs OpenShell's connect broker #730
Copy link
Copy link
Open
Description
What happens
On both openshell and openshell-bind, every network call the runner makes inside the sandbox fails:
- the model call:
"Connection error.",EAI_AGAIN getaddrinfo host.openshell.internal, with pi's auto-retries
failing too; - a plain
fetch, pi's undici dispatcher, and the OpenAI SDK, all under/opt/pi-dispatch/runner-node.
The same requests from plain /usr/local/bin/node, in the same sandbox, resolve and get answers.
Found while fixing #725 (PR #727). Diagnostic CI run 39620248996:
node get=status 200
node post-sse=status 200 text/event-stream complete 384
runner get=error TypeError EAI_AGAIN getaddrinfo EAI_AGAIN host.openshell.internal
runner pi-openai-sdk=error APIConnectionError EAI_AGAIN fetch failed
No lane case has caught this until now. Every "real runner" case stops at cost cap 0 before the first call, and every
network probe runs plain node under sh.
Why (read at the pins, to be confirmed by measurement)
- The runner binary is non-dumpable.
runner-nodeis installed root-owned and0711(image/Dockerfile:268-275,
The job's own tools can write a fake runner exit line #545), so the kernel makes the runner non-dumpable. That keeps the per-job exit key in its memory away from the
job's own tools. - The broker cannot read a non-dumpable caller. OpenShell 0.1.4 mediates every workload
connect()through a
seccomp-notify broker. The broker runs as the workload uid with no capabilities, and reads the caller's sockaddr
throughprocess_vm_readvor/proc/<tid>/mem. For a non-dumpable caller that read fails, and the broker returns
EACCES:openshell-sandbox/src/network_broker.rs:829-857,1732;task_memory.rs:46-97,152-153("an exec'd workload must be observable by its same-UID ancestor").
- DNS is the first connect to fail. The first such
connect()is to the sandbox's DNS relay, which glibc reports
asEAI_AGAIN. - The tool children are probably affected too (inferred). Binary identity walks every ancestor's
/proc/<pid>/exe(openshell-binary-identity/src/lib.rs:409). So the runner's tool children, such as git, gh and
node, are probably denied external connections too.
Impact
No real job on either openshell venue can reach its model or the network. Neither venue is in a release yet (they
ship in 5.0.0), so this blocks #695.
Plan
- Investigate on CI. Confirm the broker's own denial log line. Measure a dumpable runner (0755), and a tool child
under the real runner. Measure what a dumpable runner exposes to a tool child inside OpenShell: whether OpenShell's
seccomp, Yama or landlock blocksprocess_vm_readv,/proc/<ppid>/mem,/proc/<ppid>/environ,
/proc/<ppid>/fd/0andptrace. Do the same on bind mode, and check whether OpenShell offers another observation
path. - Fix it, most likely with a readable runner binary on the openshell venues only. The exit-key protection there
comes from whatever the sandbox proves blocks a descendant.localandpodmankeep the 0711 runner. The specs
then state what protects the key on each venue. - Make the lane prove real networking. Add a real zero-cost model call and a tool child's external connection on
both modes, plus the key-unreadable check. openshell lane: the copy-mode "mid-run second" steering assertion races the run's end #725's steering case then passes for real.
Part of #650.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
test(ci): investigate OpenShell runner networking, E1-E4 copy mode (#730
test(ci): investigate OpenShell runner networking, E5 bind mode (#730)
test(ci): #730 probes under the job's own policy, the new path's own …
edgehero commentedon Oct 22, 2026
Phase 1 findings (test-only investigation, draft PR #731)
Measured on the openshell lane (OpenShell 0.1.4,
5a7728c; pi 1.2.0; job image built from the PR), copy and bind mode, in runs 39655548819 and 39674000732. Every experiment is a printeddiagblock, none asserted. The probe images are the job image with the entrypoint replaced, plus two variants:runner-nodechmod 0755 (same bytes, same path), and a 0755 copy at/opt/pi-dispatch/runner-observableadded todev.pi-dispatch.net-binaries.Root cause
The OpenShell sandbox mediates every workload
connectwith a seccomp user notification. Its broker runs as the workload uid with no capabilities (the Docker driver drops ALL), and it reads the caller'ssockaddrout of the caller's memory: firstprocess_vm_readv, then/proc/<tid>/mem(openshell-sandbox/src/linux/task_memory.rsread_exact). It also hashes/proc/<pid>/exeof the caller and of every ancestor (openshell-binary-identity/src/lib.rs).runner-nodeis root-owned 0711 (issue #545), so exec leaves the supervisor and the runner NONDUMPABLE. The broker therefore cannot read them, and the connect is refused. A DNS lookup is the first connect, so the error isEAI_AGAIN. OpenShell's own startup probe states the assumption outright: "an exec'd workload must be observable by its same-UID ancestor" (task_memory.rsprobe_child_access).Every openshell job is affected, copy and bind, any provider. No lane case had made a real network call from the runner before; every earlier probe used the image's plain
node.Measured
runner-nodemode /nodemodenodereadable (dumpable);runner-nodedenied (nondumpable)kernel.yama.ptrace_scope, uidopenshell logs <sandbox> --source sandbox --level warn, polled during the run, matched nothing. I did not record the raw line count, so I cannot tell whether the query (source, level, a sandbox deleted at teardown) or the absence of the line is the cause. Not established.noderunner-node(0711)EAI_AGAINon every step, before and after loading pi, and through the OpenAI SDKrunner-node0755, same pathchunks 2)/opt/pi-dispatch/runner-observabletrue, label too)nodefetch,git ls-remote https://github.com/...) under the 0711 runnerEACCES, gitcode 128(cannot connect)node, orrunner-node0755)code 0/proc/<ppid>/environ, parent dumpable/proc/<ppid>/mem, parent dumpableEACCES(Yama, scope 1)process_vm_readv,ptrace(PTRACE_ATTACH), parent dumpableEPERMboth (OpenShell's workload seccomp,child_seccomp.rs)EACCES/EPERMThe first run's copy-mode E2 and E3 rows read
ENOTFOUNDfor plainnodetoo. That was a defect in my probe (the probe runs had no policy endpoints), fixed in the second run. It was not a result.E4's
fd/0row (READ) is not about the runner. My harness gave its parent a regular file on fd 0, and a regular file can be reopened through/proc. The real runner's fds are covered below.The process tree in a real openshell job (from source)
/opt/pi-dispatch/pi-landlock: a static helper, readable, so dumpable. It confines writes, then execs:/entrypoint.sh(/bin/sh), which execs:runner-node --disable-sigusr1 supervise.mjs, the job supervisor. It holds the exit key in memory. Its fd 0 is the exec's stdin, which carries every frame (key frame and steer frames). Today it is nondumpable./bin/sh -c '…; exec runner-node --disable-sigusr1 run-job.mjs', the runner. It holds the exit key in memory. Its fd 0 is a Node"pipe"(a socketpair) carrying the key frame, closed at once; fd 3 is a socketpair for later frames, not inherited by children. Reopening a socketpair through/procisENXIO(measured in OpenShell: steering and live transcripts over stdio, optional bind mode #686). pi runs in this process and makes the provider calls.runner-nodeis one binary at one path for both (4) and (5), so making it 0755 makes BOTH the supervisor and the runner dumpable. E3 shows that any nondumpable ancestor (the supervisor included) also blocks every descendant's network. So a fix that keeps the supervisor nondumpable while making only the runner dumpable cannot work as long as the supervisor is an ancestor.What would sit in the environ of every process that becomes dumpable
/proc/<pid>/environis the process's initial environment block. Changingprocess.envlater does not change it.GH_TOKEN/GITHUB_TOKEN), endpoint-bound bundle variables: OpenShell placeholders only (openshell:resolve:env:<NAME>), resolved by OpenShell outside the sandbox. Measured in the existing lane: the placeholder is in the environment, and no process's environ holds a value (environ-values 0).bundle-env.json), and the runner puts them into its ownprocess.envafter start (bundle-env.mjs), which/proc/<pid>/environdoes not show. They are values the operator gives the agent anyway, and every tool inherits them by design.ENXIOon reopen).PI_*job configuration,PI_DISPATCH_KEYLESS=keyless,HOME, the Playwright settings and the forge host variables. No secret values.So a tool reading a dumpable supervisor's or runner's environ learns configuration and placeholders, not secrets.
Still open: the supervisor's fd 0 is the exec's stdin, whose kind under OpenShell (pipe or socket) I did not measure. If it is a pipe, a tool could reopen it through a dumpable supervisor's
/proc/<pid>/fd/0. Every frame is verified under the key, so that could drop or delay messages, never forge one. Measure this before shipping a dumpable supervisor.Does the protection depend on the host?
Yes, for one route. With the supervisor and runner dumpable, the exit key in their memory is protected by:
process_vm_readv,ptrace,pidfd_getfdandprocess_vm_writev. This holds on every host./proc/<pid>/mem. Opening and reading that file is not a filtered syscall. Yamaptrace_scope >= 1stops a child from attaching to its ancestor. Atptrace_scope = 0, which is the kernel default on hosts that do not set it, a same-uid tool could read a dumpable ancestor's memory, and with it the key.Nothing inside the sandbox can lower
ptrace_scope. It is a host-wide sysctl, writing it needsCAP_SYS_PTRACEin the initial user namespace, and the sandbox runs with no capabilities andno_new_privs. The venue already requires the gateway to run on the worker's own host (openshell-gateway-not-local), so the worker can read the setting that applies to its jobs.Other ways around it (E6, from source)
CAP_SYS_PTRACE: no configuration in 0.1.4 does this. The Docker driver hard-codescap_drop: ALLand adds none.process_vm_readvand/proc/<tid>/mem. The notification carries pointers, not thesockaddr.SECCOMP_ADDFDinjects descriptors and reads nothing. The workload filter deniespidfd_getfd.prctl(PR_SET_DUMPABLE, 1)from the runner after exec: Node has no binding for it, so it would need a native addon. Calling it before exec does nothing, because exec of an unreadable binary resets the process to nondumpable. Not pursued.Decision table
local/podmankeep 0711./proc/<pid>/memat scope ≥ 1. Environ exposes no secret. Refuse openshell before spend when the gateway host'sptrace_scope< 1, plus a doctor line./proc/<pid>/mem, so the same Yama dependence) could still make it request signatures. It narrows exfiltration, not integrity.CAP_SYS_PTRACE, or reading thesockaddranother way).Recommendation
(a), with a hard pre-spend refusal on
ptrace_scope< 1 (and a doctor line naming the sysctl). On a host with Yama at 1 or higher, a dumpable runner exposes no secret: the measured routes are closed and the environ holds only placeholders and configuration. On a host without Yama the venue refuses by name rather than running unprotected. Before shipping, measure the supervisor's exec-stdin kind. Record anOQ-entry for the upstream ask, so that (a) can be undone once OpenShell can observe a nondumpable workload. Keep (c) as the fallback if the operator will not accept a Yama dependence. (b) does not justify its size, since it keeps the same dependence for the runner's integrity.Phase 2 not started.
test(ci): #730 the steered exec's stdin kind and whether it reopens t…
test(ci): bind parity for the OpenShell runner's networking and key g…
fix(image,worker): suppress core dumps and require Yama exactly 1 for…
test(ci): make the openshell keyguard probe print its errors, not bla…