feat: auto-degrade when jail setup is denied inside the child #287

Closed
coilyco-ops wants to merge 1 commit from feat/auto-degrade-mode2 into main
Member

Closes #277.

docs/sandbox.md named two denial modes and umbra handled one.

  • Mode 1, clone denied. The child never starts, cmd.Run returns EPERM/EACCES with a nil ProcessState, SetupDenied reports it, shell.Runner retries unsandboxed. Already handled.
  • Mode 2, setup denied inside the child. The namespace is created, the child starts, then a mount or capability step is refused. Because the child ran, ProcessState is non-nil and SetupDenied returns false at cli/shell/shell.go:105. The verb died.

Mode 2 is the live one - Ubuntu 24.04's apparmor_restrict_unprivileged_userns=1 hits it. With no escape, the only remedy was CLIGUARD_NO_SANDBOX, which is exactly why it spread to 30 standing declarations across the fleet instead of staying a deliberate choice.

The mechanism

Wrap attaches a sentinel pipe through ExtraFiles. The jail child writes one byte when setup is refused, and shell.Runner takes the existing retry path.

A dedicated fd was chosen over a reserved exit code because a wrapped tool can exit with any code, and a false positive here silently drops a security boundary. Three properties hold:

property how
Side-effect-safe jailSetup performs every step and returns the target; execJailTarget is its only caller. An error means the tool never ran, so the retry cannot double an effect.
Unforgeable The sentinel is marked close-on-exec before the target is exec'd, so the wrapped tool never inherits the fd.
Fails loud when it should Only EPERM/EACCES degrade. A malformed spec, missing path, or read-only filesystem still fails rather than quietly dropping the jail.

Verified on real Linux, with a forced mode-2 denial

Docker, root-owned /tmp and uid 1000, so the child's stash mkdir is refused after the namespace is created - genuine mode 2, not the already-handled mode 1:

# without this change
exit=1
harness: jail: sandbox: mkdir stash: permission denied
harness: exec failed: exit status 9        <- the tool never ran

# with this change
exit=0
umbra: namespace sandbox unavailable here; running tools unsandboxed ...
TOOL-RAN-OK                                 <- ran, unsandboxed

No CLIGUARD_NO_SANDBOX set in either run. That is #277's acceptance criterion.

I checked that this actually exercises mode 2 rather than flattering itself: in a default Docker container both the old and new binaries succeed, because that environment denies the clone and hits mode 1. The comparison above only diverges once the denial moves inside the child.

Also: full suite green on Linux in Docker, and the darwin gate (build, vet, tidy, test, lint 0 issues).

Test coverage, and its limit

Six new Linux unit tests cover the channel: fires on the sentinel, ignores foreign bytes, does not block on a silent child (a bug I hit and fixed - the parent must drop its own write end or Read hangs forever), nil-safe, and the EPERM/EACCES-only classification.

What is not automated: the end-to-end environmental proof above is a documented Docker invocation, not a CI test. Forcing a real mode-2 denial needs a namespace-capable runner and a hostile /tmp, which the unit tests cannot fake without adding a fault-injection hook into the security path - which I judged worse than the gap. The mechanism is unit-tested; the environment behaviour is reproducible by hand.

The env var stays

CLIGUARD_NO_SANDBOX remains a deterministic opt-out that skips the per-exec retry, not a correctness requirement. ward container still sets it deliberately, since the container is already the boundary. docs/sandbox.md now says that instead of telling operators to set it.

Stripping the fleet's 30 standing declarations follows separately, once this releases.

Closes #277. `docs/sandbox.md` named two denial modes and umbra handled one. - **Mode 1, clone denied.** The child never starts, `cmd.Run` returns `EPERM`/`EACCES` with a nil `ProcessState`, `SetupDenied` reports it, `shell.Runner` retries unsandboxed. Already handled. - **Mode 2, setup denied inside the child.** The namespace is created, the child starts, then a mount or capability step is refused. Because the child *ran*, `ProcessState` is non-nil and `SetupDenied` returns false at `cli/shell/shell.go:105`. The verb died. Mode 2 is the live one - Ubuntu 24.04's `apparmor_restrict_unprivileged_userns=1` hits it. With no escape, the only remedy was `CLIGUARD_NO_SANDBOX`, which is exactly why it spread to **30 standing declarations** across the fleet instead of staying a deliberate choice. ## The mechanism `Wrap` attaches a sentinel pipe through `ExtraFiles`. The jail child writes one byte when setup is refused, and `shell.Runner` takes the existing retry path. A dedicated fd was chosen over a reserved exit code because **a wrapped tool can exit with any code**, and a false positive here silently drops a security boundary. Three properties hold: | property | how | | --- | --- | | **Side-effect-safe** | `jailSetup` performs every step and returns the target; `execJailTarget` is its only caller. An error means the tool never ran, so the retry cannot double an effect. | | **Unforgeable** | The sentinel is marked close-on-exec before the target is exec'd, so the wrapped tool never inherits the fd. | | **Fails loud when it should** | Only `EPERM`/`EACCES` degrade. A malformed spec, missing path, or read-only filesystem still fails rather than quietly dropping the jail. | ## Verified on real Linux, with a forced mode-2 denial Docker, root-owned `/tmp` and uid 1000, so the child's stash `mkdir` is refused **after** the namespace is created - genuine mode 2, not the already-handled mode 1: ``` # without this change exit=1 harness: jail: sandbox: mkdir stash: permission denied harness: exec failed: exit status 9 <- the tool never ran # with this change exit=0 umbra: namespace sandbox unavailable here; running tools unsandboxed ... TOOL-RAN-OK <- ran, unsandboxed ``` No `CLIGUARD_NO_SANDBOX` set in either run. That is #277's acceptance criterion. I checked that this actually exercises mode 2 rather than flattering itself: in a **default** Docker container both the old and new binaries succeed, because that environment denies the *clone* and hits mode 1. The comparison above only diverges once the denial moves inside the child. Also: full suite green on Linux in Docker, and the darwin gate (`build`, `vet`, `tidy`, `test`, `lint` 0 issues). ## Test coverage, and its limit Six new Linux unit tests cover the channel: fires on the sentinel, ignores foreign bytes, **does not block on a silent child** (a bug I hit and fixed - the parent must drop its own write end or `Read` hangs forever), nil-safe, and the `EPERM`/`EACCES`-only classification. **What is not automated:** the end-to-end environmental proof above is a documented Docker invocation, not a CI test. Forcing a real mode-2 denial needs a namespace-capable runner and a hostile `/tmp`, which the unit tests cannot fake without adding a fault-injection hook into the security path - which I judged worse than the gap. The mechanism is unit-tested; the environment behaviour is reproducible by hand. ## The env var stays `CLIGUARD_NO_SANDBOX` remains a **deterministic opt-out that skips the per-exec retry**, not a correctness requirement. `ward container` still sets it deliberately, since the container is already the boundary. `docs/sandbox.md` now says that instead of telling operators to set it. Stripping the fleet's 30 standing declarations follows separately, once this releases.
feat: auto-degrade when jail setup is denied inside the child
All checks were successful
ci / secrets (pull_request) Successful in 8s
ci / test (pull_request) Successful in 46s
ci / lint (pull_request) Successful in 19m42s
69fc1a4222
closes #277

docs/sandbox.md named two denial modes and umbra handled one.

  Mode 1, clone denied. The child never starts, cmd.Run returns
  EPERM/EACCES with a nil ProcessState, SetupDenied reports it, and
  shell.Runner retries unsandboxed. Already handled.

  Mode 2, setup denied inside the child. The namespace is created, the
  child starts, then a mount or capability step is refused. Because the
  child ran, ProcessState is non-nil and SetupDenied returns false at
  cli/shell/shell.go:105. The verb died.

Mode 2 is the live one - Ubuntu 24.04's
apparmor_restrict_unprivileged_userns=1 hits it - and with no escape the
only remedy was CLIGUARD_NO_SANDBOX. That is why it spread to 30
standing declarations across the fleet instead of staying a deliberate
choice.

Wrap now attaches a sentinel pipe through ExtraFiles. The jail child
writes one byte to it when setup is refused, and shell.Runner takes the
existing retry path. A dedicated fd was chosen over a reserved exit code
because a wrapped tool can exit with any code, and a false positive here
silently drops a security boundary.

Three properties the implementation holds:

  Side-effect-safe. jailSetup performs every step and returns the target;
  execJailTarget is the only caller. An error therefore means the wrapped
  tool never ran, so the retry cannot double an effect.

  Unforgeable. The sentinel is marked close-on-exec before the target is
  exec'd, so the wrapped tool never inherits the fd.

  Fails loud when it should. Only EPERM/EACCES degrade. A malformed spec,
  a missing path or a read-only filesystem still fails, rather than
  quietly dropping the jail.

Verified on real Linux in Docker, forcing a mode-2 EACCES denial
(root-owned /tmp, uid 1000, so the child's stash mkdir is refused after
the namespace is created):

  without this change: exit 1, the tool never runs
  with it:            exit 0, one warning, the tool runs unsandboxed,
                      and no CLIGUARD_NO_SANDBOX is set

Six new Linux unit tests cover the channel itself: fires on the sentinel,
ignores foreign bytes, does not block on a silent child, nil-safe, and
the EPERM/EACCES-only classification.

The env var stays as a deterministic opt-out that skips the retry, which
is why ward container still sets it. Removing the fleet's standing
declarations follows separately.

Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
coilyco-ops closed this pull request 2026-08-14 05:25:39 +00:00
All checks were successful
ci / secrets (pull_request) Successful in 8s
ci / test (pull_request) Successful in 46s
ci / lint (pull_request) Successful in 19m42s

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/umbra!287
No description provided.