Repository navigation
Description
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"):killandrt_sigqueueinfowith pid 0 are denied;killandrt_sigqueueinfowith 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)forfgandbg, andkillpg(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,bgand 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:
- Change the child filter so that
kill(0, sig)andkill(-pgid, sig)(andrt_sigqueueinfo) go to the user notification listener instead of the flat deny. Keepkill(-1, sig)denied. - 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. - Close the "rejoin a trusted group" path the current comment worries about: deny
setpgidandsetsidcalls 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). - 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 60then Ctrl-Z stops the job,jobslists it, andfgresumes it. (On v0.1.6 the first two work andfghangs.) -
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
setpgidorsetsid. -
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.rsL204-L223 (main 0e96e8e; same lines in v0.1.6). - Mediation of single-pid signals:
crates/openshell-sandbox/src/network_broker.rsL600-L613;crates/openshell-sandbox/src/linux/process_signal.rs. - Notify set:
crates/openshell-sandbox/src/linux/seccomp_notify.rsL344-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 execcommand 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