Skip to content

feat(sandbox): allow job-control signals to the caller's own process group #5148

Open

Description

@vineethsai7

User Story

I run coding agents in OpenShell sandboxes on RHEL, Ubuntu and macOS hosts, including enterprise managed hosts, with the Docker driver on Linux and the MicroVM driver on macOS and Linux. I work in them interactively through openshell sandbox connect and openshell sandbox exec --tty.

When I press Ctrl-Z in an agent's terminal UI, the agent restores the terminal and sends SIGTSTP to its process group, then waits for SIGCONT. In a sandbox that signal is refused, so the agent hangs with the terminal in cooked mode. I saw this with Claude Code 2.1.156 and OpenCode 1.18.31 on OpenShell 0.1.1. Copilot CLI 1.0.88 never gave the prompt back, and Hermes 0.19.0 printed a Python traceback and waited for Enter. Those four agent runs were on OpenShell 0.1.1. On v0.1.6 I re-ran the cause with a plain shell and a small script (below): the same signals are refused with EPERM. I could not re-run the agents on v0.1.6: OpenCode 1.19.5 installed in the sandbox but its terminal UI would not start there ("Failed to initialize OpenTUI render library"), and the sandbox proxy rejected the scoped npm package @anthropic-ai/claude-code (request-target with an encoded /).

A job-control shell has the same problem: fg resumes a job with killpg(), which is refused too, so a job that the terminal did stop can never be resumed.

Problem Statement

The child seccomp filter denies every signal sent to a process group:

  • crates/openshell-sandbox/src/linux/child_seccomp.rs (main 0e96e8e, unchanged in v0.1.6), around L213-L223 (the behavior below was observed on v0.1.6, see "Reproduced on v0.1.6"):
    • kill and rt_sigqueueinfo with pid 0 are denied;
    • kill and rt_sigqueueinfo with any negative pid are denied (append_argument_masked_deny(..., 1 << 31)).
  • The comment gives the reason: "an untrusted child can otherwise rejoin a trusted group first", and the workload must not include trusted sandbox workers in a broad signal.

Ordinary interactive programs depend on these calls:

  • terminal UIs call kill(0, SIGTSTP) for Ctrl-Z;
  • shells call killpg(pgid, SIGCONT) for fg and bg, and killpg(pgid, SIGINT/SIGTERM) to stop a pipeline;
  • build tools and test runners signal their own process group on cancel.

Single-pid signals already go through the supervisor's notify path (network_broker.rs around L600: kill and rt_sigqueueinfo go to process_signal::mediate_process_signal). Group signals never reach that path.

Reproduced on v0.1.6 (2026-11-02)

Sandbox from node:22-slim on a stock v0.1.6 gateway (vm driver, macOS 15.8 on Apple silicon; Linux guest 6.12.109 aarch64), user node (uid 1000). A Perl script run with openshell sandbox exec jc -- perl /tmp/probe.pl calls kill() and prints the errno (evidence/job-control-signals/01-probe.txt):

kill(CONT, self pid)                       r=1 errno=0 (allowed)
kill(CONT, 0)  [caller's group]            r=0 errno=1 (Operation not permitted)
kill(CONT, -own_pgid)                      r=0 errno=1 (Operation not permitted)
kill(TSTP, -9999999) [group, none exists]  r=0 errno=1 (Operation not permitted)
kill(CONT, -1) [everyone]                  r=0 errno=1 (Operation not permitted)
kill(TSTP, -child_pgid) [shell job group]  r=0 errno=1 (Operation not permitted)
kill(CONT, -child_pgid) [fg/bg resume]     r=0 errno=1 (Operation not permitted)
kill(TSTP, child pid) [single pid]         r=1 errno=0 (allowed)
kill(CONT, child pid) [single pid]         r=1 errno=0 (allowed)

The refusal is EPERM (errno 1) for pid 0 and for every negative pid, including the caller's own group, a group the caller just created, and a group that does not exist. Single-pid signals are allowed. In bash: kill -CONT 0 prints kill: (0) - Operation not permitted.

Interactive repro in tmux (evidence/job-control-signals/02-interactive.txt): openshell sandbox exec --tty jc -- bash -i, then sleep 60, then Ctrl-Z:

node@jc:~$ sleep 60
^Z
[1]+  Stopped                 sleep 60
node@jc:~$ jobs
[1]+  Stopped                 sleep 60
node@jc:~$ fg
sleep 60

The terminal driver stops the job, but fg never gives the prompt back. Reading /proc in the sandbox 35 s and 90 s later shows sleep 60 still in state T (stopped) in its own process group, and bash -i waiting in state S. So the job stays stopped, which is the hang I see in the agents. (The Perl probe above ran without a tty; the interactive shell used one.)

Impact / Why This Matters

  • Ctrl-Z in any agent TUI hangs the session. Users lose the terminal and kill the session.
  • fg, bg and pipeline cancellation do not work in interactive shells in a sandbox.
  • Today I work around it per program: a shim that turns Hermes' Ctrl-Z into a notice, and a launcher that answers Ctrl-Z for the other agents. Each new agent version can break the shim.

Proposed Design

Mediate group signals instead of denying them, using the notify path that already mediates single-pid signals:

  1. Change the child filter so that kill(0, sig) and kill(-pgid, sig) (and rt_sigqueueinfo) go to the user notification listener instead of the flat deny. Keep kill(-1, sig) denied.
  2. In process_signal::mediate_process_signal, resolve the target group (the caller's group for pid 0), list its members, and allow the signal only when every member is a workload process. Deny it when any member is a trusted supervisor worker.
  3. Close the "rejoin a trusted group" path the current comment worries about: deny setpgid and setsid calls that would move a workload process into a group or session that holds a trusted worker. A workload may still create its own groups (as shells do for jobs).
  4. Optionally limit the allowed set to job-control and termination signals (SIGTSTP, SIGTTIN, SIGTTOU, SIGCONT, SIGINT, SIGHUP, SIGTERM, SIGQUIT, SIGKILL, SIGWINCH) if maintainers prefer.

The supervisor already starts each workload in its own process group, so in the common case the check is cheap: the group holds only workload processes.

Acceptance Criteria

  • In openshell sandbox exec --tty <name> -- bash -i, sleep 60 then Ctrl-Z stops the job, jobs lists it, and fg resumes it. (On v0.1.6 the first two work and fg hangs.)
  • python3 -c 'import os, signal; os.kill(0, signal.SIGCONT)' succeeds in a sandbox.
  • A workload cannot signal a supervisor worker through a group signal, also after calling setpgid or setsid.
  • kill(-1, sig) stays denied.
  • The behavior is the same on the Docker, Podman, Kubernetes and MicroVM drivers.

Alternatives Considered

  • Per-program shims (what I do today). They break with each agent release and do not help shells.
  • Middleware, interceptors or providers. These cover network and credential paths, not process signals, so none applies.
  • Allow group signals unconditionally. Simpler, but it reopens the case the current filter guards against.

Notional example

$ openshell sandbox exec --tty demo -- bash -i
sandbox$ sleep 60
^Z
[1]+  Stopped                 sleep 60
sandbox$ fg
sleep 60

Agent Investigation

  • Filter: crates/openshell-sandbox/src/linux/child_seccomp.rs L204-L223 (main 0e96e8e; same lines in v0.1.6).
  • Mediation of single-pid signals: crates/openshell-sandbox/src/network_broker.rs L600-L613; crates/openshell-sandbox/src/linux/process_signal.rs.
  • Notify set: crates/openshell-sandbox/src/linux/seccomp_notify.rs L344-L346.
  • I found no existing issue for job control, SIGTSTP, SIGCONT or killpg in the sandbox (searched all issue titles on 2026-11-02). feat(sandbox): let the caller terminate a sandbox exec command and everything it spawned #3159 (terminate an exec and its children) is related but different.

Checklist

  • I've reviewed existing issues and the published docs
  • This is a design proposal, not a "please build this" request

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    state:triage-neededOpened without agent diagnostics and needs triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions