zed/nix/tests/sandboxing/default.nix
Cameron Mcloughlin 3648fe6f19
agent: Sandboxing polish (#60173)
Removes git sandbox feature

The reason is essentially:
- write access to a `.git` dir can be trivially escalated to unsandboxed
access
- therefore, it is misleading to offer git access separate from
unsandboxed access
- instead, we encourage the model to use `--no-optional-locks` to avoid
needing write access to `git status`, etc.


Adds sandboxing to fetch tool
 


---

Release Notes:

- N/A or Added/Fixed/Improved ...

---------

Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
2026-07-01 14:55:30 +00:00

351 lines
12 KiB
Nix

# NixOS VM tests for the Linux Bubblewrap (`bwrap`) sandbox.
#
# What actually changes the sandbox's behavior is the *host configuration* —
# whether a usable `bwrap` exists and whether the kernel lets us create user
# namespaces. We can only set those at boot, which is exactly what a VM gives
# us. So we run one machine per host configuration:
#
# working - `bwrap` present, unprivileged user namespaces available;
# the sandbox must be enforced.
# no-bwrap - no `bwrap` on PATH; `Sandbox::can_create` must report
# `BwrapNotFound` and the consumer must fail closed.
# setuid-bwrap - the only `bwrap` is setuid-root; `can_create` must report
# `BwrapSetuidRejected` (we refuse to run setuid bwrap).
# userns-disabled - `bwrap` present but unprivileged user namespaces are
# disabled via sysctl; `can_create` must report
# `SandboxProbeFailed`.
#
# The behavior to assert is declared *here* as a list of `checks`, serialized to
# JSON and handed to the `bwrap_test_helper` binary, which builds the described
# sandbox policy via the sandbox crate's public API, performs the operation, and
# asserts the outcome. Each check is one of:
#
# { read = "/path"; succeeds = true; } # read a host file
# { write = "/path"; succeeds = false; } # write a host file
# { network = "echo1"; succeeds = true; } # connect to an echo server
# { canCreate = false; error = "bwrap_not_found"; } # Sandbox::can_create
#
# plus optional policy fields applied to that check (defaults shown):
#
# fs = "restricted"; # or "unrestricted"
# writablePaths = [ ]; # writable subtrees when fs = "restricted"
# networkAccess = "blocked"; # or "unrestricted" / "restricted"
# allowedDomains = [ ]; # allowed hosts when networkAccess = "restricted"
# protectedPaths = [ ]; # paths that remain readable but not writable,
# # even if they fall under a writable subtree.
#
# Two echo servers (`echo1`, `echo2`) on separate nodes give the network checks
# real peers, so a restricted-network policy that allowlists `echo1` can be
# shown to reach `echo1` while `echo2` is blocked.
#
# Build one scenario:
# nix build .#checks.x86_64-linux.sandbox-working
# Interactive:
# nix run .#checks.x86_64-linux.sandbox-working.driverInteractive
{
pkgs,
inputs,
}:
let
lib = pkgs.lib;
bwrap-test-helper = import ./helper.nix { inherit pkgs inputs; };
# Both echo nodes listen on the same port; the helper appends it to a bare
# hostname (`echo1` -> `echo1:7000`).
echoPort = 7000;
# A node running a raw TCP echo server (socat EXEC:cat). The helper round-trips
# a byte through it, directly or via the sandbox's HTTP CONNECT proxy.
mkEchoNode = {
networking.firewall.allowedTCPPorts = [ echoPort ];
systemd.services.echo-server = {
description = "TCP echo server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
ExecStart = "${pkgs.socat}/bin/socat -d TCP-LISTEN:${toString echoPort},reuseaddr,fork EXEC:cat";
DynamicUser = true;
Restart = "on-failure";
};
};
};
# Quiet boot + a couple of cores; shared by every machine-under-test.
baseMachine = {
boot.consoleLogLevel = lib.mkForce 3; # be quiet pls :)
services.journald.extraConfig = lib.mkAfter "MaxLevelConsole=notice";
environment.systemPackages = [ bwrap-test-helper ];
virtualisation = {
memorySize = 1024;
cores = 2;
};
};
# Per-scenario host configuration. Each entry layers onto `baseMachine`.
machineConfigs = {
# Plain, working host: bwrap on PATH, user namespaces available.
working =
{ ... }:
lib.mkMerge [
baseMachine
{ environment.systemPackages = [ pkgs.bubblewrap ]; }
];
# No bwrap anywhere on PATH.
no-bwrap = { ... }: baseMachine;
# The only bwrap is a setuid-root wrapper. We deliberately do NOT also put a
# plain `pkgs.bubblewrap` on PATH: `locate_bwrap` returns the first `bwrap`
# it finds on PATH, and we want that to be the setuid one so the helper sees
# `BwrapSetuidRejected`.
setuid-bwrap =
{ ... }:
lib.mkMerge [
baseMachine
{
security.wrappers.bwrap = {
source = "${pkgs.bubblewrap}/bin/bwrap";
owner = "root";
group = "root";
setuid = true;
};
}
];
# bwrap present, but unprivileged user namespaces disabled at boot. Capping
# the count at 0 makes `bwrap --unshare-user` fail, exactly like a hardened
# host, so the probe reports `SandboxProbeFailed`.
userns-disabled =
{ ... }:
lib.mkMerge [
baseMachine
{
environment.systemPackages = [ pkgs.bubblewrap ];
boot.kernel.sysctl."user.max_user_namespaces" = 0;
}
];
};
# Build a VM test for `machine` that runs `checks` through the helper.
mkTest =
{
name,
machine,
checks,
}:
let
checksFile = pkgs.writeText "${name}-checks.json" (builtins.toJSON checks);
in
pkgs.testers.nixosTest {
inherit name;
nodes = {
echo1 = { ... }: mkEchoNode;
echo2 = { ... }: mkEchoNode;
machine = machine;
};
testScript = ''
start_all()
echo1.wait_for_unit("echo-server.service")
echo1.wait_for_open_port(${toString echoPort})
echo2.wait_for_unit("echo-server.service")
echo2.wait_for_open_port(${toString echoPort})
machine.wait_for_unit("multi-user.target")
machine.wait_until_succeeds("getent hosts echo1", timeout=30)
machine.wait_until_succeeds("getent hosts echo2", timeout=30)
# The helper logs each check tagged `[sandbox_test]:`. `succeed` fails the
# whole test on a non-zero exit; we print its output so the per-check
# results show up in the build log.
print(machine.succeed(
"ZED_SANDBOX_CHECKS=${checksFile} "
"ZED_TEST_ECHO_PORT=${toString echoPort} "
"bwrap_test_helper 2>&1"
))
'';
};
in
{
sandbox-working = mkTest {
name = "sandbox-working";
machine = machineConfigs.working;
# Each check is written out in full (no shared fragments) so the policy and
# expected outcome for every case are obvious at a glance.
checks = [
# Reads of host files are always allowed, regardless of the write policy.
{
fs = "restricted";
writablePaths = [ "/sandbox-test/writable" ];
networkAccess = "blocked";
read = "/sandbox-test/readable/host.txt";
succeeds = true;
}
# Write inside the granted subtree: allowed.
{
fs = "restricted";
writablePaths = [ "/sandbox-test/writable" ];
networkAccess = "blocked";
write = "/sandbox-test/writable/ok.txt";
succeeds = true;
}
# Write outside the granted subtree: denied.
{
fs = "restricted";
writablePaths = [ "/sandbox-test/writable" ];
networkAccess = "blocked";
write = "/sandbox-test/forbidden/no.txt";
succeeds = false;
}
# A writable path that doesn't exist yet must be bound at its exact
# location (created up front), never widened to an existing ancestor. If it
# widened to `/sandbox-test`, this write to a read-only file under that
# ancestor would succeed — the scope-widening sandbox escape this guards
# against. It must be denied.
{
fs = "restricted";
writablePaths = [ "/sandbox-test/not-yet-created/deep" ];
networkAccess = "blocked";
write = "/sandbox-test/readable/host.txt";
succeeds = false;
}
# ...and the not-yet-existing path itself is created and writable.
{
fs = "restricted";
writablePaths = [ "/sandbox-test/not-yet-created/deep" ];
networkAccess = "blocked";
write = "/sandbox-test/not-yet-created/deep/ok.txt";
succeeds = true;
}
# The fs escape hatch lets a command write anywhere.
{
fs = "unrestricted";
networkAccess = "blocked";
write = "/sandbox-test/forbidden/anywhere.txt";
succeeds = true;
}
# ---- Protected paths ---------------------------------------------------
# A path inside a writable subtree can be re-bound read-only over the
# writable bind (order matters: later binds win). On Linux, protected paths
# remain readable but writes are denied.
{
fs = "restricted";
writablePaths = [ "/sandbox-test/repo" ];
protectedPaths = [ "/sandbox-test/repo/protected" ];
networkAccess = "blocked";
write = "/sandbox-test/repo/protected/test";
succeeds = false;
}
# Protected paths affect only the protected subtree: ordinary writes
# elsewhere in the writable worktree are unaffected.
{
fs = "restricted";
writablePaths = [ "/sandbox-test/repo" ];
protectedPaths = [ "/sandbox-test/repo/protected" ];
networkAccess = "blocked";
write = "/sandbox-test/repo/src/main.rs";
succeeds = true;
}
# Protected paths block writes but NOT reads on Linux because the protected
# subtree is read-only.
{
fs = "restricted";
writablePaths = [ "/sandbox-test/repo" ];
protectedPaths = [ "/sandbox-test/repo/protected" ];
networkAccess = "blocked";
read = "/sandbox-test/repo/protected/HEAD";
succeeds = true;
}
# Protected paths stay read-only even when ordinary filesystem writes are
# otherwise unrestricted.
{
fs = "unrestricted";
protectedPaths = [ "/sandbox-test/repo/protected" ];
networkAccess = "blocked";
write = "/sandbox-test/repo/protected/test";
succeeds = false;
}
# Blocked network: the echo server is unreachable.
{
fs = "restricted";
writablePaths = [ "/sandbox-test/writable" ];
networkAccess = "blocked";
network = "echo1";
succeeds = false;
}
# Unrestricted network: both echo servers are reachable.
{
fs = "restricted";
writablePaths = [ "/sandbox-test/writable" ];
networkAccess = "unrestricted";
network = "echo1";
succeeds = true;
}
{
fs = "restricted";
writablePaths = [ "/sandbox-test/writable" ];
networkAccess = "unrestricted";
network = "echo2";
succeeds = true;
}
# Restricted network: only the allowlisted host is reachable.
{
fs = "restricted";
writablePaths = [ "/sandbox-test/writable" ];
networkAccess = "restricted";
allowedDomains = [ "echo1" ];
network = "echo1";
succeeds = true;
}
{
fs = "restricted";
writablePaths = [ "/sandbox-test/writable" ];
networkAccess = "restricted";
allowedDomains = [ "echo1" ];
network = "echo2";
succeeds = false;
}
# On a working host the sandbox can be created.
{
fs = "restricted";
networkAccess = "blocked";
canCreate = true;
}
];
};
sandbox-no-bwrap = mkTest {
name = "sandbox-no-bwrap";
machine = machineConfigs.no-bwrap;
checks = [ { canCreate = false; error = "bwrap_not_found"; } ];
};
sandbox-setuid-bwrap = mkTest {
name = "sandbox-setuid-bwrap";
machine = machineConfigs.setuid-bwrap;
checks = [ { canCreate = false; error = "setuid_rejected"; } ];
};
sandbox-userns-disabled = mkTest {
name = "sandbox-userns-disabled";
machine = machineConfigs.userns-disabled;
checks = [ { canCreate = false; error = "probe_failed"; } ];
};
}