<?xml version="1.0" encoding="utf-8"?><?xml-stylesheet type="text/xml" href="https://aldur.blog/feed.xslt.xml"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://aldur.blog/feed.xml" rel="self" type="application/atom+xml" /><link href="https://aldur.blog/" rel="alternate" type="text/html" /><updated>2026-03-13T19:25:01+00:00</updated><id>https://aldur.blog/feed.xml</id><title type="html">Universal Bits</title><subtitle>Exploring mental models, connecting the dots, and writing about it.</subtitle><author><name>aldur</name><email>hello@aldur.blog</email></author><entry><title type="html">Sandboxing local models on macOS</title><link href="https://aldur.blog/articles/2026/03/12/sandboxing-local-models-on-macos.html" rel="alternate" type="text/html" title="Sandboxing local models on macOS" /><published>2026-03-12T00:00:00+00:00</published><updated>2026-03-12T00:00:00+00:00</updated><id>https://aldur.blog/articles/2026/03/12/sandboxing-local-models-on-macos</id><content type="html" xml:base="https://aldur.blog/articles/2026/03/12/sandboxing-local-models-on-macos.html"><![CDATA[<p>The recently released <a href="https://qwen.ai/blog?id=qwen3.5">Qwen3.5 family</a> advances the capabilities of
open-weights multi-modal models: rumors are they’re <em>exceptionally</em> good
for their size. The 35B-A3B and 27B variants are small enough to run on 64GB of
unified memory (especially if quantized). So, I decided to give them a try.</p>

<h3 id="sandboxing">Sandboxing</h3>

<p>Being wary of prompt injection and the probabilistic nature of LLMs, I usually
confine AI agents to ephemeral VMs, where they can wreak havoc in YOLO mode
(<code class="language-plaintext highlighter-rouge">--dangerously-skip-permissions</code>). With hosted models, this is easy: the VM
just needs internet access and remains isolated from the host. To run local
models on the VM instead, we’d need to let it access the GPU and install
compatible drivers. My only device with 64GB of RAM is a MacBook, and I run
Linux VMs on it, so I’d need to find Linux drivers compatible with Apple
hardware (if they exist).</p>

<p>A pragmatic approach is to:</p>

<ol>
  <li>Sandbox model inference (since parsing GGUF models has led to <a href="https://github.com/ggml-org/llama.cpp/security#untrusted-inputs">code
execution vulnerabilities</a>);</li>
  <li>either sandbox the <em>client</em> as well, for quick explorations; or</li>
  <li>keep the agents in a VM and forward their API access to the local model.</li>
</ol>

<p>The go-to sandbox on macOS is <a href="https://man.freebsd.org/cgi/man.cgi?query=sandbox-exec&amp;sektion=1&amp;manpath=macOS+26.3"><code class="language-plaintext highlighter-rouge">sandbox-exec</code></a>. Apple has marked it as
deprecated years ago, but it continues to work because it powers <a href="https://developer.apple.com/documentation/security/app-sandbox">App
Sandbox</a>, the sandboxing mechanism used by the Apple Store. It’s a weird
beast, configurable through arcane profiles written in a Scheme dialect (which
got a lot easier to write with LLMs). Now that the big AI companies are using
it to sandbox their tools<sup id="fnref:caveat"><a href="#fn:caveat" class="footnote" rel="footnote" role="doc-noteref">1</a></sup>, it is seeing a renaissance. I have
been using it for a while to sandbox <code class="language-plaintext highlighter-rouge">lazyvim</code> when running outside a VM, so
that it cannot reach the internet or make changes outside <code class="language-plaintext highlighter-rouge">~/work</code>.</p>

<p>Here, we’ll take a similar approach to sandbox model inference down to its
minimal capabilities (essentially, GPU and driver access) and then sandbox the
agents to run offline and within the well-defined boundaries of a workspace.
Importantly, all sandbox profiles have <code class="language-plaintext highlighter-rouge">default deny</code>, so we can allow exactly
what we need and deny the rest.</p>

<h3 id="sandboxing-profiles">Sandboxing profiles</h3>

<div class="hint">

  <p>If you want to jump to the code, you’ll find everything at the
  <a href="https://github.com/aldur/sandboxed-ai/tree/master" title="code class=&quot;language-plaintext highlighter-rouge&quot;sandboxed-ai/code GitHub repository"><svg class="svg-icon grey" viewBox="0 0 512 512"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>
  <code class="language-plaintext highlighter-rouge">sandboxed-ai</code> GitHub repository</a>.</p>

</div>

<h4 id="server">Server</h4>

<p>We’ll use <a href="https://github.com/ggml-org/llama.cpp"><code class="language-plaintext highlighter-rouge">llama-server</code></a> to do model inference, which also provides
OpenAI-compatible REST APIs for the clients. The <a href="https://github.com/aldur/sandboxed-ai/blob/master/llama-server.sb" title="sandbox file"><svg class="svg-icon grey" viewBox="0 0 512 512"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>
  sandbox file</a> I came up
with through trial and error only allows access to the executable, the model
weights, the GPU and its drivers, and some cache directories. In addition,
<code class="language-plaintext highlighter-rouge">llama-server</code> can bind to port <code class="language-plaintext highlighter-rouge">8080</code> to serve its APIs, but cannot reach the
network (outbound).</p>

<p>The result is a simple <a href="https://github.com/aldur/sandboxed-ai/blob/master/sandbox.sh" title="code class=&quot;language-plaintext highlighter-rouge&quot;sandbox.sh/code"><svg class="svg-icon grey" viewBox="0 0 512 512"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>
  <code class="language-plaintext highlighter-rouge">sandbox.sh</code></a> script (no external dependencies)
that calls <code class="language-plaintext highlighter-rouge">sandbox-exec</code> and spins up the server. All additional arguments are
forwarded to <code class="language-plaintext highlighter-rouge">llama-server</code>. Because the sandbox prevents network access, the
script does some special handling of the <code class="language-plaintext highlighter-rouge">--model</code> parameter: if a model
doesn’t exist locally, it will fetch it (outside the sandbox) through <code class="language-plaintext highlighter-rouge">curl</code>.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./sandbox.sh llama-server <span class="nt">--model</span> unsloth/Qwen3.5-9B-GGUF:Qwen3.5-9B-Q8_0.gguf
</code></pre></div></div>

<p>We can now run Qwen3.5. Here, I am using the <a href="https://unsloth.ai/docs/models/qwen3.5">unsloth</a> quantized model and
including their recommended sampling parameters:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>./sandbox.sh llama-server <span class="se">\</span>
    <span class="nt">--model</span> unsloth/Qwen3.5-35B-A3B-GGUF:UD-Q4_K_XL.gguf <span class="se">\</span>
    <span class="nt">--ctx-size</span> 16384 <span class="se">\</span>
    <span class="nt">--temp</span> 0.7 <span class="se">\</span>
    <span class="nt">--top-p</span> 0.8 <span class="se">\</span>
    <span class="nt">--top-k</span> 20 <span class="se">\</span>
    <span class="nt">--min-p</span> 0.00 <span class="se">\</span>
    <span class="nt">--chat-template-kwargs</span> <span class="s1">'{"enable_thinking":false}'</span>

Starting sandboxed llama-server:
  binary:        /nix/store/l4xdm13zilm71n1jad95rpzk49h57is5-llama-cpp-metalkit-0.0.0/bin/llama-server
  model:         /Users/aldur/Work/local-opencode/.opencode/models/unsloth/Qwen3.5-35B-A3B-GGUF/Qwen3.5-35B-A3B-UD-Q4_K_XL.gguf
  <span class="nb">alias</span>:         Qwen3.5-35B-A3B-GGUF
  port:          8080
  extra:         <span class="nt">--ctx-size</span> 16384 <span class="nt">--temp</span> 0.7 <span class="nt">--top-p</span> 0.8 <span class="nt">--top-k</span> 20 <span class="nt">--min-p</span> 0.00 <span class="nt">--chat-template-kwargs</span> <span class="o">{</span><span class="s2">"enable_thinking"</span>:false<span class="o">}</span>

...
</code></pre></div></div>

<h4 id="local-clients">Local clients</h4>

<p>With the model ready, we can now chat with it. To do that, I have also prepared
sandbox profiles for:</p>

<ol>
  <li><a href="https://github.com/simonw/llm"><code class="language-plaintext highlighter-rouge">simonw/llm</code></a>, which I use to run quick queries;</li>
  <li><a href="https://opencode.ai"><code class="language-plaintext highlighter-rouge">opencode</code></a>, which I use interactively.</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>./sandbox.sh llm
Hello there!
^D
Hello! How can I <span class="nb">help </span>you today?
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">sandbox.sh</code> script automatically takes care of the configuration files
that both tools require to interface with <code class="language-plaintext highlighter-rouge">llama-server</code> (you’ll find an
<code class="language-plaintext highlighter-rouge">opencode.json</code> in the script directory).</p>

<p>The sandbox profiles are quite hardened: they don’t allow network outbound,
restrict all writes to a single workspace directory (<code class="language-plaintext highlighter-rouge">-w</code>), and whitelist only
selected executables (restricting allowed tools). Cache files are written
alongside the script. Because of the sandbox restrictions, a few things will
break: <code class="language-plaintext highlighter-rouge">opencode web</code>, for instance, because it requires remote access to load
the frontend assets. On the other hand, the sandbox guarantees both integrity
and confidentiality on bare macOS, guaranteeing that the computation remains
local and preventing <code class="language-plaintext highlighter-rouge">opencode</code> from leaking your prompts<sup id="fnref:leak"><a href="#fn:leak" class="footnote" rel="footnote" role="doc-noteref">2</a></sup>.</p>

<h4 id="in-a-qemu-vm">In a QEMU VM</h4>

<p>When I need to do anything more than quick checks/chats, I just spin up an
ephemeral QEMU VM. My <a href="https://github.com/aldur/dotfiles?tab=readme-ov-file#qemu-vm-1" title="code class=&quot;language-plaintext highlighter-rouge&quot;qemu-vm/code"><svg class="svg-icon grey" viewBox="0 0 512 512"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>
  <code class="language-plaintext highlighter-rouge">qemu-vm</code></a> script uses SLiRP user network<sup id="fnref:slirp"><a href="#fn:slirp" class="footnote" rel="footnote" role="doc-noteref">3</a></sup>,
so <code class="language-plaintext highlighter-rouge">llama-server</code> from the host is available at <code class="language-plaintext highlighter-rouge">10.0.2.2:8080</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>qemu-vm <span class="nt">-d</span> <span class="si">$(</span><span class="nb">pwd</span><span class="si">)</span> <span class="nt">--</span> <span class="nt">-ephemeral</span>
Starting VM...
...
qemu-nixos login: aldur <span class="o">(</span>automatic login<span class="o">)</span>
<span class="o">[</span>I] aldur@qemu-nixos ~&gt; curl http://10.0.2.2:8080/health
<span class="o">{</span><span class="s2">"status"</span>:<span class="s2">"ok"</span><span class="o">}</span>
<span class="o">[</span>I] aldur@qemu-nixos ~&gt; <span class="nb">cat </span>opencode.json
<span class="o">{</span>
  <span class="s2">"</span><span class="nv">$schema</span><span class="s2">"</span>: <span class="s2">"https://opencode.ai/config.json"</span>,
  <span class="s2">"model"</span>: <span class="s2">"llama/Qwen3.5-35B-A3B-GGUF"</span>,
  <span class="s2">"provider"</span>: <span class="o">{</span>
    <span class="s2">"llama"</span>: <span class="o">{</span>
      <span class="s2">"npm"</span>: <span class="s2">"@ai-sdk/openai-compatible"</span>,
      <span class="s2">"name"</span>: <span class="s2">"llama.cpp (local)"</span>,
      <span class="s2">"options"</span>: <span class="o">{</span>
        <span class="s2">"baseURL"</span>: <span class="s2">"http://10.0.2.2:8080/v1"</span>,
        <span class="s2">"apiKey"</span>: <span class="s2">"dummy"</span>
      <span class="o">}</span>,
      <span class="s2">"models"</span>: <span class="o">{</span>
        <span class="s2">"Qwen3.5-35B-A3B-GGUF"</span>: <span class="o">{</span>
          <span class="s2">"name"</span>: <span class="s2">"Qwen3.5-35B-A3B-GGUF"</span>,
          <span class="s2">"tool_call"</span>: <span class="nb">true</span>
        <span class="o">}</span>
      <span class="o">}</span>
    <span class="o">}</span>
  <span class="o">}</span>,
  <span class="s2">"autoupdate"</span>: <span class="nb">false</span>
<span class="o">}</span>
<span class="o">[</span>I] aldur@qemu-nixos ~&gt; nix run github:nixos/nixpkgs#opencode
                                   ▄
  █▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
  █  █ █  █ █▀▀▀ █  █ █    █  █ █  █ █▀▀▀
  ▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀

  Session   Greeting and quick check-in

Hello Qwen! How are you doing today?
I<span class="s1">'m doing well, thanks for asking! How can I help you today?
</span></code></pre></div></div>

<h3 id="test-drives">Test drives</h3>

<p>On a MacBook Pro with an M3 Max CPU and 64GB of RAM, <code class="language-plaintext highlighter-rouge">Qwen3.5-35B-A3B-Q8_0</code> is
quick enough that any prompt feels snappy and interactive, even with thinking
enabled. When using <code class="language-plaintext highlighter-rouge">opencode</code>, it churns for a few seconds through the initial
big prompt (on a cold cache) and is then able to use tools and read files
quickly enough. Although the quality of results is lower than frontier hosted
models, it’s a big step forward: this relatively small model can make small,
interactive changes. Plus, the OCR capabilities of the whole family are
impressive.</p>

<p>I also gave <code class="language-plaintext highlighter-rouge">Qwen3.5-27B-Q8_0.gguf</code> a shot, which trades inference speed for
better accuracy. To test drive it, I stuck to the default parameters, fed it a
draft of this blog post, then asked for edits<sup id="fnref:writing"><a href="#fn:writing" class="footnote" rel="footnote" role="doc-noteref">4</a></sup>.
<a href="https://gist.github.com/aldur/94378954caa0829bc5cb5dcca6962379">Here’s</a> the transcript of the session, the server logs and the model
info. Producing the output required more than 10 minutes of computation and
used about 15% of battery charge, with fans spinning and GPU at 100%
utilization. The task was relatively easy, and the results are satisfying: I
integrated almost all of its suggestions.</p>

<p>Thank you for reading and ‘til next time! 👋</p>

<h4 id="footnotes">Footnotes</h4>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:caveat">
      <p>With the important caveat that often the agent can <em>disable</em> the
sandbox at runtime. <a href="#fnref:caveat" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:leak">
      <p>In the default configuration, the <code class="language-plaintext highlighter-rouge">small_model</code> configuration
parameter <a href="https://github.com/anomalyco/opencode/issues/8609">uploads prompts to OpenCode’s servers</a> to generate session titles. <a href="#fnref:leak" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:slirp">
      <p>SLiRP user network isn’t ideal for isolation because it
     doesn’t firewall the VM, which appears to macOS as a process that can
     access <code class="language-plaintext highlighter-rouge">localhost</code> sockets. The proper solution is to use TAP bridges,
     but we’ll leave that to another post. <a href="#fnref:slirp" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:writing">
      <p>As a reminder, all writing on this blog (typos and weird sentences
included) is mine. Writing is thinking and I don’t see the point in having
the LLM think for me. <a href="#fnref:writing" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>aldur</name><email>hello@aldur.blog</email></author><category term="articles" /><summary type="html"><![CDATA[Default-deny sandbox profiles for inference and agents, plus local Qwen3.5 and a QEMU VM.]]></summary></entry><entry><title type="html">Why my internet dropped like clockwork</title><link href="https://aldur.blog/articles/2026/02/12/fiber-drops.html" rel="alternate" type="text/html" title="Why my internet dropped like clockwork" /><published>2026-02-12T00:00:00+00:00</published><updated>2026-02-12T00:00:00+00:00</updated><id>https://aldur.blog/articles/2026/02/12/fiber-drops</id><content type="html" xml:base="https://aldur.blog/articles/2026/02/12/fiber-drops.html"><![CDATA[<p>I recently upgraded my home network to gigabit internet provided by O2, the
low-cost brand of Movistar Spain. They run fiber to the home (<a href="https://en.wikipedia.org/wiki/Fiber_to_the_x">FTTH</a>) and
lease consumers their routers to convert the optical signal into an electric
one. In my case, I got a Mitrastar HGU router (<code class="language-plaintext highlighter-rouge">GPT-2742GX4X5 v6</code>, running
firmware <code class="language-plaintext highlighter-rouge">GL_g2.5_100XNT0b23</code>).</p>

<p>ISP routers come with security and privacy concerns: where possible, I replace
them with alternatives I can customize and control. At home, I already own a
router that supports OpenWRT. It’s much better from a security/privacy
standpoint, but it doesn’t have an <a href="https://en.wikipedia.org/wiki/Network_interface_device#Optical_network_terminals">ONT</a> and so I cannot connect it directly
to fiber.</p>

<p>To fix that, I configured the ISP router in <em>bridge</em> network mode and connected
one of its LAN ports to the WAN on my router. Then, within OpenWRT, I
configured the required <a href="https://bandaancha.eu/foros/configurar-openwrt-movistar-internet-1742525">PPPoE credentials</a> and created a VLAN. I got
online, made a few speed measurements, and ensured that everything was working
great.</p>

<p>After some time I noticed that the connection dropped and that the web
interface of the router wasn’t reachable anymore. I brushed it off, thinking
that the network would eventually stabilize. But it kept happening! So I dug a
bit more. At first, I thought of a hardware issue (maybe on the fiber line).
Then, I noticed the following pattern:</p>

<p align="center">
<picture class="text-align-center">
  <source srcset="/images/fiber-drop-light.svg" media="(prefers-color-scheme: light)" />
  <source srcset="/images/fiber-drop-dark.svg" media="(prefers-color-scheme: dark)" />
  <img src="/images/fiber-drop-light.svg" alt="A plot measuring the ICMP drop rate and showing peaks every two hours." class="centered" />
</picture>
  <small>
    <em>OpenWRT’s statistics showed connection drops every two hours, like clockwork.</em>
  </small>
</p>

<p>Drops so predictable likely indicated a software issue. After ensuring that the
issue wasn’t on OpenWRT’s side, I started looking at the HGU router. Mitrastar
firmwares have a <a href="https://forocoches.com/foro/showthread.php?t=7024832">history</a> of bugs; it is also likely that bridge mode,
isn’t as battle-tested as the rest, because few users enable it.</p>

<p>ISP routers can be a mixed bag in terms of debug access. This one is weirder
than usual:</p>

<ol>
  <li>User <code class="language-plaintext highlighter-rouge">1234</code> can <code class="language-plaintext highlighter-rouge">ssh</code> into the router with a password printed on a label on
the router’s back.</li>
  <li>By default, <code class="language-plaintext highlighter-rouge">ssh</code> drops the user into a restricted shell, unless you use
<code class="language-plaintext highlighter-rouge">ssh 1234@192.168.1.1 /bin/sh</code> to get a better shell.</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>ssh 1234@192.168.1.1
1234@192.168.1.1<span class="s1">'s password:
&gt;ls
Can'</span>t find <span class="nb">command</span>: <span class="o">[</span><span class="nb">ls</span><span class="o">]</span><span class="nb">.</span> Type <span class="s1">'?'</span> <span class="k">for </span>usage
<span class="o">&gt;</span>?
<span class="o">&gt;</span>?
<span class="c"># Confused at what's going on</span>
&lt;c-d&gt;
<span class="nv">$ </span>ssh 1234@192.168.1.1 /bin/sh
1234@192.168.1.1<span class="s1">'s password:
ls
rom-t
busybox
BusyBox v1.26.2 (2025-06-19 15:12:31 CST) multi-call binary.
...
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">1234</code> user is technically <code class="language-plaintext highlighter-rouge">root</code>; the shell is based on <code class="language-plaintext highlighter-rouge">busybox</code> and it
is half-broken: there are no prompts and <code class="language-plaintext highlighter-rouge">ls</code> doesn’t show the full directory
listing. However, it’s enough to start digging.</p>

<p>After poking around, I discovered the router configuration at
<code class="language-plaintext highlighter-rouge">/tmp/var/pdm/config.xml</code>. It refers to a watchdog, which seems to be one of
the customizations that <code class="language-plaintext highlighter-rouge">TELEFONICA</code> (Movistar’s brand in Spain) applied to it:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;X_TELEFONICA_COM_Watchdog&gt;</span>
  <span class="nt">&lt;Enable</span> <span class="na">PARAMETER=</span><span class="s">"configured"</span> <span class="na">TYPE=</span><span class="s">"boolean"</span><span class="nt">&gt;</span>1<span class="nt">&lt;/Enable&gt;</span>
  <span class="nt">&lt;PPP&gt;</span>
    <span class="nt">&lt;LookupDomain</span> <span class="na">PARAMETER=</span><span class="s">"configured"</span> <span class="na">TYPE=</span><span class="s">"string"</span><span class="nt">&gt;</span>hgu.rima-tde.net<span class="nt">&lt;/LookupDomain&gt;</span>
    <span class="nt">&lt;MaxReset</span> <span class="na">PARAMETER=</span><span class="s">"configured"</span> <span class="na">TYPE=</span><span class="s">"uint32"</span><span class="nt">&gt;</span>1<span class="nt">&lt;/MaxReset&gt;</span>
    <span class="nt">&lt;AlertAfter</span> <span class="na">PARAMETER=</span><span class="s">"configured"</span> <span class="na">TYPE=</span><span class="s">"uint32"</span><span class="nt">&gt;</span>2<span class="nt">&lt;/AlertAfter&gt;</span>
    <span class="nt">&lt;WD_CheckChange</span> <span class="na">PARAMETER=</span><span class="s">"configured"</span> <span class="na">TYPE=</span><span class="s">"uint32"</span><span class="nt">&gt;</span>900<span class="nt">&lt;/WD_CheckChange&gt;</span>
  <span class="nt">&lt;/PPP&gt;</span>
  <span class="nt">&lt;Info&gt;</span>
    <span class="nt">&lt;Process</span> <span class="na">PARAMETER=</span><span class="s">"configured"</span> <span class="na">TYPE=</span><span class="s">"string"</span><span class="nt">&gt;</span>pppd;ztr69;zebra;ripd;zywifid;igmpproxy;voiceApp;tefdog<span class="nt">&lt;/Process&gt;</span>
  <span class="nt">&lt;/Info&gt;</span>
<span class="nt">&lt;/X_TELEFONICA_COM_Watchdog&gt;</span>
</code></pre></div></div>

<p>The watchdog itself appears to be <code class="language-plaintext highlighter-rouge">tefdog</code>. When running in bridge mode some of
its health checks likely fail and trigger a restart of the connection. It also
seems to leak memory, which explains why the web interface would become
unreachable after a while. In particular, the list of monitored processes
always includes <code class="language-plaintext highlighter-rouge">pppd</code>, even though it doesn’t run in bridge mode.</p>

<p>To test the hypothesis of a badly configured watchdog, I first tried to <code class="language-plaintext highlighter-rouge">kill</code>
it and wait. That did not work, because PID <code class="language-plaintext highlighter-rouge">1</code> would restart it after a few
minutes. Because the file-system on the router is read-only, I couldn’t edit
the executable directly. But I could use a bind mount <em>over</em> it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">echo</span> <span class="s1">'#!/bin/sh'</span> <span class="o">&gt;</span> /tmp/tefdog_fake
<span class="nb">echo</span> <span class="s1">'exit 0'</span> <span class="o">&gt;&gt;</span> /tmp/tefdog_fake
<span class="nb">chmod</span> +x /tmp/tefdog_fake
mount <span class="nt">--bind</span> /tmp/tefdog_fake /usr/bin/tefdog
</code></pre></div></div>

<p>At this point, I killed it again, ensured it would not restart, and waited for
a few hours to see if the connection would drop. When it didn’t, I knew I had
found the issue!</p>

<p>The bind mount hack, however, wouldn’t survive a reboot. After a power loss,
the router would restart and the watchdog would resume killing the connection
every two hours. I kept poking around for ways to persist the change or to
overwrite the router configuration, until I remembered that a router’s web
interface typically allows backing up and re-uploading the configuration. In
some cases, modified configurations would also unlock settings not available
through the web interface.</p>

<p>I went ahead, exported the configuration through the web UI, and quickly
discovered that it is encrypted:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>file romfile.cfg
romfile.cfg: openssl enc<span class="s1">'d data with salted password
</span></code></pre></div></div>

<p>I mentioned that this router is a bit <em>weird</em>, didn’t I? <code class="language-plaintext highlighter-rouge">ssh</code> provides <code class="language-plaintext highlighter-rouge">root</code>
access, but only if you know which process to launch. You can read the
configuration through SSH, but then the export is encrypted. After a bit more
digging, I figured out where the decryption happens in the firmware:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/usr/bin/ccc_preload.sh: openssl aes-256-cbc <span class="nt">-md</span> MD5 <span class="nt">-k</span> <span class="nv">$2</span> <span class="nt">-d</span> <span class="se">\</span>
  <span class="nt">-in</span> /usr/etc/smt.cfg <span class="nt">-out</span> /var/pdm/config.xml
<span class="c"># NOTE: I added the line break for readability.</span>
</code></pre></div></div>

<p>Later on, I discovered where the encryption password is stored and that it’s a
<code class="language-plaintext highlighter-rouge">base64</code> encoding of 24 (random?) bytes:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span><span class="nb">grep </span>ENCRYPT /etc/MLD_Config.sh
<span class="nv">MLD_APPS_ENABLE_ENCRYPT_CONF_FILE_KEY</span><span class="o">=</span>zRUqIM1VCqPBrlYbf6CXiOZoZwiIAMHJ
<span class="c"># NOTE: this isn't my encryption key, but a similar-looking one.</span>
</code></pre></div></div>

<p>I don’t have another router to check whether this password is hardcoded into
the firmware or generated depending on the hardware (e.g., the MAC address).
Out of an abundance of caution, I haven’t shared the exact key I found on my
router, but feel free to <a href="mailto:hello@aldur.blog">reach out</a> if you are
interested in it.</p>

<p>After testing the decryption key, I modified the configuration by disabling the
watchdog:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gh">diff -u config.xml.back config.xml
</span><span class="gd">--- config.xml.back 2026-02-01 18:28:36
</span><span class="gi">+++ config.xml 2026-02-01 18:41:10
</span><span class="p">@@ -292,7 +292,7 @@</span>
     &lt;/Firewall&gt;
   &lt;/X_TELEFONICA_Firewall&gt;
   &lt;X_TELEFONICA_COM_Watchdog&gt;
<span class="gd">-    &lt;Enable PARAMETER="configured" TYPE="boolean"&gt;1&lt;/Enable&gt;
</span><span class="gi">+    &lt;Enable PARAMETER="configured" TYPE="boolean"&gt;0&lt;/Enable&gt;
</span>     &lt;PPP&gt;
       &lt;LookupDomain PARAMETER="configured" TYPE="string" LENGTH="256"&gt;hgu.rima-tde.net&lt;/LookupDomain&gt;
       &lt;MaxReset PARAMETER="configured" TYPE="uint32" MAX="9" MIN="0"&gt;1&lt;/MaxReset&gt;
</code></pre></div></div>

<p>While I was at it, I also made a few more changes to disable analytics, prevent
the router from phoning home, turn off WPS and block <a href="https://en.wikipedia.org/wiki/TR-069"><code class="language-plaintext highlighter-rouge">TR069</code></a> at the
firewall level, preventing remote firmware upgrades or configuration changes.</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gh">diff -u config.xml.back config.xml
</span><span class="gd">--- config.xml.back 2026-02-01 18:28:36
</span><span class="gi">+++ config.xml 2026-02-01 18:41:10
</span><span class="p">@@ -150,7 +150,7 @@</span>
             &lt;Action PARAMETER="configured" TYPE="string" LENGTH="16"&gt;Permit&lt;/Action&gt;
             &lt;Protocol PARAMETER="configured" TYPE="string" LENGTH="16"&gt;TCP&lt;/Protocol&gt;
             &lt;X_5067F0_RuleName PARAMETER="configured" TYPE="string" LENGTH="64"&gt;Default_TR069&lt;/X_5067F0_RuleName&gt;
<span class="gd">-            &lt;Enabled PARAMETER="configured" TYPE="boolean"&gt;1&lt;/Enabled&gt;
</span><span class="gi">+            &lt;Enabled PARAMETER="configured" TYPE="boolean"&gt;0&lt;/Enabled&gt;
</span>             &lt;Origin&gt;
<span class="p">@@ -602,7 +602,7 @@</span>
             &lt;ConfigMethodsEnabled PARAMETER="configured" TYPE="string" LENGTH="128"&gt;PushButton&lt;/ConfigMethodsEnabled&gt;
             &lt;X_5067F0_WPS_Last_State PARAMETER="configured" TYPE="boolean"&gt;1&lt;/X_5067F0_WPS_Last_State&gt;
             &lt;SetupLock PARAMETER="configured" TYPE="boolean"&gt;1&lt;/SetupLock&gt;
<span class="gd">-            &lt;Enable PARAMETER="configured" TYPE="boolean"&gt;1&lt;/Enable&gt;
</span><span class="gi">+            &lt;Enable PARAMETER="configured" TYPE="boolean"&gt;0&lt;/Enable&gt;
</span>             &lt;DevicePassword PARAMETER="configured" TYPE="uint32" MAX="4294967295" MIN="0"&gt;0&lt;/DevicePassword&gt;
           &lt;/WPS&gt;
           &lt;PreSharedKey&gt;
<span class="p">@@ -921,7 +921,7 @@</span>
             &lt;ConfigMethodsEnabled PARAMETER="configured" TYPE="string" LENGTH="128"&gt;PushButton&lt;/ConfigMethodsEnabled&gt;
             &lt;X_5067F0_WPS_Last_State PARAMETER="configured" TYPE="boolean"&gt;1&lt;/X_5067F0_WPS_Last_State&gt;
             &lt;SetupLock PARAMETER="configured" TYPE="boolean"&gt;1&lt;/SetupLock&gt;
<span class="gd">-            &lt;Enable PARAMETER="configured" TYPE="boolean"&gt;1&lt;/Enable&gt;
</span><span class="gi">+            &lt;Enable PARAMETER="configured" TYPE="boolean"&gt;0&lt;/Enable&gt;
</span>             &lt;DevicePassword PARAMETER="configured" TYPE="uint32" MAX="4294967295" MIN="0"&gt;0&lt;/DevicePassword&gt;
           &lt;/WPS&gt;
           &lt;PreSharedKey&gt;
<span class="p">@@ -1502,7 +1502,7 @@</span>
     &lt;X_5067F0_Installed PARAMETER="configured" TYPE="boolean"&gt;1&lt;/X_5067F0_Installed&gt;
     &lt;X_5067F0_CheckCertificateCN PARAMETER="configured" TYPE="boolean"&gt;0&lt;/X_5067F0_CheckCertificateCN&gt;
<span class="gd">-    &lt;PeriodicInformEnable PARAMETER="configured" EXTATTR="0x0800" TYPE="boolean"&gt;1&lt;/PeriodicInformEnable&gt;
</span><span class="gi">+    &lt;PeriodicInformEnable PARAMETER="configured" EXTATTR="0x0800" TYPE="boolean"&gt;0&lt;/PeriodicInformEnable&gt;
</span>     &lt;PeriodicInformInterval PARAMETER="configured" EXTATTR="0x0800" TYPE="uint32" MAX="4294967295" MIN="30"&gt;604800&lt;/PeriodicInformInterval&gt;
     &lt;X_5067F0_CAContent&gt;
       &lt;i1&gt;
</code></pre></div></div>

<p>After re-encrypting the modified configuration, I uploaded it, rebooted the
router, and it has since been running smoothly:</p>

<p align="center">
<picture class="text-align-center">
  <source srcset="/images/fiber-drop-fixed-light.svg" media="(prefers-color-scheme: light)" />
  <source srcset="/images/fiber-drop-fixed-dark.svg" media="(prefers-color-scheme: dark)" />
  <img src="/images/fiber-drop-fixed-light.svg" alt="A plot measuring the ICMP drop rate with no recent peaks." class="centered" />
</picture>
  <small>
    <em>No more drops after the fix!</em>
  </small>
</p>

<p>To wrap things up, I also dumped the bootloader and the firmware (just in case,
as neither seems to be available online):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> /dev/mtd0 <span class="o">&gt;</span> /tmp/bootloader.bin
<span class="nb">cat</span> /dev/mtd4 <span class="o">&gt;</span> /tmp/tclinux.bin
<span class="nb">md5sum</span> /tmp/bootloader.bin
16fa4cafc4e2c412e053e8757af3d60b  /tmp/bootloader.bin
<span class="nb">md5sum</span> /tmp/tclinux.bin
8bb6e6d4e1fb231e4fbb0a98a494ccaf  /tmp/tclinux.bin
nc 192.168.1.3 1234 &lt; /tmp/bootloader.bin
nc 192.168.1.3 1234 &lt; /tmp/tclinux.bin
</code></pre></div></div>

<p>One mystery remains. Why did the drops happen <em>exactly</em> every two hours? I
looked at the router configuration and then had an AI agent look at the
firwmware through Ghidra’s MCP. The most convincing explanation (so far) is
that the watchdog performs a check every 15 minutes. After 4 failed checks, it
tries to restart the PPP interfaces (a noop in bridge mode). After 4 more
failed checks, it sends a telemetry event to the ISP’s IoT bridge on Azure.
Which, in response, requests a reset of the network stack and temporarily drops
the connection. Here are the relevant parameters from the decrypted
configuration:</p>

<table>
  <thead>
    <tr>
      <th>Parameter name</th>
      <th>Value</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>WD_CheckChange</td>
      <td>900</td>
      <td>seconds, frequency of checks</td>
    </tr>
    <tr>
      <td>AlertAfter</td>
      <td>2</td>
      <td>how many failed checks before resetting PPP (off-by-one, the decompiled shows it resets when ≥ 3, 0-based)</td>
    </tr>
    <tr>
      <td>MaxReset</td>
      <td>1</td>
      <td>how many resets to try before notifying upstream</td>
    </tr>
  </tbody>
</table>

<p>Thank you for reading so far! <a href="mailto:hello@aldur.blog">Reach out</a> if
you’d like to chat. 👋</p>]]></content><author><name>aldur</name><email>hello@aldur.blog</email></author><category term="articles" /><summary type="html"><![CDATA[A misconfigured watchdog in my ISP router brought me offline every two hours.]]></summary></entry><entry><title type="html">Impermanent NixOS VMs in ChromeOS</title><link href="https://aldur.blog/articles/2026/02/01/impermanent-nixos-vms-in-baguette.html" rel="alternate" type="text/html" title="Impermanent NixOS VMs in ChromeOS" /><published>2026-02-01T00:00:00+00:00</published><updated>2026-02-01T00:00:00+00:00</updated><id>https://aldur.blog/articles/2026/02/01/impermanent-nixos-vms-in-baguette</id><content type="html" xml:base="https://aldur.blog/articles/2026/02/01/impermanent-nixos-vms-in-baguette.html"><![CDATA[<p>We have <a href="/tags/chromeos.html">talked about</a> using NixOS to run
VMs under ChromeOS. The VM image doesn’t include any secrets and relies on
hardware keys for authentication and signatures (e.g., to push and sign commits
on GitHub). This way, even if the VM was compromised, the hardware-backed
credentials would remain safe.</p>

<p>In practice, though, the VM slowly accumulates other (possibly) confidential
information: source code, <a href="/micros/2025/11/30/short-lived-openrouter-api-keys/">authentication tokens</a>, LLM sessions, and even shell
history. To clean things up, a user would need to periodically destroy and
recreate the VM. But users (myself included) get sloppy, for instance when
overworked. Worse, being in a VM might give them a wrong sense of confidence
that there is nothing to leak!</p>

<h3 id="what-is-impermanence">What is impermanence</h3>

<p>Good safety systems should not rely on user behavior to maintain their safety
properties. Neither should VMs meant to be ephemeral working environments.
Luckily NixOS can help us remediate that.</p>

<p>Due to its reproducibility, NixOS can boot by exclusively relying on a <code class="language-plaintext highlighter-rouge">/init</code>
file and the <code class="language-plaintext highlighter-rouge">/nix</code> store<sup id="fnref:baguette"><a href="#fn:baguette" class="footnote" rel="footnote" role="doc-noteref">1</a></sup>. Everything else can be re-created at
runtime (typically, by symlinking files and directories to the appropriate path
in the store). We can <em>leverage</em> that to achieve <em>impermanence</em> and ensure a
clean system after each reboot.</p>

<p>The Nix community has contributed a few ways to achieve impermanence.
Typically, they require configuring the system to erase itself at boot and
maintaining a whitelist that will survive reboots (for instance SSH host keys,
which would otherwise result in a different remote fingerprint at each reboot).</p>

<h3 id="erasing-home">Erasing <code class="language-plaintext highlighter-rouge">/home</code></h3>

<p>For the NixOS VMs I use under ChromeOS, I configure impermanence as follows:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">/home</code> mounts through <code class="language-plaintext highlighter-rouge">tmpfs</code>. This way, anything I don’t explicitly
whitelist will be gone at power down. I don’t have to worry about automating
its deletion, but I need to cap <code class="language-plaintext highlighter-rouge">/home</code> size to (a portion of) the available
memory.</li>
  <li>The <a href="https://github.com/nix-community/preservation"><code class="language-plaintext highlighter-rouge">preservation</code></a> module takes care of safe-keeping a few required
files and directories under <code class="language-plaintext highlighter-rouge">/home</code> (for instance known SSH hosts or source
code I explicitly want to keep across reboots).</li>
  <li>The rest of the filesystem is <em>not</em> impermament, for simplicity and to save
RAM. My VM user cannot become <code class="language-plaintext highlighter-rouge">root</code>, so (assuming correct permissions)
should not be able to modify system files anyways.</li>
</ul>

<h3 id="settings-things-up">Settings things up</h3>

<p>A minimal Nix module to achieve impermanence looks as follows:</p>

<div class="language-nix highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nv">inputs</span><span class="p">,</span> <span class="o">...</span><span class="p">}:</span>
<span class="kd">let</span> <span class="nv">username</span> <span class="o">=</span> <span class="s2">"aldur"</span><span class="p">;</span> <span class="kn">in</span> <span class="p">{</span>
  <span class="nv">imports</span> <span class="o">=</span> <span class="p">[</span>
    <span class="c"># Impermanence: import the preservation module</span>
    <span class="nv">inputs</span><span class="o">.</span><span class="nv">preservation</span><span class="o">.</span><span class="nv">nixosModules</span><span class="o">.</span><span class="nv">preservation</span>
  <span class="p">];</span>

  <span class="c"># Impermanence: tmpfs home with preservation</span>
  <span class="nv">fileSystems</span><span class="o">.</span><span class="s2">"/home"</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nv">device</span> <span class="o">=</span> <span class="s2">"none"</span><span class="p">;</span>
    <span class="nv">fsType</span> <span class="o">=</span> <span class="s2">"tmpfs"</span><span class="p">;</span>
    <span class="nv">options</span> <span class="o">=</span> <span class="p">[</span>
      <span class="s2">"defaults"</span>
      <span class="s2">"size=4G"</span>
      <span class="s2">"mode=755"</span>
    <span class="p">];</span>
  <span class="p">};</span>

  <span class="c"># Impermanence: whitelist to preserve</span>
  <span class="c"># See `preservation` docs for more configuration options.</span>
  <span class="nv">preservation</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nv">enable</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>

    <span class="nv">preserveAt</span><span class="o">.</span><span class="s2">"/persist"</span><span class="o">.</span><span class="nv">users</span><span class="o">.</span><span class="p">${</span><span class="nv">username</span><span class="p">}</span><span class="o">.</span><span class="nv">directories</span> <span class="o">=</span> <span class="p">[</span>
      <span class="s2">"Documents/"</span>

      <span class="p">{</span>
        <span class="nv">directory</span> <span class="o">=</span> <span class="s2">".ssh"</span><span class="p">;</span>
        <span class="nv">mode</span> <span class="o">=</span> <span class="s2">"0700"</span><span class="p">;</span>
      <span class="p">}</span>
    <span class="p">];</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="playing-nicely-with-home-manager">Playing nicely with <code class="language-plaintext highlighter-rouge">home-manager</code></h3>

<p>While setting things up and testing the result, the VM would occasionally fail
to correctly load <code class="language-plaintext highlighter-rouge">home-manager</code>’s configuration (which, importantly,
configures the <code class="language-plaintext highlighter-rouge">fish</code> shell).</p>

<p>After quite some debugging, I figured out that <code class="language-plaintext highlighter-rouge">home-manager</code> was <em>racing</em>
against <code class="language-plaintext highlighter-rouge">garcon</code>, a service that automatically starts and spawns a shell when a
Baguette VM starts from ChromeOS through <code class="language-plaintext highlighter-rouge">vmc start</code>. When <code class="language-plaintext highlighter-rouge">garcon</code> won the
race and executed before <code class="language-plaintext highlighter-rouge">home-manager</code>, it would launch <code class="language-plaintext highlighter-rouge">fish</code> without any
customization.</p>

<p>The fix is to delay <code class="language-plaintext highlighter-rouge">garcon</code> (a user service) until after <code class="language-plaintext highlighter-rouge">home-manager</code> has
completed:</p>

<div class="language-nix highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">_</span><span class="p">:</span>
<span class="kd">let</span> <span class="nv">username</span> <span class="o">=</span> <span class="s2">"aldur"</span><span class="p">;</span> <span class="kn">in</span> <span class="p">{</span>
  <span class="nv">services</span><span class="o">.</span><span class="s2">"user@"</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nv">overrideStrategy</span> <span class="o">=</span> <span class="s2">"asDropin"</span><span class="p">;</span>
    <span class="nv">after</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"home-manager-</span><span class="si">${</span><span class="nv">username</span><span class="si">}</span><span class="s2">.service"</span> <span class="p">];</span>
    <span class="nv">wants</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"home-manager-</span><span class="si">${</span><span class="nv">username</span><span class="si">}</span><span class="s2">.service"</span> <span class="p">];</span>

    <span class="c"># In case something goes wrong</span>
    <span class="nv">serviceConfig</span><span class="o">.</span><span class="nv">TimeoutStartSec</span> <span class="o">=</span> <span class="s2">"90"</span><span class="p">;</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="wrapping-up">Wrapping up</h3>

<p>I have run with impermanence for a few weeks now and, so far, I haven’t had any
issue. With 16GB of RAM, I typically size <code class="language-plaintext highlighter-rouge">/home</code> to 4GB. I suspect that this
could not be enough to complete artifact-heavy or memory-intensive builds, e.g.
Rust workspaces or numeric Python projects. If that happens, I’ll configure the
build tools to store assets in <code class="language-plaintext highlighter-rouge">/tmp</code>. Similarly, if a project’s cache is wiped
on reboot, offline rebuilds would fail (e.g., while on a flight). I avoid that
by relying on <code class="language-plaintext highlighter-rouge">nix develop</code> for local development shells, which caches
requirements in the <code class="language-plaintext highlighter-rouge">nix</code> store.</p>

<p>I also see a few security limitations of the approach. Sophisticated malware
could persist through the system configuration or in the <code class="language-plaintext highlighter-rouge">nix</code> store (to which
my user has write access). Although it isn’t a silver bullet, impermanence for
<code class="language-plaintext highlighter-rouge">/home</code> still adds to defense in depth. It prevents <em>some classes</em> of attacks
(e.g., supply chain compromises) from harvesting credentials that accumulated
over time. I’ll continue hardening the VM image against the remaining attacks:
I would love to enable mandatory access control policies, but doing that in
NixOS seems like a deep rabbit hole to explore.</p>

<p>👋 Thank you for reading so far! <a href="mailto:hello@aldur.blog">Shoot me an email</a> if you’d like to comment, discuss, or just say hi. Until
next time!</p>

<h4 id="footnotes">Footnotes</h4>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:baguette">
      <p>Pretty much what happens when the <a href="/articles/2025/10/29/nixos-baguette-images-in-chromeos.html">Baguette NixOS image</a> starts. <a href="#fnref:baguette" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>aldur</name><email>hello@aldur.blog</email></author><category term="articles" /><category term="ChromeOS" /><summary type="html"><![CDATA[Automatically erasing /home in NixOS VMs.]]></summary></entry><entry><title type="html">Effective salespeople</title><link href="https://aldur.blog/articles/2025/11/09/effective-salespeople.html" rel="alternate" type="text/html" title="Effective salespeople" /><published>2025-11-09T00:00:00+00:00</published><updated>2025-11-09T00:00:00+00:00</updated><id>https://aldur.blog/articles/2025/11/09/effective-salespeople</id><content type="html" xml:base="https://aldur.blog/articles/2025/11/09/effective-salespeople.html"><![CDATA[<p>Nick Huber, in “<em>The Sweaty Startup</em>”, writes that <em>sales is the foundation of
every business</em>.</p>

<p>Having a tech background <em>and</em> being mostly surrounded by other builders, sales
is both one of my weakest skills and one that I want to improve. I’ve witnessed
great ideas fail because nobody was selling them, disproving the “<em>build it and
they will come</em>” myth, and I have seen what a truly good seller can bring to
the table.</p>

<p>To me, good sales boil down to mutual <em>wins</em>. A good salesman will figure out
what the other party needs, a way to provide it at a fair price, and manage
expectations, while avoiding deception and the <a href="/articles/2025/09/08/nuggets-of-psychology.html">psychological biases</a> that trap both buyers and
sellers.</p>

<p>A corresponding section of the book describes a few down-to-earth habits to
become more effective at sales. I found them particularly valuable because they
align with good product management or successful <em>negotiations</em>. I am writing
them here with a few reflections so that, eventually, they can become part of
my mental models.</p>

<!-- prettier-ignore-start -->
<ul id="markdown-toc">
  <li><a href="#1-realize-not-everyone-wants-to-buy-what-youre-selling" id="markdown-toc-1-realize-not-everyone-wants-to-buy-what-youre-selling">1. Realize not everyone wants to buy what you’re selling</a></li>
  <li><a href="#2-get-comfortable-being-uncomfortable" id="markdown-toc-2-get-comfortable-being-uncomfortable">2. Get comfortable being uncomfortable</a></li>
  <li><a href="#3-prove-that-you-are-an-expert" id="markdown-toc-3-prove-that-you-are-an-expert">3. Prove that you are an expert</a></li>
  <li><a href="#4-manage-expectations" id="markdown-toc-4-manage-expectations">4. Manage expectations</a></li>
  <li><a href="#5-add-value-first" id="markdown-toc-5-add-value-first">5. Add value first</a></li>
  <li><a href="#6-make-scarcity-work-for-you" id="markdown-toc-6-make-scarcity-work-for-you">6. Make scarcity work for you</a></li>
  <li><a href="#7-let-the-other-party-sell-themselves" id="markdown-toc-7-let-the-other-party-sell-themselves">7. Let the other party sell themselves</a></li>
  <li><a href="#closing-thoughts" id="markdown-toc-closing-thoughts">Closing thoughts</a></li>
</ul>
<!-- prettier-ignore-end -->

<h5 id="1-realize-not-everyone-wants-to-buy-what-youre-selling">1. Realize not everyone wants to buy what you’re selling</h5>

<p>Some will never be <em>your</em> users.</p>

<p>This is both true and hard to accept at first, especially when we compare
ourselves to the hyperscaler of the day, or when technology is so pervasive
that almost everyone browses social media through a smartphone.</p>

<p>For those who would be <em>your</em> users, though, remember that <a href="https://en.wikipedia.org/wiki/Crossing_the_Chasm">innovation and
adoption take time</a>.</p>

<h5 id="2-get-comfortable-being-uncomfortable">2. Get comfortable being uncomfortable</h5>

<p>Improving at something requires discovering one’s limits and overcoming them,
while facing both the unknown and what’s visible around us. Sales require that
same attitude, times a thousand.</p>

<p>Especially when cold-calling or finding an initial market fit, sales are a
numbers game. You will have dozens of conversations, each equally uncomfortable.
Make them worth it: each sale is an opportunity to figure out someone’s needs
and an opportunity to adapt your offer.</p>

<h5 id="3-prove-that-you-are-an-expert">3. Prove that you are an expert</h5>

<p>Experts know about both the upsides and the downsides of an opportunity. They
have been in the trenches, and they know that things can and will go wrong, but
they also recognize the value that an opportunity can provide.</p>

<p>Some salesmen will only try to sell you on the upside, without mentioning the
downsides. This sometimes results in potential buyers getting defensive,
feeling that something is “too good to be true” and that they aren’t seeing the
full picture yet. They’ll likely stall or simply back out. I know I do, in
those cases.</p>

<p>Instead of hiding risks and difficulties, acknowledge them in your pitch and
show you know how to manage them. Exposing the negative aspects makes them less
frightening. It <em>defuses</em> them while reinforcing what’s positive.</p>

<h5 id="4-manage-expectations">4. Manage expectations</h5>

<p><em>Success</em> depends on how you define it. Pick success criteria that you are
<em>very likely</em> to meet, while ensuring to satisfy the customer needs.</p>

<p>The classic examples draw from forecasting how long something will take.
Instead of over-promising on a tight timeline that requires <em>everything to go
smoothly</em>, be realistic and think about risks, uncertainties, and unknown
unknowns. Discuss them upfront with your potential buyers. Together, you’ll
pick good success criteria.</p>

<p>Those conversations will be hard but necessary to manage <em>all</em> stakeholders’
expectations. Have them early on: you will also find out that <em>some</em> potential
buyers are not the right fit for <em>you</em>.</p>

<h5 id="5-add-value-first">5. Add value first</h5>

<p>Business is a positive-sum game. Someone’s success will enable them to spend
more, and their spending is going to be someone else’s income.</p>

<p>Adding value means <em>first</em> providing something that your users need, so they
can test it and eventually <em>trust</em> you. At that point, the <em>sale</em> becomes a lot
easier.</p>

<p>Successful SaaS companies (e.g., Tailscale) have found a good balance around
this, with a free tier generous enough to suit most needs and a paid tier you
(or your employer) can purchase after finding trust and value in their service.</p>

<h5 id="6-make-scarcity-work-for-you">6. Make scarcity work for you</h5>

<p>Not every customer will provide you the same value: 20% of your users might
generate 80% of your sales.</p>

<p>Gently push back potential buyers after you have made it clear that you are an
expert, you know how to manage expectations, and how to add value. You don’t
need to win every sale and if you do, your prices are probably too low. It will
be hard because it looks counter-intuitive.</p>

<p>Pushing back allows <em>you</em> to select customers that are right for you, while
turning away potentially bad customers.</p>

<h5 id="7-let-the-other-party-sell-themselves">7. Let the other party sell themselves</h5>

<p>After having <em>defused</em> the negatives and having gently pushed them away, ask
your potential buyer “<em>What makes you think I’d be a good fit?</em>”.</p>

<p>In negotiations, open ended questions and well-timed silences will allow
information to flow while you discover what the other party needs. In sales,
they’ll do something as powerful: they’ll change perspective and framing. Then,
the other party will start selling themselves to you, explaining how they’d be
a great fit or a good customer. If that happens, they’ll have overcome any
objection they might have had and convinced themselves that they want to work
with you.</p>

<h5 id="closing-thoughts">Closing thoughts</h5>

<p>I find that sales, negotiations, and product management share these traits.
They boil down to a few core principles: being upfront and providing true
value. Forecasting what to expect and know what you don’t know. Managing
stakeholders’ expectations by defusing the negatives and, as a result,
reinforcing the positives. Not everyone will be your user, nor will every user
be right for you. Change perspective and re-frame things to let the other party
sell <em>to you</em>. You will learn what they really need and they will work with you
to overcome objections and biases.</p>

<p>How do you approach sales? Have you found yourself nodding along or violently
shaking your head? I’d love to hear about it – shoot me <a href="mailto:hello@aldur.blog?subject=My thoughts on effective sales">an email</a>!</p>

<p>Thank you for reading and ‘til next time! 👋</p>]]></content><author><name>aldur</name><email>hello@aldur.blog</email></author><category term="articles" /><summary type="html"><![CDATA[Good sales create mutual wins by managing expectations and honestly defusing the negatives.]]></summary></entry><entry><title type="html">NixOS Baguette images in ChromeOS</title><link href="https://aldur.blog/articles/2025/10/29/nixos-baguette-images-in-chromeos.html" rel="alternate" type="text/html" title="NixOS Baguette images in ChromeOS" /><published>2025-10-29T00:00:00+00:00</published><updated>2025-10-29T00:00:00+00:00</updated><id>https://aldur.blog/articles/2025/10/29/nixos-baguette-images-in-chromeos</id><content type="html" xml:base="https://aldur.blog/articles/2025/10/29/nixos-baguette-images-in-chromeos.html"><![CDATA[<div class="hint">

  <p>This post extends <a href="/articles/2025/06/19/nixos-in-crostini.html">the one about NixOS containers in ChromeOS</a> to build Baguette images. Give Baguette a
  try and let me know how it works for you!</p>

</div>

<p>The ChromiumOS team is experimenting with <em>Baguette</em> 🥖, a way to run
<em>containerless</em> VM images in ChromeOS. By not running through LXC, Baguette
gives users more freedom (e.g., to run Kubernetes without KVM or access the
GPU).</p>

<p>The <a href="https://github.com/aldur/nixos-crostini" title="`nixos-crostini`"><svg class="svg-icon grey" viewBox="0 0 512 512"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"></path></svg>  <code class="language-plaintext highlighter-rouge">nixos-crostini</code></a> repository already provided the magic glue to build
NixOS containers that fully integrate with Crostini. When <a href="https://github.com/aldur/nixos-crostini/issues/1" title="someone asked"><svg class="svg-icon grey" viewBox="0 0 512 512"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>
  someone asked</a> if
it would be possible to support Baguette as well, I started taking a look at
what that would take. This post describes the results:</p>

<!-- prettier-ignore-start -->

<ul id="markdown-toc">
  <li><a href="#background-chromeos-vms" id="markdown-toc-background-chromeos-vms">Background: ChromeOS VMs</a></li>
  <li><a href="#baguette-nixos-images" id="markdown-toc-baguette-nixos-images">Baguette NixOS images</a>    <ul>
      <li><a href="#how-to-make-it-yours" id="markdown-toc-how-to-make-it-yours">How-to: Make it yours</a></li>
      <li><a href="#how-to-additional-shell-sessions" id="markdown-toc-how-to-additional-shell-sessions">How-to: Additional shell sessions</a></li>
      <li><a href="#how-to-usb-forwarding" id="markdown-toc-how-to-usb-forwarding">How-to: USB forwarding</a></li>
      <li><a href="#how-to-launch-nixos-from-terminal" id="markdown-toc-how-to-launch-nixos-from-terminal">How-to: Launch NixOS from “Terminal”</a></li>
      <li><a href="#how-to-root-login" id="markdown-toc-how-to-root-login">How-to: Root login</a></li>
    </ul>
  </li>
  <li><a href="#conclusion" id="markdown-toc-conclusion">Conclusion</a></li>
</ul>
<!-- prettier-ignore-end -->

<div class="todo">

  <p><em>tl;dr</em>: You can build both LXC containers and Baguette images to run your
  NixOS configuration in Crostini. They provide the same features and UX (e.g.,
  clipboard sharing, Wayland/port forwarding, notifications, file browsing from
  ChromeOS).</p>

</div>

<h2 id="background-chromeos-vms">Background: ChromeOS VMs</h2>

<p>Under the hood, ChromeOS runs VMs through <a href="https://crosvm.dev/book/devices/virtual_u2f.html"><code class="language-plaintext highlighter-rouge">crosvm</code></a>, a hardened virtual
machine monitor. We already met it when <a href="/micros/2025/07/30/fido2-almost-works-in-linux-on-chromeos/">investigating FIDO2 support in Linux
ChromeOS guests</a>.</p>

<p>Crostini uses <code class="language-plaintext highlighter-rouge">crosvm</code> to run a stripped-down VM called <a href="https://chromium.googlesource.com/chromiumos/overlays/board-overlays/+/HEAD/project-termina/"><code class="language-plaintext highlighter-rouge">termina</code></a> that
boots quickly to run the user’s containers. It also does a few more things:</p>

<ol>
  <li>It mounts <a href="https://chromium.googlesource.com/chromiumos/containers/cros-container-guest-tools/+/refs/heads/main"><code class="language-plaintext highlighter-rouge">crosvm-tools</code></a>, made available by the host through <code class="language-plaintext highlighter-rouge">crosvm</code>,
into a guest directory (and later into containers as well).</li>
  <li>It runs <a href="https://chromium.googlesource.com/chromiumos/platform2/+/HEAD/vm_tools/vsh"><code class="language-plaintext highlighter-rouge">vshd</code></a>, allowing the host to get a shell on the guest.</li>
  <li>It handles the lifecycle of the VM and of its processes through
<a href="https://chromium.googlesource.com/chromiumos/platform2/+/HEAD/vm_tools/docs/init.md"><code class="language-plaintext highlighter-rouge">maitred</code></a>.</li>
</ol>

<p>The <a href="https://source.chromium.org/chromiumos/chromiumos/codesearch/+/main:src/platform2/vm_tools/baguette_image/src/setup_in_guest.sh">default Baguette image</a> is Debian-based and replicates all this. In
addition, it configures the VM to run <code class="language-plaintext highlighter-rouge">garcon</code> and <code class="language-plaintext highlighter-rouge">sommelier</code> directly (while
in Crostini they <a href="/articles/2025/06/19/nixos-in-crostini.html#nixos-containers">run within the container</a>). Together, they provide URI
handling, file browsing, and X/Wayland forwarding: all the things that make
Crostini container very pleasant to use.</p>

<h2 id="baguette-nixos-images">Baguette NixOS images</h2>

<p>Our NixOS Baguette image will need to replicate the Debian image setup. This
could turn out to be tricky, because NixOS cannot run <a href="https://nix.dev/guides/faq.html#how-to-run-non-nix-executables">non-Nix executables</a>
due to the lack of <a href="https://refspecs.linuxfoundation.org/FHS_3.0/fhs/index.html">FHS</a> and of a global library path. Luckily, we won’t
need to worry about that: <code class="language-plaintext highlighter-rouge">crosvm-tools</code> include their own libraries and
dynamic linker, so they run without issues in NixOS.</p>

<p>When we prepared NixOS LXC images for Crostini, we already figured out how to
run <code class="language-plaintext highlighter-rouge">garcon</code> and <code class="language-plaintext highlighter-rouge">sommelier</code> at user log in. Mounting <code class="language-plaintext highlighter-rouge">crosvm-tools</code> and
starting <code class="language-plaintext highlighter-rouge">vshd</code> and <code class="language-plaintext highlighter-rouge">maitred</code> was straightforward and simply required adding
their <code class="language-plaintext highlighter-rouge">systemd</code> unit definitions.</p>

<p>A Baguette image is a compressed BTRFS image <a href="https://source.chromium.org/chromiumos/chromiumos/codesearch/+/main:src/platform2/vm_tools/baguette_image/src/generate_disk_image.py">built from a RootFS tarball</a>.
To build the tarball from the NixOS, I took a page from the <a href="https://github.com/aldur/nixpkgs/blob/7271a39b1cd7d9b6799399dc2fbf1d5a6f16edea/nixos/modules/virtualisation/lxc-container.nix#L67" title="code class=&quot;language-plaintext highlighter-rouge&quot;lxc-container/code
NixOS module"><svg class="svg-icon grey" viewBox="0 0 512 512"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>
  <code class="language-plaintext highlighter-rouge">lxc-container</code>
NixOS module</a>. Then, to package it:</p>

<ul>
  <li>I initially tried the Python script used by Google, but it depends on
<code class="language-plaintext highlighter-rouge">libguestfs-appliance</code> and is not available for <code class="language-plaintext highlighter-rouge">aarch64-linux</code> in
<code class="language-plaintext highlighter-rouge">nixpkgs</code><sup id="fnref:arm"><a href="#fn:arm" class="footnote" rel="footnote" role="doc-noteref">1</a></sup>.</li>
  <li>I later switched to a QEMU-based approach that works with Nix and supports
ARM<sup id="fnref:kvm"><a href="#fn:kvm" class="footnote" rel="footnote" role="doc-noteref">2</a></sup>.</li>
</ul>

<p>After transferring the compressed image to the Chromebook “Downloads” directory
we can run it from <code class="language-plaintext highlighter-rouge">crosh</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>vmc create <span class="nt">--vm-type</span> BAGUETTE <span class="se">\</span>
  <span class="nt">--size</span> 15G <span class="se">\</span>
  <span class="nt">--source</span> /home/chronos/user/MyFiles/Downloads/baguette_rootfs.img.zst <span class="se">\</span>
  baguette

vmc start <span class="nt">--vm-type</span> BAGUETTE baguette
</code></pre></div></div>

<div class="hint">

  <p>You might have heard about the <a href="https://chromium.googlesource.com/chromium/src/+/0d439926c092142a02d96d38cfbb6a68044f2382"><code class="language-plaintext highlighter-rouge">#crostini-containerless</code> flag</a>: you can
  actually run <code class="language-plaintext highlighter-rouge">vmc start --vm-type BAGUETTE</code> even <em>without setting</em> it. It
  only affects what happens when you “Configure Linux” in ChromeOS or use the
  “Terminal” app to launch a Linux guest.</p>

  <p>This way, you can try Baguette without losing your Crostini containers.</p>

</div>

<p>At boot, <code class="language-plaintext highlighter-rouge">maitred</code> relies on <code class="language-plaintext highlighter-rouge">/usr/sbin/usermod</code> to configure users and groups.
The <code class="language-plaintext highlighter-rouge">usermod</code> lives under a different path in NixOS, but I symlinked it to
<code class="language-plaintext highlighter-rouge">/usr/sbin/</code> to solve the issue and correctly get to a shell. <em>Within</em> the VM,
I configured the DNS to rely on the host and set the environment variables
required by <code class="language-plaintext highlighter-rouge">crosvm-tools</code>.</p>

<p>X/Wayland and port forwarding were the last pieces of the puzzle. By diving
into the source code and the logs, I discovered that the <code class="language-plaintext highlighter-rouge">/dev/wl0</code> device was
missing read/write permissions for non-root users. By fixing it with a quick
<code class="language-plaintext highlighter-rouge">udev</code> rule, clipboard sharing and GUI apps started to work. I also created a
<code class="language-plaintext highlighter-rouge">systemd</code> unit to start <code class="language-plaintext highlighter-rouge">cros-port-listener</code> and enable automated
port-forwarding from Baguette to ChromeOS (very handy when writing this blog to
preview its HTML in Chrome).</p>

<p>With our image prepared and all issues fixed, Baguette is ready to shine! The <a href="https://github.com/aldur/nixos-crostini/blob/main/baguette.nix" title="`baguette.nix`"><svg class="svg-icon grey" viewBox="0 0 512 512"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"></path></svg>  <code class="language-plaintext highlighter-rouge">baguette.nix</code></a> file includes all the configuration in details, if you
are curious. Here is the result, showing a <code class="language-plaintext highlighter-rouge">baguette-nixos</code> VM correctly
forwarding a Wayland session to ChromeOS.</p>

<p class="text-align-center"><img src="/images/baguette.webp" alt="A screenshot showing the `baguette-nixos` VM running Featherpad" class="centered" />
<em>Wayland forwarding working in a Baguette VM.</em></p>

<h3 id="how-to-make-it-yours">How-to: Make it yours</h3>

<p><a href="https://github.com/aldur/nixos-crostini/" title="`nixos-crostini`"><svg class="svg-icon grey" viewBox="0 0 512 512"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"></path></svg>  <code class="language-plaintext highlighter-rouge">nixos-crostini</code></a> can now build both Baguette images and LXC
containers. If you give it a try, let me know how it goes through any of the
contacts in the footer.</p>

<div class="todo">

  <p><em>tip</em>: <a href="https://github.com/aldur/nixos-crostini/" title="`nixos-crostini`"><svg class="svg-icon grey" viewBox="0 0 512 512"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"></path></svg>  <code class="language-plaintext highlighter-rouge">nixos-crostini</code></a>
  builds NixOS Baguette images in CI and uploads them as GitHub workflow
  artifacts. Download them to quickly boot Baguette and then re-build NixOS
  from your customized configuration.</p>

  <p>If you want to change the default username, fork the repository and edit the
  configuration. The CI will re-build the image for you.</p>

</div>

<h3 id="how-to-additional-shell-sessions">How-to: Additional shell sessions</h3>

<p>To get additional shell sessions from new <code class="language-plaintext highlighter-rouge">crosh</code> tabs, use:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>vsh baguette penguin
</code></pre></div></div>

<p>We don’t really need the <code class="language-plaintext highlighter-rouge">penguin</code> argument, but without it we will get the
following error:</p>

<blockquote>
  <p>if attempting to connect to a containerless guest please use <code class="language-plaintext highlighter-rouge">vsh termina
  penguin</code>.</p>
</blockquote>

<h3 id="how-to-usb-forwarding">How-to: USB forwarding</h3>

<div class="hint">

  <p>With Baguette, you can easily configure USB devices from Settings → Linux →
 <em>Manage USB devices</em>:</p>

  <ol>
    <li>Click on: <em>Enable persistent USB device sharing with guests</em>.</li>
    <li>Enable any USB device you’d like available to Baguette.</li>
  </ol>

  <p>Selected devices will automatically be forwarded to Baguette once plugged in.</p>

</div>

<p>If you want to enable USB forwarding through <code class="language-plaintext highlighter-rouge">crosh</code>, Baguette <a href="/articles/2025/06/19/nixos-in-crostini.html#how-to-usb-forwarding">simplifies the
LXC approach</a> because it doesn’t need a container name.</p>

<p>Insert the device and then navigate to <code class="language-plaintext highlighter-rouge">chrome://usb-internals</code>. In the
<code class="language-plaintext highlighter-rouge">devices</code> tab, note the Bus number and Port number of your device.
<code class="language-plaintext highlighter-rouge">dmesg</code> in <code class="language-plaintext highlighter-rouge">crosh</code> will provide the same information, if you prefer.</p>

<p>Now open a <code class="language-plaintext highlighter-rouge">crosh</code> shell and attach the USB to the VM:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Replace &lt;bus&gt; and &lt;port&gt; with the Bus and Port number from above.</span>
vmc usb-attach baguette &lt;bus&gt;:&lt;port&gt;
</code></pre></div></div>

<h3 id="how-to-launch-nixos-from-terminal">How-to: Launch NixOS from “Terminal”</h3>

<div class="hint">

  <p>This <em>does require</em> setting the
  <a href="chrome://flags/#crostini-containerless"><code class="language-plaintext highlighter-rouge">#crostini-containerless</code> flag</a>.</p>

</div>

<p>The Terminal application will default to launching a VM named <code class="language-plaintext highlighter-rouge">termina</code>. To
launch our VM, we will need to destroy and replace the default one. From
<code class="language-plaintext highlighter-rouge">crosh</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>vmc stop termina
vmc stop baguette

<span class="c"># Optional: backup `termina`</span>
vmc <span class="nb">export </span>termina /home/chronos/user/MyFiles/Downloads/termina.img

<span class="c"># WARNING: This will destroy your existing `termina` VM and any data it contains.</span>
vmc destroy termina

vmc <span class="nb">export </span>baguette /home/chronos/user/MyFiles/Downloads/baguette-nixos.img

vmc create <span class="nt">--vm-type</span> BAGUETTE <span class="se">\</span>
  <span class="nt">--size</span> 15G <span class="se">\</span>
  <span class="nt">--source</span> /home/chronos/user/MyFiles/Downloads/baguette-nixos.img <span class="se">\</span>
  termina

<span class="c"># Optional: destroy the other `baguette` VM</span>
vmc destroy baguette

vmc start <span class="nt">--vm-type</span> BAGUETTE termina
</code></pre></div></div>

<div class="warning">

  <p>Using Baguette in “Terminal” is a bit <a href="https://issues.chromium.org/issues/458443474#comment3">wonky</a> and shows that it is
  currently under development. The ChromeOS team will not consider it stable
  until Chrome 143.</p>

  <p>In my experiments, I noticed that <code class="language-plaintext highlighter-rouge">congierce</code> will request <code class="language-plaintext highlighter-rouge">maitred</code> to
  configure the VM using a <em>default</em> username (the one displayed when
  “Configuring Linux” from the settings). Trying to use a custom username seems
  to be hit or miss.</p>

  <p>I typically rename the VM to <code class="language-plaintext highlighter-rouge">termina</code> and then ditch “Terminal” and just
  <a href="#how-to-additional-shell-sessions">use <code class="language-plaintext highlighter-rouge">crosh</code></a>.</p>

</div>

<h3 id="how-to-root-login">How-to: Root login</h3>

<p>The Debian image allows passwordless <code class="language-plaintext highlighter-rouge">sudo</code>. The default NixOS configuration in
<code class="language-plaintext highlighter-rouge">nixos-crostini</code> replicates the approach, so that you can use escalate
privileges to rebuild your configuration from within the VM.</p>

<p>In my configuration, I <a href="/articles/2025/06/29/yubikey-root-login.html">prefer to SSH as <code class="language-plaintext highlighter-rouge">root</code></a> to passwordless <code class="language-plaintext highlighter-rouge">sudo</code>. This way, I
can use a hardware key to prove my physical presence and login as <code class="language-plaintext highlighter-rouge">root</code>, but
an attacker cannot automatically escalate privileges.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Getting Baguette and NixOS to work together required a bit of trial and error
to build the image in the right format, figure out a few quirks, and adapt to
ChromeOS’ CLI updates. I am now satisfied with the result: I wrote this blog
post from Baguette and I couldn’t tell the difference from LXC.</p>

<p>I don’t run Kubernetes (which seems to be one of the biggest pain point for LXC
users), but Baguette improves a few things for me as well:</p>

<ol>
  <li>In addition to being able to automatically forward USB devices, Baguette
does <a href="/micros/2025/07/30/fido2-almost-works-in-linux-on-chromeos/">not hold an exclusive lock</a> on USB hardware keys.
So I can use them <em>both</em> in Baguette and in ChromeOS (as a passkey) at the
same time without having to fiddle with <code class="language-plaintext highlighter-rouge">crosh</code>.</li>
  <li>A containerless VM has better access to the underlying hardware and better
control of its <code class="language-plaintext highlighter-rouge">init</code>. This might make it easier to implement <a href="https://github.com/nix-community/impermanence">ephemeral
storage</a> and seems to fix an issue with <a href="https://linux.die.net/man/8/pcscd"><code class="language-plaintext highlighter-rouge">pcscd</code></a> that would make it
fail reading from Yubikeys after some time, until restarted.</li>
  <li>Using <code class="language-plaintext highlighter-rouge">crosh</code> + <code class="language-plaintext highlighter-rouge">vsh</code> makes it easy to attach/detach USB devices and manage
the VM itself without breaking <em>flow</em> when switching from “Terminal”. I
could have done the same with LXC containers, but I didn’t know about the
<code class="language-plaintext highlighter-rouge">vsh</code> command.</li>
</ol>

<p>Thanks for reading, and ‘til next time! 👋</p>

<hr />

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:arm">
      <p>I was experimenting with all this on the ARM-based Chromebook that
I use for couch-computing. <a href="#fnref:arm" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:kvm">
      <p>Because I wanted the build scripts to run within the default <code class="language-plaintext highlighter-rouge">penguin</code>
image, I also <a href="https://github.com/aldur/nixos-crostini/blob/2e3318ec0f72d775a22c35929887f93f1f17dbd7/baguette.nix#L236-L237" title="overrode the derivation"><svg class="svg-icon grey" viewBox="0 0 512 512"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>
  overrode the derivation</a> so that it falls back to
<a href="https://www.qemu.org/docs/master/devel/index-tcg.html">emulation</a> when <code class="language-plaintext highlighter-rouge">/dev/kvm</code> is missing. <a href="#fnref:kvm" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>aldur</name><email>hello@aldur.blog</email></author><category term="articles" /><category term="ChromeOS" /><summary type="html"><![CDATA[Running containerless NixOS VMs in ChromeOS.]]></summary></entry><entry><title type="html">Nuggets of psychology</title><link href="https://aldur.blog/articles/2025/09/08/nuggets-of-psychology.html" rel="alternate" type="text/html" title="Nuggets of psychology" /><published>2025-09-08T00:00:00+00:00</published><updated>2025-09-08T00:00:00+00:00</updated><id>https://aldur.blog/articles/2025/09/08/nuggets-of-psychology</id><content type="html" xml:base="https://aldur.blog/articles/2025/09/08/nuggets-of-psychology.html"><![CDATA[<p>Understanding the world requires seeking good explanations, not fooling
oneself, and figuring out <em>why</em> things truly happen. Only this way we can do
better next time.</p>

<p>In my quest to achieve this, I have found that many mental models are rooted in
psychology. They start right there and lean into marketing, behavioral
economics, product management, negotiation, and even how we approach everyday
tasks.</p>

<p>This living post collects those <em>nuggets</em> of psychology I wish to remember and
add to my <em>latticework</em> of mental models. I have learned them from multiple
authors and books: <em>Thinking Fast and Slow</em>, <em>The Paradox of Choice</em>, <em>Poor
Charlie’s Almanack</em>, and more. In describing them here, I will make
simplifications that might make experts shiver. If you find something that
interests you and you want to dive into it, start out with those books – or
better yet, the original research. And if you spot any mistake or
over-simplification, please do reach out and let me know!</p>

<!-- prettier-ignore-start -->

<ul id="markdown-toc">
  <li><a href="#the-paradox-of-choice" id="markdown-toc-the-paradox-of-choice">The paradox of choice</a></li>
  <li><a href="#availability-bias" id="markdown-toc-availability-bias">Availability bias</a></li>
  <li><a href="#anchoring" id="markdown-toc-anchoring">Anchoring</a></li>
  <li><a href="#framing" id="markdown-toc-framing">Framing</a></li>
  <li><a href="#prospect-theory" id="markdown-toc-prospect-theory">Prospect theory</a></li>
  <li><a href="#sunk-costs" id="markdown-toc-sunk-costs">Sunk costs</a></li>
  <li><a href="#inversion" id="markdown-toc-inversion">Inversion</a></li>
  <li><a href="#the-man-with-the-hammer" id="markdown-toc-the-man-with-the-hammer">The man with the hammer</a></li>
  <li><a href="#confirmation-bias" id="markdown-toc-confirmation-bias">Confirmation bias</a></li>
  <li><a href="#the-hedonic-treadmill" id="markdown-toc-the-hedonic-treadmill">The hedonic treadmill</a></li>
  <li><a href="#perception-of-past-experiences" id="markdown-toc-perception-of-past-experiences">Perception of past experiences</a></li>
  <li><a href="#incentives" id="markdown-toc-incentives">Incentives</a></li>
  <li><a href="#interest-not-reason" id="markdown-toc-interest-not-reason">Interest, not reason</a></li>
  <li><a href="#seven-plus-or-minus-two" id="markdown-toc-seven-plus-or-minus-two">Seven, plus or minus two</a></li>
</ul>
<!-- prettier-ignore-end -->

<h4 id="the-paradox-of-choice">The paradox of choice</h4>

<p>It is much easier to spend several hours binge-watching a TV series than to
pick a good movie and commit a couple of hours to it. Why? Because freedom of
choice can lead to difficult decisions and, sometimes, to less happiness.</p>

<p>If you have already watched a few episodes of a show, you know what to expect
from it. If you have previously enjoyed it, you expect to continue doing so.
Instead, modern streaming platforms encourage us to pick a movie among
hundreds. It is much easier to decide <em>not</em> to pick and just watch another
episode of that familiar show.</p>

<p>When we do try picking something, we face uncertainty and we fear regret: “What
if I pick a worse movie than the TV show?”. Even when exclusively focusing on
movies, we will compare two or more options on multiple features (genre,
duration, actors, etc.). No movie will clearly win across all dimensions, but
the act of weighting the options will be enough to frustrate us, so that we
will be <em>less</em> satisfied, regardless of what we choose.</p>

<p>Forcing people to make a decision along a trade-off will make them unhappy and
indecisive. When applied to product management, this teaches us that <em>more
choices are not always better</em>. With limitless choices we might achieve better
results, but at the cost of significant struggle and energy to sort among the
different options. This is particularly bad for <em>maximizers</em>, who, as opposed
to <em>satisficers</em> won’t settle for something “<em>good enough</em>”.</p>

<h4 id="availability-bias">Availability bias</h4>

<p>Kahneman and Tversky originally termed it <a href="https://en.wikipedia.org/wiki/Availability_heuristic">availability <em>heuristic</em></a>
because we take this mental shortcut to save energy: when something is readily
available or very vivid in our memory, we <em>think</em> it is also <em>frequent</em> or
<em>important</em>. I prefer calling it a <em>bias</em> because it hinders our ability to
make good decisions <em>unless</em> we take measures to do better.</p>

<p>I think of this bias also when we are blindsided by information or options that
are in front of us, but fail to see that with a bit more digging we would find
much better options.</p>

<p>Availability bias is also why we misestimate facts. For instance:</p>

<ul>
  <li>That deadly car accidents are more frequent than disease-related deaths (they
are not).</li>
  <li>Why we tend to form judgements or stereotypes based on extremely small (and
likely non-representative) samples.</li>
  <li>Why we attribute to our egocentric self more merits or faults than we really
should.</li>
</ul>

<h4 id="anchoring">Anchoring</h4>

<blockquote>
  <p>Hi dad, can I have 100 dollars?</p>

  <p>80 dollars? What do you need 50 dollars for, kid? Here, take 10 and bring
  back some change.</p>
</blockquote>

<p>We make decisions based on relativeness and reference points. That is why
anchoring can be powerful or lead to biased decisions. The number we hear first
in a negotiation sets a reference point and we judge what comes next against
it. If someone successfully manages to place an artificial <em>anchor</em>, then they
can bias the decision-maker into making that direction.</p>

<p>Some stores seem to always offer a discount on merchandise. The original price
is typically shown prominently and serves as an anchor which makes the
discounted price look like a bargain. In a negotiation, an extreme “opening”
has better odds to the desired outcome than a moderate proposal, closer to
one’s true value.</p>

<h4 id="framing">Framing</h4>

<p>The way information gets presented influences how we make decisions about
it. This is why it is more effective to advertise “a discount on credit cards”
instead of a “surcharge on cash”. This is also why when juries are asked to
choose <em>one</em> parent for childcare, they will look for markedly <em>good</em> traits.
If they were asked to <em>discard</em> a parent as being ineligible, they would look
for markedly <em>bad</em> traits.</p>

<h4 id="prospect-theory">Prospect theory</h4>

<p>When faced with the choice of getting $100 or having a 50% chance of either
getting nothing ($0) or $200, most people will choose the sure choice. Even
though they have the same expected value, the “psychological” value of $200 is
not twice as much as the one from $100. For this reason, regardless of the
same expected value, people will not take the bet. Things are not symmetrical.
As humans, we tend to be <em>risk averse</em>.</p>

<p>There is <em>diminishing marginal utility</em> in satisfaction. This is also why $100
provide different satisfaction to someone with a thousand and a million dollars
in their bank accounts.</p>

<p>When facing losses, things <em>do</em> work symmetrically, but not the way you would
expect. If asked to surely loose $100 or flip a coin and either lose nothing
or $200, most will choose the coin flip. Why? Because losing $200 does not
hurt twice as much as losing $100: there is a <em>decreasing marginal disutility
of losses</em>.</p>

<p>Lastly, losing $100 will produce, in absolute terms, a much stronger reaction
than winning $100. We are <em>loss averse</em>. This applies even to small, symbolic
losses as small as a few dollars. Loss aversion also explains why, on average,
few people return products after they have bought them, even if return is free.
They would experience more loss than the pleasure of getting it in the first
place. This is the <em>endowment effect</em>.</p>

<h4 id="sunk-costs">Sunk costs</h4>

<p>You are months into an expensive project at work, only to discover that you are
not building the right thing, or that no one will use it. Do you keep working
on it, or do you scrap it? The work or money you have put into is already
<em>sunk</em>. Dropping the project would be the right thing to do – completing it,
would not benefit anyone. But loss aversion will sometimes trick us into
continuing that work, worsening an already non-optimal situation.</p>

<p>This recently happened to me. I had invested significant time, money, and
effort into training for a bicycle competition. A few weeks before the race, I
developed a small injury which prevented me from enjoying cycling and put
competitiveness out of the picture. Due to the sunk costs, I considered racing
anyways, almost surely dropping out and, possibly, making the injury worse.
Luckily, I decided to <em>ignore</em> sunk costs and make a decision based on present
conditions. Time and money were already gone and I now had to think about
recovery and future rides.</p>

<h4 id="inversion">Inversion</h4>

<p>This is a tool, not a fallacy, where we think <em>backward</em> rather than <em>forward</em>,
succeeding by avoiding mistakes instead of seeking brilliance.</p>

<p>By inverting “<em>How do I get there?</em>”, picture where you want to <em>avoid</em> going
and then describe what it would take to get there. This is the principle
<em>inversion</em>. It helps because it allows us to look at problems differently and
leverage the ability of our minds to <em>reduce</em> and <em>simplify</em> rather than to
<em>add</em>. It allows us to first figure out what we would define bad outcomes and
then prune any decision-tree branch that would end up there.</p>

<p>When building proofs by contradiction, mathematicians often use something
similar to inversion. They assume that what they want to prove is false and use
logic to find a contradiction – indicating that what they are trying to prove
is true.</p>

<h4 id="the-man-with-the-hammer">The man with the hammer</h4>

<p>A Charlie Munger’s favorite, this fallacy warns about how having only a limited
set of mental models will encourage you to use the <em>wrong</em> one for the job.
Somehow, this relates to <a href="#availability-bias">availability bias</a> since
“shinier”, more available tools will lure you into thinking they are the most
appropriate.</p>

<p>This is the reason why I am collecting <em>several</em> tools here and why I like
learning from unfamiliar disciplines (e.g., biology, systems thinking,
psychology).</p>

<h4 id="confirmation-bias">Confirmation bias</h4>

<p>We often make this mistake when navigating through available facts by
prioritising information that supports <em>our</em> belief (or hypotheses, etc.). This
applies to search, recall, interpretation and “favoring”. Essentially, we
select the information that fits outs view of the world.</p>

<p>It typically happens without us realizing it and we fall for it because it
makes us save energy <em>plus</em> it makes us feel good about ourselves. To fight it,
we should first be aware it exists (exactly what we are doing here) and then
try to actively <em>falsify</em> our hypotheses instead of looking for information
that confirm them.</p>

<p>Social media “bubbles” create resonance chambers that make confirmation bias
<em>insidious</em>. Social media timelines tend to weigh more shared beliefs, which will
make it more likely for users to only be exposed to other users of the same
“opinions”. This compounds the effect of confirmation bias, because it actively
makes it harder to find conflicting information and debate them with others.</p>

<h4 id="the-hedonic-treadmill">The hedonic treadmill</h4>

<p>As humans, we will adapt to everything. The hedonic treadmill conjectures that
our levels of pleasure, happiness, and sadness will return to a relatively
stable level even after major life events. We “adapt” to those new levels. I
think of it like “mean reversion” (described both in finance and mathematics)
for our feelings.</p>

<p>A few studies have tested this conjecture, finding out that people would return
to their “average” happiness after significant spikes (e.g., when winning the
lottery) or downturns (e.g., falling victims of a debilitating accident).</p>

<p>It has strong pragmatic implications: it will make new things shine less over
time and it might lead to exaggerate (or unconstrained) spending while seeking
additional stimuli – always seeking more.</p>

<p>Knowing about the hedonic treadmill is a powerful way to counteract it. We
should not fool ourselves by thinking that a new purchase (or a promotion, or
more money) will make us happier, because we will quickly adapt to it. Instead,
we can focus on those long-lasting things whose effect compound over time and
bring us meaningfulness and purpose (e.g., social relationship or long-term
missions).</p>

<h4 id="perception-of-past-experiences">Perception of past experiences</h4>

<p>We rely on past experiences to make decisions about our future, but we often
misremember and misjudge the past. Our memories of an event rely on:</p>

<ol>
  <li>The <em>absolute high</em> (or low) we experienced.</li>
  <li>What we felt when the experience <em>ended</em>.</li>
</ol>

<p><a href="https://pubmed.ncbi.nlm.nih.gov/12855328/">This study</a> ingeniously demonstrated this. Two groups of patients underwent
a colonscopy. In one group, the exam was made artificially longer by leaving the
probe still for a few minutes before ending the procedure. Because a still
probe is less unpleasant, the patients fell <em>less discomfort</em> when the
colonscopy ended. As a result, they were <em>more likely</em> to come back for
additional checkups than those in the other group, despite the actual exam
being <em>longer</em> and the absolute discomfort being the same.</p>

<h4 id="incentives">Incentives</h4>

<p>Incentives are powerful system levers and keeping them in mind is fundamental
in decision-making. According to Charlie Munger, we routinely underestimate
their effects on behavior and forget that people will do what they are paid to
do.</p>

<p>In his almanack, Munger tells two stories about incentives. One is the story of
a new Xerox product. Despite being <em>better</em> than older products, it was selling
a lot less. Why? Because salespeople were motivated by outdated incentives,
rewarding them for each sale of an <em>old</em> product.</p>

<p>In another story, FedEx needed packages to move from A to B, every night. The
task was not completed unless <em>all</em> packages had moved. And they needed to move
<em>fast</em>! FedEx management tried a whole set of things to incentivize workers.
None worked, until someone realized that workers were paid <em>by the hour</em> and
were lacking incentives to complete their tasks quickly. When they started to
be paid by <em>shift</em>, their incentives aligned with FedEx’s: to quickly and
effectively complete their tasks before going home.</p>

<h4 id="interest-not-reason">Interest, not reason</h4>

<blockquote>
  <p>If you would persuade, appeal to interest and not to reason.</p>

  <p>Ben Franklin, Poor Richard’s Almanack</p>
</blockquote>

<p>Rather than pure rationality, we are often motivated by self-interest and
<a href="#incentives">incentives</a>. Sometimes all this happens subconsciously: we
rationalize flawed reasoning without even noticing it.</p>

<p>To change someone’s behaviour through <em>product management</em>, we need to keep all
this in mind. Instead of trying airtight logic or reasons, build products or
systems that <em>support</em> our users’ interest. Only then their behavior will
change.</p>

<p>This has profound implications in business as well. Users won’t buy a new
technology because it is shinier, faster, or better than some legacy. They will
only adopt it if it aligns with their interests by letting them save or gain
money.</p>

<h4 id="seven-plus-or-minus-two">Seven, plus or minus two</h4>

<p>Our <em>working memory</em> can hold roughly <em>seven (± two) “items”</em> before
performance declines. Once that happens, we become unable to effectively
<em>remember</em> all the options or even <em>differentiate</em> among them. Grouping
related items together by “chunking” them alleviates the burden on our brains.</p>

<p>This cognitive limitation is also known as <a href="https://en.wikipedia.org/wiki/The_Magical_Number_Seven,_Plus_or_Minus_Two">Miller’s law</a>. It should impact
the design of products and features by ensuring that we do not overwhelm users
with <em>more</em> than they can effectively work with – remembering that, in the
worst case, five choices will already push them to their limit. Structuring
information in a hierarchy helps. Simplifying the user journey and reducing the
number of choices will do even better.</p>]]></content><author><name>aldur</name><email>hello@aldur.blog</email></author><category term="articles" /><summary type="html"><![CDATA[A living collection of psychological mental models for making better decisions.]]></summary></entry><entry><title type="html">Code integrity for web apps</title><link href="https://aldur.blog/articles/2025/09/02/web-code-verify.html" rel="alternate" type="text/html" title="Code integrity for web apps" /><published>2025-09-02T00:00:00+00:00</published><updated>2025-09-02T00:00:00+00:00</updated><id>https://aldur.blog/articles/2025/09/02/web-code-verify</id><content type="html" xml:base="https://aldur.blog/articles/2025/09/02/web-code-verify.html"><![CDATA[<p>I used to prefer web-apps to their native equivalents, especially if the
“native” one is just wrapping a browser anyway (e.g., through Electron). Here
is why:</p>

<ul>
  <li>Web-apps run within the browser sandbox, adding to <em>defense in depth</em>. The
browser is <em>designed</em> to execute untrusted code and prevent it from “spilling
over” to the rest of the system<sup id="fnref:silver_bullet"><a href="#fn:silver_bullet" class="footnote" rel="footnote" role="doc-noteref">1</a></sup>. A malicious web page should
<em>not</em> be able to access your documents or read your emails.</li>
  <li>Native apps, instead, can access the underlying OS more directly<sup id="fnref:sandbox"><a href="#fn:sandbox" class="footnote" rel="footnote" role="doc-noteref">2</a></sup>.
They can read and write the filesystem, execute arbitrary commands, connect
to other machines, start whenever you log-in, etc.</li>
</ul>

<p>A few days ago, a potential flip side of web-apps made me rethink things more
thoroughly. How do we know that we are running a “honest” web-app, instead of
one modified by an attacker?</p>

<h3 id="web-vs-native-apps">Web vs native apps</h3>

<picture class="text-align-center">
  <source srcset="/images/web-app-light.svg" media="(prefers-color-scheme: light)" />
  <source srcset="/images/web-app-dark.svg" media="(prefers-color-scheme: dark)" />
  <img src="/images/web-app-dark.svg" alt="A web-browser fetching a web-app and an encrypted payload from a server. The browser holds a cryptographic key, used to decrypt the encrypted payload." class="centered" />
</picture>

<p>The browser downloads web-apps every time we use them (unless cached). If an
attacker could <em>modify</em> the web-app being downloaded, then they would control
<em>its context</em>. If it’s a messaging app, they could read or send messages. If
it’s a password manager, they could access passwords or secrets.</p>

<picture class="text-align-center">
  <source srcset="/images/attacker-app-light.svg" media="(prefers-color-scheme: light)" />
  <source srcset="/images/attacker-app-dark.svg" media="(prefers-color-scheme: dark)" />
  <img src="/images/attacker-app-dark.svg" alt="A web-browser fetching a web-app, modified by an attacker, and an encrypted payload from a server. The browser holds a cryptographic key, used to decrypt the encrypted payload. The cryptographic key leaks to the attacker." class="centered" />
</picture>

<p>To pull this off, the attacker would have to compromise the web-servers
providing the code (including any content delivery networks) or succeed in a
man-in-the-middle attack. Realistically, these attacks are relatively hard to
pull off at scale, but are possible for sophisticated-enough attackers.</p>

<p>In comparison, native apps deploy counter-measures against all this. They are
typically bundled, signed, and then downloaded through a separate channel
(e.g., the App Store or a link to a DMG or deb package). This allows users (or
their OSs) to validate the bundle’s integrity before executing the app,
assuming: 1. a public key infrastructure (PKI) to associate the application
signing keys to the application developer; 2. that the signing keys have not
been compromised; 3. that the user downloaded the right application in the
first place. The bar for an attacker to compromise this process <em>can</em> be
higher, e.g., if the developers appropriately protect their signing keys in
cold storage.</p>

<h3 id="with-e2ee">With E2EE</h3>

<p>Code integrity is particularly important around end-to-end encryption (E2EE).
With E2EE messaging apps (Signal, WhatsApp, Matrix) and password managers
(1Password, Bitwarden), the server cannot decrypt user data, but simply holds
encrypted payloads. The client receives them, decrypts them, and displays the
result to the user. The integrity of the client is <em>fundamental</em>. A compromised
client can completely circumvent E2EE and leak messages, keys, and passwords.</p>

<h4 id="signal">Signal</h4>

<p>The lack of code verification in the browser is <a href="https://www.reddit.com/r/privacy/comments/uwpoyb/comment/i9tj457/">why Signal does not offer a
web client</a> and instead provides a native one based on Electron
(essentially, a web page served by a <em>signed</em> native app). It all boils down to
verifiable distribution. This way, users download the Signal app only once,
right before they install it. They do not need to “blindly” trust it, but can
verify its integrity by checking that it was signed by the Signal developers<sup id="fnref:signal"><a href="#fn:signal" class="footnote" rel="footnote" role="doc-noteref">3</a></sup>.
With web-apps, instead, users download the code at every visit <em>and</em> they
cannot easily verify what is being served to them.</p>

<h4 id="meta">Meta</h4>

<p>Meta has also addressed this threat for their E2EE apps as well. To mitigate
it, they released the <a href="https://github.com/facebookincubator/meta-code-verify">“Code Verify”</a> extension, covering <a href="https://faq.whatsapp.com/1210420136490135/?cms_platform=web">WhatsApp</a>,
Instagram, Facebook Messenger. Under the hood, the extension compares the
hashes of each file being executed against a “root hash”, fetched both from a
manifest <em>and</em> from <a href="https://blog.cloudflare.com/cloudflare-verifies-code-whatsapp-web-serves-users/">Cloudflare</a>. In essence, the extension replicates the
checks performed by the OS’ “gatekeeper” when executing a native app. It is a
nice solution, but it is <em>not</em> perfect. First, users need to know about the
extension and install it on all their browsers. I discovered its existence just
a few days ago. Then, they need to notice the extension’s warning in case
something is off. Lastly, the extension adds another component that could be
bypassed or exploited. The code is relatively simple and open-source, so this
seems unlikely – but not impossible.</p>

<h4 id="evolving-standards">Evolving standards</h4>

<p>A better fix would be to systemically solve this by improving things for
<em>everyone</em> by providing better security, by default. For this to work, there
needs to be a coordinated effort so that browsers validate the code against a
“root of trust” before executing the application, similarly to how they ensure
the integrity of websites served through TLS.</p>

<p>While I was researching all this, someone on IRC nicely pointed me to <a href="https://github.com/WICG/isolated-web-apps">Isolated
Web Apps</a>, which have been designed to solve this problem <em>but also</em> allow a
wider set of capabilities for the browser (e.g., opening raw sockets). As it
happens with coordinated efforts, it takes some time to reach consensus on the
best approach:</p>

<ol>
  <li>Chrome is experimentally allowing Isolated Web Apps for <a href="https://chromeos.dev/en/web/isolated-web-apps">enterprise
Chromebooks</a>.</li>
  <li><a href="https://github.com/WebKit/standards-positions/issues/184">WebKit</a> hasn’t taken a position yet.</li>
  <li>Mozilla has instead <a href="https://github.com/mozilla/standards-positions/issues/799">declined to adopt them</a>, stating that the
additional capabilities introduce new hazards and that a new,
<a href="https://github.com/mozilla/standards-positions/issues/799#issuecomment-2861412906">in-development standard</a>, would be a better solution.</li>
</ol>

<p>Despite the time it will take, I consider this great news! Bright people
are working so that we will <em>all</em> be able to validate the integrity of
web-apps. Eventually, one or more standards will emerge, be implemented, and
advance everyone’s security.</p>

<h3 id="for-users">For users</h3>

<p>What does all this mean for me and you, the users? It depends on the threat
model.</p>

<ol>
  <li><strong>E2EE service</strong>: If you are protecting against <em>“someone trying to break
E2EE for a specific service”</em>, then native apps offer an additional line of
defense, preventing a modified client from running. The assumption is that
we trust the application developer, because we use their E2EE service <em>and</em>
we run their app.</li>
  <li><strong>Local device compromise</strong>: If instead you are protecting against <em>“someone
trying to compromise my endpoint”</em>, then web-apps offer additional
protection because of the browser sandbox. Same goes for <em>“I do not trust
the application developer and I want to limit their access to my system”</em>.</li>
</ol>

<p>Now, threat model (2) is <em>a lot more general</em> than (1). If someone manages to
compromise my device, chances are that they will <em>also</em> be able to break E2EE
for any app I use there<sup id="fnref:1p"><a href="#fn:1p" class="footnote" rel="footnote" role="doc-noteref">4</a></sup>. If, instead, someone serves me a malicious WhatsApp
web-app, they will be able to read messages or send new ones, but will most
likely not be able to also read my emails or steal my passwords.</p>

<p>Password managers are a notable exception, because an attacker can leverage
them to try accessing other services and increase the blast radius. But I argue
that even then, MFA should prevent most damage – as long as the <em>other</em> factors
are <em>not</em> in the password manager, the attacker won’t be able to log in. In
addition, most services today notify users about new or unusual logins,
alerting them that something might be going on. Compare this with when a device
is compromised: an attacker who steals a session token might be able to use it
without triggering a new login notification.</p>

<p>Due to all this, I consider web-apps a better fit for a majority of users
(myself included), especially when used through a thin client (e.g., a
<a href="/articles/2025/06/19/nixos-in-crostini.html">Chromebook</a>). Encouraging
users <em>not</em> to download and install native software makes device compromise
less likely and <em>localizes</em> the blast radius (e.g., to the specific compromised
app). Isolated Web Apps (or similar) will be a welcome addition once they
standardize, adding one more layer of protection to the web. Meanwhile, the
Code Verify extension probably doesn’t hurt the services it covers.</p>

<p>Threat modeling is not one-size-fits-all, though, and each user should think
carefully about what they are protecting from. For instance, if you are a
whistle-blower, a leak of your Signal messages could lead to fatal
consequences, more severe than someone being able to access your financial data
or log-in as you on Facebook. This is why I appreciate the thoughtfulness of
Signal developers, providing a client that is secure against those most-severe
threat models and prevents <em>those</em> users from making a wrong choice.</p>

<p>As for me, I hope that this deep dive will help you (as it helped me) to define
your threat model more explicitly and weight the associated trade-offs. If you
have thoughts about all this, I would love to hear them and chat about it.
Please, reach out! Meanwhile, thank you for reading and see you next time! 👋</p>

<h4 id="footnotes">Footnotes</h4>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:silver_bullet">
      <p>Not a <a href="https://nvd.nist.gov/vuln/detail/CVE-2025-6558">silver-bullet</a>, but still useful. <a href="#fnref:silver_bullet" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:sandbox">
      <p>Most operating systems run native apps in a sandbox as well, but it is
     harder for users to tell when that is the case. For instance,
     on macOS apps will run in two different sandboxes depending on whether
     they are from the App Store or not. <a href="#fnref:sandbox" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:signal">

      <p>On macOS, <code class="language-plaintext highlighter-rouge">Signal.app</code> is signed through Signal’s Apple developer account.
Debian packages instead rely on a GPG signing key. The operating system should
typically perform the verification on behalf of the user. <a href="#fnref:signal" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:1p">
      <p>As <a href="https://blog.1password.com/local-threats-device-protections/">1Password puts it</a>:</p>

      <blockquote>
        <p>There’s no password manager or other mainstream tool with the ability to
 guard your secrets on a fully compromised device.</p>
      </blockquote>
      <p><a href="#fnref:1p" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>aldur</name><email>hello@aldur.blog</email></author><category term="articles" /><summary type="html"><![CDATA[Threat modelling around web and native applications.]]></summary></entry><entry><title type="html">Paddling in circles: lagging indicators</title><link href="https://aldur.blog/articles/2025/07/12/lagging-indicators.html" rel="alternate" type="text/html" title="Paddling in circles: lagging indicators" /><published>2025-07-12T00:00:00+00:00</published><updated>2025-07-12T00:00:00+00:00</updated><id>https://aldur.blog/articles/2025/07/12/lagging-indicators</id><content type="html" xml:base="https://aldur.blog/articles/2025/07/12/lagging-indicators.html"><![CDATA[<p>Last weekend I went kayaking on the <a href="https://en.wikipedia.org/wiki/Ard%C3%A8che_(river)">Ardèche
river</a>, in southern France.
I had kayaked a couple of times before, but never in tandem over a two-day trip.
It was a terrific experience, with wonderful people in mesmerizing nature.</p>

<p>As it often happens when I do outdoor sports, my mind put together a few
thoughts. This time, about systems, their delays, and lagging indicators. I
think they generalize to more than kayaks 🛶, so I wrote about them here.</p>

<p class="text-align-center"><img src="/images/GOPR3224.webp" alt="The Ardèche river, its rocky borders, four kayaks navigating it and an angle of sky." class="centered" /></p>

<p>For most of day one (out of two), my kayak partner and I struggled to navigate
in a straight line. We often found the kayak pointing at the wrong angle and
had to bring it back on route. Then, we would find it pointing wrongly in the
<em>other</em> direction. Unsurprisingly, we reached camp for the night tired and with
low morale.</p>

<p><em>Why</em> were we doing such a bad job? Because of <em>delays</em>!</p>

<p class="text-align-center"><img src="/images/kayak.svg" alt="A diagram of a kayak showing the effect of paddling (forward and to the side)" class="centered inverted" /></p>

<p>A kayak:</p>

<ol>
  <li>Gains forward speed when pushed by the paddle, backwards, on either side.</li>
  <li>Turns in the opposite direction of the paddle in the water.</li>
</ol>

<p>These forces complement each other. Paddling on the right side will move the
kayak forward and point it slightly to the left. Paddling on the left will
offset the change in direction while maintaining the speed. This is why you
typically alternate paddling on each side: to maintain momentum and balance the
forces that would rotate the kayak.</p>

<p>Occasionally, things did not work that way for us. When the kayak leaned
one way, we paddled “harder” on that side to turn in the opposite direction.
But, by then, it was too late already! Due to the <em>delay</em> between the
energy we transmitted to the water and the kayak rotating, the kayak’s
direction was a <em>lagging indicator</em> of the quality of our efforts.</p>

<p>The kayak in the water is a <em>system</em> we can model, reason about, and <em>predict</em>.
Because no reaction is truly instantaneous, <em>all systems</em> have delays. Knowing
where they are and how to shorten (or lengthen) them means finding a leverage
point that can affect the whole system. Therefore, learning how to operate in
the absence of timely information is key: both to having fun on a kayak and to
making better decisions in business and life. When I realized that, the task of
controlling a kayak looked a lot like a scaled-down version of the challenges
we face daily and I saw an opportunity for thinking about it and
connecting the dots.</p>

<blockquote>
  <p>We can’t begin to understand the dynamic behavior of systems unless we know
where and how long the delays are. And we are aware that some delays can be
powerful policy levers. Lengthening or shortening them can produce major
changes in the behavior of systems.</p>

  <p>Thinking in Systems, A Primer. By Donella H. Meadows</p>
</blockquote>

<p>On the kayak, we went through phases of observation and experimentation. At
first, we suspected that an asymmetry in our paddling (left/right or
front/back) was at fault. We thought that if we could have perfectly symmetric
strokes, then the kayak would go straight. But then, how would we account for
the remaining factors, e.g. the water currents? We wanted to build a mental
model of the kayak so that we could diagnose our troubles and figure out how to
fix them. But we were failing, because delays were muddling the effects of our
inputs and revealing them only after some time (when the kayak turned).</p>

<p class="text-align-center"><img src="/images/kayak-delays.svg" alt="A diagram of a kayak showing how delays and other forces make it harder to predict the effect of paddling." class="centered inverted" /></p>

<p>To get better, we had to first acknowledge that the system was <em>messier</em> than
what we were trying to picture in our minds. Both delays and external forces
were contributing to the outcome. Once we learned that, we started to master
our waters. We started looking at <em>weaker</em> observations that were more readily
available: e.g., our bow <em>just hinting</em> at a direction after a
paddle stroke. This way, we shortened the feedback loop of actions,
measurements, and reactions and we stopped over-correcting our angle when it
was too late. With experience, we even started <em>seeing</em> how each paddle stroke
would slightly turn the kayak while pushing it forward and we learned how to
naturally balance for that in the next stroke. We could “point” the kayak more
effectively, control our route and pick between more options in how to face the
rapids. And, obviously, have a ton more fun!</p>

<p>So, what did we learn?</p>

<ol>
  <li>The map is not the territory. We have a natural tendency to build <em>tidy</em>
mental models, but the world is sometimes messier than that. Acknowledge
that to iteratively build a useful model.</li>
  <li>All systems have delays, since no reaction is truly instantaneous. Delays
lead to oscillations and longer feedback loops, which make systems harder to
“hold in the head”.</li>
  <li>Delays are powerful leverage points. Controlling them can result in
significant (sometimes surprising) changes to the system’s behavior.</li>
  <li>A faster loop reduces the effect of delays, which makes it easier to
understand the system.</li>
  <li>The shorter the loop, the faster we iteratively build system models. Strive
for simplicity, get it working, then improve it through experience and
insights.</li>
  <li>If something is hard to predict (or correct), you might be only looking at
<em>lagging indicators</em>. They are more visible but less useful than <em>leading
indicators</em>. A weaker signal <em>earlier</em> on is useful: try looking for those
leads.</li>
</ol>

<p>These lessons apply across disciplines. Software development, for instance,
combines different loops (write, review, bugfix, or release, deploy, rollback)
and leading and lagging indicators (number of developers vs number of bugs
released). There are delays: it takes time for a developer to onboard and
become an effective contributor; sometimes, bugs only surface years after they
have been coded. It is also <em>messy</em>: we measure team velocity and story points,
but need to acknowledge how reality changes around us and the humans that
compose the team. Endurance training can be another example. It takes weeks to
build the aerobic skills required to compete and training and recovery interact
in loops (with form and fitness). The heart rate on race day is a leading indicator,
while <a href="https://en.wikipedia.org/wiki/VO2_max">VO₂ max</a> lags behind.</p>

<p>I hope that while reading about delays and indicators you started picturing
examples from your own fields where these thoughts apply. Reach out through the
contacts in the footer; I would love to hear all about it!</p>

<p>Thank you for reading, and until next time! 👋</p>]]></content><author><name>aldur</name><email>hello@aldur.blog</email></author><category term="articles" /><summary type="html"><![CDATA[🛶 Thinking in systems through models, loops, and delays.]]></summary></entry><entry><title type="html">Root login in NixOS containers</title><link href="https://aldur.blog/articles/2025/06/29/yubikey-root-login.html" rel="alternate" type="text/html" title="Root login in NixOS containers" /><published>2025-06-29T15:25:00+00:00</published><updated>2025-06-29T15:25:00+00:00</updated><id>https://aldur.blog/articles/2025/06/29/yubikey-root-login</id><content type="html" xml:base="https://aldur.blog/articles/2025/06/29/yubikey-root-login.html"><![CDATA[<p>When working in <a href="/articles/2025/06/19/nixos-in-crostini.html">NixOS containers under ChromeOS</a>, the container users <code class="language-plaintext highlighter-rouge">root</code> and
<code class="language-plaintext highlighter-rouge">aldur</code> have no password. This is very convenient, as it avoids managing
secrets in the NixOS configuration. However, it makes securely escalating
privileges tricky: both <code class="language-plaintext highlighter-rouge">sudo</code> and <code class="language-plaintext highlighter-rouge">su</code> ask for the user password, which we
can’t provide.</p>

<p>This post details my journey to find a secure and usable privilege escalation
method for NixOS containers.</p>

<h3 id="take-0-the-baseline">Take 0: the baseline</h3>

<p>When the container runs via <code class="language-plaintext highlighter-rouge">lxc</code> within the <code class="language-plaintext highlighter-rouge">termina</code> VM in ChromeOS, we
can spawn a root shell directly from Crosh:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Replace with `bash` if that's what you are using.</span>
lxc <span class="nb">exec</span> &lt;container-name&gt; fish
</code></pre></div></div>

<p>This works well and requires no extra configuration. However, it’s
inconvenient. It requires ChromeOS, and relies on access to the host VM. If
<a href="https://chromium.googlesource.com/chromiumos/platform2/+/HEAD/vm_tools/baguette_image/">ChromeOS
Baguette</a>
graduates to production, this approach will not work anymore.</p>

<h3 id="take-1-insecure-passwordless-sudo">Take 1: <em>insecure</em>, passwordless <code class="language-plaintext highlighter-rouge">sudo</code></h3>

<p>Enabling passwordless <code class="language-plaintext highlighter-rouge">sudo</code> for the users in the <code class="language-plaintext highlighter-rouge">wheel</code> group is temptingly
simple. NixOS makes it easy by setting <code class="language-plaintext highlighter-rouge">security.sudo.wheelNeedsPassword =
false</code>. The result, however, also makes it easy for an attacker to escalate
privileges and is essentially equivalent to being <code class="language-plaintext highlighter-rouge">root</code> all the time. Not
ideal.</p>

<h3 id="take-2-unsupported-pam-u2f">Take 2: <em>unsupported</em>, <code class="language-plaintext highlighter-rouge">pam-u2f</code></h3>

<p>Since I <a href="/articles/2025/06/26/yubikey-agent.html">already use a Yubikey</a>
to authenticate through SSH, sign commits, and even decrypt vaults, I
considered using it for privilege escalation as well. This approach would
maintain a separation of concerns between the users while
providing a reasonable security posture<sup id="fnref:shared_kernel"><a href="#fn:shared_kernel" class="footnote" rel="footnote" role="doc-noteref">1</a></sup>.</p>

<p>The <a href="https://github.com/Yubico/pam-u2f"><code class="language-plaintext highlighter-rouge">pam-u2f</code></a> module is the modern way to
go for this. However, FIDO2 doesn’t currently work under Crostini, due to
<a href="https://github.com/Yubico/yubikey-manager/issues/464">missing raw USB HID device
access</a>. The
corresponding kernel changes have been
<a href="https://issuetracker.google.com/issues/215265422?pli=1">merged</a> upstream last
year, so hopefully this will be fixed soon.</p>

<h3 id="take-3-insecure-yubico-pam">Take 3: <em>insecure</em>, <code class="language-plaintext highlighter-rouge">yubico-pam</code></h3>

<p>Yubico originally maintained the aptly named
<a href="https://github.com/Yubico/yubico-pam/tree/master"><code class="language-plaintext highlighter-rouge">yubico-pam</code></a> module, which
relies on HMAC-SHA1 Challenge-Response.</p>

<p>This module is now deprecated, but I managed to get it working by digging
through the <a href="https://github.com/Yubico/yubico-pam/blob/master/doc/Authentication_Using_Challenge-Response.adoc">old
docs</a>.</p>

<details>
  <summary>Setting up <code class="language-plaintext highlighter-rouge">yubico-pam</code> in NixOS</summary>

  <p>First, setup the Yubikey:</p>

  <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Configure the Yubikey OTP Slot 2 for Challenge-Response</span>
nix shell nixpkgs#yubikey-personalization
ykpersonalize <span class="nt">-2</span> <span class="nt">-ochal-resp</span> <span class="nt">-ochal-hmac</span> <span class="nt">-ohmac-lt64</span> <span class="nt">-oserial-api-visible</span>

<span class="c"># Now generate the challenge/response file</span>
nix shell nixpkgs#yubikey-pam
ykpamcfg <span class="nt">-2</span> <span class="nt">-v</span> <span class="nt">-t</span> /tmp

<span class="c"># This will create a file named &lt;user&gt;-&lt;yubikey-serial&gt;, e.g. aldur-324448.</span>
<span class="c"># Copy its contents.</span>
</code></pre></div>  </div>

  <p>Next, configure <code class="language-plaintext highlighter-rouge">yubico-pam</code> in NixOS so that it relies on local
challenge/response (instead of cloud-based) and so that the Yubikey replaces
the password:</p>

  <div class="language-nix highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">systemd</span><span class="o">.</span><span class="nv">tmpfiles</span><span class="o">.</span><span class="nv">rules</span> <span class="o">=</span> <span class="kd">let</span>
  <span class="nv">rootChallenge</span> <span class="o">=</span>
    <span class="s2">"v2:6fb040e2db2e5b881884adc0e60f8309f4929232e266344a790e7dd2f66b0b633d9e100cd6178258de0ad3cfb23d6a16536652d995f6238c2adc5c39880afe:a6055bd637546b2a87282ef3dc9023d5c99a637c:b21fbb0c37b317d14291db0cad4ea1e9f0a3f2687fea06b87d57983c229f0646:10000:2"</span><span class="p">;</span>
<span class="kn">in</span> <span class="p">[</span>
  <span class="s2">"d /var/yubico 0700 root root - -"</span>
  <span class="s2">"f /var/yubico/root-25972834 0600 root root - </span><span class="si">${</span><span class="nv">rootChallenge</span><span class="si">}</span><span class="s2">"</span>
<span class="p">];</span>

<span class="c"># NOTE: By default this enables yubico auth for _all_ PAM services.</span>
<span class="nv">security</span><span class="o">.</span><span class="nv">pam</span><span class="o">.</span><span class="nv">yubico</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nv">mode</span> <span class="o">=</span> <span class="s2">"challenge-response"</span><span class="p">;</span>
  <span class="nv">enable</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
  <span class="nv">control</span> <span class="o">=</span> <span class="s2">"sufficient"</span><span class="p">;</span>
  <span class="nv">challengeResponsePath</span> <span class="o">=</span> <span class="s2">"/var/yubico"</span><span class="p">;</span>
<span class="p">};</span>
</code></pre></div>  </div>

  <p>Lastly, rebuild your system configuration, plug-in the Yubikey, attach it to
the Termina VM, and try using <code class="language-plaintext highlighter-rouge">su</code> or <code class="language-plaintext highlighter-rouge">sudo</code> to escalate privileges.</p>

</details>
<p><br /></p>

<p>Unlike <code class="language-plaintext highlighter-rouge">pam-u2f</code>, this module does <em>not</em> require touching the Yubikey when
authenticating. For this reason, this method is <em>almost</em> as weak as
passwordless <code class="language-plaintext highlighter-rouge">sudo</code>. An attacker can easily escalate privileges if the Yubikey
is plugged-in, while the user will have a hard time noticing it.</p>

<h3 id="take-4-good-old-ssh">Take 4: good old SSH</h3>

<p>Since the container already ships with OpenSSH enabled, why not reuse it
to get a <code class="language-plaintext highlighter-rouge">root</code> shell?</p>

<p>We have already done the heavy lifting to use SSH identities from hardware
keys to remote hosts. We can simply authorize those same identities to sign-in as
<code class="language-plaintext highlighter-rouge">root</code> locally:</p>

<div class="language-nix highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">users</span><span class="o">.</span><span class="nv">users</span><span class="o">.</span><span class="nv">root</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nv">openssh</span><span class="o">.</span><span class="nv">authorizedKeys</span><span class="o">.</span><span class="nv">keys</span> <span class="o">=</span> <span class="p">(</span><span class="kr">import</span> <span class="sx">../authorized_keys.nix</span><span class="p">);</span>
<span class="p">};</span>

<span class="nv">services</span><span class="o">.</span><span class="nv">openssh</span><span class="o">.</span><span class="nv">settings</span><span class="o">.</span><span class="nv">AllowUsers</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"root"</span> <span class="p">];</span>
</code></pre></div></div>

<p>This approach strikes an good trade-off. OpenSSH is battle-tested and would be
running anyway, adding no new services. While I typically disable SSH for
<code class="language-plaintext highlighter-rouge">root</code> to reduce attack surface, the container runs in a namespaced network
connected only to the host, which mitigates the risk.</p>

<p>The main downside is usability. This approach leaves out <code class="language-plaintext highlighter-rouge">sudo</code>; the <code class="language-plaintext highlighter-rouge">root</code>
shell comes with its own profile and environmental variables, which reduces
ergonomics with respect to a fully configured user shell.</p>

<h3 id="bonus-take-ssh-agent">Bonus take: <code class="language-plaintext highlighter-rouge">ssh-agent</code></h3>

<p>If you miss <code class="language-plaintext highlighter-rouge">sudo</code>, we can get it working through <a href="https://github.com/jbeverly/pam_ssh_agent_auth">another PAM
module</a> that authenticates
through an SSH agent (and, by extension, hardware-backed SSH keys).</p>

<p>Enabling it in NixOS is easy as usual:</p>

<div class="language-nix highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">security</span><span class="o">.</span><span class="nv">pam</span><span class="o">.</span><span class="nv">sshAgentAuth</span><span class="o">.</span><span class="nv">enable</span> <span class="o">=</span> <span class="kc">true</span>
</code></pre></div></div>

<p>By default, the module will read keys from <code class="language-plaintext highlighter-rouge">/etc/ssh/authorized_keys.d/%u</code>
(where <code class="language-plaintext highlighter-rouge">%u</code> is the username authenticating), which is exactly where
<code class="language-plaintext highlighter-rouge">user.users.&lt;name&gt;.openssh.authorizedKeys.keys</code> stores keys.</p>

<p>This setup leverages the existing <code class="language-plaintext highlighter-rouge">SSH_AUTH_SOCK</code> and, for this, is also
compatible with
<a href="https://github.com/FiloSottile/yubikey-agent"><code class="language-plaintext highlighter-rouge">yubikey-agent</code></a>. running <code class="language-plaintext highlighter-rouge">sudo
echo "Hello world!"</code> will prompt for your Yubikey PIN (just like SSH) and
elevate your permissions.</p>

<p>The trade-off here is harder to evaluate: it brings a usability win with
seamless <code class="language-plaintext highlighter-rouge">sudo</code>; but it also adds the surface of another PAM module. The module
code is written in C, its last commit was a few years ago, and it is <a href="https://github.com/NixOS/nixpkgs/issues/31611">not
hard</a> to configure it so that
public keys are user-writable and render the system insecure. Use it with care!</p>

<h4 id="footnotes">Footnotes</h4>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:shared_kernel">
      <p>Yes, I am aware of the perils of a shared kernel and its exploits. <a href="#fnref:shared_kernel" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>aldur</name><email>hello@aldur.blog</email></author><category term="articles" /><category term="ChromeOS" /><summary type="html"><![CDATA[Finding a secure and usable privilege escalation method for NixOS containers.]]></summary></entry><entry><title type="html">Two SSH keys on a Yubikey</title><link href="https://aldur.blog/articles/2025/06/26/yubikey-agent.html" rel="alternate" type="text/html" title="Two SSH keys on a Yubikey" /><published>2025-06-26T09:17:00+00:00</published><updated>2025-06-26T09:17:00+00:00</updated><id>https://aldur.blog/articles/2025/06/26/yubikey-agent</id><content type="html" xml:base="https://aldur.blog/articles/2025/06/26/yubikey-agent.html"><![CDATA[<p>With <a href="/articles/2025/06/19/nixos-in-crostini.html">NixOS containers in ChromeOS</a>, I use an SSH key in a Yubikey to
authenticate to remote hosts and keep the key separated from the container.</p>

<p>Creating keys on the Yubikey and ensuring good UX while using them can be
tricky, but <a href="https://github.com/FiloSottile/yubikey-agent"><code class="language-plaintext highlighter-rouge">yubikey-agent</code></a> makes it seamless. There’s a catch though! It
supports one SSH key only, while I’d like to use two:</p>

<ol>
  <li>One to authenticate (e.g., to GitHub).</li>
  <li>The other to <a href="https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#ssh-commit-signature-verification">sign <code class="language-plaintext highlighter-rouge">git</code> commits</a>.</li>
</ol>

<p>This way, I can implement separation of duties and can
“decommission”<sup id="fnref:decommission"><a href="#fn:decommission" class="footnote" rel="footnote" role="doc-noteref">1</a></sup> each key independently.</p>

<p>A few PRs in the <code class="language-plaintext highlighter-rouge">yubikey-agent</code> repository add support for multiple keys and
even different policies (touch, PIN). Most try to develop a generic approach,
while I only need to support two keys. So, I decided to scratch my own itch and
implement a solution.</p>

<p>Luckily, the agent is written in Go, is easy to understand. The
<a href="https://github.com/go-piv/piv-go"><code class="language-plaintext highlighter-rouge">piv-go</code></a> does the heavy lifting. Under the
hood:</p>

<ul>
  <li>It asks the Yubikey to generate an elliptic curve (ECC256) key pair in the
PIV Authentication slot.</li>
  <li>Then it creates a self-signed X509 certificate to wrap the public key and
make it available to clients (our SSH agent) through the PKCS#11 interface.</li>
</ul>

<p><a href="https://developers.yubico.com/PIV/Guides/PIV_Walk-Through.html">This Yubikey tutorial</a> describes roughly the same process, but I find the
Go code easier to read and more precise. In addition, the setup guides the user
through the PIN/PUK setup and removes the need for a management key by
delegating its control to the PIN/PUK.</p>

<p>To support two keys, I decided to go the simplest approach and make the minimum
set of changes to the code to generate a new key (during the setup phase) and
then serve it through the SSH agent. I initially considered generating the key
through the CLI, but the delegation of the management key made it hard to do,
so I just went with code for the setup as well. By poking around the <code class="language-plaintext highlighter-rouge">piv-go</code>
code and the PIV standard, I decided to use slot <code class="language-plaintext highlighter-rouge">9c</code>, which is used for
Signature (the Yubikey docs even mention using it for <code class="language-plaintext highlighter-rouge">git commit</code>).</p>

<p>The result is in <a href="https://github.com/aldur/yubikey-agent" title="my fork of the project"><svg class="svg-icon grey" viewBox="0 0 512 512"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"></path></svg>  my fork of the project</a>.
I intentionally kept the code as simple as possible, so that it is easy to
maintain and follow upstream (in case I need to). If you want to try it out,
setup the Yubikey as usual and then run <code class="language-plaintext highlighter-rouge">yubikey-agent -setup-sign</code> to generate
the additional key. When running the agent, it will gracefully try loading both
keys and ignore the one for Signatures if it cannot be found.</p>

<p>I have also ensured that the ordering of the keys doesn’t change and have
configured my <code class="language-plaintext highlighter-rouge">git</code> client to sign with the <em>second</em> key returned by the agent.
This is convenient to use and matches my configuration on other hosts.</p>

<p>Here is the result:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">[</span>I] aldur@lxc-nixos ~&gt; ssh-add <span class="nt">-L</span>
ecdsa-sha2-nistp256 AAAAE2V...iUkW4JQUDA<span class="o">=</span> YubiKey <span class="c">#25972834 PIV Slot 9a</span>
ecdsa-sha2-nistp256 AAAAE2V...pKYi/Zh/HA<span class="o">=</span> YubiKey <span class="c">#25972834 PIV Slot 9c</span>
</code></pre></div></div>

<p>To deploy my changes, I wrote a small Nix overlay that applies my patch:</p>

<div class="language-nix highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nv">final</span><span class="p">:</span> <span class="nv">prev</span><span class="p">:</span> <span class="p">{</span>
  <span class="nv">yubikey-agent</span> <span class="o">=</span> <span class="p">(</span>
    <span class="nv">prev</span><span class="o">.</span><span class="nv">yubikey-agent</span><span class="o">.</span><span class="nv">overrideAttrs</span> <span class="p">(</span><span class="nv">old</span><span class="p">:</span> <span class="p">{</span>
      <span class="c"># Used a patch instead of overriding the source so that it will keep</span>
      <span class="c"># working (or explicitly break) on upstream updates.</span>
      <span class="nv">patches</span> <span class="o">=</span> <span class="p">(</span><span class="nv">old</span><span class="o">.</span><span class="nv">patches</span> <span class="nv">or</span> <span class="p">[</span> <span class="p">])</span> <span class="o">++</span> <span class="p">[</span>
        <span class="p">(</span><span class="nv">prev</span><span class="o">.</span><span class="nv">fetchurl</span> <span class="p">{</span>
          <span class="nv">url</span> <span class="o">=</span> <span class="s2">"https://github.com/aldur/yubikey-agent/commit/f7a6769fd832a867e62228c8ddb0133174db64bf.patch"</span><span class="p">;</span>
          <span class="nv">hash</span> <span class="o">=</span> <span class="s2">"sha256-swQb3N89yAJSQ4pkUq2DDKvEFBlzhr/tbNMdC2p60VE="</span><span class="p">;</span>
        <span class="p">})</span>
      <span class="p">];</span>
    <span class="p">})</span>
  <span class="p">);</span>
<span class="p">})</span>
</code></pre></div></div>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:decommission">
      <p>Ideally I’d want to revoke keys, but there is no built-in way
to do it for SSH (that I am aware of). The PIV standard relies on
certificates, so there might be a way to revoke them – but then, consumers
would need to check a revocation list, and I don’t think they do. GPG keys
can be revoked, but come with other downsides. <a href="#fnref:decommission" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>aldur</name><email>hello@aldur.blog</email></author><category term="articles" /><category term="ChromeOS" /><summary type="html"><![CDATA[I use a single Yubikey and two SSH keys to authenticate to remote hosts and sign commits.]]></summary></entry></feed>