Skip to content

PTY access fails in sandbox (/dev/ptmx / openpty() => Permission denied), and allowing PTY device paths in policy leaves sandbox stuck in Provisioning #749

@kamilbakierzynski

Description

@kamilbakierzynski

Agent Diagnostic

I used the openshell-cli and debug-openshell-cluster skill context to trace the sandbox runtime. This looks like two real bugs sharing the same policy surface.

Findings

• Nested PTY allocation happens after the shell has already dropped to the sandbox user and applied Landlock, via the pre_exec path in crates/openshell-sandbox/src/ssh.rs:1076, crates/openshell-sandbox/src/ssh.rs:1124; the
default restrictive policy only makes /sandbox, /tmp, and /dev/null writable in crates/openshell-policy/src/lib.rs:427, so /dev/ptmx is not allowed by default.
• The sandbox code explicitly documents that Landlock blocks device-file opens unless the device path is present in read_write in crates/openshell-sandbox/src/lib.rs:913; that matches your os.open("/dev/ptmx") and
os.openpty() returning EACCES.
• The reason ssh -tt still gives you a working outer TTY is that the supervisor allocates the first PTY before sandboxing the child in crates/openshell-sandbox/src/ssh.rs:764; the shell just inherits those already-open
slave FDs, so being able to use /dev/pts/0 does not prove the sandbox can allocate a new PTY later.
• Adding /dev/ptmx or /dev/pts to read_write currently goes through prepare_filesystem() before the sandbox becomes ready in crates/openshell-sandbox/src/lib.rs:214; that helper assumes every read_write path should be
created/chowned, rejects symlinks in crates/openshell-sandbox/src/lib.rs:1418, and then unconditionally chown()s the path in crates/openshell-sandbox/src/lib.rs:1430. That is a bad fit for PTY paths: /dev/ptmx is commonly a
symlink on Linux/devpts setups, and /dev/pts is a special filesystem mount, not a normal writable directory to prepare.
• The “stuck in Provisioning” part also makes sense from the server side: policy validation is only lexical today in crates/openshell-policy/src/lib.rs:542 and crates/openshell-server/src/grpc.rs:3306, so it won’t catch
image-specific PTY path behavior up front, and the server keeps ReconcilerError / DependenciesNotReady mapped to Provisioning in crates/openshell-server/src/sandbox/mod.rs:1399, which can hide a permanent startup problem
behind a retrying provisioning state.
• Net: your report is valid as written. Default nested PTY allocation is effectively unsupported by the current default filesystem allowlist, and explicit PTY path allowances are broken by startup filesystem-prep behavior.

Likely Fix

• prepare_filesystem() should stop treating every read_write entry as a path to create/chown; it needs to distinguish normal directories from existing special files, device nodes, devpts mounts, and symlinks in crates/
openshell-sandbox/src/lib.rs:1367.
• PTY support then needs an explicit allowlist story for /dev/pts plus the ptmx endpoint used by openpty(); /dev/ptmx alone is probably not sufficient, because openpty() also opens the slave under /dev/pts/.

Description

PTY allocation appears broken inside a sandbox on openshell 0.0.19.

In a sandbox created from ghcr.io/nvidia/openshell-community/sandboxes/base:latest, the default sandbox user
cannot access PTYs:

• os.open("/dev/ptmx", ...) returns Permission denied
• os.openpty() returns Permission denied

This still happens when the outer session already has a TTY (ssh -tt / equivalent): the remote shell gets /
dev/pts/0, but nested PTY allocation still fails.

I also tested custom policies that explicitly allow PTY device paths:

• /dev/ptmx
• /dev/pts

In both cases, the sandbox never reaches Ready and remains stuck in Provisioning.

Expected behavior

One of these should work:

• the default sandbox runtime should allow PTY allocation for the configured process user, or
• explicitly allowing /dev/ptmx / /dev/pts in policy should enable PTY allocation without breaking sandbox
startup

If PTY device paths are unsupported in policy, I would expect a validation/startup error instead of an
indefinitely provisioning sandbox.

Reproduction Steps

A. Create a control sandbox with a default-like custom policy

Create /tmp/openshell-policy-control.yaml:

version: 1
filesystem_policy:
  include_workdir: true
  read_only:
    - /usr
    - /lib
    - /proc
    - /dev/urandom
    - /app
    - /etc
    - /var/log
  read_write:
    - /sandbox
    - /tmp
    - /dev/null
landlock:
  compatibility: best_effort
process:
  run_as_user: sandbox
  run_as_group: sandbox
network_policies: {}

Create the sandbox:

openshell sandbox create \
  --name pty-policy-control \
  --from ghcr.io/nvidia/openshell-community/sandboxes/base:latest \
  --policy /tmp/openshell-policy-control.yaml \
  --no-tty -- true

Verify it becomes ready:

openshell sandbox get pty-policy-control

B. Probe PTY access inside the ready sandbox

tmp=$(mktemp)
openshell sandbox ssh-config pty-policy-control > "$tmp"

ssh -F "$tmp" openshell-pty-policy-control -- /bin/sh -lc '
  id
  python3 - << "PY"
import os, sys
try:
    fd = os.open("/dev/ptmx", os.O_RDWR | os.O_NOCTTY)
    print("ptmx_open_ok", fd)
    os.close(fd)
except OSError as e:
    print("ptmx_open_err", e.errno, e.strerror)

try:
    m, s = os.openpty()
    print("openpty_ok", m, s)
    os.close(m); os.close(s)
except OSError as e:
    print("openpty_err", e.errno, e.strerror)
    sys.exit(1)
PY
'

C. Verify that an outer TTY exists but nested PTY still fails

tmp=$(mktemp)
openshell sandbox ssh-config pty-policy-control > "$tmp"

ssh -tt -F "$tmp" openshell-pty-policy-control -- /bin/sh -lc '
  tty
  ls -l /dev/pts/0
  python3 - << "PY"
import os, sys
try:
    m, s = os.openpty()
    print("openpty_ok", m, s)
    os.close(m); os.close(s)
except OSError as e:
    print("openpty_err", e.errno, e.strerror)
    sys.exit(1)
PY
'

D. Create a sandbox with /dev/ptmx explicitly allowed

Create /tmp/openshell-policy-ptmx.yaml:

version: 1
filesystem_policy:
  include_workdir: true
  read_only:
    - /usr
    - /lib
    - /proc
    - /dev/urandom
    - /app
    - /etc
    - /var/log
  read_write:
    - /sandbox
    - /tmp
    - /dev/null
    - /dev/ptmx
landlock:
  compatibility: best_effort
process:
  run_as_user: sandbox
  run_as_group: sandbox
network_policies: {}

Create the sandbox:

openshell sandbox create \
  --name pty-policy-ptmx-only \
  --from ghcr.io/nvidia/openshell-community/sandboxes/base:latest \
  --policy /tmp/openshell-policy-ptmx.yaml \
  --no-tty -- true

Check status:

openshell sandbox get pty-policy-ptmx-only

E. Create a sandbox with /dev/ptmx and /dev/pts explicitly allowed

Create /tmp/openshell-policy-pty.yaml:

version: 1
filesystem_policy:
  include_workdir: true
  read_only:
    - /usr
    - /lib
    - /proc
    - /dev/urandom
    - /app
    - /etc
    - /var/log
  read_write:
    - /sandbox
    - /tmp
    - /dev/null
    - /dev/ptmx
    - /dev/pts
landlock:
  compatibility: best_effort
process:
  run_as_user: sandbox
  run_as_group: sandbox
network_policies: {}

Create the sandbox:

openshell sandbox create \
  --name pty-policy-test2 \
  --from ghcr.io/nvidia/openshell-community/sandboxes/base:latest \
  --policy /tmp/openshell-policy-pty.yaml \
  --no-tty -- true

Check status:

openshell sandbox get pty-policy-test2

Environment

  • OpenShell version: 0.0.19
  • Host OS: Darwin 25.3.0
  • Host architecture: arm64 (Apple Silicon)
  • Sandbox image: ghcr.io/nvidia/openshell-community/sandboxes/base:latest

Logs

PTY probe in ready sandbox

uid=998(sandbox) gid=998(sandbox) groups=998(sandbox)
ptmx_open_err 13 Permission denied
openpty_err 13 Permission denied

Outer TTY exists, nested PTY still fails

/dev/pts/0
crw--w---- 1 root tty 136, 0 Apr  3 09:03 /dev/pts/0
openpty_err 13 Permission denied

Sandbox with /dev/ptmx added to policy

Phase: Provisioning

Sandbox with /dev/ptmx and /dev/pts added to policy

Phase: Provisioning

Agent-First Checklist

  • I pointed my agent at the repo and had it investigate this issue
  • I loaded relevant skills (e.g., debug-openshell-cluster, debug-inference, openshell-cli)
  • My agent could not resolve this — the diagnostic above explains why

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions