The agent service was running as User=erik, which meant: - Sessions polluted erik's ~/.claude/projects/ - erik's .claude/settings.local.json (months of accumulated dev permissions for docker/git/dotnet/etc.) was loaded by the production agent, defeating the --allowed-tools whitelist - Subscription rate quota mingled between human-erik's interactive Claude Code use and the production assistant - Theoretical access to /home/erik/.ssh, .bash_history, .gitconfig Now: - User=overlord-agent (system account, no shell, /var/lib/overlord-agent home) - HOME=/var/lib/overlord-agent — claude state fully isolated from erik - /home/erik/.claude permissions tightened to 0700 (was 0755) - group=overlord-agent on the repo + /etc/overlord/agent.env (read-only) Project settings: - New strict committed .claude/settings.json: deny Bash/Read/Write/Edit/ Glob/Grep/NotebookEdit/WebSearch; allow only WebFetch(domain:acpedia.org) - .claude/settings.local.json now gitignored (was leaking dev permissions to the server through the deploy)
113 lines
4.8 KiB
Desktop File
113 lines
4.8 KiB
Desktop File
[Unit]
|
|
Description=Overlord Agent (Claude Code shell-out service)
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
# Dedicated unprivileged user — kernel-level isolation from `erik`.
|
|
# overlord-agent has NO access to /home/erik/.claude (mode 0700),
|
|
# /home/erik/.ssh, /home/erik/.bash_history, /home/erik/.gitconfig, etc.
|
|
# Its own claude state lives at /var/lib/overlord-agent/.claude/ and its
|
|
# claude session JSONLs land there — completely separate from any
|
|
# interactive Claude Code use by the human user.
|
|
User=overlord-agent
|
|
Group=overlord-agent
|
|
# Working directory: the repo root (group-readable to overlord-agent).
|
|
# claude session JSONLs path-encode this cwd so it's important to keep
|
|
# stable across restarts.
|
|
WorkingDirectory=/home/erik/MosswartOverlord
|
|
# HOME explicitly set so claude reads /var/lib/overlord-agent/.claude/*
|
|
# instead of trying /home/erik/.claude/* (which is now 0700, locked out).
|
|
Environment="HOME=/var/lib/overlord-agent"
|
|
# Secrets file (root:overlord-agent 0640).
|
|
EnvironmentFile=-/etc/overlord/agent.env
|
|
# Run inside the venv populated by install.sh.
|
|
ExecStart=/home/erik/MosswartOverlord/agent/.venv/bin/python -m agent.service
|
|
Restart=on-failure
|
|
RestartSec=3
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
|
|
# ─── Resource caps ─────────────────────────────────────────────────
|
|
MemoryMax=512M
|
|
CPUQuota=200%
|
|
TasksMax=128
|
|
|
|
# ─── Filesystem hardening ──────────────────────────────────────────
|
|
# /usr, /boot, /efi become read-only; /etc + /var get a writable overlay
|
|
# that's discarded on stop. Subprocesses inherit these protections.
|
|
ProtectSystem=strict
|
|
ProtectHome=read-only
|
|
# Allow writing only to the explicit paths claude / our service need.
|
|
# - ~/.claude — session JSONL files
|
|
# - .venv pycache — minor pip cache writes
|
|
ReadWritePaths=/var/lib/overlord-agent/.claude
|
|
ReadWritePaths=/home/erik/MosswartOverlord/agent/.venv
|
|
ReadWritePaths=/var/log/overlord-agent
|
|
# StateDirectory creates/owns /var/lib/overlord-agent automatically.
|
|
StateDirectory=overlord-agent
|
|
LogsDirectory=overlord-agent
|
|
LogsDirectoryMode=0755
|
|
PrivateTmp=true
|
|
PrivateDevices=true
|
|
ProtectClock=true
|
|
ProtectKernelTunables=true
|
|
ProtectKernelModules=true
|
|
ProtectKernelLogs=true
|
|
ProtectControlGroups=true
|
|
ProtectHostname=true
|
|
ProtectProc=invisible
|
|
ProcSubset=pid
|
|
|
|
# Hide sensitive host paths even if something in the python or claude
|
|
# subprocess tree tries to read them.
|
|
InaccessiblePaths=/etc/shadow
|
|
InaccessiblePaths=/etc/gshadow
|
|
InaccessiblePaths=/etc/ssh
|
|
InaccessiblePaths=/root
|
|
InaccessiblePaths=-/home/erik/.ssh
|
|
InaccessiblePaths=-/home/erik/.bash_history
|
|
InaccessiblePaths=-/home/erik/.zsh_history
|
|
|
|
# ─── Privilege & capability hardening ──────────────────────────────
|
|
NoNewPrivileges=true
|
|
CapabilityBoundingSet=
|
|
AmbientCapabilities=
|
|
LockPersonality=true
|
|
RestrictRealtime=true
|
|
RestrictSUIDSGID=true
|
|
RemoveIPC=true
|
|
# MemoryDenyWriteExecute would break Node.js (V8 JIT requires W^X
|
|
# transitions via mprotect with PROT_EXEC on JITted code pages). Claude
|
|
# Code is a Node app, so omit this. Without JIT we'd lose all model
|
|
# performance. The other restrictions still prevent shellcode injection
|
|
# in practice (no Bash/Write tools, no shellcraft surface).
|
|
# MemoryDenyWriteExecute=true ← DO NOT enable; breaks Node V8 JIT
|
|
RestrictNamespaces=true
|
|
|
|
# ─── Network family restriction ────────────────────────────────────
|
|
# Block raw/packet sockets so even a kernel-LPE-class bug can't sniff
|
|
# traffic or forge packets. We don't IPAddressAllow-restrict because
|
|
# Anthropic's Cloudflare IPs shift and the whitelist would break claude.
|
|
# If you need true egress filtering, run nftables scoped to this
|
|
# service's cgroup — that's reliable in a way IPAddressAllow isn't.
|
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
|
|
|
# ─── Syscall filter ────────────────────────────────────────────────
|
|
# Use the standard @system-service preset which is what almost every
|
|
# hardened systemd unit uses. It already excludes the dangerous groups
|
|
# (privileged, mount, reboot, raw-io, etc.) by NOT including them, while
|
|
# being broad enough to host typical apps including Node.js.
|
|
#
|
|
# We tried adding extra "~@..." negations on top — they killed Claude
|
|
# (Node) with SIGSYS during startup. The default @system-service preset
|
|
# is the right balance; the rest of the hardening covers what we need.
|
|
SystemCallArchitectures=native
|
|
SystemCallFilter=@system-service
|
|
SystemCallFilter=~@privileged
|
|
SystemCallFilter=~@reboot
|
|
SystemCallFilter=~@mount
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|