<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Gancho Stoychev</title>
    <description>The latest articles on DEV Community by Gancho Stoychev (@gjstoychev).</description>
    <link>https://dev.to/gjstoychev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4177109%2F9e83d678-6796-4b6b-901d-76c9c3265a9f.jpeg</url>
      <title>DEV Community: Gancho Stoychev</title>
      <link>https://dev.to/gjstoychev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gjstoychev"/>
    <language>en</language>
    <item>
      <title>Hardening Ollama with systemd: limits, sandboxing and two GPUs</title>
      <dc:creator>Gancho Stoychev</dc:creator>
      <pubDate>Thu, 15 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/gjstoychev/hardening-ollama-with-systemd-limits-sandboxing-and-two-gpus-4093</link>
      <guid>https://dev.to/gjstoychev/hardening-ollama-with-systemd-limits-sandboxing-and-two-gpus-4093</guid>
      <description>&lt;p&gt;Ollama on my server runs the way its installer left it: a systemd unit with a user, a restart policy and nothing else. No memory limit, no CPU limit, the whole file system writable where Unix permissions allow it, every device in &lt;code&gt;/dev&lt;/code&gt; reachable. systemd has dozens of directives to change that, and a tool that grades how much of it a service uses: &lt;code&gt;systemd-analyze security&lt;/code&gt; gives every unit an exposure score from 0 (tightly sandboxed) to 10 (nothing applied). Ollama started at &lt;strong&gt;9.2 UNSAFE&lt;/strong&gt; 😨.&lt;/p&gt;

&lt;p&gt;This is the fourth post in my &lt;a href="https://gshell.dev/blog/" rel="noopener noreferrer"&gt;AI homelab series&lt;/a&gt;. The first built a container by hand from namespaces and cgroups, the second set up Ansible, and the third put the AI agent I work with into a sandbox. That agent wrote the roles in this lab from inside its sandbox; every command that touched systemd, I ran.&lt;/p&gt;

&lt;p&gt;This one is Lab 0b. Ollama and a small service of my own under systemd, with limits taken from measurements, hardening applied in rounds with a regression test after each, and failures caused on purpose to prove the settings do what they say. Ollama ended at &lt;strong&gt;1.4 OK&lt;/strong&gt; 🙂, the small service at &lt;strong&gt;0.9 SAFE&lt;/strong&gt; 😀. One memory limit did nothing at all until I found out why.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guide:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/docs/guides/lab0b.md" rel="noopener noreferrer"&gt;every command, step by step&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Results:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/results/lab0b.md" rel="noopener noreferrer"&gt;measurements, scores and surprises&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role &lt;code&gt;ollama&lt;/code&gt;:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/tree/main/ansible/roles/ollama" rel="noopener noreferrer"&gt;drop-ins for the installer's unit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ollama's hardening:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/ansible/roles/ollama/files/hardening.conf" rel="noopener noreferrer"&gt;&lt;code&gt;hardening.conf&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role &lt;code&gt;lab0b_demo&lt;/code&gt;:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/tree/main/ansible/roles/lab0b_demo" rel="noopener noreferrer"&gt;a service of my own with a secret&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The demo unit:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/ansible/roles/lab0b_demo/files/lab0b-demo.service" rel="noopener noreferrer"&gt;&lt;code&gt;lab0b-demo.service&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What "done" meant
&lt;/h2&gt;

&lt;p&gt;Three checks, taken from the roadmap:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Criterion&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Result&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Ollama and a second service under systemd with &lt;code&gt;MemoryMax&lt;/code&gt;, &lt;code&gt;CPUQuota&lt;/code&gt;, &lt;code&gt;Restart=on-failure&lt;/code&gt;, &lt;code&gt;ProtectSystem=strict&lt;/code&gt;, &lt;code&gt;PrivateTmp&lt;/code&gt;, &lt;code&gt;NoNewPrivileges&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ two Ansible roles&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Raise the exposure score of &lt;code&gt;systemd-analyze security&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ Ollama 9.2 → 1.4, the demo 8.2 → 0.9&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Ad-hoc limits with &lt;code&gt;systemd-run --scope&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ an OOM kill inside a transient scope&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The setup: Ubuntu 24.04, kernel 7.0, cgroup v2, systemd 255, Ollama 0.32.14, two RTX 5060 Ti 16 GB, 64 GB of RAM and an 8 GiB swap file. Every directive was read in the manual pages of the installed systemd before it was used, not taken from memory.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where Ollama started
&lt;/h2&gt;

&lt;p&gt;Ollama's install script writes &lt;code&gt;/etc/systemd/system/ollama.service&lt;/code&gt;: &lt;code&gt;User=ollama&lt;/code&gt;, &lt;code&gt;Restart=always&lt;/code&gt;, &lt;code&gt;RestartSec=3&lt;/code&gt;, a &lt;code&gt;PATH&lt;/code&gt;. Next to it I had one drop-in of my own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"OLLAMA_HOST=0.0.0.0:11434"&lt;/span&gt;
&lt;span class="py"&gt;ExecStartPre&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;+/usr/bin/timeout 60 /bin/sh -c 'until /usr/bin/nvidia-smi -L &amp;gt;/dev/null 2&amp;gt;&amp;amp;1; do sleep 1; done'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The listen address is there because other clients on the server use Ollama. The wait is a guard against a race at boot: started too early, Ollama sees fewer cards and runs on the CPU until the next restart. The &lt;code&gt;+&lt;/code&gt; runs that command with full privileges, outside the unit's sandboxing. That detail mattered later.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Reading&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Value&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Exposure score&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;9.2 UNSAFE 😨&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Checks passed&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;6 of 81&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;MemoryMax&lt;/code&gt;, &lt;code&gt;CPUQuota&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;none&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;TasksMax&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;70833 (systemd's default)&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Memory, idle&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;≈ 550 MiB&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What Ollama's memory really is
&lt;/h2&gt;

&lt;p&gt;A memory limit on a model server is only safe if you know what the server's cgroup counts. Models live in VRAM, so I expected the cgroup to stay small. It did not.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Model loaded&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Placement&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;&lt;code&gt;memory.current&lt;/code&gt;&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Page cache (file)&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Anonymous (anon)&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;none&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;—&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;550 MiB&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;—&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;—&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;llama3.1:8b&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;100% GPU, one card&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;5.8 GiB&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;5.3 GiB&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;0.5 GiB&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;qwen3.8:27b&lt;/code&gt; after unloading the first&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;100% GPU, both cards&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;23.1 GiB&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;22.4 GiB&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;0.7 GiB&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most of it is page cache: the model files Ollama read on their way into VRAM. The kernel charges page cache to the cgroup that read the file, and unloading a model frees its VRAM, not its cache. After two models the cgroup counted 23 GiB, of which Ollama needed about 1.4 GiB.&lt;/p&gt;

&lt;p&gt;The interesting case is a model that does not fit into VRAM. None of mine is larger than both cards together, so for this test I limited Ollama to one card for a while and restored the second afterwards. &lt;code&gt;qwen3.8:27b&lt;/code&gt; then ran 25% on the CPU:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Process memory (&lt;code&gt;llama-server&lt;/code&gt;, the runner)&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Context 4096&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Context 32768&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Mapped from the model file&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;4.1 GiB&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;5.4 GiB&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Anonymous&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;0.6 GiB&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;1.3 GiB&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;CPU share of the model&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;25%&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;35%&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The layers on the CPU are mapped from the model file: clean pages the kernel can drop and read again. What grew with the context was anonymous memory, and llama.cpp's own log named it: &lt;code&gt;CPU KV buffer size = 640.00 MiB&lt;/code&gt;, the KV cache of the layers on the CPU.&lt;/p&gt;

&lt;p&gt;One more case turned up later. With a model entirely on the CPU, Ollama disables mmap (&lt;code&gt;load_mode = none&lt;/code&gt; in its log), and everything is anonymous: 8.65 GiB for &lt;code&gt;llama3.1:8b&lt;/code&gt; with a 32k context, none of it cache.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Where the model runs&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Weights on the CPU&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;KV cache on the CPU&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;100% GPU&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;none&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;none&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Partly on the CPU&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;mapped from the file, reclaimable&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;anonymous&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Entirely on the CPU&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;anonymous&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;anonymous&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Limits from measurements
&lt;/h2&gt;

&lt;p&gt;First the mechanism, on a throwaway command. &lt;code&gt;systemd-run --scope&lt;/code&gt; puts any command into a transient cgroup with limits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemd-run &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="nt"&gt;--expand-environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;MemoryMax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100M &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;MemorySwapMax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;cmd&amp;gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 300 MB allocation was killed inside the scope, and with systemd's default &lt;code&gt;OOMPolicy=stop&lt;/code&gt; the whole scope went with it: the wrapping shell printed &lt;code&gt;Terminated&lt;/code&gt; before it could report anything. With &lt;code&gt;OOMPolicy=continue&lt;/code&gt; only the allocation died.&lt;/p&gt;

&lt;p&gt;Then the numbers for Ollama, each from a measurement:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Setting&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Value&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Measured basis&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;MemoryHigh&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;16G&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Ollama needs 0.6–1.6 GiB; above this the kernel reclaims page cache&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;MemoryMax&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;32G&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;half of the host; reached only by memory that cannot be dropped&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;CPUQuota&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;1600%&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;one model entirely on the CPU uses 8 threads, about 790%&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;TasksMax&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;512&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;21 tasks idle, 76 at peak with one model&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The test that matters: load two models in a row, about 22 GiB of files. The cgroup stopped 3.2 MiB below &lt;code&gt;MemoryHigh&lt;/code&gt;, the kernel reclaimed cache 916 times, nothing was killed, and the model answered from both GPUs. The CPU quota never throttled a model on the CPU (13.2 tokens/s with it, 13.6 without).&lt;/p&gt;




&lt;h2&gt;
  
  
  Drop-ins, not the unit
&lt;/h2&gt;

&lt;p&gt;Ollama's install script rewrites &lt;code&gt;ollama.service&lt;/code&gt; on every run and never touches &lt;code&gt;ollama.service.d/&lt;/code&gt;. So the role manages drop-ins only: my existing one, and &lt;code&gt;hardening.conf&lt;/code&gt; for everything in this post.&lt;/p&gt;

&lt;p&gt;Three decisions went into the first round:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Setting&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Chosen&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Why&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;Restart=&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;on-failure&lt;/code&gt; (was &lt;code&gt;always&lt;/code&gt;)&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;a clean exit is deliberate, a crash is not&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;OOMPolicy=&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;continue&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;an OOM kill should take the model runner, not the server&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Start limit&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;5 starts in 5 minutes&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;systemd's default never triggers here&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last one surprised me. systemd stops restarting a unit after 5 starts in 10 seconds, by default. Ollama waits 3 seconds between restarts, so it can never start 5 times in 10 seconds: a permanently broken Ollama would restart forever.&lt;/p&gt;




&lt;h2&gt;
  
  
  The file system and the GPU
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ProtectSystem=strict&lt;/code&gt; makes the whole file system read-only for the service, except what &lt;code&gt;ReadWritePaths=&lt;/code&gt; lists. Ollama's home is &lt;code&gt;/usr/share/ollama&lt;/code&gt;, under &lt;code&gt;/usr&lt;/code&gt;, so even the mildest setting would have broken it. Two paths stay writable: &lt;code&gt;~/.ollama&lt;/code&gt; (models, cache, its key) and &lt;code&gt;~/.nv&lt;/code&gt; (NVIDIA's compute cache).&lt;/p&gt;

&lt;p&gt;Devices were the risky part, because a GPU service without its devices does not fail: it starts and runs on the CPU. &lt;code&gt;PrivateDevices=yes&lt;/code&gt; hides &lt;code&gt;/dev/nvidia*&lt;/code&gt;, so it was out. Instead I looked at what Ollama actually opens with a model loaded:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Device&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Opened by the runner&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;/dev/nvidiactl&lt;/code&gt;, &lt;code&gt;/dev/nvidia0&lt;/code&gt;, &lt;code&gt;/dev/nvidia-uvm&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;/dev/nvidia1&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ when a model spans both cards&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;/dev/dri/*&lt;/code&gt;, &lt;code&gt;/dev/nvidia-caps/*&lt;/code&gt;, &lt;code&gt;/dev/nvidia-modeset&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;❌ never&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That became &lt;code&gt;DevicePolicy=closed&lt;/code&gt; with two device groups, &lt;code&gt;char-nvidia&lt;/code&gt; and &lt;code&gt;char-nvidia-uvm&lt;/code&gt;. Two details from the manual made it work. Device rules apply to the whole cgroup, so the &lt;code&gt;+&lt;/code&gt; of the GPU wait does not bypass them: get them wrong and the wait times out. And a device group that does not exist when the unit starts is silently dropped, so &lt;code&gt;nvidia_uvm&lt;/code&gt; is loaded first with &lt;code&gt;Wants=&lt;/code&gt;/&lt;code&gt;After=modprobe@nvidia_uvm.service&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A test that only shows "it still works" could also mean "nothing is restricted", so a transient service with the same rules tried four devices as user &lt;code&gt;ollama&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Device&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;With the rules&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Without&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;/dev/nvidiactl&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ opened&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ opened&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;/dev/nvidia-uvm&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ opened&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ opened&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;/dev/dri/renderD128&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;❌ Operation not permitted&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ opened&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;/dev/nvidia-caps/nvidia-cap2&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;❌ Operation not permitted&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ opened&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Turning the screws, round by round
&lt;/h2&gt;

&lt;p&gt;Each round went through the role, a check run, a real run, &lt;code&gt;systemctl show&lt;/code&gt; for what systemd actually loaded, and a test:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Round&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Added&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Score&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Start&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;nothing&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;9.2 UNSAFE 😨&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;1&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;NoNewPrivileges&lt;/code&gt;, &lt;code&gt;PrivateTmp&lt;/code&gt;, restart and OOM policy&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;8.7 EXPOSED 🙁&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;2&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;ProtectSystem=strict&lt;/code&gt;, &lt;code&gt;ProtectHome&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;8.3 EXPOSED 🙁&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;3&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;DevicePolicy=closed&lt;/code&gt;, &lt;code&gt;DeviceAllow&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;8.2 EXPOSED 🙁&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;4&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;16 kernel, process and privilege restrictions&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;3.4 OK 🙂&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;5&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;address families, system call filter, private user namespace, no writable-executable memory&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;1.5 OK 🙂&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;6&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;remove privileged system calls&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;1.4 OK 🙂&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Round 4 did most of the work: empty capability set, protected kernel tunables, modules, logs and control groups, &lt;code&gt;ProtectProc=invisible&lt;/code&gt;, no new namespaces, native system calls only.&lt;/p&gt;

&lt;p&gt;Round 5 had five directives that can break a GPU service, so each was tried alone in a temporary drop-in under &lt;code&gt;/run&lt;/code&gt; before it went into the role:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Directive&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Risk&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Result&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;RestrictAddressFamilies&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;blocks a socket type Ollama needs&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ pass&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;SystemCallFilter&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;a system call outside the list kills the process&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ pass&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;PrivateUsers&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;GPU access through the render and video groups&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ pass&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;MemoryDenyWriteExecute&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;CUDA compiling kernels at load time&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ pass&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;ProcSubset=pid&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;NVIDIA's files in &lt;code&gt;/proc&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;❌ not used&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What went into the role: &lt;code&gt;RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK&lt;/code&gt;; &lt;code&gt;SystemCallFilter=@system-service&lt;/code&gt; with &lt;code&gt;SystemCallErrorNumber=EPERM&lt;/code&gt;, so that a missing call fails instead of killing the process; &lt;code&gt;PrivateUsers=yes&lt;/code&gt;, which works because the NVIDIA device nodes are world-accessible; and &lt;code&gt;MemoryDenyWriteExecute=yes&lt;/code&gt;, which works because CUDA has ready-made kernels for these cards and compiles nothing at load time.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ProcSubset=pid&lt;/code&gt; was the instructive one. The GPU kept working and every test passed, but Ollama logged &lt;code&gt;error looking up system memory … open /proc/meminfo: no such file or directory&lt;/code&gt;. It would have kept running, deciding blind whether a model fits into memory.&lt;/p&gt;

&lt;p&gt;Round 6 came from reading the score: &lt;code&gt;@system-service&lt;/code&gt;, systemd's own system call set for ordinary services, still allows &lt;code&gt;chown&lt;/code&gt;, &lt;code&gt;setuid&lt;/code&gt; and &lt;code&gt;chroot&lt;/code&gt;. A second line, &lt;code&gt;SystemCallFilter=~@privileged&lt;/code&gt;, removes them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Does it still work?
&lt;/h2&gt;

&lt;p&gt;"It answers" is not enough for a service that can fall back to the CPU without an error. After round 4, and again on the final configuration:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Test&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Result&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Copy and delete a model&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Pull a model (network, large files, temporary files)&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Embeddings&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ 384 dimensions&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;A client in a Docker container&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;A model on both cards&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ 100% GPU, same VRAM as before&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;An image through the model's vision encoder&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ "Red" for a red square&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;A model entirely on the CPU&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ 13.5 tokens/s, as before hardening&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;A reboot&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ both cards found 6 s after the start&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;After the reboot the journal shows the order the unit asks for: &lt;code&gt;nvidia_uvm&lt;/code&gt; loaded, then Ollama started, then both cards found through CUDA.&lt;/p&gt;




&lt;h2&gt;
  
  
  A service of my own
&lt;/h2&gt;

&lt;p&gt;Ollama's unit belongs to its installer. A unit of my own shows what is possible from the start, so the lab has a second service: &lt;code&gt;python3 -m http.server&lt;/code&gt; on &lt;code&gt;127.0.0.1:8765&lt;/code&gt;, deliberately trivial, with a secret.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Directive&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;What it does&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;DynamicUser=yes&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;a user allocated at start and released at stop, nothing in &lt;code&gt;/etc/passwd&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;StateDirectory=lab0b-demo&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;/var/lib/lab0b-demo&lt;/code&gt;, created by systemd for that user&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;EnvironmentFile=/etc/lab0b-demo.env&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;read by systemd as root before the process starts&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The secret never enters git: I create the file by hand as root, mode 0600, and the role only asserts that it exists with that owner and mode.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Who tries to read it&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Result&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;systemctl show&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ only the path, never the content&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;The service process&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ &lt;code&gt;DEMO_TOKEN&lt;/code&gt; is set&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Another user&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;❌ Permission denied&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;A service with a dynamic user, reading the file&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;❌ Permission denied&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The other user tried the process environment, &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/environ&lt;/code&gt;; root can read it, nobody else.&lt;/p&gt;

&lt;p&gt;With nothing to protect but a socket on loopback, the demo could have what Ollama cannot. It was hardened in one go, so there are two measurements, not six:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Round&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Added&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Score&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Start&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;DynamicUser&lt;/code&gt;, &lt;code&gt;StateDirectory&lt;/code&gt;, &lt;code&gt;EnvironmentFile&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;8.2 EXPOSED 🙁&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;1&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;ProtectHome=yes&lt;/code&gt;, &lt;code&gt;PrivateDevices=yes&lt;/code&gt;, &lt;code&gt;PrivateUsers=yes&lt;/code&gt;, &lt;code&gt;CapabilityBoundingSet=&lt;/code&gt; (empty), &lt;code&gt;ProtectKernelTunables=yes&lt;/code&gt;, &lt;code&gt;ProtectKernelModules=yes&lt;/code&gt;, &lt;code&gt;ProtectKernelLogs=yes&lt;/code&gt;, &lt;code&gt;ProtectControlGroups=yes&lt;/code&gt;, &lt;code&gt;ProtectClock=yes&lt;/code&gt;, &lt;code&gt;ProtectHostname=yes&lt;/code&gt;, &lt;code&gt;ProtectProc=invisible&lt;/code&gt;, &lt;code&gt;ProcSubset=pid&lt;/code&gt;, &lt;code&gt;RestrictNamespaces=yes&lt;/code&gt;, &lt;code&gt;RestrictRealtime=yes&lt;/code&gt;, &lt;code&gt;LockPersonality=yes&lt;/code&gt;, &lt;code&gt;MemoryDenyWriteExecute=yes&lt;/code&gt;, &lt;code&gt;RestrictAddressFamilies=AF_INET&lt;/code&gt;, &lt;code&gt;IPAddressDeny=any&lt;/code&gt;, &lt;code&gt;IPAddressAllow=localhost&lt;/code&gt;, &lt;code&gt;SystemCallArchitectures=native&lt;/code&gt;, &lt;code&gt;SystemCallFilter=@system-service&lt;/code&gt;, &lt;code&gt;SystemCallFilter=~@privileged @resources&lt;/code&gt;, &lt;code&gt;SystemCallErrorNumber=EPERM&lt;/code&gt;, &lt;code&gt;UMask=0077&lt;/code&gt;, limits: &lt;code&gt;MemoryMax=64M&lt;/code&gt;, &lt;code&gt;MemorySwapMax=0&lt;/code&gt;, &lt;code&gt;CPUQuota=50%&lt;/code&gt;, &lt;code&gt;TasksMax=16&lt;/code&gt;, &lt;code&gt;Restart=on-failure&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;0.9 SAFE 😀&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;DynamicUser=yes&lt;/code&gt; alone implies six protections, among them &lt;code&gt;ProtectSystem=strict&lt;/code&gt; and &lt;code&gt;NoNewPrivileges&lt;/code&gt;, yet the start was 8.2: those weigh little in the score.&lt;/p&gt;

&lt;p&gt;The IP filter had one surprise. From inside the service's cgroup, a connection to the internet did not fail with "permission denied". It timed out: systemd's filter drops packets silently.&lt;/p&gt;




&lt;h2&gt;
  
  
  Breaking it on purpose
&lt;/h2&gt;

&lt;p&gt;The restart and OOM settings had only been read in the documentation. Four tests:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Test&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Result&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;kill -9&lt;/code&gt; Ollama&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ back in 4 s, both cards found&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Five more kills within a minute&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ sixth start refused&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;200 MB inside the demo's 64M limit&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;⚠️ no OOM at all&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;6 GiB inside Ollama's cgroup under a temporary 4G limit&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ only the allocation killed, Ollama kept running&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The start limit shows up only in the journal, as &lt;code&gt;Start request repeated too quickly&lt;/code&gt;; the unit's &lt;code&gt;Result=&lt;/code&gt; still says &lt;code&gt;signal&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The third one was the finding of the lab. The cgroup's counters showed where the memory went:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Counter&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Value&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;memory.max&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;64 MiB&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;memory.peak&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;64 MiB&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;memory.swap.max&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;unlimited&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;memory.swap.peak&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;155 MiB&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Key takeaway:&lt;/strong&gt; &lt;code&gt;MemoryMax=&lt;/code&gt; limits RAM. Swap has its own limit, &lt;code&gt;MemorySwapMax=&lt;/code&gt;, and it is unlimited by default. On a host with swap, a service above its memory limit does not get killed; it moves to swap and slows down. For Ollama that means a model or its KV cache in swap, answers in minutes instead of an error a client can handle.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Both services got &lt;code&gt;MemorySwapMax=0&lt;/code&gt;, and the same test then went as designed: the allocation was killed, and &lt;code&gt;Restart=on-failure&lt;/code&gt; brought the demo back within the same second. That also answered the one question the manual had left open: a unit stopped by &lt;code&gt;OOMPolicy=stop&lt;/code&gt; counts as failed and is restarted.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the score does and does not tell you
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Note:&lt;/strong&gt; The manual is explicit: a high score means little sandboxing is applied, not that a service is vulnerable, and a low one says nothing about the program's own code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What is left is a list of things each service genuinely needs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Still failed&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Ollama&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Demo&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Why&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;PrivateNetwork&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;⚠️&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;⚠️&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;both serve clients&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Internet sockets&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;⚠️&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;⚠️&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;both listen&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;IPAddressDeny&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;⚠️&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;partly&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Ollama downloads models&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;PrivateDevices&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;⚠️&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;the GPU&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;DeviceAllow&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;partly&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;partly&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;device lists&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;ProcSubset&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;⚠️&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Ollama needs &lt;code&gt;/proc/meminfo&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;@resources&lt;/code&gt; system calls&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;⚠️&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;llama.cpp places its threads on cores&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;UMask&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;⚠️&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Ollama's files stay readable for its group&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;RootDirectory&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;⚠️&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;⚠️&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;both run in the host's root&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Partly" means half the exposure of the check: the demo's IP filter allows loopback, and both device lists contain what the tool calls "special devices". Ollama's are the GPU's; the demo's, despite &lt;code&gt;PrivateDevices=yes&lt;/code&gt;, is the clock device that &lt;code&gt;ProtectClock=yes&lt;/code&gt; adds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Small things that bite
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;MemoryMax=&lt;/code&gt; without &lt;code&gt;MemorySwapMax=0&lt;/code&gt; limits RAM, not memory.&lt;/li&gt;
&lt;li&gt;A service without its GPU devices does not fail; it waits, then runs on the CPU. Check the log for the cards after every change.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Result=&lt;/code&gt; does not show the start limit, and the restart counter includes the restart the limit refused. Read the journal.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemctl revert&lt;/code&gt; removes every drop-in of a unit, not just the one you meant. To undo &lt;code&gt;systemctl set-property --runtime&lt;/code&gt;, set the old value and remove its file under &lt;code&gt;/run/systemd/system.control/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sudo cmd &amp;lt; file&lt;/code&gt; opens the file as you, not as root.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What surprised me
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;A memory limit that did nothing: &lt;code&gt;MemoryMax=&lt;/code&gt; let a service grow into swap until &lt;code&gt;MemorySwapMax=0&lt;/code&gt; was set.&lt;/li&gt;
&lt;li&gt;Ollama's memory is mostly page cache, even of models it has already unloaded; what it needs is a gigabyte or two.&lt;/li&gt;
&lt;li&gt;Whether the CPU part of a model can be reclaimed depends on how Ollama loads it: mapped from the file when the model is partly on the GPU, all anonymous memory when it is entirely on the CPU.&lt;/li&gt;
&lt;li&gt;A CUDA service took far more hardening than I expected, down to 1.4. What stopped the last points was the device list, the network and &lt;code&gt;/proc/meminfo&lt;/code&gt;, not CUDA.&lt;/li&gt;
&lt;li&gt;The failures that matter are quiet: a GPU service on the CPU, a server blind to system memory with one warning in the log, a firewall that looks like a slow network.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The next lab is the firewall: iptables to nftables, and how ufw, Docker's rules and nftables share one kernel. Ollama listens on all interfaces because other clients on the server need it, so what reaches its port is the firewall's decision, and systemd's per-service IP filter is a second layer worth comparing with it. Later, the observability lab gets the alerts systemd cannot send by itself: a unit in &lt;code&gt;failed&lt;/code&gt;, a restart counter going up, an &lt;code&gt;oom_kill&lt;/code&gt; in a service's memory events.&lt;/p&gt;

&lt;p&gt;Everything from this post, in full:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guide:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/docs/guides/lab0b.md" rel="noopener noreferrer"&gt;every command, step by step&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Results:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/results/lab0b.md" rel="noopener noreferrer"&gt;measurements, scores and surprises&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role &lt;code&gt;ollama&lt;/code&gt;:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/tree/main/ansible/roles/ollama" rel="noopener noreferrer"&gt;drop-ins for the installer's unit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ollama's hardening:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/ansible/roles/ollama/files/hardening.conf" rel="noopener noreferrer"&gt;&lt;code&gt;hardening.conf&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role &lt;code&gt;lab0b_demo&lt;/code&gt;:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/tree/main/ansible/roles/lab0b_demo" rel="noopener noreferrer"&gt;a service of my own with a secret&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The demo unit:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/ansible/roles/lab0b_demo/files/lab0b-demo.service" rel="noopener noreferrer"&gt;&lt;code&gt;lab0b-demo.service&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://gshell.dev/blog/systemd-ollama/" rel="noopener noreferrer"&gt;gshell.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>security</category>
      <category>ollama</category>
    </item>
    <item>
      <title>Sandboxing Claude Code on Ubuntu</title>
      <dc:creator>Gancho Stoychev</dc:creator>
      <pubDate>Wed, 14 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/gjstoychev/sandboxing-claude-code-on-ubuntu-2h1</link>
      <guid>https://dev.to/gjstoychev/sandboxing-claude-code-on-ubuntu-2h1</guid>
      <description>&lt;p&gt;An AI coding agent on my server runs shell commands I approve one at a time. Or, with the sandbox switched on, commands it does not need to ask about at all. That second mode is only as good as the box around it, so before trusting it I wanted to know exactly what that box is: which files a command can read, where it can connect, what it can do as root inside, and what happens when the box itself is missing.&lt;/p&gt;

&lt;p&gt;This is the third post in my &lt;a href="https://gshell.dev/blog/" rel="noopener noreferrer"&gt;AI homelab series&lt;/a&gt;. The first built a container by hand and ended with a plan to sandbox Claude Code; the second set up Ansible so that the sandbox could arrive as a role instead of another manual tweak.&lt;/p&gt;

&lt;p&gt;This one is Lab 0f. Claude Code's built-in sandbox on Ubuntu 24.04, enforced by settings that no repository can loosen, deployed by Ansible, and tested with a matrix of probes run from inside the sandbox by the agent itself. Almost everything held. &lt;strong&gt;One thing did not, and it cannot be fixed from outside.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guide:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/docs/guides/lab0f.md" rel="noopener noreferrer"&gt;every command, step by step&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Results:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/results/lab0f.md" rel="noopener noreferrer"&gt;the probe matrix, versions and surprises&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role &lt;code&gt;claude_code&lt;/code&gt;:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/tree/main/ansible/roles/claude_code" rel="noopener noreferrer"&gt;packages, AppArmor profile, managed settings&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed settings:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/ansible/roles/claude_code/files/managed-settings.json" rel="noopener noreferrer"&gt;&lt;code&gt;managed-settings.json&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AppArmor profile:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/ansible/roles/claude_code/files/claude-bwrap" rel="noopener noreferrer"&gt;&lt;code&gt;claude-bwrap&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules for the agent:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/CLAUDE.md" rel="noopener noreferrer"&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What "done" meant
&lt;/h2&gt;

&lt;p&gt;Four checks, taken from the roadmap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A matrix of probes is blocked: writing outside the repository, reaching the network without the proxy, reading &lt;code&gt;~/.ssh&lt;/code&gt;, connecting to a host that is not allowed.&lt;/li&gt;
&lt;li&gt;The session refuses to start without the sandbox.&lt;/li&gt;
&lt;li&gt;A repository's own settings cannot switch the sandbox off or widen it.&lt;/li&gt;
&lt;li&gt;The whole configuration is an Ansible role, so the next node is one playbook run.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What the sandbox is
&lt;/h2&gt;

&lt;p&gt;Claude Code's sandbox wraps every shell command the agent runs, and everything that command starts, in two independent layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Filesystem&lt;/strong&gt; (&lt;code&gt;bubblewrap&lt;/code&gt; on Linux): writes only to the working directory and a per-user temp directory. Reads to almost the whole machine, &lt;code&gt;~/.ssh&lt;/code&gt; included, unless you deny them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network&lt;/strong&gt;: the command gets its own network namespace with no route out. The only exit is a proxy Claude Code runs outside the sandbox, which checks every hostname against an allowlist that starts empty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is off by default, and on Linux it needs two packages, &lt;code&gt;bubblewrap&lt;/code&gt; and &lt;code&gt;socat&lt;/code&gt;. Equally important is what it does not wrap: Claude's own file tools (&lt;code&gt;Read&lt;/code&gt;, &lt;code&gt;Edit&lt;/code&gt;, &lt;code&gt;Write&lt;/code&gt;), web tools, hooks, MCP servers, and anything you type yourself at the &lt;code&gt;!&lt;/code&gt; prompt. Those follow permission rules, not the sandbox.&lt;/p&gt;




&lt;h2&gt;
  
  
  A pinned Claude Code first
&lt;/h2&gt;

&lt;p&gt;The first surprise came before any sandboxing. Claude Code's native installer lives in &lt;code&gt;~/.local/share/claude&lt;/code&gt; and updates itself in the background. The session I was working in had moved to a release from that same morning. In a repo where every version is pinned, that is not a decision anyone made.&lt;/p&gt;

&lt;p&gt;Anthropic publishes a signed apt repository with &lt;code&gt;stable&lt;/code&gt; and &lt;code&gt;latest&lt;/code&gt; channels, and it keeps older versions. So the role installs Claude Code like any other package:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the signing key, committed to the role after checking its fingerprint against the documentation;&lt;/li&gt;
&lt;li&gt;the repository as a deb822 &lt;code&gt;.sources&lt;/code&gt; file;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;claude-code=2.1.287-1&lt;/code&gt;, the &lt;code&gt;stable&lt;/code&gt; release, two weeks old at the time;&lt;/li&gt;
&lt;li&gt;an apt pin with priority 1001, so a manual &lt;code&gt;apt upgrade&lt;/code&gt; does not move it. An upgrade is one changed line in the role.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before deleting the native installation I looked inside. If you do the same switch, know what you are deleting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.local/share/claude/versions/2.1.292
~/.local/share/claude/versions/2.1.296
~/.local/share/claude/versions/2.1.299   # 739 MB in total
~/.local/bin/claude -&amp;gt; ~/.local/share/claude/versions/2.1.299
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only binaries. Your settings, conversation history, memory and everything else live in &lt;code&gt;~/.claude/&lt;/code&gt;, which the switch does not touch; &lt;code&gt;claude --continue&lt;/code&gt; resumes the same conversation on the new binary.&lt;/p&gt;

&lt;p&gt;Two small traps along the way. The first &lt;code&gt;--check&lt;/code&gt; of the role on a new host always ends with &lt;code&gt;failed=1&lt;/code&gt;: check mode reports that it would add the repository but writes nothing, so apt has never heard of &lt;code&gt;claude-code&lt;/code&gt; when the install task asks for it. Ansible cannot follow a chain of tasks where a later one depends on what an earlier one would have changed. And after the switch, &lt;code&gt;claude doctor&lt;/code&gt; still remembers the old install method and suggests &lt;code&gt;claude install&lt;/code&gt; to "repair" it. That would put the self-updating build back in front of the package in &lt;code&gt;PATH&lt;/code&gt;. Managed settings now set &lt;code&gt;DISABLE_UPDATES=1&lt;/code&gt;, which makes that command refuse.&lt;/p&gt;




&lt;h2&gt;
  
  
  Letting bwrap through AppArmor
&lt;/h2&gt;

&lt;p&gt;On Ubuntu 24.04 an unprivileged process may create a user namespace, but AppArmor immediately moves it into a profile that denies every capability. &lt;code&gt;bwrap&lt;/code&gt; needs a few of them for its own setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;bwrap &lt;span class="nt"&gt;--ro-bind&lt;/span&gt; / / &lt;span class="nt"&gt;--unshare-all&lt;/span&gt; &lt;span class="nt"&gt;--die-with-parent&lt;/span&gt; &lt;span class="nb"&gt;id
&lt;/span&gt;bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Audit log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apparmor="AUDIT" operation="userns_create" ... profile="unconfined" ... target="unprivileged_userns" execpath="/usr/bin/bwrap"
apparmor="DENIED" operation="capable" profile="unprivileged_userns" ... comm="bwrap" capname="net_admin"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The namespace is created; giving it a loopback interface is not allowed. That loopback matters: sandboxed commands reach the proxy through a local port inside their own namespace.&lt;/p&gt;

&lt;p&gt;The documentation's fix is an AppArmor profile for &lt;code&gt;/usr/bin/bwrap&lt;/code&gt;. That would lift the restriction for every program on the host that runs &lt;code&gt;bwrap&lt;/code&gt;. I used the same pattern as &lt;code&gt;unshare&lt;/code&gt; in Lab 0a instead: a dedicated copy, &lt;code&gt;/usr/local/bin/claude-bwrap&lt;/code&gt;, with its own profile, and Claude Code pointed at it through &lt;code&gt;sandbox.bwrapPath&lt;/code&gt;, a setting only managed settings can set. The system &lt;code&gt;bwrap&lt;/code&gt; stays restricted, and the &lt;code&gt;aa-exec&lt;/code&gt; bypass that Lab 0a closed stays closed for the new profile too.&lt;/p&gt;




&lt;h2&gt;
  
  
  Settings a repository cannot loosen
&lt;/h2&gt;

&lt;p&gt;Claude Code reads settings from several levels: managed (&lt;code&gt;/etc/claude-code/managed-settings.json&lt;/code&gt;), the command line, the repository's &lt;code&gt;.claude/&lt;/code&gt;, and your own &lt;code&gt;~/.claude/settings.json&lt;/code&gt;. Managed settings win, and they are root-owned, so neither a cloned repository nor the agent can edit them. The role installs them with these choices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed.&lt;/strong&gt; &lt;code&gt;enabled: true&lt;/code&gt;, &lt;code&gt;failIfUnavailable: true&lt;/code&gt; (refuse to start without a sandbox, instead of the default: run commands without one), and &lt;code&gt;allowUnsandboxedCommands: false&lt;/code&gt; (no "retry this outside the sandbox").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin-required.&lt;/strong&gt; That last setting, set in managed settings, makes Claude Code ignore everything in a repository's settings that would loosen the sandbox: excluded commands, allowed domains, extra writable paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Four allowed hosts&lt;/strong&gt;, locked to managed settings, with &lt;code&gt;strictAllowlist&lt;/code&gt;: anything else is refused without even asking me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets, twice.&lt;/strong&gt; &lt;code&gt;~/.ssh&lt;/code&gt;, &lt;code&gt;~/.aws&lt;/code&gt;, every &lt;code&gt;.env&lt;/code&gt; under my home directory and Claude Code's own OAuth token file are hidden from sandboxed commands (&lt;code&gt;sandbox.credentials&lt;/code&gt;) and from the file tools (&lt;code&gt;Read(...)&lt;/code&gt; deny rules), because the file tools run outside the sandbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual permission mode by default&lt;/strong&gt;, with sandboxed commands approved automatically: the box is the boundary, and I still review every diff before a commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;allowManagedHooksOnly: true&lt;/code&gt;&lt;/strong&gt; and no managed hooks: hooks run outside the sandbox with my full access, so no cloned repository may bring one.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Important note:&lt;/strong&gt; One file must stay world-readable: Claude Code silently starts without a managed settings file it cannot read.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The probe matrix
&lt;/h2&gt;

&lt;p&gt;Every probe was run by Claude in its own sandbox. Commands typed at the &lt;code&gt;!&lt;/code&gt; prompt run outside the sandbox and prove nothing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Probe / Command&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Expected / Action&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Result&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Stopped By&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;touch ~/sandbox-probe&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Write to home dir&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Read-only file system&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ Sandbox (bwrap)&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;touch .git/hooks/sandbox-probe&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Write inside repo&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Read-only file system&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ Sandbox (Protected path)&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;ls ~/.ssh&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Inspect SSH keys&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;0 entries&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ Sandbox (&lt;code&gt;--tmpfs&lt;/code&gt;)&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Read &lt;code&gt;.env&lt;/code&gt; / token file&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Read secrets&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Permission denied&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ Sandbox (&lt;code&gt;sandbox.credentials&lt;/code&gt;)&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;Read&lt;/code&gt; tool on &lt;code&gt;~/.ssh/config&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Read config file&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Refused&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ Permission rule&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;curl --noproxy '*' https://example.com&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Bypass HTTP proxy&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Could not resolve host&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ Sandbox (network namespace)&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;curl https://example.com&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Unlisted host HTTP&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Proxy 403, no prompt&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ Sandbox proxy&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;curl https://pypi.org/simple/&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Allowed host HTTP&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;200 OK&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ Allowed (control)&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;curl localhost:11434&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Access host Ollama&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Connection refused&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ Sandbox (private loopback)&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;ssh -T git@github.com&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;SSH outbound&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Could not resolve hostname&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ Sandbox&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;nvidia-smi&lt;/code&gt;, CUDA &lt;code&gt;cuInit&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Access GPU devices&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;No devices&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ Sandbox (minimal &lt;code&gt;/dev&lt;/code&gt;)&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Retry with &lt;code&gt;dangerouslyDisableSandbox&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Override sandbox&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Runs sandboxed anyway&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ Managed settings&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Repo settings with &lt;code&gt;enabled: false&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Loosen security&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;No effect&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ Admin-required sandbox&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;git push&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Push repository&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Refused before run&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;✅ Permission rule&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;unshare --user --map-root-user&lt;/code&gt;&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Elevate privileges&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Root with CapEff&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;⚠️ &lt;strong&gt;NOT STOPPED (the gap)&lt;/strong&gt;&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Inside, a sandboxed command has no effective capabilities, &lt;code&gt;no_new_privs&lt;/code&gt; is set (so &lt;code&gt;sudo&lt;/code&gt; cannot raise privileges) and a seccomp filter is active. Then the last line of the table.&lt;/p&gt;

&lt;p&gt;And the two checks of the sandbox itself, done with headless &lt;code&gt;claude -p&lt;/code&gt; from a terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;0644 /usr/local/bin/claude-bwrap&lt;span class="p"&gt;;&lt;/span&gt; claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'Run: echo sandbox-ok'&lt;/span&gt;
Error: sandbox required but unavailable: ... bubblewrap &lt;span class="o"&gt;(&lt;/span&gt;bwrap&lt;span class="o"&gt;)&lt;/span&gt; not executable at /usr/local/bin/claude-bwrap ...
  sandbox.failIfUnavailable is &lt;span class="nb"&gt;set&lt;/span&gt; — refusing to start without a working sandbox.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ansible saw the changed mode and restored it. The second check removed the AppArmor profile from the kernel while leaving its file in place. Claude Code started anyway: its start-up check looks for an executable binary, not for a working sandbox. Every command then failed closed, nothing ran outside the box, and the first version of the role reported &lt;code&gt;ok&lt;/code&gt; because the file had not changed. The role now reads &lt;code&gt;/sys/kernel/security/apparmor/profiles&lt;/code&gt; and reloads the profile when it is missing, the same "check the running state, not the file" pattern as the sysctl in the previous lab.&lt;/p&gt;




&lt;h2&gt;
  
  
  Looking inside
&lt;/h2&gt;

&lt;p&gt;From another terminal, while a sandboxed &lt;code&gt;sleep&lt;/code&gt; was running, the process tree shows how the box is built:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;claude-bwrap --new-session --die-with-parent --unsetenv CLAUDE_CODE_MESSAGING_TOKEN ...
 └─ bash -c socat ...
     ├─ socat TCP-LISTEN:3128 → UNIX-CONNECT:/tmp/claude-http-&amp;lt;id&amp;gt;.sock  # HTTP proxy bridge
     ├─ socat TCP-LISTEN:1080 → UNIX-CONNECT:/tmp/claude-http-&amp;lt;id&amp;gt;.sock  # SOCKS bridge
     └─ 3 (/proc/self/fd/3 ...)                                          # apply-seccomp
         └─ 3
             └─ bash -c ...
                 └─ sleep 300
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two &lt;code&gt;socat&lt;/code&gt; processes are the only way out: they listen on ports inside the command's private loopback and forward to a Unix socket that leads to the proxy outside. They start before the seccomp filter, which blocks Unix sockets for the command itself. The process called &lt;code&gt;3&lt;/code&gt; is Claude Code's seccomp helper, &lt;code&gt;apply-seccomp&lt;/code&gt;, executed straight from a file descriptor: it has no path on disk.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lsns&lt;/code&gt; on the &lt;code&gt;sleep&lt;/code&gt;, compared with my own shell:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;Namespace&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Created by&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;net&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;bwrap&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;user, mnt&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;apply-seccomp (nested inside bwrap's)&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;pid&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;apply-seccomp's child&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;time, cgroup, uts, ipc&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Shared with the host&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;bwrap&lt;/code&gt; command line reads like a recipe: &lt;code&gt;--ro-bind / /&lt;/code&gt; (the whole host, read-only), then &lt;code&gt;--bind&lt;/code&gt; for the writable parts (the repository, the temp directory), then the protected paths mounted read-only again on top (&lt;code&gt;.git/hooks&lt;/code&gt;, &lt;code&gt;.git/config&lt;/code&gt;, &lt;code&gt;.claude&lt;/code&gt;), an empty &lt;code&gt;--tmpfs&lt;/code&gt; over &lt;code&gt;~/.ssh&lt;/code&gt;, and &lt;code&gt;/dev/null&lt;/code&gt; bound over every denied file. Every protocol it can think of is pointed at the proxy, including git over SSH through a &lt;code&gt;socat&lt;/code&gt; &lt;code&gt;ProxyCommand&lt;/code&gt;. The proxy URL even carries credentials: its user name is the base64 of the tool call's ID, so every request can be attributed to one command.&lt;/p&gt;




&lt;h2&gt;
  
  
  The gap
&lt;/h2&gt;

&lt;p&gt;Back to the last line of the matrix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;unshare &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;--map-root-user&lt;/span&gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'id -u; grep CapEff /proc/self/status'&lt;/span&gt;
0
CapEff: 000001ffffffffff
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A sandboxed command can create a nested user namespace and be root with every capability inside it. This is not an escape on its own: the nested namespace sits inside bwrap's mounts and network, so the home directory stays read-only and the network still ends at the proxy. But it re-opens exactly what Ubuntu's user namespace restriction closes: kernel code that otherwise needs root, the usual route of local privilege escalation bugs.&lt;/p&gt;

&lt;p&gt;The cause is the AppArmor exception itself. Programs started by &lt;code&gt;bwrap&lt;/code&gt; inherit its profile, &lt;code&gt;claude-bwrap (unconfined)&lt;/code&gt;, &lt;code&gt;userns&lt;/code&gt; permission included. The documentation says the profile "applies only to bwrap itself"; with that profile form it does not, and the documented profile for &lt;code&gt;/usr/bin/bwrap&lt;/code&gt; behaves the same way, for every program on the host.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Upstream incompatibility:&lt;/strong&gt; Ubuntu has an answer for bwrap: &lt;code&gt;bwrap-userns-restrict&lt;/code&gt;, an experimental profile in &lt;code&gt;apparmor-profiles&lt;/code&gt;, enabled by default from 26.04. It lets &lt;code&gt;bwrap&lt;/code&gt; use capabilities for its setup and stacks everything &lt;code&gt;bwrap&lt;/code&gt; runs with a second profile that has &lt;code&gt;audit deny capability&lt;/code&gt;. I adapted it to the copy and tested it. No sandboxed command started:&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apply-seccomp: write /proc/self/setgroups (nested userns is capability-restricted; caller must provide CAP_SYS_ADMIN): Permission denied
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude Code's own seccomp helper creates a nested user namespace and needs &lt;code&gt;CAP_SYS_ADMIN&lt;/code&gt; in it. The sandbox relies on precisely the thing Ubuntu's fix takes away, and since the helper has no path on disk, AppArmor cannot make a narrow exception for it. This is known upstream: claude-code#87680 reports the same denial on Ubuntu 26.04, where the sandbox does not start at all, and sandbox-runtime#428 proposes fixes inside the runtime. The workaround in circulation gives the capability back to bwrap's children, which re-opens the same gap for every bwrap user on the system. I kept the simple profile and documented the gap.&lt;/p&gt;




&lt;h2&gt;
  
  
  Is all this control worth it?
&lt;/h2&gt;

&lt;p&gt;A fair question at that point: if the gap cannot be closed, why manage all of this instead of switching on the built-in sandbox and stopping there?&lt;/p&gt;

&lt;p&gt;Because this &lt;em&gt;is&lt;/em&gt; the built-in sandbox. Nothing sits on top of it. Around it there are three things, each with a reason:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;small&gt;What&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Why&lt;/small&gt;&lt;/th&gt;
&lt;th&gt;&lt;small&gt;Without it&lt;/small&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;&lt;code&gt;bubblewrap&lt;/code&gt;, &lt;code&gt;socat&lt;/code&gt;, AppArmor exception&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Required on Ubuntu 24.04&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;The sandbox does not start at all&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;Built-in options in root-owned managed settings&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Repo or agent cannot loosen them&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Agent can edit &lt;code&gt;~/.claude/settings.json&lt;/code&gt;, opening the box with one "Yes"&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;small&gt;An Ansible role&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Repeatable configuration&lt;/small&gt;&lt;/td&gt;
&lt;td&gt;&lt;small&gt;Manual steps required on every node&lt;/small&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gap is in the built-in sandbox itself. The probes found it; without them it would be there unnoticed.&lt;/p&gt;

&lt;p&gt;And there is no single command to block. &lt;code&gt;unshare&lt;/code&gt; is a thin wrapper around a system call that &lt;code&gt;python3 -c 'import os; os.unshare(os.CLONE_NEWUSER)'&lt;/code&gt; or any small program can make. A permission rule for &lt;code&gt;unshare *&lt;/code&gt; stops the literal command and nothing else, so it is a speed bump that looks like protection. AppArmor cannot target a helper without a path. The right tool is a seccomp rule against &lt;code&gt;CLONE_NEWUSER&lt;/code&gt; for the command, and that rule belongs in Claude Code's own helper.&lt;/p&gt;

&lt;p&gt;Until then the practical defence is boring: a patched kernel. The node had 54 pending updates during the lab, AppArmor among them. Closing the gap for good is part of a later lab that compares four sandboxes for the same agent, two of them with seccomp and gVisor of my own.&lt;/p&gt;

&lt;p&gt;What the built-in sandbox does deliver, proven: no writes outside the repository and the temp directory, protected paths read-only even inside it, secrets unreadable, no network except four hosts through the proxy, no way back out of the box, and a refusal to start without it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Small things that bite
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The permission mode is separate from the sandbox.&lt;/strong&gt; Managed settings that enforce the sandbox do not set the mode; a resumed session kept auto mode until &lt;code&gt;permissions.defaultMode&lt;/code&gt; said otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-approval is not universal.&lt;/strong&gt; Sandboxed commands still prompt when they name files outside the working directory, assign variables computed at run time, or ask for the escape hatch. &lt;code&gt;curl&lt;/code&gt; to a URL does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Globs in deny rules are expanded at every command.&lt;/strong&gt; On Linux, &lt;code&gt;~/**/.env&lt;/code&gt; becomes the list of &lt;code&gt;.env&lt;/code&gt; files that exist when a command starts, and deny rules for the file tools are merged into the sandbox too. &lt;code&gt;bwrap&lt;/code&gt; accepts at most 9000 arguments, about 3000 mounts, so a glob over &lt;code&gt;/proc&lt;/code&gt; is out of the question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The file tools see what commands cannot.&lt;/strong&gt; A sandboxed command has its own &lt;code&gt;/proc&lt;/code&gt;; the &lt;code&gt;Read&lt;/code&gt; tool runs as you, and &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/environ&lt;/code&gt; of your processes holds tokens. Without an allow rule it asks first. Do not approve it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;git add .&lt;/code&gt; from inside the sandbox&lt;/strong&gt; would try to stage the empty placeholders it mounts for protected paths (&lt;code&gt;.bashrc&lt;/code&gt;, &lt;code&gt;.gitconfig&lt;/code&gt;, &lt;code&gt;.mcp.json&lt;/code&gt;), which appear as untracked files only inside it. Stage explicit paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ansible in the sandbox&lt;/strong&gt; fails at start-up because &lt;code&gt;~/.ansible/tmp&lt;/code&gt; is on the read-only home; &lt;code&gt;ANSIBLE_LOCAL_TEMP&lt;/code&gt; in the repository's settings fixes it without widening anything.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What surprised me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Seccomp vs AppArmor collision:&lt;/strong&gt; The sandbox's own seccomp helper needs root capabilities in a nested user namespace, so Ubuntu's fix for bwrap and Claude Code's sandbox cannot run together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AppArmor profile inheritance:&lt;/strong&gt; Programs started by bwrap inherit its AppArmor exception, contrary to what the documentation says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shallow startup check:&lt;/strong&gt; &lt;code&gt;failIfUnavailable&lt;/code&gt; checks for a binary, not for a working sandbox; a profile missing from the kernel fails closed, but only at the first command.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-update surprises:&lt;/strong&gt; The agent's update channel had moved my session to a release from the same morning before I had changed anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outer boundary reliance:&lt;/strong&gt; Half of the protection lives outside the sandbox: file tools, hooks and your own settings file follow permission rules, so managed settings matter as much as bwrap.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The next lab is systemd as a platform: Ollama and Ghostwheel as hardened services with memory and CPU limits, and &lt;code&gt;systemd-analyze security&lt;/code&gt; as the score. The nested user namespace stays on the list for the sandbox comparison later in the series, along with the question of how a sandboxed agent should reach a local model: through an authenticated gateway, not Ollama's open API.&lt;/p&gt;

&lt;p&gt;Everything from this post, in full:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guide:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/docs/guides/lab0f.md" rel="noopener noreferrer"&gt;every command, step by step&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Results:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/results/lab0f.md" rel="noopener noreferrer"&gt;the probe matrix, versions and surprises&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role &lt;code&gt;claude_code&lt;/code&gt;:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/tree/main/ansible/roles/claude_code" rel="noopener noreferrer"&gt;packages, AppArmor profile, managed settings&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed settings:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/ansible/roles/claude_code/files/managed-settings.json" rel="noopener noreferrer"&gt;&lt;code&gt;managed-settings.json&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AppArmor profile:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/ansible/roles/claude_code/files/claude-bwrap" rel="noopener noreferrer"&gt;&lt;code&gt;claude-bwrap&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules for the agent:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/CLAUDE.md" rel="noopener noreferrer"&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://gshell.dev/blog/claude-code-sandbox/" rel="noopener noreferrer"&gt;gshell.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>security</category>
      <category>ansible</category>
    </item>
    <item>
      <title>From manual tweaks to Ansible roles: first steps</title>
      <dc:creator>Gancho Stoychev</dc:creator>
      <pubDate>Tue, 13 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/gjstoychev/from-manual-tweaks-to-ansible-roles-first-steps-1ca</link>
      <guid>https://dev.to/gjstoychev/from-manual-tweaks-to-ansible-roles-first-steps-1ca</guid>
      <description>&lt;p&gt;SSH in. Tweak something. Move on to the next machine. Repeat. Sound familiar? Ansible turns those manual tweaks into configuration you can read, review and run again, on one machine or on ten.&lt;/p&gt;

&lt;p&gt;This is the second post in my &lt;a href="https://gshell.dev/blog/" rel="noopener noreferrer"&gt;AI homelab series&lt;/a&gt;. The previous ended with a plan to sandbox Claude Code. That lab will set up its sandbox with an Ansible role, and Lab 0a had also left me with two fixes I'd made by hand: an AppArmor exception and a kernel setting. Before moving on, it made sense to step back, set up Ansible properly and learn the basics.&lt;/p&gt;

&lt;p&gt;That's what this post is about: installing Ansible, configuring it, and taking the first steps toward managing the homelab as code. The very first run against a server I had configured by hand found exactly one difference: two spaces of indentation.&lt;/p&gt;

&lt;p&gt;Even if you're not following the series, you're in the right place: you can follow along and learn the Ansible basics without reading any of the previous posts.&lt;/p&gt;

&lt;p&gt;This one is Lab 0g. A laptop becomes the control node, the server is reached over plain SSH, the inventory holds nothing but host names, and the two fixes from Lab 0a become two roles that Ansible checks, applies and keeps in place.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guide:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/docs/guides/lab0g.md" rel="noopener noreferrer"&gt;every command, step by step&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Results:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/results/lab0g.md" rel="noopener noreferrer"&gt;versions, runs and surprises&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Entry point:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/ansible/site.yml" rel="noopener noreferrer"&gt;&lt;code&gt;site.yml&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role &lt;code&gt;common&lt;/code&gt;:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/tree/main/ansible/roles/common" rel="noopener noreferrer"&gt;the AppArmor sysctl, on disk and in the kernel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role &lt;code&gt;lab_tools&lt;/code&gt;:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/tree/main/ansible/roles/lab_tools" rel="noopener noreferrer"&gt;the &lt;code&gt;unshare&lt;/code&gt; copy and its profile from Lab 0a&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configuration:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/ansible/ansible.cfg" rel="noopener noreferrer"&gt;&lt;code&gt;ansible.cfg&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interpreter pin:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/ansible/inventory/group_vars/all.yml" rel="noopener noreferrer"&gt;&lt;code&gt;group_vars/all.yml&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What "done" meant
&lt;/h2&gt;

&lt;p&gt;Four checks, decided before the first run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;From the control node over SSH, a dry run shows the drift between the server and the repo, a real run fixes it, and a second run changes nothing.&lt;/li&gt;
&lt;li&gt;A change I make by hand on purpose is detected by the dry run and corrected.&lt;/li&gt;
&lt;li&gt;The kernel setting from Lab 0a lives in a role, the bypass it closes stays closed, and the AppArmor exception still works.&lt;/li&gt;
&lt;li&gt;Every concept used is explained in the guide.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;Two machines with two roles:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Machine&lt;/th&gt;
&lt;th&gt;What it needs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Control node&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MacBook (Apple M1), macOS 15&lt;/td&gt;
&lt;td&gt;Ansible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Managed node&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Z790, Ubuntu 24.04&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sshd&lt;/code&gt; and Python 3.9+, nothing else&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ansible has no agent. For every task the control node packages a small Python module with its arguments, copies it to the node over SSH, runs it there with the node's Python, reads back a JSON result and deletes the temporary files. That is why the node needs nothing installed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Installing Ansible
&lt;/h2&gt;

&lt;p&gt;Ubuntu 24.04's archive ships &lt;code&gt;ansible-core&lt;/code&gt; 2.16.3, from early 2024. PyPI had &lt;strong&gt;2.21.5&lt;/strong&gt;, which needs Python 3.12 or newer on the control node. I took that, with two decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ansible-core&lt;/code&gt;, not &lt;code&gt;ansible&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;ansible&lt;/code&gt; package is a bundle of hundreds of community collections. Everything in this lab uses only &lt;code&gt;ansible.builtin&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pipx with an exact version.&lt;/strong&gt; pipx installs a Python application into its own virtualenv, so the system Python and its packages are left alone, and &lt;code&gt;==2.21.5&lt;/code&gt; makes the lab repeatable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On macOS there was a catch. The system Python is 3.9, too old, and Homebrew's &lt;code&gt;pipx&lt;/code&gt; runs on its own Python, 3.15 at the time, which &lt;code&gt;ansible-core&lt;/code&gt; 2.21.5 does not list as supported (3.12–3.14). So Ansible's virtualenv gets an explicit interpreter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;python@3.12 pipx
pipx &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--python&lt;/span&gt; /opt/homebrew/bin/python3.12 &lt;span class="s1"&gt;'ansible-core==2.21.5'&lt;/span&gt;
pipx ensurepath
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;==2.21.5&lt;/code&gt; pins Ansible, not its dependencies. &lt;code&gt;pipx runpip ansible-core freeze&lt;/code&gt; lists the exact versions; I also installed Ansible on the server for checks that need no connection, and both freezes matched package for package (Jinja2 3.1.6, PyYAML 6.0.3, cryptography 50.0.2 and the rest).&lt;/p&gt;




&lt;h2&gt;
  
  
  Reaching the node
&lt;/h2&gt;

&lt;h3&gt;
  
  
  SSH does the connecting
&lt;/h3&gt;

&lt;p&gt;Ansible connects with the control node's OpenSSH and honours &lt;code&gt;~/.ssh/config&lt;/code&gt;. So everything needed to reach the server goes there, and nowhere else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; z790
  &lt;span class="k"&gt;HostName&lt;/span&gt; z790
  &lt;span class="k"&gt;User&lt;/span&gt; &amp;lt;user&amp;gt;
  &lt;span class="k"&gt;Port&lt;/span&gt; &lt;span class="m"&gt;22&lt;/span&gt;
  &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/id_ed25519_lab
  &lt;span class="k"&gt;IdentitiesOnly&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IdentityFile&lt;/code&gt; names the key instead of relying on whatever the SSH agent happens to hold, and &lt;code&gt;IdentitiesOnly&lt;/code&gt; offers only that key. Before Ansible ever runs, the same connection can be tested the way Ansible will use it, with prompts forbidden:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;BatchMode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;ConnectTimeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5 z790 &lt;span class="s1"&gt;'hostname; python3 --version'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  An inventory that holds only names
&lt;/h3&gt;

&lt;p&gt;With the connection details in &lt;code&gt;~/.ssh/config&lt;/code&gt;, the inventory is just a list of hosts. No addresses, users or ports in the repo, and adding a node later is one more line.&lt;/p&gt;

&lt;p&gt;I did consider running Ansible on the server itself with a local connection. It would have worked, and it would have hidden exactly the parts that break in real use: SSH authentication, privilege escalation over an SSH session, and Python discovery on another machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;ansible.cfg&lt;/code&gt; is read from the current directory
&lt;/h3&gt;

&lt;p&gt;The whole configuration is one setting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# Read only when Ansible runs from this directory (or via ANSIBLE_CONFIG).
&lt;/span&gt;&lt;span class="nn"&gt;[defaults]&lt;/span&gt;
&lt;span class="py"&gt;inventory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;inventory/hosts.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ansible looks for &lt;code&gt;ansible.cfg&lt;/code&gt; in &lt;code&gt;$ANSIBLE_CONFIG&lt;/code&gt;, then the &lt;strong&gt;current directory&lt;/strong&gt;, then &lt;code&gt;~/.ansible.cfg&lt;/code&gt;, then &lt;code&gt;/etc/ansible/&lt;/code&gt;. A relative path inside it resolves against the file's own directory. So all commands run from &lt;code&gt;ansible/&lt;/code&gt;; from anywhere else there is no inventory. &lt;code&gt;ansible-config dump --only-changed&lt;/code&gt; shows which settings are in effect and where each one came from.&lt;/p&gt;




&lt;h2&gt;
  
  
  First contact
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;ping&lt;/code&gt; is not ICMP
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ansible z790 &lt;span class="nt"&gt;-m&lt;/span&gt; ansible.builtin.ping
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ansible's &lt;code&gt;ping&lt;/code&gt; module tests the whole chain: SSH with the configured key, copying a module to the node, finding Python there, running it and getting JSON back. The first one returned &lt;code&gt;pong&lt;/code&gt;, and a warning.&lt;/p&gt;

&lt;h3&gt;
  
  
  Python discovery does not pick the system Python
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[WARNING]: Host 'z790' is using the discovered Python interpreter at '/usr/bin/python3.12', but future installation of another Python interpreter could cause a different interpreter to be discovered.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default discovery walks a fixed list that starts at &lt;code&gt;python3.14&lt;/code&gt; and goes down, and only near the end tries &lt;code&gt;/usr/bin/python3&lt;/code&gt;. On Ubuntu 24.04 it found &lt;code&gt;python3.12&lt;/code&gt; first. Today that is the same binary. But a newer &lt;code&gt;python3.13&lt;/code&gt; installed later, from a PPA or as a dependency, would silently become Ansible's interpreter, and modules that need the distribution's Python packages would break: &lt;code&gt;ansible.builtin.apt&lt;/code&gt; needs &lt;code&gt;python3-apt&lt;/code&gt;, which is built only for the system Python. One line in &lt;code&gt;group_vars/all.yml&lt;/code&gt; pins it for every managed host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ansible_python_interpreter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/bin/python3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, &lt;code&gt;ping&lt;/code&gt; returned &lt;code&gt;pong&lt;/code&gt; with no warning. Privilege escalation got its own test with a harmless command, &lt;code&gt;ansible z790 -m ansible.builtin.command -a id --become&lt;/code&gt;, which printed &lt;code&gt;uid=0(root)&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two spaces of drift
&lt;/h2&gt;

&lt;p&gt;The first role, &lt;code&gt;lab_tools&lt;/code&gt;, holds the AppArmor exception from Lab 0a: a dedicated copy of &lt;code&gt;unshare&lt;/code&gt; and a profile that lets it create user namespaces. I had installed both by hand. The first run was a dry run, &lt;code&gt;--check --diff&lt;/code&gt;, which runs every check as root and applies nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TASK [lab_tools : Copy unshare to a dedicated binary for Lab 0a] *****
ok: [z790]

TASK [lab_tools : Install AppArmor profile that allows user namespaces for the copy] *****
--- before: /etc/apparmor.d/lab0a-unshare
+++ after: &amp;lt;repo&amp;gt;/ansible/roles/lab_tools/files/lab0a-unshare
@@ -2,5 +2,5 @@
 include &amp;lt;tunables/global&amp;gt;

 profile lab0a-unshare /usr/local/bin/lab0a-unshare flags=(unconfined) {
- userns,
+userns,
 }

changed: [z790]

RUNNING HANDLER [lab_tools : Reload lab0a-unshare AppArmor profile] *****
skipping: [z790]

PLAY RECAP *****
z790 : ok=3 changed=1 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The binary was identical. The profile on the server was indented, the one in the repo was not. The repo is the source of truth, so Ansible reported a change.&lt;/p&gt;

&lt;p&gt;The handler line is worth a second look. A handler runs only when a task that notifies it reports &lt;code&gt;changed&lt;/code&gt;, so here it started. Its task is a &lt;code&gt;command&lt;/code&gt; (&lt;code&gt;apparmor_parser -r&lt;/code&gt;), and &lt;code&gt;command&lt;/code&gt; cannot know what an arbitrary command would do, so in check mode it does not run it and reports &lt;code&gt;skipped&lt;/code&gt;. Ansible does not even evaluate &lt;code&gt;changed_when&lt;/code&gt; for a skipped task: I checked that in the ansible-core source before trusting the recap.&lt;/p&gt;

&lt;p&gt;The real run replaced the profile and reloaded it (&lt;code&gt;ok=4 changed=2&lt;/code&gt;). The file was now byte-for-byte the repo's, 124 bytes instead of 126, and an unprivileged user still got root in a user namespace through the copy, while the system &lt;code&gt;unshare&lt;/code&gt; stayed blocked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
unshare: write failed /proc/self/uid_map: Operation not permitted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second run: &lt;code&gt;ok=3 changed=0&lt;/code&gt;, no diff and no handler. That is the whole promise of Ansible: once the machine is in the described state, running it again changes nothing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Key takeaway:&lt;/strong&gt; AppArmor does not care about indentation. The policy the kernel loaded was the same before and after; the profile's entries in &lt;code&gt;/sys/kernel/security/apparmor/&lt;/code&gt; still carried the boot time. Ansible compares text, not meaning. A whitespace-only change is a change, and it fires the handler.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Breaking it on purpose
&lt;/h2&gt;

&lt;p&gt;Next I changed something by hand, on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;0775 /usr/local/bin/lab0a-unshare
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Group-write is not a random choice. Git does not store it, which is why every file task in the role sets &lt;code&gt;mode&lt;/code&gt; explicitly. And on this binary it is dangerous: AppArmor lets this one file create user namespaces, and if its group can write to it, any member of that group can replace it and the replacement inherits the exception.&lt;/p&gt;

&lt;p&gt;The dry run caught it: the binary task &lt;code&gt;changed&lt;/code&gt;, &lt;code&gt;ok=3 changed=1&lt;/code&gt;, no handler. But there was no diff. The &lt;code&gt;copy&lt;/code&gt; module's diff covers file content only; when only the owner, group or mode differ, it reports &lt;code&gt;changed&lt;/code&gt; and shows nothing else. A &lt;code&gt;changed&lt;/code&gt; without a diff from &lt;code&gt;copy&lt;/code&gt; means attributes.&lt;/p&gt;

&lt;p&gt;The real run fixed it with the same output, and the next run was &lt;code&gt;changed=0&lt;/code&gt; again. On the server, ctime was new and mtime unchanged: Ansible had reset the mode without rewriting the file, because the checksums matched.&lt;/p&gt;




&lt;h2&gt;
  
  
  A setting that lives in two places
&lt;/h2&gt;

&lt;p&gt;The second fix from Lab 0a is a kernel setting. With &lt;code&gt;kernel.apparmor_restrict_unprivileged_unconfined = 1&lt;/code&gt;, an unprivileged user can no longer use &lt;code&gt;aa-exec&lt;/code&gt; to slip into a permissive AppArmor profile and get a user namespace with full capabilities, one of the bypasses Qualys published in 2025. I had put it in &lt;code&gt;/etc/sysctl.d/&lt;/code&gt; by hand.&lt;/p&gt;

&lt;p&gt;It became a new role, &lt;code&gt;common&lt;/code&gt;, rather than a task in &lt;code&gt;lab_tools&lt;/code&gt;: &lt;code&gt;lab_tools&lt;/code&gt; is meant to be removed when the labs no longer need it, and this protection must stay.&lt;/p&gt;

&lt;p&gt;A file in &lt;code&gt;/etc/sysctl.d/&lt;/code&gt; takes effect at boot. The obvious design, writing the file and reloading it from a handler when the file changes, misses one case: someone runs &lt;code&gt;sysctl -w&lt;/code&gt; at runtime. The file stays right, Ansible says &lt;code&gt;ok&lt;/code&gt;, and the protection is off until the next reboot. With only &lt;code&gt;ansible.builtin&lt;/code&gt; there is no sysctl module, so the role does it in three tasks: install the file, read the running value, and load the file when the value differs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read the running value of kernel.apparmor_restrict_unprivileged_unconfined&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sysctl -n kernel.apparmor_restrict_unprivileged_unconfined&lt;/span&gt;
  &lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;common_restrict_unconfined&lt;/span&gt;
  &lt;span class="na"&gt;changed_when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;check_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Apply the drop-in when the running value differs&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cmd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sysctl -p /etc/sysctl.d/60-apparmor-restrict-unconfined.conf&lt;/span&gt;
    &lt;span class="c1"&gt;# With removes, check mode reports "would run" as changed instead of skipping.&lt;/span&gt;
    &lt;span class="na"&gt;removes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/sysctl.d/60-apparmor-restrict-unconfined.conf&lt;/span&gt;
  &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;common_restrict_unconfined.stdout != "1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;check_mode: false&lt;/code&gt; lets the read run during a dry run too; it only reads. The &lt;code&gt;removes&lt;/code&gt; line is the interesting one. As the handler showed earlier, &lt;code&gt;command&lt;/code&gt; in check mode reports &lt;code&gt;skipped&lt;/code&gt; whether it would run or not, so a dry run would never show this drift. With &lt;code&gt;creates&lt;/code&gt; or &lt;code&gt;removes&lt;/code&gt; set, the module can tell, and reports &lt;code&gt;changed&lt;/code&gt; when it would run.&lt;/p&gt;

&lt;p&gt;I tested exactly that case. On the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; kernel.apparmor_restrict_unprivileged_unconfined&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a minute, the &lt;code&gt;aa-exec&lt;/code&gt; bypass from Lab 0a was open again. The dry run reported the apply task as &lt;code&gt;changed&lt;/code&gt; (&lt;code&gt;ok=7 changed=1&lt;/code&gt;) and left the kernel at &lt;code&gt;0&lt;/code&gt;; the real run loaded the file and set it back to &lt;code&gt;1&lt;/code&gt;; the next run was &lt;code&gt;changed=0&lt;/code&gt;. Then the check that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;aa-exec &lt;span class="nt"&gt;-p&lt;/span&gt; trinity &lt;span class="nt"&gt;--&lt;/span&gt; unshare &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;--map-root-user&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
unshare: write failed /proc/self/uid_map: Operation not permitted
&lt;span class="nv"&gt;$ &lt;/span&gt;lab0a-unshare &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;--map-root-user&lt;/span&gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'id -u; grep CapEff /proc/self/status'&lt;/span&gt;
0
CapEff: 000001ffffffffff
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The detour through a foreign profile is closed; the exception I granted on purpose still works.&lt;/p&gt;




&lt;h2&gt;
  
  
  Guard rails
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One entry point.&lt;/strong&gt; &lt;code&gt;site.yml&lt;/code&gt; only imports: &lt;code&gt;common.yml&lt;/code&gt; (the baseline for every host) and &lt;code&gt;lab0a.yml&lt;/code&gt;. &lt;code&gt;ansible-playbook site.yml&lt;/code&gt; brings everything to the described state; &lt;code&gt;--limit&lt;/code&gt; narrows it to one host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lint from day one.&lt;/strong&gt; ansible-lint 26.9.0, installed with &lt;code&gt;pipx inject&lt;/code&gt; into the same virtualenv, so it lints against the same ansible-core that runs the playbooks. It passed at &lt;code&gt;production&lt;/code&gt;, its strictest profile: 0 failures, 0 warnings. It also creates a cache directory, &lt;code&gt;.ansible/&lt;/code&gt;, at the repo root, which now sits in &lt;code&gt;.gitignore&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every run, from the first ping to the last:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Recap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;lab_tools&lt;/code&gt;, dry run&lt;/td&gt;
&lt;td&gt;ok=3 changed=1 skipped=1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;lab_tools&lt;/code&gt;, real run&lt;/td&gt;
&lt;td&gt;ok=4 changed=2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;lab_tools&lt;/code&gt;, again&lt;/td&gt;
&lt;td&gt;ok=3 changed=0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;after &lt;code&gt;chmod 0775&lt;/code&gt;: dry run, real run, again&lt;/td&gt;
&lt;td&gt;changed=1 → changed=1 → changed=0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;site.yml&lt;/code&gt;, first run&lt;/td&gt;
&lt;td&gt;ok=6 changed=0 skipped=1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;after &lt;code&gt;sysctl -w …=0&lt;/code&gt;: dry run, real run, again&lt;/td&gt;
&lt;td&gt;changed=1 → changed=1 → changed=0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ansible-lint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0 failures, 0 warnings&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What surprised me
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Python discovery skips the system Python whenever a versioned &lt;code&gt;python3.X&lt;/code&gt; is found first; it has to be pinned.&lt;/li&gt;
&lt;li&gt;A whitespace-only change is a change, and it fires handlers, even when the result is semantically identical.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;copy&lt;/code&gt; shows no diff when only the mode changes.&lt;/li&gt;
&lt;li&gt;In check mode, &lt;code&gt;command&lt;/code&gt; stays silent (&lt;code&gt;skipped&lt;/code&gt;) unless &lt;code&gt;creates&lt;/code&gt; or &lt;code&gt;removes&lt;/code&gt; gives it a way to tell.&lt;/li&gt;
&lt;li&gt;Homebrew's &lt;code&gt;pipx&lt;/code&gt; runs on a Python that &lt;code&gt;ansible-core&lt;/code&gt; does not support yet; the interpreter for Ansible has to be chosen explicitly.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The next lab is the one Lab 0a promised: sandboxing Claude Code, the AI coding agent I use in this lab. Its sandbox needs an AppArmor profile and settings on the server, and now they will arrive as a role instead of another manual tweak. Later, these roles get automated tests, and a new node in the homelab becomes one more line in the inventory.&lt;/p&gt;

&lt;p&gt;Everything from this post, in full:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guide:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/docs/guides/lab0g.md" rel="noopener noreferrer"&gt;every command, step by step&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Results:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/results/lab0g.md" rel="noopener noreferrer"&gt;versions, runs and surprises&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Entry point:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/ansible/site.yml" rel="noopener noreferrer"&gt;&lt;code&gt;site.yml&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role &lt;code&gt;common&lt;/code&gt;:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/tree/main/ansible/roles/common" rel="noopener noreferrer"&gt;the AppArmor sysctl, on disk and in the kernel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role &lt;code&gt;lab_tools&lt;/code&gt;:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/tree/main/ansible/roles/lab_tools" rel="noopener noreferrer"&gt;the &lt;code&gt;unshare&lt;/code&gt; copy and its profile from Lab 0a&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configuration:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/ansible/ansible.cfg" rel="noopener noreferrer"&gt;&lt;code&gt;ansible.cfg&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interpreter pin:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/ansible/inventory/group_vars/all.yml" rel="noopener noreferrer"&gt;&lt;code&gt;group_vars/all.yml&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://gshell.dev/blog/ansible-first-steps/" rel="noopener noreferrer"&gt;gshell.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>ansible</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>A container in 81 lines of Bash: what the kernel primitives cost on Ubuntu 24.04</title>
      <dc:creator>Gancho Stoychev</dc:creator>
      <pubDate>Mon, 12 Oct 2026 09:35:46 +0000</pubDate>
      <link>https://dev.to/gjstoychev/a-container-in-81-lines-of-bash-what-the-kernel-primitives-cost-on-ubuntu-2404-gkm</link>
      <guid>https://dev.to/gjstoychev/a-container-in-81-lines-of-bash-what-the-kernel-primitives-cost-on-ubuntu-2404-gkm</guid>
      <description>&lt;p&gt;Early in this lab I had a shell that was PID 1, and from that same shell &lt;code&gt;ps&lt;/code&gt; listed &lt;code&gt;systemd&lt;/code&gt; as PID 1. Both were right. The kernel had put the shell in a new PID namespace; &lt;code&gt;ps&lt;/code&gt; was still reading a &lt;code&gt;/proc&lt;/code&gt; mounted for the host. Isolation and what the tools report are two different things, and that gap is where most container debugging happens.&lt;/p&gt;

&lt;p&gt;This is the first post in a series about building AI infrastructure on a two-node home lab. I ran Linux servers for years before moving to front-end development, and I am now working my way back to infrastructure, this time for AI workloads: GPU nodes, inference serving, observability and sandboxes for AI agents. Everything happens in the public &lt;a href="https://github.com/gjstoychev/ai-homelab" rel="noopener noreferrer"&gt;ai-homelab repository&lt;/a&gt;, one lab at a time. Each lab has a step-by-step guide with every command and a results file with the raw numbers, mistakes included; each post tells the story of one lab.&lt;/p&gt;

&lt;p&gt;This one is Lab 0a. Every inference server, sandbox and Kubernetes pod I will run later is built from the same few kernel primitives: namespaces, cgroups, a root filesystem and a virtual network cable. So before running any of them, I built a container by hand, one primitive at a time, and wrote down every number and every wrong prediction. The result is &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/scripts/minicontainer.sh" rel="noopener noreferrer"&gt;&lt;code&gt;minicontainer.sh&lt;/code&gt;&lt;/a&gt;, 81 lines of Bash.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guide:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/docs/guides/lab0a.md" rel="noopener noreferrer"&gt;every command, step by step&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Results:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/results/lab0a.md" rel="noopener noreferrer"&gt;measurements, surprises and mistakes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Script:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/scripts/minicontainer.sh" rel="noopener noreferrer"&gt;&lt;code&gt;minicontainer.sh&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What "done" meant
&lt;/h2&gt;

&lt;p&gt;Six checks, decided before writing any code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ps&lt;/code&gt; inside shows only the container's processes, and the shell is PID 1.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hostname&lt;/code&gt; inside differs from the host, and &lt;code&gt;ls /&lt;/code&gt; shows Alpine, not the host.&lt;/li&gt;
&lt;li&gt;A process exceeding &lt;code&gt;memory.max&lt;/code&gt; is killed, and &lt;code&gt;memory.events&lt;/code&gt; shows &lt;code&gt;oom_kill 1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ping&lt;/code&gt; from the container to the host end of a veth pair works.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lsns&lt;/code&gt; shows distinct namespace inodes for the container.&lt;/li&gt;
&lt;li&gt;The script cleans up after itself: no leftover cgroup, network namespace or veth.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;The Z790 node of my two-node home lab: i7-14700, 64 GB DDR5, two RTX 5060 Ti cards (unused here), Ubuntu 24.04 with the HWE kernel &lt;code&gt;7.0.0-34-generic&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The container's userland is Alpine minirootfs 3.24.2: a 3.7 MB tarball, 8.7 MB extracted. I read the current version from Alpine's release metadata, then downloaded it by exact path (&lt;code&gt;v3.24/…&lt;/code&gt;, not &lt;code&gt;latest-stable/…&lt;/code&gt;) so every run uses the same rootfs, and verified it with &lt;code&gt;sha256sum -c&lt;/code&gt;. A checksum from the same server proves integrity, not authenticity; Alpine also publishes a GPG signature, which I left for a later lab.&lt;/p&gt;




&lt;h2&gt;
  
  
  Namespaces, one at a time
&lt;/h2&gt;

&lt;h3&gt;
  
  
  UTS: the name of the machine
&lt;/h3&gt;

&lt;p&gt;The UTS namespace isolates only the hostname, which makes it the safest way to see the whole mechanism. Changing the hostname inside a new UTS namespace leaves the host's name untouched, and &lt;code&gt;readlink /proc/self/ns/uts&lt;/code&gt; shows why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;inside:  uts:[4026533010]
outside: uts:[4026531838]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The number is an inode, and two processes share a namespace exactly when the numbers match. The host's value is no accident: 4026531838 is 0xEFFFFFFE, a constant. The kernel's initial namespaces have the fixed inode numbers 4026531832–4026531839; new namespaces get dynamic numbers, which are recycled after a namespace dies. Seeing one of those constants means "this is the host's namespace".&lt;/p&gt;

&lt;h3&gt;
  
  
  PID: why &lt;code&gt;--fork&lt;/code&gt; is not optional
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;unshare()&lt;/code&gt; moves the calling process into most new namespaces immediately, but not into a new PID namespace. A process's PID is fixed at birth, so the only way in is to be born there: the PID namespace applies only to the caller's future children.&lt;/p&gt;

&lt;p&gt;Without &lt;code&gt;--fork&lt;/code&gt;, the first child becomes PID 1 of the new namespace. When it exits, the namespace has no init, and the kernel refuses to create any more processes in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sh PID: 6000
after 1st child
sh: 1: Cannot fork
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also ran &lt;code&gt;sudo unshare --pid&lt;/code&gt; interactively by accident. It printed &lt;code&gt;fork: Cannot allocate memory&lt;/code&gt; before the prompt even appeared: &lt;code&gt;.bashrc&lt;/code&gt; forks at startup, that child became PID 1 and exited, and every command after that failed to fork. Only the builtin &lt;code&gt;exit&lt;/code&gt; worked. The error says "memory", but the real problem is a dead namespace.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;ps&lt;/code&gt; lies until &lt;code&gt;/proc&lt;/code&gt; is remounted
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;--fork&lt;/code&gt;, the shell really is PID 1 — and &lt;code&gt;ps&lt;/code&gt; still printed &lt;code&gt;1 systemd&lt;/code&gt;, &lt;code&gt;2 kthreadd&lt;/code&gt; and the rest of the host. &lt;code&gt;ps&lt;/code&gt; does not ask the kernel which processes it can see; it reads &lt;code&gt;/proc&lt;/code&gt;, and a proc mount always shows the PID namespace it was mounted for. Adding &lt;code&gt;--mount-proc&lt;/code&gt; (a new mount namespace plus a fresh &lt;code&gt;/proc&lt;/code&gt;) left exactly two lines: &lt;code&gt;1 sh&lt;/code&gt; and &lt;code&gt;2 ps&lt;/code&gt;. First check passed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mount propagation: a tmpfs that leaked
&lt;/h3&gt;

&lt;p&gt;systemd makes every host mount shared, so a mount in one member of a peer group propagates to the others. A new mount namespace started with &lt;code&gt;--propagation shared&lt;/code&gt; stays in the host's peer group: I mounted a tmpfs inside, the namespace ended, and &lt;code&gt;findmnt&lt;/code&gt; on the host still showed it. With &lt;code&gt;--propagation private&lt;/code&gt;, the same mount never left the namespace. This is why &lt;code&gt;unshare&lt;/code&gt; defaults to private, and it is also a precondition for the next step.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;chroot&lt;/code&gt; versus &lt;code&gt;pivot_root&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;chroot&lt;/code&gt; into the Alpine rootfs worked: &lt;code&gt;NAME="Alpine Linux"&lt;/code&gt;, but &lt;code&gt;uname -r&lt;/code&gt; still printed the host's &lt;code&gt;7.0.0-34-generic&lt;/code&gt;. A container has its own filesystem and shares the kernel.&lt;/p&gt;

&lt;p&gt;I expected &lt;code&gt;/proc/mounts&lt;/code&gt; inside the chroot to list dozens of host mounts. It listed one. Same mount namespace, two views:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Root change&lt;/th&gt;
&lt;th&gt;Mounts before&lt;/th&gt;
&lt;th&gt;Mounts after&lt;/th&gt;
&lt;th&gt;The host filesystem is…&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;chroot&lt;/code&gt; (outside / inside, same namespace)&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;hidden, still attached&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pivot_root&lt;/code&gt; (before / after &lt;code&gt;umount -l&lt;/code&gt; of the old root)&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;detached&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;/proc/self/mounts&lt;/code&gt; only lists mounts reachable from the process's root, so &lt;code&gt;chroot&lt;/code&gt; hides the host's mounts without detaching them. &lt;code&gt;pivot_root&lt;/code&gt; swaps the root mount and parks the old root at &lt;code&gt;/.oldroot&lt;/code&gt;; one lazy &lt;code&gt;umount -l&lt;/code&gt; then detaches it with all its submounts. After that, &lt;code&gt;/.oldroot&lt;/code&gt; was empty and the process had no path back to the host.&lt;/p&gt;

&lt;h3&gt;
  
  
  Side quest: a 3.9 MB root filesystem
&lt;/h3&gt;

&lt;p&gt;Any directory can become &lt;code&gt;/&lt;/code&gt;. I copied &lt;code&gt;/bin/bash&lt;/code&gt; into an empty &lt;code&gt;tiny/&lt;/code&gt; directory and tried to &lt;code&gt;chroot&lt;/code&gt; into it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;chroot: failed to run command '/bin/bash': No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file existed. What was missing was the dynamic loader that the kernel starts first; the error names bash, the problem is the loader. After copying the loader and the two libraries &lt;code&gt;ldd&lt;/code&gt; listed, bash ran, &lt;code&gt;ls&lt;/code&gt; did not exist, and the whole rootfs was 3.9 MB. An image is literally the program plus everything it depends on, which is why statically linked binaries and distroless images exist.&lt;/p&gt;




&lt;h2&gt;
  
  
  cgroups v2 by hand
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How systemd runs a real service
&lt;/h3&gt;

&lt;p&gt;Before writing my own cgroup, I looked at a real one: Ollama, running as a systemd service. It lives in &lt;code&gt;system.slice/ollama.service&lt;/code&gt;; idle with no model loaded, &lt;code&gt;memory.current&lt;/code&gt; was about 552 MiB (page cache included) and &lt;code&gt;pids.current&lt;/code&gt; was 18. There is no separate "cgroup config": the unit file is the configuration, and systemd writes it into the cgroup's files.&lt;/p&gt;

&lt;p&gt;One limit nobody wrote: &lt;code&gt;pids.max&lt;/code&gt; was 70833. systemd applies a default &lt;code&gt;TasksMax&lt;/code&gt; to every service, so a single fork bomb cannot exhaust the machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  The swap trap and an OOM kill
&lt;/h3&gt;

&lt;p&gt;Creating a cgroup is a &lt;code&gt;mkdir&lt;/code&gt; in &lt;code&gt;/sys/fs/cgroup&lt;/code&gt;; the kernel fills the directory with 86 control files. I set &lt;code&gt;memory.max&lt;/code&gt; to 100M, &lt;code&gt;pids.max&lt;/code&gt; to 20 and &lt;code&gt;cpu.max&lt;/code&gt; to &lt;code&gt;50000 100000&lt;/code&gt; (half a core), and &lt;code&gt;memory.swap.max&lt;/code&gt; to 0. That last one matters: this machine has an 8 GB swap file, and without it a process at &lt;code&gt;memory.max&lt;/code&gt; gets swapped instead of killed.&lt;/p&gt;

&lt;p&gt;Then a Python process inside the cgroup allocated 200 MB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Killed
exit code: 137
Memory cgroup out of memory: Killed process 5462 (python3) total-vm:223152kB, anon-rss:101884kB, file-rss:6404kB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;memory.events&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;max&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;oom&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;oom_kill&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;oom_group_kill&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Exit code 137 is 128 + 9, a SIGKILL with no chance to clean up. &lt;code&gt;max 18&lt;/code&gt; is how many times the cgroup hit its ceiling and the kernel tried to reclaim memory before giving up. &lt;code&gt;total-vm&lt;/code&gt; is what the process asked for, &lt;code&gt;anon-rss&lt;/code&gt; is what was resident when it died, right at the 100 MB limit. That &lt;code&gt;dmesg&lt;/code&gt; line is what to look for when a container "just disappears".&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;pids.max&lt;/code&gt; versus 30 sleeps
&lt;/h3&gt;

&lt;p&gt;I predicted that starting 30 background &lt;code&gt;sleep&lt;/code&gt; processes under &lt;code&gt;pids.max = 20&lt;/code&gt; would produce 11 failed forks. I got one: &lt;code&gt;pids.current&lt;/code&gt; was 19, a single &lt;code&gt;sh: 0: Cannot fork&lt;/code&gt;, and &lt;code&gt;pids.events&lt;/code&gt; showed &lt;code&gt;max 1&lt;/code&gt;. A non-interactive dash gives up on the first failed fork. A real fork bomb would keep retrying, and the cgroup would keep stopping it at the ceiling.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;dmem&lt;/code&gt; and NVIDIA: no VRAM limit
&lt;/h3&gt;

&lt;p&gt;The kernel's list of cgroup controllers ends with &lt;code&gt;dmem&lt;/code&gt;, the controller for device memory: &lt;code&gt;dmem.min&lt;/code&gt;, &lt;code&gt;dmem.low&lt;/code&gt; and &lt;code&gt;dmem.max&lt;/code&gt; work like their &lt;code&gt;memory&lt;/code&gt; counterparts, per memory region of a device. On this machine &lt;code&gt;dmem.capacity&lt;/code&gt; exists and is empty.&lt;/p&gt;

&lt;p&gt;Drivers have to opt in by registering their memory regions. On this kernel, Intel's &lt;code&gt;xe&lt;/code&gt; and AMD's &lt;code&gt;amdgpu&lt;/code&gt; do; none of the NVIDIA 580 open kernel modules reference &lt;code&gt;dmem&lt;/code&gt; at all. So on this lab, GPU memory cannot be limited through cgroups.&lt;/p&gt;




&lt;h2&gt;
  
  
  Network: a cable between namespaces
&lt;/h2&gt;

&lt;p&gt;A named network namespace (&lt;code&gt;ip netns add&lt;/code&gt;) plus a veth pair, one end moved inside, is a virtual Ethernet cable between two network stacks. Inside, the new namespace had only &lt;code&gt;lo&lt;/code&gt; (down) and its end of the cable, shown as &lt;code&gt;@if8&lt;/code&gt;: the index of the peer in the other namespace. No LAN interface, no Docker bridges, not even a working loopback.&lt;/p&gt;

&lt;p&gt;With addresses on both ends, the ping went through: 0.148, 0.077 and 0.079 ms. The first packet is slower because it includes ARP resolution; all of them stay well under a millisecond because they never leave the kernel. There is no default route, so the internet is unreachable — NAT is a topic for a later lab. Deleting the namespace destroyed its end of the cable, and since a veth pair cannot exist with one end, the kernel removed the host end too. One command cleans up the whole thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  User namespaces and AppArmor on Ubuntu 24.04
&lt;/h2&gt;

&lt;p&gt;Everything so far ran with &lt;code&gt;sudo&lt;/code&gt;. A user namespace is the way to do it without: root inside, an ordinary user outside. As an unprivileged user it failed with &lt;code&gt;write failed /proc/self/uid_map: Operation not permitted&lt;/code&gt;, and the kernel audit log explained why in two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apparmor="AUDIT" operation="userns_create" … info="Userns create - transitioning profile" profile="unconfined" … target="unprivileged_userns"
apparmor="DENIED" operation="capable" profile="unprivileged_userns" … capability=21 capname="sys_admin"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Creating the namespace is allowed, but the process is immediately moved into the restrictive &lt;code&gt;unprivileged_userns&lt;/code&gt; profile, which denies &lt;code&gt;CAP_SYS_ADMIN&lt;/code&gt; inside. User namespaces give an unprivileged user "root" over large parts of the kernel, and that path has carried many privilege-escalation bugs. Ubuntu 24.04 allows them only for programs with an explicit AppArmor profile.&lt;/p&gt;

&lt;p&gt;I did not turn the protection off globally. Instead, a root-owned copy of &lt;code&gt;unshare&lt;/code&gt; gets its own profile, whose only addition is &lt;code&gt;userns&lt;/code&gt;, so AppArmor no longer moves it into the restrictive profile. The profile is part of the repo as an Ansible role. The trade-off is written down rather than hidden: the copy does not receive util-linux security updates, so the role must be re-run after upgrades, or the copy removed.&lt;/p&gt;

&lt;p&gt;With the copy, the rootless test worked, and showed what "root" in a user namespace means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
CapEff: 000001ffffffffff
touch: cannot touch '/etc/lab0a-test': Permission denied
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All capabilities, and still no write access to &lt;code&gt;/etc&lt;/code&gt;: the kernel checks host resources against the UID outside the namespace.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Key takeaway:&lt;/strong&gt; In March 2025, Qualys published three bypasses of this restriction. Canonical treats it as a defense-in-depth measure. The restriction is a tripwire that leaves audit records, not a wall; the real defense against kernel exploits is a patched kernel.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On my host, the first one worked: &lt;code&gt;kernel.apparmor_restrict_unprivileged_unconfined&lt;/code&gt; was 0, and &lt;code&gt;aa-exec -p trinity&lt;/code&gt; gave an unprivileged user a user namespace with all capabilities, through the profile of a program that is not even installed. One sysctl closes it: with &lt;code&gt;kernel.apparmor_restrict_unprivileged_unconfined = 1&lt;/code&gt;, the same command fails with &lt;code&gt;Operation not permitted&lt;/code&gt;, and the dedicated &lt;code&gt;unshare&lt;/code&gt; copy still works.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 81-line script
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;minicontainer.sh&lt;/code&gt; puts all of this together in four blocks, after some pre-checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;cgroup:&lt;/strong&gt; create it and write the limits, swap off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;network:&lt;/strong&gt; namespace, veth pair, the container end renamed to &lt;code&gt;eth0&lt;/code&gt; as Docker does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;inside, as PID 1:&lt;/strong&gt; hostname, bind mount, &lt;code&gt;pivot_root&lt;/code&gt;, a fresh &lt;code&gt;/proc&lt;/code&gt;, &lt;code&gt;umount -l&lt;/code&gt; of the old root, then &lt;code&gt;exec&lt;/code&gt; Alpine's shell with a clean environment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;launch:&lt;/strong&gt; a subshell moves itself into the cgroup first, so everything it starts inherits it, then enters the network namespace and creates the rest with &lt;code&gt;unshare --uts --pid --mount --ipc --cgroup --fork&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A trap on EXIT deletes the network namespace (and with it both veth ends) and removes the cgroup with a short retry loop, since a cgroup can only go once its last process has exited. After exit, the cgroup, the namespace, the veth pair and the mounts were all gone. All six checks passed.&lt;/p&gt;

&lt;p&gt;This is the process tree while the container runs, with the host PIDs from one run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;subshell → exec ip netns exec → exec unshare      host PID 16242
  joins the lab0a network namespace and cgroup,
  unshare() moves it into new uts, mnt, ipc and cgroup namespaces
  └── fork → /bin/sh                                host PID 16244, PID 1 inside
        the first process in the new pid namespace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Seen from the host: mine versus Docker
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;lsns&lt;/code&gt; shows all eight namespace types of a process. Here is my container next to &lt;code&gt;docker run alpine:3.24&lt;/code&gt; on Docker 29.8.0:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Namespace&lt;/th&gt;
&lt;th&gt;&lt;code&gt;minicontainer.sh&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Docker&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;mnt&lt;/code&gt;, &lt;code&gt;uts&lt;/code&gt;, &lt;code&gt;ipc&lt;/code&gt;, &lt;code&gt;cgroup&lt;/code&gt;, &lt;code&gt;net&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;shared with the host&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;shared with the host&lt;/td&gt;
&lt;td&gt;shared with the host&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same core model: new &lt;code&gt;mnt&lt;/code&gt;, &lt;code&gt;uts&lt;/code&gt;, &lt;code&gt;ipc&lt;/code&gt;, &lt;code&gt;pid&lt;/code&gt;, &lt;code&gt;cgroup&lt;/code&gt; and &lt;code&gt;net&lt;/code&gt; namespaces, and no user namespace. Root in my container is root on the host, and the same is true of a default Docker container.&lt;/p&gt;

&lt;p&gt;Two differences. Docker also created a &lt;code&gt;time&lt;/code&gt; namespace, which I had predicted it would not. It is part of the OCI spec that Docker 29.8.0 generates with its default configuration, with zero offsets: a new namespace that changes nothing visible. And Docker's process model is cleaner: every namespace starts at the container's own process, because &lt;code&gt;runc&lt;/code&gt; creates it directly in all of them, while my script leaves the &lt;code&gt;unshare&lt;/code&gt; parent (16242 above) inside five of the six new namespaces. That is exactly the PID-namespace rule from earlier, made visible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Start-up: 124 ms versus 518 ms
&lt;/h2&gt;

&lt;p&gt;Five runs of each, with &lt;code&gt;true&lt;/code&gt; as the command, so only create and destroy are measured:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Implementation&lt;/th&gt;
&lt;th&gt;5 runs (real)&lt;/th&gt;
&lt;th&gt;Per run&lt;/th&gt;
&lt;th&gt;User + Sys (5 runs)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;minicontainer.sh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.619 s&lt;/td&gt;
&lt;td&gt;~124 ms&lt;/td&gt;
&lt;td&gt;0.022 + 0.047 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker run --rm alpine:3.24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.589 s&lt;/td&gt;
&lt;td&gt;~518 ms&lt;/td&gt;
&lt;td&gt;0.025 + 0.042 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;About four times faster, and still slower than my guess of "tens of milliseconds". CPU time is about 14 ms per run, so most of the rest is waiting: on namespace, veth and mount setup and teardown in the kernel. Profiling exactly where it goes is a job for a later lab.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Key takeaway:&lt;/strong&gt; Docker's user and sys are just as small, and that is misleading. &lt;code&gt;time&lt;/code&gt; only counts the client and its children; the real work happens in &lt;code&gt;dockerd&lt;/code&gt;, &lt;code&gt;containerd&lt;/code&gt; and &lt;code&gt;runc&lt;/code&gt;, which are not children of the shell. On a client-server system, &lt;code&gt;time&lt;/code&gt; measures latency, not cost.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;None of this makes Docker slow. It pays for layered images shared between containers, overlayfs, bridge networking with NAT, logging, restart policies and an API. My 124 ms is the cost of the kernel primitives alone.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I got wrong
&lt;/h2&gt;

&lt;p&gt;Mistakes were the most useful part of this lab, so here they are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I expected dozens of mounts in &lt;code&gt;/proc/mounts&lt;/code&gt; after &lt;code&gt;chroot&lt;/code&gt;. It showed one.&lt;/li&gt;
&lt;li&gt;I expected 11 failed forks against &lt;code&gt;pids.max&lt;/code&gt;. dash stopped after the first.&lt;/li&gt;
&lt;li&gt;I used &lt;code&gt;ip -br addr&lt;/code&gt; inside the container. &lt;code&gt;-br&lt;/code&gt; is iproute2; Alpine's &lt;code&gt;ip&lt;/code&gt; is a BusyBox applet. Inside the container, the tools are Alpine's.&lt;/li&gt;
&lt;li&gt;I expected start-up in tens of milliseconds. It was ~124 ms.&lt;/li&gt;
&lt;li&gt;I expected Docker to share the &lt;code&gt;time&lt;/code&gt; namespace. It created a new one.&lt;/li&gt;
&lt;li&gt;I ran an interactive &lt;code&gt;sudo unshare --pid&lt;/code&gt; by accident, and nothing could execute in it.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Gaps and what's next
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Not for untrusted code:&lt;/strong&gt; The script is deliberately incomplete. It has no &lt;code&gt;/dev&lt;/code&gt; (not even &lt;code&gt;/dev/null&lt;/code&gt;), no &lt;code&gt;/tmp&lt;/code&gt; or &lt;code&gt;/sys&lt;/code&gt;, no capability dropping, no seccomp and no user namespace, and, like default Docker, root inside is root on the host. That is fine for learning what each primitive does; it is not something to run untrusted code in.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The next lab puts these primitives to work on the same server: sandboxing Claude Code, the AI coding agent I use in this lab. On Linux its sandbox is built on bubblewrap, which creates the same namespaces this post built by hand, and on Ubuntu 24.04 it meets the same AppArmor wall. Further down the road, the gaps above become a hardened-sandbox comparison: Claude Code's built-in sandbox, NVIDIA OpenShell, gVisor and a hardened version of this script.&lt;/p&gt;

&lt;p&gt;Everything from this post, in full:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guide:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/docs/guides/lab0a.md" rel="noopener noreferrer"&gt;every command, step by step&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Results:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/results/lab0a.md" rel="noopener noreferrer"&gt;measurements, surprises and mistakes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Script:&lt;/strong&gt; &lt;a href="https://github.com/gjstoychev/ai-homelab/blob/main/scripts/minicontainer.sh" rel="noopener noreferrer"&gt;&lt;code&gt;minicontainer.sh&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://gshell.dev/blog/container-from-scratch/" rel="noopener noreferrer"&gt;gshell.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>containers</category>
      <category>bash</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
