
Fifteen years building engineering platforms, currently focused on advanced AI infrastructure at yeet. I love turning the deeply complex topics into something everyone can understand. I relate deeply with the core yeet philosophy that you can just build things.
Last updated: September 2026
Quick answer. Work through the Linux privilege toolkit in order of what each layer actually buys. A dedicated user stops the agent reading your keys and shell history and does nothing to separate one agent from another, because Linux grants on identity. An empty
CapabilityBoundingSet=is free, since a coding agent needs no capability at all, and the ones people add for inspection are wider than their names suggest:CAP_SYS_PTRACEincludesprocess_vm_writev. ThenNoNewPrivileges=trueandProtectSystem=strictwith explicitReadWritePaths. SkipMemoryDenyWriteExecute=true, which systemd's manual says is "incompatible with... JIT execution engines" and your agent is one. What none of it fixes is the agent misusing rights it legitimately holds, which is why the last step is a record rather than a restriction.
I write eBPF tooling, which means I spend more time on the observation half of this than the enforcement half, and you should weight the recommendations accordingly. Everything about what a directive does here is quoted from systemd's own manual rather than remembered. What I keep seeing is teams applying a hardening template from a blog post to an agent service, watching it crash or silently misbehave, and concluding that agents cannot be constrained at all. The truth is narrower and more useful: one specific class of protection is incompatible with the workload, and the rest is fine.
Yes, and it is the cheapest boundary in this post, but be clear about what it buys. A dedicated agent user with its own home means the agent cannot read your SSH keys, your shell history or your browser profile, because file permissions stop it.
What it does not do is separate one agent from another, or from anything else running as that user. Linux grants on identity, so two agents sharing a uid are one principal to the kernel, and a machine with several pre-wired agent CLIs has several processes with identical rights and no way to tell their actions apart afterwards. That is the permission model meeting a workload it was never designed for, rather than a configuration you got wrong.
It also does not bound what the agent reaches over the network, or what it does to files it legitimately owns, which for a coding agent is your entire source tree. A separate user is a good first move and a poor last one, because the things it protects are the things you were least worried about and the source tree it leaves fully writable is the thing you actually care about.
The practical shape, if you take this route, is three decisions rather than one. Give the agent a home it owns, so its config and session state live somewhere it can write without opening your home directory. Add it to no supplementary groups, because docker is root-equivalent on most systems and sudo obviously is, and group membership is the quiet way a dedicated user stops being a boundary. And decide deliberately how it reaches your code: a bind mount or an ACL granting access to one project is a different grant from adding the agent to a group that owns all of them.
The failure mode to watch for is convenience erosion. A dedicated user is mildly inconvenient by design, and the fixes people reach for, adding it to docker, granting passwordless sudo, chmod-ing a shared directory to 777, each quietly return the privilege the separation removed. If you find yourself doing several of those, the separation is no longer buying anything and it is worth asking what you actually wanted from it.
Removing them helps. Granting them almost never does, and the ones people reach for are worse than their names suggest. An agent doing ordinary development work needs no capability at all, so an empty CapabilityBoundingSet= is a real reduction with no functional cost, which is a rare combination worth taking whenever it appears.
The trouble starts when an agent needs to inspect something. capabilities(7) describes CAP_SYS_PTRACE as permitting the holder to "transfer data to or from the memory of arbitrary processes using process_vm_readv(2) and process_vm_writev(2)", which is a write primitive against every process on the box, granted under a name that sounds like observation. CAP_DAC_READ_SEARCH is the power to "bypass file read permission checks and directory read and execute permission checks", which is every secret on the machine.
So capabilities are a good subtractive tool and a bad additive one, and the asymmetry is worth holding onto because the names encourage the opposite reading. Removing every capability costs a coding agent nothing; adding one to enable inspection hands over a power far wider than the job needed.
Two mechanisms are worth separating here, because they get conflated and only one of them is a ceiling. The bounding set is the maximum a process or its children can ever hold, so emptying it forecloses the class permanently, including for anything the agent spawns. File capabilities, set with setcap on a binary, are the other direction: they grant a specific power to a specific program without making it setuid root. The second is the better tool when something genuinely needs one capability, because it scopes the grant to one binary rather than to the agent's whole process tree.
The reason it still rarely helps for agents is that the capabilities an agent would want are the wide ones. There is no capability for "read another process's memory but not write it", because CAP_SYS_PTRACE covers both, and no capability for "read logs but not secrets", because CAP_DAC_READ_SEARCH bypasses the check rather than narrowing it. The kernel's granularity was designed for daemons with one job, and an agent's job is unbounded by construction. The four access models compared covers what to do when the agent genuinely needs to inspect a host, and the short version is that the answer is rarely a capability.
The filesystem and privilege ones, which are also the ones that buy the most, so the incompatible directive costs you less than it first appears. Four are worth setting on any agent service, and all four are quoted from the manual below rather than recommended from memory, because the exact wording is what tells you the scope.
| Directive | What the manual says it does |
|---|---|
NoNewPrivileges=true | "ensures that the service process and all its children can never gain new privileges through execve()" |
ProtectSystem=strict | "the entire file system hierarchy is mounted read-only, except for the API file system subtrees /dev/, /proc/ and /sys/" |
ProtectHome=read-only | Takes "a boolean argument or the special values 'read-only' or 'tmpfs'"; when true, /home/, /root and /run/user are "made inaccessible and empty" |
CapabilityBoundingSet= | "Capabilities listed will be included in the bounding set, all others are removed" |
NoNewPrivileges is the highest value per character in the whole list. It closes the setuid and setcap escalation path for the process and every child it spawns, which matters more for an agent than for most services precisely because an agent spawns children you did not enumerate. A normal service runs the binaries its author shipped; an agent runs whatever the task turns out to need.
An empty CapabilityBoundingSet= is the other cheap win. An agent editing code and running builds needs no capabilities at all, and removing the whole set means a bug or an injected instruction cannot reach for one. The bounding set is a ceiling rather than a grant, so emptying it forecloses the whole class rather than denying particular members of it.
Because ~/.claude is not in ReadWritePaths=, and every other path you forgot is queued behind it. The failures are quiet enough to waste an afternoon before you suspect the unit file. ProtectSystem=strict mounts everything read-only, so every directory the agent legitimately writes has to come back through ReadWritePaths=, and you discover the list empirically rather than deriving it.
For a coding agent that is more paths than you would guess: the project directory, the agent's own configuration and session state, and whatever cache the language toolchain uses. The worked account above hit exactly this, with mkdir '/home/<user>/.claude/projects/<hash>' returning EACCES until ReadWritePaths=%h/.claude was added.
The subtler trap in the same account is worth more than the loud one. ProtectHome=tmpfs overlays the home directory with a volatile filesystem, so writes succeed, produce no error, and vanish on restart. Conversation history disappeared silently. ProtectHome=read-only with selective ReadWritePaths is the shape that behaves.
The general rule: a hardening directive that fails loudly costs you an hour, and one that fails silently costs you trust in the data. Prefer the loud ones where you have a choice, and test a restart before you believe a configuration works, since persistence is the property a running process cannot demonstrate.
Because MemoryDenyWriteExecute=true is set and the agent is a just-in-time compiler, so the strongest memory protection available forbids exactly what a JIT does. Claude Code, Codex and most of the rest run on Node, which runs V8, which compiles JavaScript to machine code at runtime and then executes it. That is not an implementation detail you can configure away: it is how the runtime achieves its performance, and it holds for Python and the JVM too.
That requires taking a page that was writable and making it executable. systemd's manual describes what MemoryDenyWriteExecute= blocks:
"a system call filter is added (or preferably, an equivalent kernel check is enabled with prctl(2)) that rejects mmap(2) system calls with both PROT_EXEC and PROT_WRITE set, mprotect(2) or pkey_mprotect(2) system calls with PROT_EXEC set and shmat(2) system calls with SHM_EXEC set"
And then states the consequence directly, without waiting for you to discover it:
"Note that this option is incompatible with programs and libraries that generate program code dynamically at runtime, including JIT execution engines, executable stacks, and code 'trampoline' feature of various C compilers."
So the failure is not a misconfiguration you can tune out of. An agent service with MemoryDenyWriteExecute=true dies immediately, and a worked account of hardening Claude Code this way reports it exiting on startup with status=5/TRAP, because V8 needs the mprotect(W→X) transition the directive rejects. The crash arrives before the agent does any work, which at least makes it the loud kind of failure.
Systemd is candid about the directive's limits even where it does apply, noting that "the protection can be circumvented, if the service can write to a filesystem, which is not mounted with noexec (such as /dev/shm), or it can use memfd_create()." So for an agent: drop it, and compensate with SystemCallFilter=@system-service and ProtectKernelTunables=true, neither of which touches memory permissions.
The useful reframing is that privilege reduction for an agent is not a dial you turn up until something breaks. One protection is structurally incompatible with the workload, everything else in the standard template applies normally, and the interesting questions are the ordinary ones below: which user it runs as, and which capabilities it holds.
Start from the directives that survive a JIT and add paths back until it runs. This is a starting point rather than a finished policy, and every path in it is a decision about your own setup:
[Service]
NoNewPrivileges=true
CapabilityBoundingSet=
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=%h/.claude %h/Work
PrivateTmp=true
ProtectKernelTunables=true
SystemCallFilter=@system-service
# MemoryDenyWriteExecute=true # omitted deliberately: breaks V8's JIT
The commented line is the point of the whole unit. Leaving it in with a comment is better than leaving it out silently, because the next person to harden this service will reach for it otherwise, find the agent dead at startup, and spend an afternoon on a crash that systemd's manual explains in one sentence.
Two things to check before trusting it. Restart the service and confirm state you expect to persist actually persisted, which is what catches a tmpfs mistake. And run the agent through a real task rather than starting it, because a missing ReadWritePaths entry only appears when the agent reaches the path.
At the point where the remaining privileges are the ones the agent needs to do its job. An agent that can edit your code and run your build has, by construction, the ability to edit your code badly and run something else. No directive in this post addresses that, because it is not a privilege problem.
This is the honest limit of the whole exercise. NoNewPrivileges stops escalation. ProtectSystem bounds the filesystem. An empty capability set removes powers the agent never needed. None of them distinguishes a correct change from a destructive one, because the syscalls are identical and the difference is intent, which nothing on a Linux box can evaluate.
What is left once privileges are minimised is knowing what actually happened, and that record cannot come from the agent, whose own transcript stops at the boundary of what its framework mediated. On a JIT runtime you have deliberately not locked down, the kernel is the layer that still sees everything:
curl -fsSL https://yeet.cx | sh
yeet login
yeet run gh:yeet-src/claudefeed
claudefeed streams every command, file open and TCP connection an agent session makes, scoped to its process subtree, and needs no cooperation from the agent and no change to its memory permissions. It observes rather than enforces, which is the correct division of labour here: systemd bounds what the process may do, and the kernel record says what it did.
Work down the layers in order of what each buys, and stop expecting privilege reduction to answer a question it cannot. Give the agent its own user first, knowing it separates the agent from you and not from another agent, and resist the group memberships that quietly undo it. NoNewPrivileges=true and an empty CapabilityBoundingSet= are free and close real escalation paths. ProtectSystem=strict with an explicit ReadWritePaths is worth the setup, and ProtectHome=read-only rather than tmpfs avoids a failure that loses data without an error. Skip MemoryDenyWriteExecute=true, because systemd's own manual says it "is incompatible with... JIT execution engines" and your agent is one, and compensate with SystemCallFilter=@system-service and ProtectKernelTunables=true instead. Give the agent its own user, knowing that separates it from you and not from another agent. Then keep a record from below with claudefeed on yeet, because the privileges you had to leave in place are exactly the ones worth watching.
The failure worth avoiding is concluding that agents cannot be constrained because a hardening template broke one. Almost all of that template works. One directive is incompatible with the workload by design, it is documented as such, and the rest of the job is ordinary Linux administration plus the acceptance that a process allowed to write code and run builds can write bad code and run the wrong build.
MemoryDenyWriteExecute=true is the one that reliably does, because it rejects the mprotect call that makes a writable page executable and that is exactly what a JIT does during compilation. Systemd's manual names the incompatibility explicitly, covering JIT execution engines, executable stacks and compiler trampolines. SystemCallFilter can also break a runtime if you narrow it past @system-service, so change one directive at a time.
Yes. An agent editing files and running builds needs none, so an empty CapabilityBoundingSet= removes the whole set with no functional cost. Capabilities only become a question when the agent needs to inspect processes it does not own, and that is usually better answered another way.
It closes one specific path, where a child process gains privileges through a setuid or setcap binary on execve(). That is worth having and it is not a boundary around what the agent does with the privileges it already has, which for a coding agent includes your entire source tree.
read-only leaves the real home directory visible but unwritable, so selective writes can be granted back with ReadWritePaths. tmpfs overlays it with a volatile filesystem, so writes succeed and then disappear on restart with no error. For an agent that keeps session state in its home, tmpfs loses that state silently.
Only if each agent gets its own user, which almost nobody does. Linux grants on identity, so several agents sharing one uid are a single principal to the kernel and can read each other's config, session state and credentials freely. One dedicated agent user separates the agents from you; it does not separate them from each other.
It bounds what an injected instruction can accomplish rather than preventing injection. A read-only filesystem means an instruction to modify a system file fails, and an empty capability set means it cannot reach for a privilege. Neither inspects the instruction, and an agent acting within its granted rights is indistinguishable from one working correctly.
Systemd's own manual names two routes: writing to a filesystem not mounted noexec, such as /dev/shm, or using memfd_create(). That is a useful reminder that even where the directive applies, it raises cost rather than closing the class outright, and it is the kind of caveat most projects leave for someone else to discover.
Run it through a real task rather than just starting it, because a missing path only surfaces when the agent reaches for it. Expect at minimum the project directory, the agent's own configuration and session directory, and any cache the language toolchain writes to.
Not materially. omp is another coding-agent CLI, so it runs as your user with your rights like the rest, and the same layers apply: a dedicated user, an empty capability bounding set, and a systemd unit if it runs as a service. The one difference worth checking per agent is which paths it writes, since each keeps config and session state in its own dotfile directory.
Not for coding work. Running as root removes the only permission boundary you had, and most agent tasks need no elevated rights at all. If a specific operation genuinely requires privilege, the narrower answer is a targeted sudo rule for that operation rather than a root session for everything around it.
Anything the agent does within the rights it legitimately holds. A minimised agent that can still edit your source and run your build can still edit it wrongly, and no directive distinguishes a good change from a bad one because the system calls are identical. That is why the remaining work is observation rather than restriction.
NoNewPrivileges= preventing new privileges through execve(), ProtectSystem=strict mounting the hierarchy read-only except the API filesystems, ProtectHome= and its read-only and tmpfs values, CapabilityBoundingSet= semantics, and the full MemoryDenyWriteExecute= entry including its incompatibility with JIT execution engines and its own circumvention notes about /dev/shm and memfd_create().CAP_SYS_PTRACE actually grants, including process_vm_writev(2), and for CAP_DAC_READ_SEARCH bypassing file read and directory permission checks. Read before granting either to an agent that only needs to look at things.status=5/TRAP JIT crash, the EACCES on ~/.claude under ProtectSystem=strict, and the silent state loss under ProtectHome=tmpfs.openat only and UDP unhooked.Built with yeet, a JS runtime for writing eBPF programs on Linux machines. Join us on discord.