Skip to content

Commit a4846c1

Browse files
stackptrclaude
andcommitted
feat(glyph): add sandboxed agent container infrastructure
NixOS module for running AI coding agents (Claude Code) in disposable systemd-nspawn containers. Each run gets an isolated filesystem with overlay on a minimal rootfs, bind-mounted workspace, and network access for API calls. Usage: agent-sandbox <repo-url-or-path> <prompt> Requires creating agent-sandbox-api-key.age secret with the Anthropic API key, encrypted for glyph. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fd09c9f commit a4846c1

3 files changed

Lines changed: 254 additions & 0 deletions

File tree

‎hosts/glyph/services/default.nix‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@
8484
group = "graphite-mcp";
8585
};
8686

87+
age.secrets.agent-sandbox-api-key = {
88+
file = ./../secrets/agent-sandbox-api-key.age;
89+
mode = "400";
90+
};
91+
92+
services.agent-sandbox = {
93+
enable = true;
94+
apiKeyFile = config.age.secrets.agent-sandbox-api-key.path;
95+
};
96+
8797
services.basic-memory.enable = true;
8898
rc.backup = {
8999
enable = true;
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
{
2+
config,
3+
lib,
4+
pkgs,
5+
...
6+
}: let
7+
inherit (lib) mkIf mkOption mkEnableOption types;
8+
9+
cfg = config.services.agent-sandbox;
10+
11+
# Packages available inside the sandbox
12+
sandboxPackages = with pkgs;
13+
[
14+
bash
15+
claude-code
16+
coreutils
17+
curl
18+
findutils
19+
gawk
20+
git
21+
gnugrep
22+
gnused
23+
gnutar
24+
gzip
25+
jq
26+
nodejs_24
27+
openssh
28+
ripgrep
29+
xz
30+
]
31+
++ cfg.extraPackages;
32+
33+
# Build a minimal NixOS root filesystem for the container
34+
sandboxRootfs = pkgs.runCommand "agent-sandbox-rootfs" {} ''
35+
mkdir -p $out/{bin,etc,tmp,nix,usr/bin,var/tmp}
36+
mkdir -p $out/etc/ssl/certs
37+
38+
# Symlink all sandbox packages into a unified profile
39+
${lib.concatMapStringsSep "\n" (pkg: ''
40+
for dir in bin lib share etc; do
41+
if [ -d "${pkg}/$dir" ]; then
42+
mkdir -p $out/$dir
43+
for f in "${pkg}/$dir"/*; do
44+
ln -sf "$f" "$out/$dir/" 2>/dev/null || true
45+
done
46+
fi
47+
done
48+
'')
49+
sandboxPackages}
50+
51+
# SSL certificates for HTTPS
52+
ln -sf ${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt $out/etc/ssl/certs/ca-certificates.crt
53+
ln -sf ${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt $out/etc/ssl/certs/ca-bundle.crt
54+
55+
# Basic system files
56+
echo "root:x:0:0:root:/root:/bin/bash" > $out/etc/passwd
57+
echo "agent:x:1000:1000:agent:/home/agent:/bin/bash" >> $out/etc/passwd
58+
echo "root:x:0:" > $out/etc/group
59+
echo "agent:x:1000:" >> $out/etc/group
60+
echo "nameserver 100.100.100.100" > $out/etc/resolv.conf
61+
echo "nameserver 1.1.1.1" >> $out/etc/resolv.conf
62+
63+
# Env wrapper script
64+
cat > $out/bin/agent-entry <<'ENTRY'
65+
#!/bin/bash
66+
set -euo pipefail
67+
export HOME=/home/agent
68+
export PATH="/bin:/usr/bin"
69+
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
70+
export NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
71+
72+
if [ -f /home/agent/.env ]; then
73+
set -a
74+
source /home/agent/.env
75+
set +a
76+
fi
77+
78+
cd /workspace
79+
exec "$@"
80+
ENTRY
81+
chmod +x $out/bin/agent-entry
82+
'';
83+
84+
# Script to launch a sandboxed agent run
85+
launchScript = pkgs.writeShellScriptBin "agent-sandbox" ''
86+
set -euo pipefail
87+
88+
usage() {
89+
echo "Usage: agent-sandbox [options] <repo-url-or-path> [prompt]"
90+
echo ""
91+
echo "Options:"
92+
echo " -b, --branch BRANCH Branch to checkout"
93+
echo " -n, --name NAME Session name (default: auto-generated)"
94+
echo " -p, --prompt-file FILE Read prompt from file instead of argument"
95+
echo " -k, --keep Keep sandbox after completion"
96+
echo " -h, --help Show this help"
97+
exit 0
98+
}
99+
100+
BRANCH=""
101+
SESSION_NAME="agent-$(date +%s)-$$"
102+
PROMPT_FILE=""
103+
KEEP=false
104+
105+
while [[ $# -gt 0 ]]; do
106+
case "$1" in
107+
-b|--branch) BRANCH="$2"; shift 2 ;;
108+
-n|--name) SESSION_NAME="$2"; shift 2 ;;
109+
-p|--prompt-file) PROMPT_FILE="$2"; shift 2 ;;
110+
-k|--keep) KEEP=true; shift ;;
111+
-h|--help) usage ;;
112+
*) break ;;
113+
esac
114+
done
115+
116+
REPO="''${1:?repo URL or path required}"
117+
PROMPT="''${2:-}"
118+
119+
if [ -n "$PROMPT_FILE" ] && [ -z "$PROMPT" ]; then
120+
PROMPT="$(cat "$PROMPT_FILE")"
121+
fi
122+
123+
if [ -z "$PROMPT" ]; then
124+
echo "Error: prompt required (as argument or via --prompt-file)"
125+
exit 1
126+
fi
127+
128+
SANDBOX_BASE="${cfg.stateDirectory}/sessions/$SESSION_NAME"
129+
WORKSPACE="$SANDBOX_BASE/workspace"
130+
OVERLAY_UPPER="$SANDBOX_BASE/upper"
131+
OVERLAY_WORK="$SANDBOX_BASE/work"
132+
OVERLAY_MERGED="$SANDBOX_BASE/merged"
133+
134+
mkdir -p "$WORKSPACE" "$OVERLAY_UPPER" "$OVERLAY_WORK" "$OVERLAY_MERGED"
135+
136+
cleanup() {
137+
if [ "$KEEP" = "false" ]; then
138+
umount "$OVERLAY_MERGED" 2>/dev/null || true
139+
rm -rf "$SANDBOX_BASE"
140+
else
141+
umount "$OVERLAY_MERGED" 2>/dev/null || true
142+
echo "Sandbox preserved at: $SANDBOX_BASE"
143+
echo "Workspace: $WORKSPACE"
144+
fi
145+
}
146+
trap cleanup EXIT
147+
148+
# Clone or copy the repo
149+
if [[ "$REPO" == http* ]] || [[ "$REPO" == git@* ]]; then
150+
echo "Cloning $REPO..."
151+
git clone ''${BRANCH:+--branch "$BRANCH"} --depth=1 "$REPO" "$WORKSPACE"
152+
elif [ -d "$REPO" ]; then
153+
echo "Copying $REPO..."
154+
cp -a "$REPO/." "$WORKSPACE/"
155+
if [ -n "$BRANCH" ]; then
156+
git -C "$WORKSPACE" checkout "$BRANCH"
157+
fi
158+
else
159+
echo "Error: $REPO is not a valid URL or directory"
160+
exit 1
161+
fi
162+
163+
# Set up overlay filesystem for rootfs (sandbox packages are read-only)
164+
mount -t overlay overlay \
165+
-o lowerdir=${sandboxRootfs},upperdir=$OVERLAY_UPPER,workdir=$OVERLAY_WORK \
166+
"$OVERLAY_MERGED"
167+
168+
mkdir -p "$OVERLAY_MERGED/home/agent" "$OVERLAY_MERGED/workspace"
169+
170+
# Write the API key into the sandbox
171+
if [ -f "${cfg.apiKeyFile}" ]; then
172+
echo "ANTHROPIC_API_KEY=$(cat "${cfg.apiKeyFile}")" > "$OVERLAY_MERGED/home/agent/.env"
173+
chmod 600 "$OVERLAY_MERGED/home/agent/.env"
174+
fi
175+
176+
echo "Starting sandboxed agent session: $SESSION_NAME"
177+
echo "Workspace: $WORKSPACE"
178+
echo "Prompt: $PROMPT"
179+
180+
# Run Claude Code inside systemd-nspawn container
181+
systemd-nspawn \
182+
--quiet \
183+
--directory="$OVERLAY_MERGED" \
184+
--bind="$WORKSPACE:/workspace" \
185+
--bind-ro=/nix/store \
186+
--private-network=false \
187+
--user=root \
188+
--setenv=HOME=/home/agent \
189+
--setenv=PATH="/bin:/usr/bin" \
190+
--setenv=SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
191+
--setenv=NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
192+
--setenv=ANTHROPIC_API_KEY="$(cat "${cfg.apiKeyFile}" 2>/dev/null || echo "")" \
193+
/bin/claude \
194+
--dangerously-skip-permissions \
195+
--print \
196+
--output-format=text \
197+
"$PROMPT"
198+
199+
echo "Agent session $SESSION_NAME completed."
200+
201+
# Show what changed
202+
if [ -d "$WORKSPACE/.git" ]; then
203+
echo ""
204+
echo "=== Changes ==="
205+
git -C "$WORKSPACE" --no-pager diff --stat
206+
fi
207+
'';
208+
in {
209+
options.services.agent-sandbox = {
210+
enable = mkEnableOption "sandboxed AI agent execution environment";
211+
212+
stateDirectory = mkOption {
213+
type = types.path;
214+
default = "/var/lib/agent-sandbox";
215+
description = "Directory for sandbox sessions and state.";
216+
};
217+
218+
apiKeyFile = mkOption {
219+
type = types.path;
220+
description = "Path to file containing ANTHROPIC_API_KEY.";
221+
};
222+
223+
extraPackages = mkOption {
224+
type = types.listOf types.package;
225+
default = [];
226+
description = "Additional packages to make available inside sandboxes.";
227+
};
228+
};
229+
230+
config = mkIf cfg.enable {
231+
# Ensure systemd-nspawn is available
232+
environment.systemPackages = [
233+
launchScript
234+
pkgs.systemd
235+
];
236+
237+
# Create state directory
238+
systemd.tmpfiles.rules = [
239+
"d ${cfg.stateDirectory} 0750 root root -"
240+
"d ${cfg.stateDirectory}/sessions 0750 root root -"
241+
];
242+
};
243+
}

‎modules/nixos/llm/default.nix‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
imports = [
3+
./agent-sandbox.nix
34
./basic-memory.nix
45
./graphite-mcp.nix
56
./kagi.nix

0 commit comments

Comments
 (0)