Auto-degrade when jail setup is denied after the child starts, so consumers stop declaring CLIGUARD_NO_SANDBOX #277

Closed
opened 2026-08-11 09:02:18 +00:00 by coilyco-ops · 0 comments
Member

Outcome

Every environment that cannot create the namespace jail degrades to an unsandboxed run on its own. No consumer repo, workflow, image, or test declares CLIGUARD_NO_SANDBOX=1 to make cli-guard usable.

Cause

docs/sandbox.md names two field failure modes and covers only one.

  • Mode 1, clone denied. The jailed child never starts, cmd.Run returns EPERM/EACCES with cmd.ProcessState == nil, sandbox.SetupDenied returns true, and shell.Runner.Exec retries unsandboxed with a one-time warning. Handled.
  • Mode 2, setup denied inside the child. The namespace is created, the child starts, and prepareJailMounts fails at unix.Mount("", "/", "", MS_REC|MS_PRIVATE, "") with sandbox: make-rprivate: permission denied. The child ran, so cmd.ProcessState != nil and SetupDenied returns false at cli/shell/shell.go:105. The verb dies. Not handled.

Mode 2 is the live one. infrastructure#370 records it verbatim on ser8, where Ubuntu 24.04's apparmor_restrict_unprivileged_userns=1 denies the userns to an unprofiled Homebrew ward binary, and it hard-failed every ward exec including the silent fleet-converge timer. Option 3 of that issue is exactly this fix and was never filed upstream here.

Because mode 2 has no escape, the only remedy consumers have is the opt-out env, and it has spread as a standing declaration rather than a deliberate choice. cli-guard#256 is the same family from the other direction: a masking bug whose acceptance is "the explicit no-sandbox compatibility switch is no longer required for this case."

Why a retry is safe here

RunJail reaches the target only through execJailTarget, the last statement after prepareJailMounts, installToolShims, setJailEnvironment, clearAmbientCaps, and lockdownSyscalls. Any error returned from RunJail is pre-exec by construction, and exec either replaces the process image or fails without running the target. So a mode-2 failure carries the same guarantee mode 1 already relies on: the wrapped tool never ran, and the retry cannot double an effect.

The setup work that did happen lives in the child's private mount namespace and dies with it, so there is no host state to unwind.

Scope

  • Give the jail helper a way to report "setup denied, target never ran" that a real tool exit code cannot forge. An ExtraFiles sentinel fd from shell.Runner is collision-free. A reserved exit code is cheaper but needs a stated argument about tool collision.
  • Distinguish setup denial (EPERM/EACCES from mount, seccomp, or capability operations) from a genuine setup bug. A malformed spec or missing stash directory should still fail loudly rather than silently drop the boundary.
  • Extend sandbox.SetupDenied to accept the mode-2 signal and let shell.Runner.Exec take the existing retry path and one-time warning.
  • Keep the degrade visible. cli-guard#261 already asks that fallback to unsandboxed execution reach callers and audit logs, so route this through whatever that issue settles on rather than inventing a second channel.
  • Update docs/sandbox.md: the "choosing" section currently tells container operators to set the opt-out because auto-degrade does not catch mode 2. After this, the env var is for deterministic opt-out with no per-exec retry cost, not a correctness requirement.

Acceptance

  • A ward exec verb on a host with apparmor_restrict_unprivileged_userns=1 completes, unsandboxed, with one warning, and no CLIGUARD_NO_SANDBOX set.
  • A Linux regression test forces a make-rprivate denial and asserts the command runs exactly once, unsandboxed, and succeeds.
  • A jail-setup failure that is not a permission denial still fails the command.
  • A wrapped tool exiting with the reserved code, if that design is chosen, is not mistaken for a setup denial.
  • docs/sandbox.md and docs/FEATURES.md describe one automatic degrade path covering both modes.

Fleet follow-through

The consumer-side removal of the declarations is tracked separately in coilysiren/inbox. Confirmed declarations today: agentic-os ci.yml, promote.yml, docker/dev-base/verify-common.sh, and tests/test_aosguard.py, plus job-level env in deploy and agentic-os-xxx CI. Ward's cmd/ward/container_bootstrap.go sets it deliberately for ward container and stays as a legitimate opt-out either way.

Filed from a native session that cannot land on main.

## Outcome Every environment that cannot create the namespace jail degrades to an unsandboxed run on its own. No consumer repo, workflow, image, or test declares `CLIGUARD_NO_SANDBOX=1` to make cli-guard usable. ## Cause `docs/sandbox.md` names two field failure modes and covers only one. * **Mode 1, clone denied.** The jailed child never starts, `cmd.Run` returns `EPERM`/`EACCES` with `cmd.ProcessState == nil`, `sandbox.SetupDenied` returns true, and `shell.Runner.Exec` retries unsandboxed with a one-time warning. Handled. * **Mode 2, setup denied inside the child.** The namespace is created, the child starts, and `prepareJailMounts` fails at `unix.Mount("", "/", "", MS_REC|MS_PRIVATE, "")` with `sandbox: make-rprivate: permission denied`. The child ran, so `cmd.ProcessState != nil` and `SetupDenied` returns false at `cli/shell/shell.go:105`. The verb dies. Not handled. Mode 2 is the live one. infrastructure#370 records it verbatim on ser8, where Ubuntu 24.04's `apparmor_restrict_unprivileged_userns=1` denies the userns to an unprofiled Homebrew `ward` binary, and it hard-failed every `ward exec` including the silent `fleet-converge` timer. Option 3 of that issue is exactly this fix and was never filed upstream here. Because mode 2 has no escape, the only remedy consumers have is the opt-out env, and it has spread as a standing declaration rather than a deliberate choice. cli-guard#256 is the same family from the other direction: a masking bug whose acceptance is "the explicit no-sandbox compatibility switch is no longer required for this case." ## Why a retry is safe here `RunJail` reaches the target only through `execJailTarget`, the last statement after `prepareJailMounts`, `installToolShims`, `setJailEnvironment`, `clearAmbientCaps`, and `lockdownSyscalls`. Any error returned from `RunJail` is pre-exec by construction, and `exec` either replaces the process image or fails without running the target. So a mode-2 failure carries the same guarantee mode 1 already relies on: the wrapped tool never ran, and the retry cannot double an effect. The setup work that did happen lives in the child's private mount namespace and dies with it, so there is no host state to unwind. ## Scope * Give the jail helper a way to report "setup denied, target never ran" that a real tool exit code cannot forge. An `ExtraFiles` sentinel fd from `shell.Runner` is collision-free. A reserved exit code is cheaper but needs a stated argument about tool collision. * Distinguish setup denial (`EPERM`/`EACCES` from mount, seccomp, or capability operations) from a genuine setup bug. A malformed spec or missing stash directory should still fail loudly rather than silently drop the boundary. * Extend `sandbox.SetupDenied` to accept the mode-2 signal and let `shell.Runner.Exec` take the existing retry path and one-time warning. * Keep the degrade visible. cli-guard#261 already asks that fallback to unsandboxed execution reach callers and audit logs, so route this through whatever that issue settles on rather than inventing a second channel. * Update `docs/sandbox.md`: the "choosing" section currently tells container operators to set the opt-out because auto-degrade does not catch mode 2. After this, the env var is for deterministic opt-out with no per-exec retry cost, not a correctness requirement. ## Acceptance * A `ward exec` verb on a host with `apparmor_restrict_unprivileged_userns=1` completes, unsandboxed, with one warning, and no `CLIGUARD_NO_SANDBOX` set. * A Linux regression test forces a `make-rprivate` denial and asserts the command runs exactly once, unsandboxed, and succeeds. * A jail-setup failure that is not a permission denial still fails the command. * A wrapped tool exiting with the reserved code, if that design is chosen, is not mistaken for a setup denial. * `docs/sandbox.md` and `docs/FEATURES.md` describe one automatic degrade path covering both modes. ## Fleet follow-through The consumer-side removal of the declarations is tracked separately in coilysiren/inbox. Confirmed declarations today: agentic-os `ci.yml`, `promote.yml`, `docker/dev-base/verify-common.sh`, and `tests/test_aosguard.py`, plus job-level env in deploy and agentic-os-xxx CI. Ward's `cmd/ward/container_bootstrap.go` sets it deliberately for `ward container` and stays as a legitimate opt-out either way. Filed from a native session that cannot land on main.
Sign in to join this conversation.
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#277
No description provided.