Add nono (Landlock) sandbox for Duo Workflow behind feature flag

What does this MR do and why?

Adds the nono (Landlock-based) sandbox as an alternative to SRT/bubblewrap for Duo Agent Platform ambient flow execution, behind the duo_workflow_nono_sandbox feature flag (gitlab_com_derisk, default disabled).

Why nono? SRT relies on Linux user namespaces (unshare), which require CAP_SYS_ADMIN or unprivileged user-namespace support. On hardened runner images that run as a non-root UID without those capabilities, SRT silently falls back to running the executor unsandboxed. nono uses Landlock (an unprivileged LSM available since Linux 5.13) - it needs no namespace, works for any UID, and fails closed once running rather than silently bypassing the sandbox. It also has ~15ms overhead.

Changes

  • ee/lib/gitlab/duo_workflow/sandbox.rb: wrap_command now branches to wrap_command_nono or wrap_command_srt based on the flag. Both are private methods. The SRT path is byte-for-byte unchanged when the flag is off. Two new private helpers (computed_domains, build_nono_flags) reuse the existing combine_settings / parent_settings / minimal_allowlisted_domains logic so the nono policy stays in sync with SRT without duplicating code.

  • Landlock capability pre-check: wrap_command_nono now probes the real policy with nono run <policy> -- true before running the executor, mirroring the SRT path's srt --settings ... true. Because it exercises the actual grants it also catches a grant that overlaps one of nono's own deny rules (which makes nono refuse to start), not just a missing or too-old kernel. On failure it prints nono's own output and exits 1 instead of running the command unsandboxed. Neither the pre-check nor the executor run uses --silent, so a refused grant is distinguishable from a missing Landlock ABI and nono's grant warnings and enforcement summary reach the job trace.

  • Fail closed on an empty egress allowlist: nono only switches to default-deny once at least one --allow-domain is passed, so an empty computed allowlist would have left the network unrestricted - the opposite of SRT, which treats an empty allowedDomains as "allow nothing". wrap_command_nono now emits an error and exit 1 instead of running unsandboxed. Raised by @allparts in the AppSec review below.

  • ee/config/feature_flags/gitlab_com_derisk/duo_workflow_nono_sandbox.yml: New gitlab_com_derisk flag, default_enabled: false, actor-scoped on project, milestone 19.4, group group::agent foundations.

  • ee/spec/lib/gitlab/duo_workflow/sandbox_spec.rb: All existing SRT tests are wrapped in a context 'when duo_workflow_nono_sandbox flag is disabled' with stub_feature_flags(duo_workflow_nono_sandbox: false). New nono context covers: command structure, --allow-domain/--deny-domain flag generation, shell-escaping of domain values, the empty-allowlist, pre-check and nono-missing fail-closed paths, and absence of SRT constructs.

nono policy mapping

SRT config nono CLI flag
filesystem.allowWrite ["./", "/tmp"] --allow . + --allow /tmp. Deliberately not --allow-cwd, which grants the CWD read-only and would leave .git/ unwritable
filesystem.allowGitConfig: true the --read-file/--read grants over git's config include chain, glab credentials and git's exec path (NONO_FS_GRANTS)
filesystem.denyRead ["~/.ssh"] nono built-in sensitive-path denylist
filesystem.denyWrite [SANDBOX_SYSTEM_DIR] no counterpart needed - no settings file is written under nono, and /var/tmp is not granted, so it is deny-by-default
network.allowedDomains --allow-domain <domain> (shell-escaped). Wildcards are supported: *.host matches subdomains but not the bare host, which is why minimal_allowlisted_domains emits both
network.deniedDomains --deny-domain <domain> (shell-escaped), evaluated before the allowlist

Grants are leaf paths only: Landlock cannot enforce a deny nested under an allowed parent, so nono refuses to start if a grant contains one of its own deny rules - which rules out $HOME and $HOME/.config as grants.

allowAllUnixSockets has no 1:1 nono flag yet; tracked as follow-up.

Executor image note

The current executor image ships srt, not nono. When the flag is enabled, the image must provide nono on PATH (custom image or setup_script).

Both of these fail closed (exit 1), unlike the SRT path, which falls back to running unsandboxed:

  1. nono is not on PATH.
  2. the capability pre-check fails - Landlock unavailable (kernel < 5.13 or compiled out), or a grant that nono refuses.

These were originally fail-open for SRT parity. They were changed to fail closed in c29f17d1 at AppSec's request (!252331 (comment 3812424596)): nono is opt-in, so the duo_workflow_nono_sandbox flag is itself the opt-out, and disabling it restores the SRT path.

Hardening the install (pinned install with checksum verification) and a customer-facing opt-out apply equally to the existing SRT path and are tracked separately in #624831 (closed), so they are deliberately out of scope here.

User-facing documentation updates will follow at rollout time (flag is off by default).

References

Follow-ups agreed during the AppSec review:

Screenshots or screen recordings

N/A - no UI changes.

How to set up and validate locally

  1. Enable the flag for a project in rails console:

    Feature.enable(:duo_workflow_nono_sandbox, Project.find(<id>))
  2. Ensure nono is on PATH in the executor image (or install it in setup_script).

    setup_script:
      - curl -fsSL https://nono.sh/install.sh | sh
      - export PATH="$HOME/.local/bin:$PATH"
  3. Trigger a Duo Workflow ambient session and confirm the executor runs under nono run.

  4. Disable the flag and confirm the SRT path is used unchanged.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Andras Herczeg

Merge request reports

Loading
Loading