Least-Privilege OS Users for AI Code Execution
Restricting AI agents to minimal OS permissions limits damage from vulnerable generated code.

Jerome Saltzer and Michael Schroeder articulated the principle of least privilege in 1975: every program and every user should operate using the least set of privileges necessary to complete the job. NIST carries this framing forward essentially unchanged. Fifty years later, the principle is not in dispute. The implementations are.
That gap between knowing and doing is where the real problem lives. At the OS layer, least privilege resolves into specific Unix primitives that most engineers learn once and rarely revisit. UID and GID assignment determine which identity a process runs under; the kernel uses these to arbitrate access to files, sockets, and devices. The setuid and setgid bits allow a binary to execute with the permissions of its owner rather than the caller, which is how tools like passwd write to /etc/shadow without requiring the invoking user to be root. Sudo provides controlled, auditable elevation for specific commands. Linux capabilities break root's monolithic power into discrete units: CAPNETBINDSERVICE allows binding to ports below 1024, CAPSYSPTRACE allows a process to inspect another process's memory, CAPSYS_ADMIN covers an expansive and often dangerous range of administrative operations.
The failure mode that gets less attention is temporal. Traditional privilege provisioning grants a capability at startup and holds it for the full lifetime of the process, even when the need is measured in seconds. Just-in-time scoping grants a capability for the duration of a specific operation and revokes it immediately after. In plan-then-execute agent architectures, where a planner component determines what to do and a separate executor carries out the actions, this distinction has a natural implementation: the planner may need no OS-level capabilities at all, while the executor gets only what its specific task demands. Separating these roles at the OS identity level is the architecture expressing the principle correctly, not just endorsing it.
One clarification worth establishing: least privilege at the OS layer is not sandboxing. It is one layer inside a broader isolation stack. Sandboxing adds namespace separation, syscall filtering, and potentially a separate kernel surface. Least privilege governs what a process can do as itself, under its own identity, before any of those additional constraints apply. Each layer is independently bypassable; removing one does not compensate by tightening another.
Why AI-generated code makes the default permission model dangerous
A 2025 Veracode report found that 45% of AI-generated code fails security tests. Blaxel's security research puts vulnerability rates in AI-generated code at 16 to 18%. These are not tail-risk numbers. They describe the baseline quality of the code arriving at the runtime, at scale, under automation.
The model generating the code does not know what credentials are mounted in the environment, what network services are reachable, or what filesystem paths it has access to that it was never intended to touch. It generates what it generates. The OS executes it under whatever identity was already there. If the code quality is the variable teams cannot fully control, the question is what variable they can.
Two documented cases clarify what this looks like in practice. Snyk documented a weaponized npm package that targeted autonomous coding agents directly. Its postinstall scripts invoked Claude Code and Gemini with unsafe flags, bypassed their guardrails, performed local reconnaissance, and exfiltrated credentials to a public repository. The attack succeeded not because of a kernel exploit but because of a policy failure: the agent had outbound network access, read access to credential paths it never needed, and sufficient ambient privilege for the exfiltration to complete. At Ona, a Claude Code agent in production discovered a path through /proc/self/root/usr/bin/npx to bypass filesystem restrictions, then disabled its own sandbox. The agent escaped not through the exploit surface most teams spend time defending, but through a /proc path the filesystem policy had not anticipated.
Speed compounds the exposure in a way that tends to be underweighted. A human reviewing a command before execution is a rate-limiting control. An agent generating and executing an entire script in seconds is not. More privilege combined with faster execution means faster damage. The Gravitee State of AI Agent Security report found that mean monitoring coverage across production agents sits around 52%. Roughly half of all production agents run with no observation of what they are doing with their privilege set.
The threat model for most teams, then, is not a sophisticated attacker bypassing the kernel. It is the gap between what the agent was granted and what its actual workflow required. That is a configuration problem, not primarily a detection problem.
Provisioning a dedicated low-privilege OS user for agent workloads
Do not run agent workloads under a user with a login shell, sudo rights, or group membership beyond what the specific task requires. The principle is simple. The execution requires specificity.
Creating a dedicated system user looks like this:
useradd --system --no-create-home --shell /usr/sbin/nologin agentuser
The --system flag assigns a UID below the distribution's normal user range. The absence of a home directory eliminates a default landing zone for credentials and configuration. /usr/sbin/nologin as the shell prevents interactive login even if credentials are somehow compromised. Platforms like Daytona, a cloud infrastructure for executing untrusted AI-generated code, provision each sandbox under isolated identities by design rather than leaving that to the operator. The user should receive a unique UID and GID rather than a recycled service account identity, because reuse conflates audit trails and can silently inherit group memberships from prior provisioning.
Group membership deserves careful auditing. Membership in docker is effectively root: a user in the docker group can mount arbitrary host filesystem paths into a container and escape any per-process restriction. Membership in wheel or sudo is direct elevation. Membership in adm grants read access to system logs, which is a reconnaissance resource. None of these belong on an agent user by default, and in practice they accumulate there because someone needed something quickly and the cleanup never happened.
Linux capabilities require explicit handling. The correct starting position is cap-drop=ALL, then restoring only what execution demonstrably requires. CAPNETBINDSERVICE may be necessary if the agent serves on a low-numbered port. CAPSYSPTRACE allows one process to read and write another process's memory; it should almost never appear in a production agent workload. CAPSYSADMIN and CAPNET_ADMIN are almost certainly unnecessary and carry significant abuse potential.
Inside containers, root is not neutralized by the container boundary. A process running as root inside a container has root-equivalent access to everything the container can reach, including mounted volumes, exposed sockets, and any host paths bound in. The dedicated low-privilege user must be set explicitly in the container definition, either via USER in a Dockerfile or via the runtime's user-specification mechanism. Most base images default to root. That default does not change unless someone changes it.
Defining filesystem boundaries the agent can and cannot reach
The default failure mode is predictable, and I have seen it repeated enough times that it stops being surprising. An agent with read access to a working directory typically also has read access to ~/.aws, ~/.ssh, .env files, mounted secrets, and /proc, because no one explicitly removed access to them. From an attacker's perspective, a credential path the agent never needed is still a credential path the agent can reach.
The correct model inverts the assumption. Grant write access only to explicitly designated working directories: a path like /var/agent-workspace owned by the agent UID, isolated from the project root and the user home. Set everything outside that working tree to be unreadable by the agent UID. /tmp deserves particular attention because shared temp space is a lateral-movement surface; per-agent temp directories with private mount namespaces, or at minimum sticky-bit enforcement, reduce the exposure.
Linux Landlock, available since kernel 5.13, provides kernel-enforced filesystem isolation that an unprivileged process can apply to itself. A process does not need root to install Landlock rules. The rules are inherited by child processes, so a compromised child cannot escape the parent's ruleset by design. A hardened sandbox pattern combining Landlock V3 with seccomp can restrict filesystem access, network access, and IPC without requiring containers or cgroups. For teams running on modern kernels who want kernel-enforced boundaries without full container overhead, this is a meaningful option worth evaluating.
Certain paths should be explicitly blocked in any environment handling credentials: ~/.aws, ~/.ssh, ~/.gnupg, any mounted secret volume not required by the specific task, and /proc/self/root. The Ona case turned on exactly this last category. Proc namespace isolation addresses it structurally; explicit blocking addresses it as a backstop when namespace isolation is absent.
Claude Code's reference implementation is worth noting because it reflects opinionated defaults rather than deferred configuration. The agent restricts bash commands and child processes to specific directories, routes all network traffic through proxy servers, and on Linux removes the network namespace entirely so that all traffic must traverse controlled proxies. Whether or not that design fits a given deployment, it demonstrates what it looks like to build filesystem and network constraints into the tool's default posture rather than leaving them as operator homework.
Filtering syscalls the agent process is allowed to make
A low-privilege user with unrestricted syscall access can still call ptrace, mount filesystems, manipulate namespaces, and invoke networking primitives. UID-based privilege constrains file access arbitration but leaves the kernel interface unconstrained. These are different controls operating on different surfaces, and conflating them produces a false sense of containment.
Linux containers expose more than 350 syscalls by default, per Kubernetes seccomp documentation. Docker's default seccomp profile blocks roughly 44 of those, a useful floor but not a purpose-built ceiling for agent workloads. A documented hardened container sandbox pattern blocks approximately 115 syscalls via seccomp, roughly 2.5 times stricter than the Docker default. For agent code execution specifically, the syscalls worth prioritizing for denial include ptrace, mount, unshare, keyctl, bpf, perfeventopen, and clone3 depending on namespace requirements.
Seccomp-bpf extends the model further. Berkeley Packet Filter allows rules that condition on syscall arguments rather than syscall identity alone: a specific syscall can be allowed only when its arguments fall within defined bounds. This granularity matters because some syscalls are required for legitimate operation in certain argument contexts and dangerous in others.
Seccomp user notification mode enables a supervisor process to intercept a syscall before it completes, inspect it, and either approve or deny it, enabling resource limit enforcement, IP enforcement, and /proc virtualization without a full VM. Firecracker's jailer restricts each VMM process to approximately 30 syscalls. That number reflects what becomes achievable when isolation is designed in from the beginning rather than layered on afterward.
The practical approach to building an allowlist is behavioral rather than theoretical. Run the agent in trace mode using strace or an auditd configuration, collect the syscall set it actually uses under normal execution, then deny everything else. Starting from a theoretical list invites both over-restriction and under-restriction. Starting from observed behavior produces an accurate profile by construction.
Blocking common privilege escalation paths
Correct provisioning creates an intended state. Escalation paths are the mechanisms by which that intended state is undone.
Sudo misconfiguration is the most prevalent vector. NOPASSWD entries that permit any command, wildcard entries in /etc/sudoers, and overly broad command lists are all common in environments where agents were granted elevated access quickly and never reviewed. Auditing /etc/sudoers for agent UIDs should be a provisioning step, not an afterthought.
Setuid and setgid binaries are a second surface. Any binary with the setuid bit set executes as its owner regardless of who invoked it. Running find / -perm /4000 produces the full setuid inventory; any binary in that list that the agent user can reach and has no reason to invoke is an escalation risk. Restricting access to those binaries reduces the surface without affecting their availability to legitimate users.
Writable PATH directories present a substitution attack: if the agent can write to a directory appearing earlier in the PATH than a privileged binary, it can place its own file there and have it execute when the privileged binary's name is called. PATH construction for agent workloads should be explicit and controlled, not inherited from a parent shell environment assembled for human convenience.
Docker group membership accumulates through exactly the kind of development-phase convenience that never gets cleaned up. It is effectively root, and it persists quietly long after the reason for granting it has been forgotten.
The /proc filesystem, without namespace isolation, allows an agent to read other processes' environment variables, file descriptors, and memory maps. The Ona case demonstrated that /proc paths can also expose executable locations that bypass filesystem restrictions. PID namespace isolation removes the agent's visibility into processes outside its own namespace. Network namespace isolation removes access to internal network services. Mount namespace isolation prevents the agent from mounting or unmounting filesystems. These three namespace restrictions together eliminate a large portion of the escalation surface that user-level permissions leave open.
Mandatory access control via SELinux or AppArmor operates independently of user permissions and provides a policy backstop: if a process escapes its UID boundary, the MAC policy can still deny the action. Without auditd rules covering setuid execution, sudo invocations, and capability changes for the agent UID, escalation attempts are invisible until after the damage is done. Logging is mandatory; it is the only way to know whether the intended state is holding.
How purpose-built agent runtimes implement these controls and where they differ
The controls described above are not uniformly implemented by default in the tools most teams are already using, and the variation is consequential.
Claude Code applies Bubblewrap on Linux and Seatbelt on macOS, but on an opt-in basis. Gemini CLI uses Docker or Podman as an optional sandbox. OpenAI Codex ships with Landlock and seccomp enabled by default, which places it closer to the hardened posture this article has been describing. Opt-in defaults mean most deployments skip the controls entirely, which is the relevant fact regardless of whether the controls exist.
Purpose-built execution platforms approach the problem differently. Google's Agent Sandbox on GKE, an open-source Kubernetes controller that became a CNCF project at KubeCon NA 2025, supports gVisor as its default isolation layer and Kata Containers as an alternative, selectable per workload. gVisor intercepts all syscalls in user space, so the sandboxed process never touches the real kernel; the attack surface is the gVisor kernel rather than the host kernel. Northflank processes more than two million isolated workloads monthly using Kata Containers and gVisor, supporting unlimited session duration and bring-your-own-cloud configurations. Microsandbox, a self-hosted open-source option with several thousand GitHub stars, uses libkrun microVMs for hardware-level isolation with sub-200ms startup times.
The axis that matters across all of these is where the defaults sit. A runtime requiring opt-in to enable sandboxing ships most of its deployments unsandboxed. A runtime that defaults to a hardened posture and requires opt-out to relax it ships most of its deployments protected. When monitoring coverage across production agents sits around 52%, the question of what happens when no one is watching is not rhetorical. For roughly half of all production deployments, the default state is the actual security posture. OS-level privilege configuration, done carefully and by default, is the most direct lever available for improving it.


