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>
116 lines
3.7 KiB
Nix
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).";
|
|
}
|
|
];
|
|
};
|
|
}
|