Skip to content

feat(sandbox): opt-in job-control signals to the caller's own process group - #5161

Open
vineethsai7 wants to merge 2 commits into
NVIDIA:mainfrom
vineethsai7:feat/5148-v1
Open

vineethsai7 wants to merge 2 commits into
NVIDIA:mainfrom
vineethsai7:feat/5148-v1

Conversation

@vineethsai7

@vineethsai7 vineethsai7 commented Nov 2, 2026 •

Copy link
Copy Markdown

Summary

Add an opt-in process policy for job-control signals in Linux sandbox workloads. Group signals reach the existing seccomp notification broker, which verifies the target group and signals its workload members through retained pidfds. This is a draft design proposal while issue #5148 is still state:triage-needed; maintainer feedback on the boundary and policy shape is welcome.

Related Issue

Part of #5148. This change covers the shared Linux sandbox signal path; driver-specific interactive end-to-end validation remains open.

Changes

  • Add process.allow_process_group_signals to authored policies, the public protobuf/API, the core and OPA policy conversions, and the sandbox boundary protocol. The flag defaults to false and is fixed at sandbox creation.
  • When enabled, let kill(0, sig), kill(-pgid, sig), and rt_sigqueueinfo group targets reach the existing signal notification path. Continue to deny kill(-1, sig) in the child filter.
  • Resolve the caller's group or requested PGID. Require each observed live member to be in the caller's session and workload launch, reject the trusted sandbox group and PGID 1, then deliver to checked pidfds individually. Preserve the existing frozen-workload SIGCONT guard.
  • Expose the flag in the typed Go SDK, and document the policy setting and its security boundary.
  • Update the reviewed public and durable policy schema fingerprints. A fixed pre-field payload test confirms that stored policies without the boolean still decode with group signaling off.

Design and threat model

A workload may share a UID and session with sandbox workers, and it may use setpgid to join a group containing a trusted worker. An unchecked group signal could stop or kill that worker. The broker rejects a target group when it observes a process outside the caller's workload launch or session. It retains each accepted process with a pidfd before signaling, so a later process-group change cannot redirect a group-wide kernel signal to a trusted process. Broadcast kill(-1, ...), PGID 1, and groups from another launch or session remain denied.

The current blanket deny breaks shell job control. Allowing the kernel group signal outright would reopen the trusted-group case. A new setpgid/setsid interception layer would add machinery to a process-signal path that already has a notification broker; checking at delivery keeps ordinary shell group creation available and fails closed for mixed groups. Network middleware, interceptors, and providers do not mediate process signals.

Group membership is read from /proc for each request. This costs a scan of the visible process table and delivers to a checked snapshot; a member that joins afterward may miss that signal. All collected targets are checked before any signal is sent, and each delivery rechecks the live notification and frozen state.

Example

Create a policy file with the opt-in field:

version: 1
process:
  allow_process_group_signals: true
$ openshell sandbox create --name demo --policy ./policy.yaml
$ openshell sandbox exec --tty demo -- bash -i
sandbox$ sleep 60
^Z
[1]+  Stopped                 sleep 60
sandbox$ fg
sleep 60

The console sequence is the intended user flow; the automated test covers group SIGTSTP and SIGCONT, while the interactive driver checks below remain to be run. No new OCSF event or event schema is introduced.

Compatibility

Omitting the field preserves the existing group-signal denial. Old policy YAML and boundary JSON deserialize with the flag off; protobuf readers ignore the new fields. Existing single-process signal behavior is unchanged. The server rejects attempts to change the process policy on a running sandbox. The implementation is in the shared Linux guest sandbox path used by the drivers.

If a workload descendant is reparented after its launch process exits, it no longer has a verifiable launch root and group delivery fails closed. Ordinary interactive shell jobs retain a live shell parent.

Release Notes

Sandbox policies can opt in to shell job-control signals with process.allow_process_group_signals: true. The default remains off.

Testing

  • Checks appropriate to the affected code and behavior pass
  • Unit tests added/updated: policy and protocol round trips, legacy stored policy decoding, Go SDK conversion, group signal delivery and denial, static policy update rejection
  • E2E tests added/updated: interactive driver validation is a follow-up

Verification performed:

  • cargo fmt --all -- --check: passed.
  • Clippy with -D warnings for all touched Rust crates and the sandbox perf-harness feature: passed.
  • Touched-crate library tests: 5,791 passed, 40 ignored, 0 failed. This includes group SIGTSTP/SIGCONT, forbidden group targets, policy round trips, and legacy stored policy decoding.
  • Go 1.26 converter tests and gofmt check, TypeScript protobuf generation and typecheck, Python protobuf generation, and buf lint: passed.
  • Docs navigation and Markdown lint: passed. Fern check found 0 errors and 3 warnings outside the touched pages.

No driver E2E was run; those test hosts were outside this pod's scope.

Follow-ups

  • Run the interactive Ctrl-Z/fg flow and the group-signal security probes through Docker, Podman, Kubernetes, and MicroVM drivers on supported test hosts.
  • Decide with maintainers whether this policy should stay opt-in and whether to restrict the allowed signal numbers to a job-control set.

Checklist

  • Follows Conventional Commits
  • Commits are signed off (DCO)
  • Architecture docs updated (if applicable)

Review follow-up

Local review: REVIEW-5161.md.

  • F1 (P2): fixed in 8c89b986d09a2e885ee8720036c4678c9e46c266. Deliver checked group members before the notifying caller so a terminating signal cannot abort delivery to later members. The ordering regression failed before the fix and passed after it; the group SIGKILL integration test passed.
  • F2 (P2): fixed in 8c89b986d09a2e885ee8720036c4678c9e46c266. Assert the -1 broadcast and PGID 1 exclusion in a child-filter-only test for kill and rt_sigqueueinfo, and assert queued trusted-group denial. The workload test also denies groups outside the caller's launch or session and the trusted sandbox group.
  • F3 (P3): fixed in 8c89b986d09a2e885ee8720036c4678c9e46c266. State that the opt-in permits all valid group signals, including termination signals, in docs, public API comments, generated Go protobuf comments, Go SDK comments, and the policy-generation skill.
  • GitHub review comments: not applicable. The pull request had no review comments or review submissions. The only issue comment was an automated runner-vetting notice with no code finding.

Checks through dcb: cargo fmt --all -- --check; touched-crate clippy with --all-targets -- -D warnings; sandbox perf-harness clippy; touched-crate library tests (5,794 passed, 40 ignored, 0 failed); Go converter tests and gofmt -d; docs navigation, Markdown lint, and Fern check (0 errors, 3 warnings). Driver E2E remains the existing follow-up above.

Manual verification

Result: PASS. A real Docker sandbox denied group signals on upstream main and on the PR head with the new field omitted. With process.allow_process_group_signals: true, the caller's group stopped and resumed, a caught group SIGTSTP arrived, and broadcast kill(-1, 0) remained denied.

Environment

  • RHEL 9.8 x86_64 stack host with Docker 29.8.1. Runs used one disposable local account, a private HOME, private loopback gateway ports, and an Ubuntu 24.04 workload image. The installed services and standard accounts were untouched.
  • Before: upstream main branch point 1afd9f69d118eefa6972c000830899f9c68d053a. After: PR head 8c89b986d09a2e885ee8720036c4678c9e46c266. Each commit's CLI, gateway, supervisor, and sandbox binaries were built on Ubuntu 24.04 through dcb up-os-5148 using nice -n 15 cargo build --release --locked -p openshell-cli -p openshell-gateway -p openshell-supervisor -p openshell-sandbox --features openshell-gateway/vendored-z3.
  • Each case used its own local supervisor and sandbox images built from those binaries. The supervisor image used Ubuntu 24.04; the sandbox binary was packaged in a scratch image and installed into the Ubuntu workload by the Docker driver. The gateway and CLI ran with an Ubuntu 24.04 glibc loader and private gateway state.

Steps

  1. Start the commit-matched private gateway. Create a sandbox with openshell -g <gateway> sandbox create --name <sandbox> --detach --no-tty -- sleep 600. Run this once on main and once on the PR head with the policy field omitted. Both reached Ready.

  2. On the PR head, create a third sandbox with --policy <policy.yaml>. The authored policy used the default policy's filesystem_policy read-only paths (/bin, /usr, /lib, /proc, /dev/urandom, /etc, /var/log), read-write paths (/tmp, /dev/null), include_workdir: true, and landlock.compatibility: best_effort, plus:

    process:
      allow_process_group_signals: true

    The PR body's process-only policy example could not launch sleep here: it replaced the default filesystem grants and failed with Permission denied (os error 13). Adding the default grants allowed the opt-in sandbox to reach Ready; sandbox get -o json reported policy.process.allow_process_group_signals: true.

  3. In each sandbox, run the same non-TTY probe with openshell sandbox exec --no-tty --no-login-shell -- setsid -w bash -c '<probe>':

    set -m
    pgid=$$
    (sleep 1; echo HELPER_BEFORE_CONT; kill -CONT -- "-$pgid"; echo "HELPER_CONT_RC:$?") &
    helper=$!
    echo BEFORE_STOP
    kill -STOP 0
    echo "AFTER_STOP_RC:$?"
    wait "$helper"

    setsid isolates the workload session. Bash job control puts the helper in another process group in that session. The caller uses kill(0, SIGSTOP) on its own group; the helper uses kill(-pgid, SIGCONT) to resume it.

  4. In the opt-in sandbox, also run setsid -w bash -c 'trap "echo TSTP_RECEIVED" TSTP; kill -TSTP 0; echo "TSTP_RC:$?"' and bash -c 'kill -0 -- -1; echo "BROADCAST_RC:$?"'.

Before: upstream main

The sandbox reached Ready. Both group calls returned EPERM: AFTER_STOP_RC:1 appeared before the helper's one-second wait finished, so the caller did not stop; HELPER_CONT_RC:1 followed. The CLI exec itself exited 0 because the probe recorded the individual signal results and completed.

After: PR head

With the field omitted, the PR sandbox also reached Ready and produced the same two EPERM results. With the flag enabled, the helper's SIGCONT line appeared before AFTER_STOP_RC:0, proving that the caller stopped and then resumed. The helper's group SIGCONT returned 0. The caught group SIGTSTP returned 0 and printed its trap marker. kill(-1, 0) returned EPERM.

Short excerpts

main, field absent:    BEFORE_STOP → AFTER_STOP_RC:1 → HELPER_BEFORE_CONT → HELPER_CONT_RC:1
PR, field absent:      BEFORE_STOP → AFTER_STOP_RC:1 → HELPER_BEFORE_CONT → HELPER_CONT_RC:1
PR, flag true:         BEFORE_STOP → HELPER_BEFORE_CONT → HELPER_CONT_RC:0 → AFTER_STOP_RC:0
PR, flag true:         TSTP_RECEIVED; TSTP_RC:0
PR, flag true:         kill(-1, 0): Operation not permitted; BROADCAST_RC:1

Cleanup readback found no proof account or HOME, private listener, proof process, container, image, or volume. The existing host OpenShell listener retained its original PID. The dev worktree is clean at the PR head; no commit or push was made.

Not covered

The interactive TTY Ctrl-Z/fg sequence and Podman, Kubernetes, and MicroVM drivers were not run. This run did not probe rt_sigqueueinfo, mixed trusted groups, other sessions or launches, the frozen-workload SIGCONT guard, or policy-update rejection. The main comparison used the PR branch point; later upstream main commits were not separately tested.

Signed-off-by: Vineeth Sai Narajala <vnarajal@cisco.com>
@copy-pr-bot

copy-pr-bot Bot commented Nov 2, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

Send signals to other checked group members before the notifying process so a terminating signal cannot abort remaining delivery. Assert the broadcast barrier in isolation and describe the full opt-in scope.

Signed-off-by: Vineeth Sai Narajala <vnarajal@cisco.com>
@vineethsai7
vineethsai7 marked this pull request as ready for review November 3, 2026 04:50
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-03T04:55:25.708570Z 8c89b98 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8c89b986d0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

#[serde(default, skip_serializing_if = "String::is_empty")]
pub run_as_group: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub allow_process_group_signals: bool,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Model group-signal authority in containment checks

With a boundary where this flag is false or omitted and a candidate where it is true, the standalone containment prover still returns Within: ContainmentPolicy now carries this schema type, but crates/openshell-prover/src/containment/execution.rs::check compares only run_as_user and run_as_group, and common validation does not reject the new control. This lets a subagent policy gain process-group signal authority, including SIGKILL, while the boundary check reports success; model false-to-true as exceeding the boundary or fail closed as unsupported.

AGENTS.md reference: AGENTS.md:L57-L58

Useful? React with 👍 / 👎.

process: Some(ProcessPolicy {
run_as_user: "sandbox".into(),
run_as_group: "sandbox".into(),
allow_process_group_signals: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Account for group signals in MXC policy mapping

When an MXC policy sets allow_process_group_signals: true, add_static_policy_loss still inspects only the two identity fields, so EmbeddedPolicyMapper accepts the sandbox without enforcing this control or reporting it as an unsupported loss. This drift fixture sets the boolean to false, which the serializer omits, so the guard cannot expose the missing mapping; exercise the enabled value and reject, delegate, or emit a loss instead of silently dropping the policy request.

AGENTS.md reference: AGENTS.md:L55-L55

Useful? React with 👍 / 👎.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant