claudebox/modules/default.nix
Christopher Mühl 72dfde91a8
feat!: thin layer over Claude /sandbox + nftables CIDR block
Drops bwrap orchestration, history overlay, forced
--dangerously-skip-permissions, SANDBOX.md injection, env-file
loading. claude --sandbox handles kernel isolation; claudebox
manages settings.local.json sandbox.* keys and installs nftables
rules matched on claude-sandbox.slice cgroup membership.

New flake outputs: nixosModules.default + checks.wrapper-syntax.
Docs updated to reflect the layered (not structural) FS guarantee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:19:40 +02:00

116 lines
3.7 KiB
Nix

{ config, lib, pkgs, ... }:
let
cfg = config.services.claudebox;
in
{
options.services.claudebox = {
enable = lib.mkEnableOption ''
claudebox network isolation. Installs nftables rules that drop egress
to Tailscale CGNAT, RFC1918, MagicDNS resolver, and link-local ranges
for any process inside the systemd user slice `claude-sandbox.slice`.
The claudebox wrapper launches `claude` into this slice via
`systemd-run --user --scope --slice=claude-sandbox.slice`. The rules
installed here are the structural backstop that Claude Code's built-in
`/sandbox` does not provide (it does hostname allowlisting only, not
CIDR-level block).
'';
cgroupLevel = lib.mkOption {
type = lib.types.int;
default = 4;
description = ''
Cgroup level at which `claude-sandbox.slice` appears in the cgroup v2
hierarchy. The default 4 matches modern systemd user-instance layout:
```
/ (level 0)
user.slice/ (level 1)
user-1000.slice/ (level 2)
user@1000.service/ (level 3)
claude-sandbox.slice/ (level 4)
```
Verify on your system with:
```
systemd-run --user --scope --slice=claude-sandbox.slice -- sleep 5 &
cat /proc/$!/cgroup
```
Count `/`-separated components from root to find where
`claude-sandbox.slice` sits.
'';
};
blockedCidrsV4 = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"100.64.0.0/10" # Tailscale CGNAT
"100.100.100.100/32" # Tailscale MagicDNS resolver
"10.0.0.0/8" # RFC1918
"172.16.0.0/12" # RFC1918
"192.168.0.0/16" # RFC1918
"169.254.0.0/16" # link-local
];
description = ''
IPv4 CIDRs blocked for processes inside `claude-sandbox.slice`.
Defaults cover the homelab threat model: no Tailscale, no LAN, no
link-local (cloud metadata services).
'';
};
blockedCidrsV6 = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"fd7a:115c:a1e0::/48" # Tailscale IPv6
"fc00::/7" # ULA (RFC4193)
"fe80::/10" # link-local
];
description = ''
IPv6 CIDRs blocked for processes inside `claude-sandbox.slice`.
'';
};
extraOutputRules = lib.mkOption {
type = lib.types.lines;
default = "";
description = ''
Extra nftables rules to append to the claudebox `output` chain.
Useful for blocking additional internal subnets or specific ports.
Rules run after the default CIDR blocks but inside the same chain,
so they only fire for sockets in `claude-sandbox.slice`.
'';
};
};
config = lib.mkIf cfg.enable {
networking.nftables.enable = true;
networking.nftables.tables.claudebox = {
family = "inet";
content = ''
chain output {
type filter hook output priority filter; policy accept;
# IPv4 CIDR block only fires for sockets inside claude-sandbox.slice.
socket cgroupv2 level ${toString cfg.cgroupLevel} "claude-sandbox.slice" \
ip daddr { ${lib.concatStringsSep ", " cfg.blockedCidrsV4} } drop
# IPv6 CIDR block.
socket cgroupv2 level ${toString cfg.cgroupLevel} "claude-sandbox.slice" \
ip6 daddr { ${lib.concatStringsSep ", " cfg.blockedCidrsV6} } drop
${cfg.extraOutputRules}
}
'';
};
assertions = [
{
assertion = cfg.cgroupLevel >= 0 && cfg.cgroupLevel <= 16;
message = "services.claudebox.cgroupLevel must be 0..16 (cgroup hierarchy depth).";
}
];
};
}