<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.9.3">Jekyll</generator><link href="https://gofranz.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://gofranz.com/" rel="alternate" type="text/html" /><updated>2026-09-24T08:50:15+01:00</updated><id>https://gofranz.com/feed.xml</id><title type="html">Franz Geffke</title><subtitle>Senior Identity Engineer &amp; Lead AI Architect, ½ Machine - Personal website and blog covering digital identity, verifiable credentials, AI in regulated systems, and system architecture</subtitle><entry><title type="html">Stepping Up Guix System Security</title><link href="https://gofranz.com/blog/stepping-up-guix-system-security/" rel="alternate" type="text/html" title="Stepping Up Guix System Security" /><published>2026-09-20T01:00:00+01:00</published><updated>2026-09-20T01:00:00+01:00</updated><id>https://gofranz.com/blog/stepping-up-guix-system-security</id><content type="html" xml:base="https://gofranz.com/blog/stepping-up-guix-system-security/"><![CDATA[<p>I spent a couple of weeks reading up on what Linux distributions do at runtime to contain an attack: not packaging or CVE cadence, just what the running system does once something goes wrong. Guix System doesn’t do much. <code class="language-plaintext highlighter-rouge">%default-sysctl-settings</code> is two entries (<code class="language-plaintext highlighter-rouge">fs.protected_hardlinks</code> and <code class="language-plaintext highlighter-rouge">fs.protected_symlinks</code>, that’s it). Neither <code class="language-plaintext highlighter-rouge">%base-services</code> nor <code class="language-plaintext highlighter-rouge">%desktop-services</code> includes a firewall. Thirteen programs are setuid or capability-privileged out of the box, and most Shepherd services run as root.</p>

<p>Guix does make fixing this cheap: the whole system configuration is one Scheme file in git, and <code class="language-plaintext highlighter-rouge">guix system roll-back</code> takes seconds when a change breaks something. Over the last months I worked through the list, roughly in order of effort. This is what stuck, written so it applies to any Guix System install. Nothing here needs a custom kernel.</p>

<p>If you’re on a stock <code class="language-plaintext highlighter-rouge">linux-libre</code> kernel, one caveat up front: it ships no CPU microcode, and half of the Spectre-class mitigations live in microcode. On an affected part, <code class="language-plaintext highlighter-rouge">/sys/devices/system/cpu/vulnerabilities/*</code> will read “Vulnerable” no matter what you set below. The fix is the <code class="language-plaintext highlighter-rouge">nonguix</code> channel (kernel plus <code class="language-plaintext highlighter-rouge">microcode-initrd</code>), which is an unofficial channel with no security team of its own. I use it; decide for yourself.</p>

<h3 id="sysctl">sysctl</h3>

<p>This block extends the two defaults rather than replacing them:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">use-modules</span> <span class="p">(</span><span class="nf">gnu</span> <span class="nv">services</span> <span class="nv">sysctl</span><span class="p">))</span>

<span class="p">(</span><span class="nf">modify-services</span> <span class="nv">%desktop-services</span>
  <span class="p">(</span><span class="nf">sysctl-service-type</span> <span class="nv">config</span> <span class="nv">=&gt;</span>
    <span class="p">(</span><span class="nf">sysctl-configuration</span>
     <span class="p">(</span><span class="nf">inherit</span> <span class="nv">config</span><span class="p">)</span>
     <span class="p">(</span><span class="nf">settings</span>
      <span class="p">(</span><span class="nb">append</span> <span class="p">(</span><span class="nf">sysctl-configuration-settings</span> <span class="nv">config</span><span class="p">)</span>
              <span class="o">'</span><span class="p">((</span><span class="s">"kernel.dmesg_restrict"</span> <span class="o">.</span> <span class="s">"1"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"kernel.kptr_restrict"</span> <span class="o">.</span> <span class="s">"2"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"kernel.yama.ptrace_scope"</span> <span class="o">.</span> <span class="s">"1"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"kernel.kexec_load_disabled"</span> <span class="o">.</span> <span class="s">"1"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"kernel.unprivileged_bpf_disabled"</span> <span class="o">.</span> <span class="s">"1"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"net.core.bpf_jit_harden"</span> <span class="o">.</span> <span class="s">"2"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"kernel.perf_event_paranoid"</span> <span class="o">.</span> <span class="s">"3"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"kernel.io_uring_disabled"</span> <span class="o">.</span> <span class="s">"2"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"kernel.sysrq"</span> <span class="o">.</span> <span class="s">"0"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"dev.tty.ldisc_autoload"</span> <span class="o">.</span> <span class="s">"0"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"fs.protected_fifos"</span> <span class="o">.</span> <span class="s">"2"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"fs.protected_regular"</span> <span class="o">.</span> <span class="s">"2"</span><span class="p">)</span>
                <span class="c1">;; Network</span>
                <span class="p">(</span><span class="s">"net.ipv4.conf.all.rp_filter"</span> <span class="o">.</span> <span class="s">"1"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"net.ipv4.conf.default.rp_filter"</span> <span class="o">.</span> <span class="s">"1"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"net.ipv4.conf.all.accept_redirects"</span> <span class="o">.</span> <span class="s">"0"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"net.ipv4.conf.default.accept_redirects"</span> <span class="o">.</span> <span class="s">"0"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"net.ipv6.conf.all.accept_redirects"</span> <span class="o">.</span> <span class="s">"0"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"net.ipv6.conf.default.accept_redirects"</span> <span class="o">.</span> <span class="s">"0"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"net.ipv4.conf.all.send_redirects"</span> <span class="o">.</span> <span class="s">"0"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"net.ipv4.conf.default.send_redirects"</span> <span class="o">.</span> <span class="s">"0"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"net.ipv4.conf.all.accept_source_route"</span> <span class="o">.</span> <span class="s">"0"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"net.ipv4.conf.default.accept_source_route"</span> <span class="o">.</span> <span class="s">"0"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"net.ipv6.conf.all.accept_source_route"</span> <span class="o">.</span> <span class="s">"0"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"net.ipv4.tcp_syncookies"</span> <span class="o">.</span> <span class="s">"1"</span><span class="p">)</span>
                <span class="p">(</span><span class="s">"net.ipv4.icmp_echo_ignore_broadcasts"</span> <span class="o">.</span> <span class="s">"1"</span><span class="p">)))))))</span>
</code></pre></div></div>

<p>Two of these are worth explaining.</p>

<p><strong><code class="language-plaintext highlighter-rouge">kernel.io_uring_disabled=2</code></strong> has the best evidence behind it. <a rel="noopener" href="https://security.googleblog.com/2023/06/learnings-from-kctf-vrps-42-linux.html">Google’s kCTF numbers</a> for the year to mid-2023: 60% of exploit submissions went through io_uring, and it featured in every submission that bypassed their mitigations. The sysctl has existed since 6.6; <code class="language-plaintext highlighter-rouge">2</code> means <code class="language-plaintext highlighter-rouge">io_uring_setup</code> fails with <code class="language-plaintext highlighter-rouge">EPERM</code> for everyone. On a desktop the only thing I found using it was libuv (Node, Electron), which falls back to its threadpool on that error. Recent PostgreSQL and QEMU builds can use it; if you run those natively, <code class="language-plaintext highlighter-rouge">1</code> plus <code class="language-plaintext highlighter-rouge">kernel.io_uring_group</code> is the softer setting.</p>

<p><strong><code class="language-plaintext highlighter-rouge">kernel.yama.ptrace_scope=1</code></strong> is the one you’ll notice: <code class="language-plaintext highlighter-rouge">gdb -p</code> and <code class="language-plaintext highlighter-rouge">strace -p</code> on a process that isn’t your descendant need <code class="language-plaintext highlighter-rouge">sudo</code> from now on. Chromium, Firefox and Wine already call <code class="language-plaintext highlighter-rouge">prctl(PR_SET_PTRACER)</code> for their own crash handlers, so they keep working.</p>

<p>Left out on purpose: anything that disables unprivileged user namespaces. On Guix that breaks the unprivileged daemon (see below).</p>

<h3 id="kernel-arguments">Kernel arguments</h3>

<p>The rest goes on the kernel command line. Keep <code class="language-plaintext highlighter-rouge">%default-kernel-arguments</code> at the end or you lose Guix’s module blacklist:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">kernel-arguments</span>
 <span class="p">(</span><span class="nf">cons*</span> <span class="s">"slab_nomerge"</span>
        <span class="s">"randomize_kstack_offset=on"</span>
        <span class="s">"page_alloc.shuffle=1"</span>
        <span class="s">"init_on_alloc=1"</span>
        <span class="s">"proc_mem.force_override=never"</span>
        <span class="nv">%default-kernel-arguments</span><span class="p">))</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">init_on_alloc=1</code> is the only one with a measurable cost: upstream measured +7.75% sys time on hackbench and -0.13% wall on a kernel build, so it depends entirely on what you do. I left <code class="language-plaintext highlighter-rouge">init_on_free=1</code> out - +8.38% wall and +24.42% sys on a kernel build, and Kees Cook’s own note is that catching a write between free and alloc is “pretty rare”. Kicksecure and secureblue run both; no mainstream distro does.</p>

<p>A few things you might expect here are already handled by the kernel config Guix ships, so there’s nothing to add: <code class="language-plaintext highlighter-rouge">vsyscall=none</code> (<code class="language-plaintext highlighter-rouge">CONFIG_LEGACY_VSYSCALL_NONE</code>), <code class="language-plaintext highlighter-rouge">TIOCSTI</code> compiled out, <code class="language-plaintext highlighter-rouge">SLAB_FREELIST_RANDOM</code> and <code class="language-plaintext highlighter-rouge">_HARDENED</code>, <code class="language-plaintext highlighter-rouge">FORTIFY_SOURCE</code>, <code class="language-plaintext highlighter-rouge">HARDENED_USERCOPY</code>, <code class="language-plaintext highlighter-rouge">STRICT_DEVMEM</code>. Check your own with <code class="language-plaintext highlighter-rouge">zcat /proc/config.gz</code> or the <code class="language-plaintext highlighter-rouge">.config</code> next to <code class="language-plaintext highlighter-rouge">bzImage</code> in the store, because the same file also tells you what’s <em>not</em> there: in the 7.2 config I looked at, <code class="language-plaintext highlighter-rouge">CONFIG_SECURITY_LOCKDOWN_LSM</code> and <code class="language-plaintext highlighter-rouge">CONFIG_MODULE_SIG</code> are both unset. So <code class="language-plaintext highlighter-rouge">lockdown=integrity</code> is silently ignored, and you’d need <code class="language-plaintext highlighter-rouge">customize-linux</code> and a local kernel build to get it.</p>

<h3 id="a-firewall">A firewall</h3>

<p>Guix’s example ruleset for <code class="language-plaintext highlighter-rouge">iptables-service-type</code> is called <code class="language-plaintext highlighter-rouge">%iptables-accept-all-rules</code>, and it does exactly that. Here’s a default-deny replacement. Open what you serve; I’ve left SSH in as the obvious example:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">use-modules</span> <span class="p">(</span><span class="nf">gnu</span> <span class="nv">services</span> <span class="nv">networking</span><span class="p">))</span>

<span class="p">(</span><span class="k">define</span> <span class="nv">%firewall-ipv4</span>
  <span class="p">(</span><span class="nf">plain-file</span> <span class="s">"iptables.rules"</span> <span class="s">"*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -m conntrack --ctstate INVALID -j DROP
-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A INPUT -i lo -j ACCEPT
-A INPUT -p tcp --dport 22 -j ACCEPT
COMMIT
"</span><span class="p">))</span>

<span class="c1">;; NDP and MLD are untracked by conntrack, so the ESTABLISHED,RELATED</span>
<span class="c1">;; accept never matches them. Without the ICMPv6 lines below, IPv6</span>
<span class="c1">;; simply never comes up - no neighbour ever resolves.</span>
<span class="p">(</span><span class="k">define</span> <span class="nv">%firewall-ipv6</span>
  <span class="p">(</span><span class="nf">plain-file</span> <span class="s">"ip6tables.rules"</span> <span class="s">"*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -m conntrack --ctstate INVALID -j DROP
-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A INPUT -i lo -j ACCEPT
-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type 1 -j ACCEPT
-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type 2 -j ACCEPT
-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type 3 -j ACCEPT
-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type 4 -j ACCEPT
-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type 130 -j ACCEPT
-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type 134 -j ACCEPT
-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type 135 -j ACCEPT
-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type 136 -j ACCEPT
-A INPUT -p tcp --dport 22 -j ACCEPT
COMMIT
"</span><span class="p">))</span>

<span class="p">(</span><span class="nf">service</span> <span class="nv">iptables-service-type</span>
         <span class="p">(</span><span class="nf">iptables-configuration</span>
          <span class="p">(</span><span class="nf">ipv4-rules</span> <span class="nv">%firewall-ipv4</span><span class="p">)</span>
          <span class="p">(</span><span class="nf">ipv6-rules</span> <span class="nv">%firewall-ipv6</span><span class="p">)))</span>
</code></pre></div></div>

<p>The ICMPv6 types are the part of <a rel="noopener" href="https://datatracker.ietf.org/doc/html/rfc4890">RFC 4890</a>’s must-not-drop list a host actually receives: the four error types, MLD queries, router advertisements and neighbour solicitation/advertisement. Echo and redirect are left out on purpose (<code class="language-plaintext highlighter-rouge">accept_redirects=0</code> above). I found the conntrack gap on a v6 network that looked configured and moved zero packets.</p>

<h3 id="fewer-privileged-programs">Fewer privileged programs</h3>

<p>This is where Guix is ahead of other distros: the setuid list is a field in your config, not something you discover with <code class="language-plaintext highlighter-rouge">find / -perm -4000</code>. The default is thirteen entries: <code class="language-plaintext highlighter-rouge">ping</code>, <code class="language-plaintext highlighter-rouge">ping6</code>, <code class="language-plaintext highlighter-rouge">passwd</code>, <code class="language-plaintext highlighter-rouge">chfn</code>, <code class="language-plaintext highlighter-rouge">sg</code>, <code class="language-plaintext highlighter-rouge">su</code>, <code class="language-plaintext highlighter-rouge">newgrp</code>, <code class="language-plaintext highlighter-rouge">newuidmap</code>, <code class="language-plaintext highlighter-rouge">newgidmap</code>, <code class="language-plaintext highlighter-rouge">sudo</code>, <code class="language-plaintext highlighter-rouge">sudoedit</code>, <code class="language-plaintext highlighter-rouge">fusermount</code>, <code class="language-plaintext highlighter-rouge">fusermount3</code>. <code class="language-plaintext highlighter-rouge">ping</code> and <code class="language-plaintext highlighter-rouge">ping6</code> are already capability-based rather than setuid, which is the right model.</p>

<p>I never use <code class="language-plaintext highlighter-rouge">chfn</code>, <code class="language-plaintext highlighter-rouge">sg</code>, <code class="language-plaintext highlighter-rouge">newgrp</code> or <code class="language-plaintext highlighter-rouge">su</code> (sudo covers it), and nothing mounts fuse2 here, so:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">use-modules</span> <span class="p">(</span><span class="nf">gnu</span> <span class="nv">system</span> <span class="nv">privilege</span><span class="p">)</span>
             <span class="p">(</span><span class="nf">gnu</span> <span class="nv">system</span> <span class="nv">setuid</span><span class="p">)</span>
             <span class="p">(</span><span class="nf">gnu</span> <span class="nv">packages</span> <span class="nv">admin</span><span class="p">)</span>       <span class="c1">;; sudo, shadow, inetutils</span>
             <span class="p">(</span><span class="nf">gnu</span> <span class="nv">packages</span> <span class="nv">linux</span><span class="p">))</span>      <span class="c1">;; fuse</span>

<span class="p">(</span><span class="nf">privileged-programs</span>
 <span class="p">(</span><span class="nf">cons*</span> <span class="p">(</span><span class="nf">privileged-program</span>
         <span class="p">(</span><span class="nf">program</span> <span class="p">(</span><span class="nf">file-append</span> <span class="nv">inetutils</span> <span class="s">"/bin/ping"</span><span class="p">))</span>
         <span class="p">(</span><span class="nf">capabilities</span> <span class="s">"cap_net_raw=ep"</span><span class="p">))</span>
        <span class="p">(</span><span class="nf">privileged-program</span>
         <span class="p">(</span><span class="nf">program</span> <span class="p">(</span><span class="nf">file-append</span> <span class="nv">inetutils</span> <span class="s">"/bin/ping6"</span><span class="p">))</span>
         <span class="p">(</span><span class="nf">capabilities</span> <span class="s">"cap_net_raw=ep"</span><span class="p">))</span>
        <span class="p">(</span><span class="nb">map</span> <span class="nv">file-like-&gt;setuid-program</span>
             <span class="p">(</span><span class="nb">list</span> <span class="p">(</span><span class="nf">file-append</span> <span class="nv">shadow</span> <span class="s">"/bin/passwd"</span><span class="p">)</span>
                   <span class="p">(</span><span class="nf">file-append</span> <span class="nv">shadow</span> <span class="s">"/bin/newuidmap"</span><span class="p">)</span>
                   <span class="p">(</span><span class="nf">file-append</span> <span class="nv">shadow</span> <span class="s">"/bin/newgidmap"</span><span class="p">)</span>
                   <span class="p">(</span><span class="nf">file-append</span> <span class="nv">sudo</span> <span class="s">"/bin/sudo"</span><span class="p">)</span>
                   <span class="p">(</span><span class="nf">file-append</span> <span class="nv">sudo</span> <span class="s">"/bin/sudoedit"</span><span class="p">)</span>
                   <span class="p">(</span><span class="nf">file-append</span> <span class="nv">fuse</span> <span class="s">"/bin/fusermount3"</span><span class="p">)))))</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">newuidmap</code> and <code class="language-plaintext highlighter-rouge">newgidmap</code> stay if you run rootless Podman; it writes the subuid maps through them. Drop them too if you don’t.</p>

<p><code class="language-plaintext highlighter-rouge">%desktop-services</code> adds more. <code class="language-plaintext highlighter-rouge">mount.nfs</code> and <code class="language-plaintext highlighter-rouge">mount.ntfs-3g</code> arrive setuid through a <code class="language-plaintext highlighter-rouge">simple-service</code> named <code class="language-plaintext highlighter-rouge">mount-setuid-helpers</code>, and because a <code class="language-plaintext highlighter-rouge">simple-service</code> wraps the target type in a fresh one, <code class="language-plaintext highlighter-rouge">modify-services</code> can’t delete it by type - filter by name instead:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="k">define</span> <span class="nv">%desktop-services-trimmed</span>
  <span class="p">(</span><span class="nf">filter</span> <span class="p">(</span><span class="k">lambda</span> <span class="p">(</span><span class="nf">service</span><span class="p">)</span>
            <span class="p">(</span><span class="nb">not</span> <span class="p">(</span><span class="nb">eq?</span> <span class="ss">'mount-setuid-helpers</span>
                      <span class="p">(</span><span class="nf">service-type-name</span> <span class="p">(</span><span class="nf">service-kind</span> <span class="nv">service</span><span class="p">)))))</span>
          <span class="nv">%desktop-services</span><span class="p">))</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">slock</code> and <code class="language-plaintext highlighter-rouge">xlock</code> come setuid-root via <code class="language-plaintext highlighter-rouge">screen-locker-service-type</code> as well. If you’re on Wayland with <code class="language-plaintext highlighter-rouge">swaylock</code>, delete both and declare your own locker with <code class="language-plaintext highlighter-rouge">(using-setuid? #f)</code> and <code class="language-plaintext highlighter-rouge">(using-pam? #t)</code>.</p>

<p>Four more you can’t remove: <code class="language-plaintext highlighter-rouge">unix_chkpwd</code> (PAM), <code class="language-plaintext highlighter-rouge">dbus-daemon-launch-helper</code>, and polkit’s <code class="language-plaintext highlighter-rouge">polkit-agent-helper-1</code> and <code class="language-plaintext highlighter-rouge">pkexec</code>. The last one comes bundled with polkit’s extension, and <code class="language-plaintext highlighter-rouge">polkit-service-type</code> has no option to drop it.</p>

<h3 id="run-guix-daemon-unprivileged">Run guix-daemon unprivileged</h3>

<p>Since 1.5, <code class="language-plaintext highlighter-rouge">guix-daemon</code> can run as its own user instead of root, with per-build UID isolation done through user namespaces instead of the <code class="language-plaintext highlighter-rouge">guixbuilder01..10</code> pool:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">guix-service-type</span> <span class="nv">config</span> <span class="nv">=&gt;</span>
  <span class="p">(</span><span class="nf">guix-configuration</span>
   <span class="p">(</span><span class="nf">inherit</span> <span class="nv">config</span><span class="p">)</span>
   <span class="p">(</span><span class="nf">privileged?</span> <span class="no">#f</span><span class="p">)))</span>
</code></pre></div></div>

<p>Every daemon-side root escalation of the last two years - CVE-2024-27297, CVE-2024-52867, the 2026 substitute bugs - lands in a plain user account instead. That’s a structural fix for a whole class of bugs, and it’s one line.</p>

<p>Three consequences, none of them obvious from the docs:</p>

<ul>
  <li><strong>The first reconfigure chowns the entire store.</strong> On my ~180 GB store that’s a blocking walk over ~90k items. Do it when you have time.</li>
  <li><strong>Store files are now owned by <code class="language-plaintext highlighter-rouge">guix-daemon</code>, not root.</strong> Anything that insists on root-owned config - <code class="language-plaintext highlighter-rouge">auditd</code>, <code class="language-plaintext highlighter-rouge">auditctl</code>, <code class="language-plaintext highlighter-rouge">pam_u2f</code>’s authfile - will reject a file you deploy through <code class="language-plaintext highlighter-rouge">etc-service-type</code>, because that’s a symlink into the store. Copy such files out with an activation service and <code class="language-plaintext highlighter-rouge">chown</code> them (example below).</li>
  <li><strong>Unprivileged user namespaces must stay on.</strong> No <code class="language-plaintext highlighter-rouge">user.max_user_namespaces=0</code>, no AppArmor userns restriction. That’s the one headline hardening move of the last few years (Ubuntu’s 24.04 default; ~44% of observed kernel exploit chains reportedly need userns) that’s off the table here, because the daemon uses exactly that mechanism for build isolation. I left a comment in the sysctl block so I don’t add it back by accident.</li>
</ul>

<h3 id="delete-services-you-dont-use">Delete services you don’t use</h3>

<p>The cheapest attack-surface reduction is not running things. From <code class="language-plaintext highlighter-rouge">%desktop-services</code>, on a laptop:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">modify-services</span> <span class="nv">%desktop-services-trimmed</span>
  <span class="c1">;; Hands browsers WiFi-derived street-level location via beacondb.net.</span>
  <span class="p">(</span><span class="nf">delete</span> <span class="nv">geoclue-service-type</span><span class="p">)</span>
  <span class="c1">;; Announces the hostname on every LAN; nsswitch has no mdns anyway.</span>
  <span class="p">(</span><span class="nf">delete</span> <span class="nv">avahi-service-type</span><span class="p">)</span>
  <span class="c1">;; Sends AT commands to any USB serial device; runs as root on insert.</span>
  <span class="p">(</span><span class="nf">delete</span> <span class="nv">modem-manager-service-type</span><span class="p">)</span>
  <span class="p">(</span><span class="nf">delete</span> <span class="nv">usb-modeswitch-service-type</span><span class="p">)</span>
  <span class="c1">;; Replaced by chrony with NTS - authenticated time.</span>
  <span class="p">(</span><span class="nf">delete</span> <span class="nv">ntp-service-type</span><span class="p">))</span>
</code></pre></div></div>

<p>NetworkManager depends on none of them. Check with <code class="language-plaintext highlighter-rouge">herd status</code> after a reboot what’s still running as root, and whether you need each one.</p>

<h3 id="auditd">auditd</h3>

<p><code class="language-plaintext highlighter-rouge">auditd-service-type</code> is upstream. The value on a single-user machine is a log of who read your keys, and of every <code class="language-plaintext highlighter-rouge">execve</code> as root by a logged-in user (daemons carry <code class="language-plaintext highlighter-rouge">auid -1</code>, so they’re excluded):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>-w /home/you/.ssh -p rwa -k sensitive-files
-w /home/you/.gnupg -p rwa -k sensitive-files
-w /home/you/.password-store -p rwa -k sensitive-files
-w /etc/sudoers -p wa -k privilege
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid&gt;=1000 -F auid!=-1 -k root-exec
-a always,exit -F arch=b32 -S execve -F euid=0 -F auid&gt;=1000 -F auid!=-1 -k root-exec
-e 2
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">-e 2</code> locks the rules until reboot, so a root compromise can’t quietly drop a watch. It also means rule changes land at the next boot, not on reconfigure. Note <code class="language-plaintext highlighter-rouge">-w &lt;dir&gt;</code> is a recursive subtree watch; point it at secrets, not at a state directory with a 300 MB index in it.</p>

<p>Two traps, both caused by the unprivileged daemon above. <code class="language-plaintext highlighter-rouge">auditd</code> refuses a config file not owned by uid 0, so copy it out of the store:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">simple-service</span> <span class="ss">'auditd-config</span> <span class="nv">activation-service-type</span>
  <span class="o">#</span><span class="nv">~</span><span class="p">(</span><span class="k">begin</span>
      <span class="p">(</span><span class="nf">mkdir-p</span> <span class="s">"/etc/audit"</span><span class="p">)</span>
      <span class="p">(</span><span class="nf">chown</span> <span class="s">"/etc/audit"</span> <span class="mi">0</span> <span class="mi">0</span><span class="p">)</span>
      <span class="p">(</span><span class="nf">chmod</span> <span class="s">"/etc/audit"</span> <span class="mo">#o700</span><span class="p">)</span>
      <span class="p">(</span><span class="nf">mkdir-p</span> <span class="s">"/var/log/audit"</span><span class="p">)</span>
      <span class="p">(</span><span class="nb">for-each</span> <span class="p">(</span><span class="k">lambda</span> <span class="p">(</span><span class="nf">source</span> <span class="nv">target</span><span class="p">)</span>
                  <span class="p">(</span><span class="nf">false-if-exception</span> <span class="p">(</span><span class="nf">delete-file</span> <span class="nv">target</span><span class="p">))</span>
                  <span class="p">(</span><span class="nf">copy-file</span> <span class="nv">source</span> <span class="nv">target</span><span class="p">)</span>
                  <span class="p">(</span><span class="nf">chown</span> <span class="nv">target</span> <span class="mi">0</span> <span class="mi">0</span><span class="p">)</span>
                  <span class="p">(</span><span class="nf">chmod</span> <span class="nv">target</span> <span class="mo">#o600</span><span class="p">))</span>
                <span class="p">(</span><span class="nb">list</span> <span class="o">#</span><span class="nv">$%auditd-conf</span> <span class="o">#</span><span class="nv">$%audit-rules</span><span class="p">)</span>
                <span class="p">(</span><span class="nb">list</span> <span class="s">"/etc/audit/auditd.conf"</span> <span class="s">"/etc/audit/audit.rules"</span><span class="p">))))</span>

<span class="p">(</span><span class="nf">service</span> <span class="nv">auditd-service-type</span>
         <span class="p">(</span><span class="nf">auditd-configuration</span>
          <span class="p">(</span><span class="nf">configuration-directory</span> <span class="s">"/etc/audit"</span><span class="p">)))</span>
</code></pre></div></div>

<p>And log into <code class="language-plaintext highlighter-rouge">/var/log/audit/</code>, not <code class="language-plaintext highlighter-rouge">/var/log/audit.log</code> directly: <code class="language-plaintext highlighter-rouge">auditd</code> chmods the log file’s parent directory to <code class="language-plaintext highlighter-rouge">0700</code> on every start and rotation. Pointed at <code class="language-plaintext highlighter-rouge">/var/log</code> it locks the whole directory, and the unprivileged daemon can no longer write its build logs under <code class="language-plaintext highlighter-rouge">/var/log/guix</code>.</p>

<p>The service doesn’t load rules itself; a one-shot Shepherd service running <code class="language-plaintext highlighter-rouge">auditctl -R /etc/audit/audit.rules</code> after <code class="language-plaintext highlighter-rouge">auditd</code> does. Once locked, <code class="language-plaintext highlighter-rouge">auditctl -s</code> reports <code class="language-plaintext highlighter-rouge">enabled 2</code> and a reload is refused, so treat that as success or your reconfigure fails every time.</p>

<h3 id="usbguard">USBGuard</h3>

<p>Not the biggest risk for most people. RHEL ships it disabled; it’s the one piece from there I wanted. The approach has two halves. The kernel argument <code class="language-plaintext highlighter-rouge">usbcore.authorized_default=0</code> makes every USB device start unauthorized, closing the window between boot and the daemon coming up. USBGuard then authorizes devices matching a rule and leaves everything else blocked:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ usbguard list-devices --blocked
$ usbguard allow-device -p &lt;id&gt;
</code></pre></div></div>

<p>Upstream Guix packages <code class="language-plaintext highlighter-rouge">usbguard</code> but has no service for it. I wrote <a rel="noopener" href="https://codeberg.org/gofranz/panther/src/branch/master/px/services/usbguard.scm">one</a> in my <a rel="noopener" href="https://codeberg.org/gofranz/panther">panther channel</a>; if you’d rather not pull a channel for it, the service is small: a Shepherd service running <code class="language-plaintext highlighter-rouge">usbguard-daemon</code> with a config that sets <code class="language-plaintext highlighter-rouge">ImplicitPolicyTarget=block</code>, <code class="language-plaintext highlighter-rouge">PresentDevicePolicy=apply-policy</code>, <code class="language-plaintext highlighter-rouge">AuthorizedDefault=none</code>, and an IPC group so your user can run the CLI without sudo.</p>

<p>Two warnings. <strong>Check what your keyboard is.</strong> On my laptop it’s on i8042, so the LUKS prompt and greeter work with no USB at all. If yours is internal USB, <code class="language-plaintext highlighter-rouge">authorized_default=0</code> locks you out at the disk prompt. And this guards against unknown hardware, not against a process already running as you - anyone in the IPC group can allow a device.</p>

<h3 id="apparmor-for-one-app-at-a-time">AppArmor, for one app at a time</h3>

<p>Guix has no MAC service of any kind. The kernel side is there, though: <code class="language-plaintext highlighter-rouge">linux-libre</code> is built with AppArmor and Landlock, but its <code class="language-plaintext highlighter-rouge">CONFIG_LSM</code> string leaves Landlock out and puts SELinux, Smack and TOMOYO in (none of which you can use). So you need an explicit <code class="language-plaintext highlighter-rouge">lsm=</code> on the command line, an <code class="language-plaintext highlighter-rouge">apparmor</code> userspace, and something that mounts <code class="language-plaintext highlighter-rouge">securityfs</code> and runs <code class="language-plaintext highlighter-rouge">apparmor_parser -r</code> on your profiles at boot. Again, the <a rel="noopener" href="https://codeberg.org/gofranz/panther/src/branch/master/px/services/apparmor.scm">service</a> lives in panther; the same channel carries <code class="language-plaintext highlighter-rouge">apparmor</code> 5.0.2, because a profile that denies a unix socket by path needs <code class="language-plaintext highlighter-rouge">network_v9</code> policy, which the 4.1.2 in Guix can’t emit.</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="s">"lsm=landlock,yama,apparmor"</span>
</code></pre></div></div>

<p>The realistic use isn’t confining your whole session (Fedora doesn’t either; your desktop is <code class="language-plaintext highlighter-rouge">unconfined_t</code> there too). It’s containing the one app you trust least. Mine is an Electron chat client that I’d rather not have talking to NetworkManager and BlueZ for location-revealing scan results:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>abi &lt;abi/5.0&gt;,
include &lt;tunables/global&gt;

profile chat /gnu/store/*-chat-desktop-*/{bin/chat,lib/chat/chat} {
  include &lt;abstractions/base&gt;

  userns,        # Electron's Chromium sandbox needs it
  file,
  capability,
  network,

  # The session bus at /run/user/*/bus stays open: tray, notifications
  # and portals keep working. Only the system bus is off limits.
  audit deny /run/dbus/system_bus_socket rw,
  audit deny /var/run/dbus/system_bus_socket rw,
  audit deny network netlink,
}
</code></pre></div></div>

<p>The store path changes on every upgrade, hence the glob. Two gotchas: <code class="language-plaintext highlighter-rouge">deny</code> rules are inert under <code class="language-plaintext highlighter-rouge">flags=(complain)</code>, contrary to what <code class="language-plaintext highlighter-rouge">aa-complain(8)</code> suggests, so this has to run in enforce mode from day one. And without the <code class="language-plaintext highlighter-rouge">abi &lt;abi/5.0&gt;</code> line the socket deny stops <code class="language-plaintext highlighter-rouge">open()</code> but not <code class="language-plaintext highlighter-rouge">connect()</code>, so the rule looks like it works while the bus stays reachable. Denials show up in the same audit log as everything above: <code class="language-plaintext highlighter-rouge">ausearch -m AVC</code>.</p>

<p>To be clear about what this buys: MAC contains userspace lateral movement. It does nothing against a kernel exploit (Dirty COW ran straight through SELinux). It’s worth having; it’s not a guarantee.</p>

<h3 id="integrity-the-guix-way">Integrity, the Guix way</h3>

<p>For the <code class="language-plaintext highlighter-rouge">/usr</code> equivalent you don’t need AIDE at all: <code class="language-plaintext highlighter-rouge">guix gc --verify=contents</code> rehashes the store against names the attacker can’t rewrite without also breaking every path. I still run AIDE daily, but only over <code class="language-plaintext highlighter-rouge">/etc</code>, <code class="language-plaintext highlighter-rouge">/var</code> and <code class="language-plaintext highlighter-rouge">/boot</code> - the parts that aren’t hash-addressed - via <code class="language-plaintext highlighter-rouge">mcron-service-type</code>, and Lynis weekly for the “did I regress something” check.</p>

<h3 id="what-i-left-out-and-why">What I left out, and why</h3>

<ul>
  <li><strong>Disabling unprivileged user namespaces.</strong> Breaks the unprivileged daemon. See above.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">lockdown=integrity</code> and module signing.</strong> Not compiled into Guix’s kernel. Also, lockdown refuses hibernation the kernel didn’t encrypt itself, and I hibernate to a swapfile inside LUKS.</li>
  <li><strong>Secure Boot.</strong> No signing path for Guix’s GRUB. With LUKS the kernel and initrd already live inside the encrypted root, so the exposed piece is <code class="language-plaintext highlighter-rouge">grub.efi</code> on the ESP, and that’s an evil-maid problem I accept.</li>
  <li><strong>hardened_malloc.</strong> Kicksecure, whose ecosystem produced it, stopped enabling it by default: it breaks Firefox, Chromium and Electron, and cryptsetup runs ~6x slower. No published overhead number exists.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">nosmt</code>, <code class="language-plaintext highlighter-rouge">init_on_free</code>.</strong> On a current AMD part every entry in <code class="language-plaintext highlighter-rouge">/sys/devices/system/cpu/vulnerabilities</code> reads “Not affected” or “Mitigation”. SMT off costs about 15% throughput, with nothing left to mitigate.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">noexec /tmp</code>.</strong> <code class="language-plaintext highlighter-rouge">/dev/shm</code> is mounted exec, and one writable-plus-executable mount makes the other nearly moot. Also, <code class="language-plaintext highlighter-rouge">guix-daemon</code> builds under <code class="language-plaintext highlighter-rouge">$TMPDIR</code>, so you’d have to move that first.</li>
  <li><strong>SELinux.</strong> Not in the kernel config, no service, and a labelling model that doesn’t fit an immutable store.</li>
</ul>

<h3 id="where-this-lands">Where this lands</h3>

<p>None of this is exotic; most of it is what Ubuntu or Arch already do out of the box, written by hand because Guix doesn’t ship it. The parts that are better than mainstream - a setuid list you can read, a build daemon that isn’t root, a store you can verify - come from how Guix is built. The parts that are worse - no MAC service, no firewall, two sysctls - are a few hundred lines in one file.</p>

<p>And the file is in git. The last time a change here broke something (that <code class="language-plaintext highlighter-rouge">auditd</code> chmod), I was back on the previous generation in under a minute.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="guix" /><category term="linux" /><category term="security" /><category term="hardening" /><summary type="html"><![CDATA[Guix System ships two sysctl entries, no firewall and thirteen setuid binaries. Here's what I added on top, and what I left out.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og/stepping-up-guix-system-security.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og/stepping-up-guix-system-security.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Reviving a BlackBerry KEY2</title><link href="https://gofranz.com/blog/reviving-a-blackberry-key2-and-a-bug-in-the-unlock-tool/" rel="alternate" type="text/html" title="Reviving a BlackBerry KEY2" /><published>2026-09-13T01:00:00+01:00</published><updated>2026-09-13T01:00:00+01:00</updated><id>https://gofranz.com/blog/reviving-a-blackberry-key2-and-a-bug-in-the-unlock-tool</id><content type="html" xml:base="https://gofranz.com/blog/reviving-a-blackberry-key2-and-a-bug-in-the-unlock-tool/"><![CDATA[<p>I’ve had a BlackBerry KEY2 sitting in a drawer since it stopped getting updates. Physical keyboard, small screen. The hardware was fine, the software had been dead for years. This week I finally put LineageOS on it.</p>

<p>There’s a small community keeping this device alive (<a rel="noopener" href="https://github.com/Lineage-BBKey2">Lineage-BBKey2</a>, unofficial, actively maintained), and the recipe is well documented:</p>

<ol>
  <li>Unlock the bootloader with <a rel="noopener" href="https://github.com/BotchedRPR/kibo">kibo</a>, which exploits a fastboot buffer overflow</li>
  <li>Reflash BlackBerry’s own signed firmware through their autoloader</li>
  <li>Sideload LineageOS and GApps through recovery</li>
</ol>

<p>None of that is new. The twist: I did it from a Guix machine, and Guix doesn’t play along with anything that expects a normal Linux filesystem.</p>

<h3 id="guix-has-no-lib64ld-linux-x86-64so2">Guix has no <code class="language-plaintext highlighter-rouge">/lib64/ld-linux-x86-64.so.2</code></h3>

<p>Google’s <a rel="noopener" href="https://developer.android.com/tools/releases/platform-tools">platform-tools</a> and kibo’s prebuilt binary both expect <code class="language-plaintext highlighter-rouge">/lib64/ld-linux-x86-64.so.2</code> and a handful of libraries at fixed paths. None of that exists on Guix. <code class="language-plaintext highlighter-rouge">patchelf</code> fixes it:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>patchelf <span class="nt">--set-interpreter</span> <span class="nv">$GLIBC</span>/lib/ld-linux-x86-64.so.2 <span class="se">\</span>
           <span class="nt">--set-rpath</span> <span class="s2">"</span><span class="nv">$GLIBC</span><span class="s2">/lib:</span><span class="nv">$LIBUSB</span><span class="s2">/lib"</span> kibo
</code></pre></div></div>

<p>Point the interpreter and rpath at the right store paths and the binary runs natively; no container, full USB access. Guix does package <code class="language-plaintext highlighter-rouge">adb</code> and <code class="language-plaintext highlighter-rouge">fastboot</code>, but they’re stuck at 7.1.2 from 2016. Google’s current 37.0.1 splits sparse images properly, which matters when you push a 3.2GB <code class="language-plaintext highlighter-rouge">system.img</code> against a 512MB per-transfer limit.</p>

<h3 id="kibos-device-check-was-wrong">kibo’s device check was wrong</h3>

<p>The unlock guide says to flash BlackBerry’s firmware twice (their A/B implementation is that broken). I did, and kibo still reported one of the two boot chains as <code class="language-plaintext highlighter-rouge">INVALID</code> and refused to unlock.</p>

<p>I wrote a small USB probe with <code class="language-plaintext highlighter-rouge">pyusb</code> to dump the raw response to the info query. Turns out kibo reads fixed-size buffers off the wire and never null-terminates them, so its <code class="language-plaintext highlighter-rouge">strcmp()</code> calls sometimes read past the real bytes into whatever is left on the stack. One buffer happened to sit next to a zero; the other didn’t.</p>

<p>kibo is GPL and three source files. Three lines to null-terminate each response at its actual length, then rebuild:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>guix shell cmake make gcc-toolchain pkg-config libusb-next <span class="nt">--</span> <span class="se">\</span>
    cmake <span class="nt">-B</span> build <span class="o">&amp;&amp;</span> cmake <span class="nt">--build</span> build
</code></pre></div></div>

<p>Second run: <code class="language-plaintext highlighter-rouge">Your device is vulnerable.</code></p>

<h3 id="the-result">The result</h3>

<p>The unlock went through, LineageOS 22.2 and MindTheGapps sideloaded without trouble, and the KEY2 booted Android 15 on a Snapdragon 660 from 2018. Gboard doesn’t reliably hide itself for the hardware keyboard on this build; <a rel="noopener" href="https://github.com/tim-ecoder/K12KB">K12KB</a>, which the ROM ships for exactly that reason, does.</p>

<p>It’s a 2018 phone on an unofficial port. Dual SIM has a known bug (I only use one). But the keyboard clicks like new, and the phone that sat in a drawer for two years is back in my pocket. Good luck :)</p>

<h3 id="links">Links</h3>

<ul>
  <li><a rel="noopener" href="https://github.com/Lineage-BBKey2/distribution/releases">Lineage-BBKey2 releases</a> (KEY2) and <a rel="noopener" href="https://github.com/Lineage-BBKey2LE/distribution/releases">Lineage-BBKey2LE releases</a> (KEY2 LE)</li>
  <li><a rel="noopener" href="https://github.com/BotchedRPR/kibo">kibo</a> - the bootloader unlock tool</li>
  <li><a rel="noopener" href="https://xdaforums.com/t/unlock-bootloader-blackberry-key2-athena-key2-le-luna.4781022/">XDA: Unlock bootloader, KEY2 / KEY2 LE</a> - firmware versions and step-by-step</li>
  <li><a rel="noopener" href="https://xdaforums.com/t/rom-15-unofficial-beta-athena-lineageos-22-2-for-blackberry-key2.4777062/">XDA: LineageOS 22.2 for the KEY2</a></li>
  <li><a rel="noopener" href="https://github.com/tim-ecoder/K12KB">K12KB</a> - hardware keyboard app</li>
</ul>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="guix" /><category term="android" /><category term="lineageos" /><category term="blackberry" /><category term="usb" /><summary type="html"><![CDATA[I put LineageOS on an old BlackBerry KEY2 from a Guix machine]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og/reviving-a-blackberry-key2-and-a-bug-in-the-unlock-tool.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og/reviving-a-blackberry-key2-and-a-bug-in-the-unlock-tool.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Testing GUI Apps With Claude</title><link href="https://gofranz.com/blog/testing-gui-apps-with-claude/" rel="alternate" type="text/html" title="Testing GUI Apps With Claude" /><published>2026-09-10T01:00:00+01:00</published><updated>2026-09-10T01:00:00+01:00</updated><id>https://gofranz.com/blog/testing-gui-apps-with-claude</id><content type="html" xml:base="https://gofranz.com/blog/testing-gui-apps-with-claude/"><![CDATA[<p>I’ve been working on a Qt app that logs in against an identity provider. Login means pressing a button, a QR code shows up, a phone scans it, and an approval comes back. There’s no headless mode, so every check ended with me clicking through it by hand.</p>

<p>Talking it through with Claude, it turned out it can do the clicking itself. It needs three things: a display, a way to look at it, and a way to click.</p>

<h3 id="keep-it-off-your-desktop">Keep it off your desktop</h3>

<p>The first try ran the app on my desktop and took a screenshot with <code class="language-plaintext highlighter-rouge">grim</code>. The Qt window had opened on another workspace, so the screenshot showed another application.</p>

<h3 id="the-setup">The setup</h3>

<p>Everything runs as my user from a Guix shell; no root, nothing installed:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>guix shell xorg-server <span class="nt">--</span> Xvfb :99 <span class="nt">-screen</span> 0 1280x800x24 &amp;
<span class="nv">$ DISPLAY</span><span class="o">=</span>:99 <span class="nv">QT_QPA_PLATFORM</span><span class="o">=</span>xcb ./build/app
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Xvfb</code> is an X server with no monitor behind it. Qt renders into it through the plain X11 backend, so the usual X tools work. From there it’s a loop of three commands:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>guix shell imagemagick <span class="nt">--</span> import <span class="nt">-display</span> :99 <span class="nt">-window</span> root shot.png   <span class="c"># look</span>
<span class="nv">$ </span>guix shell xdotool <span class="nt">--</span> xdotool mousemove 481 660 click 1               <span class="c"># click</span>
<span class="nv">$ </span>guix shell zbar <span class="nt">--</span> zbarimg <span class="nt">-q</span> <span class="nt">--raw</span> shot.png                          <span class="c"># read the QR</span>
</code></pre></div></div>

<p>Claude reads the screenshot, picks the coordinates, clicks, waits, and takes the next screenshot. When the app draws a QR code, <code class="language-plaintext highlighter-rouge">zbarimg</code> decodes it from the screenshot. That’s the payload a phone would scan; a small script on the IdP side simulates the phone and approves the login. The next screenshot shows the status bar switch to “Logged-in”.</p>

<p>Longer flows run the same way. One took about a dozen rounds, including a picture from the real webcam (V4L2 works without a display).</p>

<h3 id="where-it-helps">Where it helps</h3>

<p>A single happy-path login I can click through myself. What I don’t do by hand, every time, is the tedious part: wrong input, cancel halfway, an expired QR code, the same flow again after an error dialog. Claude runs through these as an adversarial review, with a screenshot for every step.</p>

<h3 id="limits">Limits</h3>

<ul>
  <li><strong>No GPU.</strong> The app got past login, then crashed in its video layer. That still needs a real display.</li>
  <li><strong>Coordinates drift.</strong> After an error dialog the window changed width, the button moved, and two clicks hit nothing. Every click needs a fresh screenshot.</li>
  <li><strong>It’s slow.</strong> A few seconds of waiting plus a screenshot per round. Fine for a verification pass, not for every commit.</li>
  <li><strong>Multiple windows.</strong> If the app opens more than one, <code class="language-plaintext highlighter-rouge">xdotool search --name '&lt;window title&gt;' windowraise</code> picks the right one, but you have to know the titles.</li>
</ul>

<p>None of these tools are new; I’ve used <code class="language-plaintext highlighter-rouge">Xvfb</code> and <code class="language-plaintext highlighter-rouge">xdotool</code> before. The new part is a model in the loop that looks at the screenshot and decides where to click next.</p>

<p>The tedious testing is now something I can hand off, and the app is better for it.</p>]]></content><author><name></name></author><category term="[&quot;Tools&quot;]" /><category term="claude" /><category term="qt" /><category term="xvfb" /><category term="testing" /><summary type="html"><![CDATA[Letting Claude drive Qt apps on a virtual X server: screenshots, xdotool clicks and a QR reader.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og/testing-gui-apps-with-claude.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og/testing-gui-apps-with-claude.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">My Laptop Is Never Idle, So I Rented One That Is</title><link href="https://gofranz.com/blog/my-laptop-is-never-idle/" rel="alternate" type="text/html" title="My Laptop Is Never Idle, So I Rented One That Is" /><published>2026-08-21T01:00:00+01:00</published><updated>2026-08-21T01:00:00+01:00</updated><id>https://gofranz.com/blog/my-laptop-is-never-idle</id><content type="html" xml:base="https://gofranz.com/blog/my-laptop-is-never-idle/"><![CDATA[<p>Here’s <code class="language-plaintext highlighter-rouge">uptime</code> on my laptop, on an ordinary evening:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span><span class="nb">uptime
 </span>00:43:55  up 7 days,  8:58,  1 user,  load average: 4.54, 5.82, 8.75
</code></pre></div></div>

<p>Load of 8.75 over the last fifteen minutes, on twelve threads. That’s a <code class="language-plaintext highlighter-rouge">guix</code> build compiling a GTK app with <code class="language-plaintext highlighter-rouge">-C lto -C codegen-units=1</code>, a <code class="language-plaintext highlighter-rouge">watchexec</code> loop running <code class="language-plaintext highlighter-rouge">cargo run</code> on the backend I’m working on, and a second one rebuilding the frontend on every save. That’s what the machine looks like while I’m working.</p>

<p>So a cold release build of the Formshive backend (660 dependencies) gets whatever’s left over. It takes sixteen minutes, and slows down everything else while it runs, including the watch loops I need responsive.</p>

<h3 id="meet-crunch">Meet crunch</h3>

<p><a rel="noopener" href="https://github.com/liamaharon/crunch">crunch</a> is a drop-in <code class="language-plaintext highlighter-rouge">cargo</code> replacement: it rsyncs your project to a remote machine, runs the cargo command there, and copies the artifacts back. It’s packaged in Guix:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>guix shell crunch <span class="nt">--</span> crunch <span class="nt">--exclude</span> <span class="s2">"target,.git"</span> build <span class="nt">--release</span>
</code></pre></div></div>

<p>The remote needs a Debian-ish box with Rust on it, and an SSH host entry called <code class="language-plaintext highlighter-rouge">crunch</code> in your <code class="language-plaintext highlighter-rouge">~/.ssh/config</code>. Set <code class="language-plaintext highlighter-rouge">ControlMaster auto</code> and <code class="language-plaintext highlighter-rouge">ControlPersist 5m</code> on that entry; without connection pooling, every rsync pays for a fresh SSH handshake.</p>

<p>crunch keeps the project in <code class="language-plaintext highlighter-rouge">~/crunch-builds</code> on the remote. Your local <code class="language-plaintext highlighter-rouge">target/</code> is excluded from the transfer, so the remote <code class="language-plaintext highlighter-rouge">target/</code> is the only build cache.</p>

<h3 id="build-times">Build times</h3>

<p>I tried three sizes of Hetzner’s CCX line, the dedicated-vCPU plans with physical cores instead of a share. All three ran AMD EPYC Milan in Falkenstein. Same project, same commit, cold each time; I wiped <code class="language-plaintext highlighter-rouge">~/crunch-builds</code> and the cargo registry between runs.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>physical cores</th>
      <th>RAM</th>
      <th>cold build</th>
      <th>warm rebuild</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ccx23</td>
      <td>2</td>
      <td>15 GB</td>
      <td>627s</td>
      <td>1s</td>
    </tr>
    <tr>
      <td>ccx33</td>
      <td>4</td>
      <td>30 GB</td>
      <td>357s</td>
      <td>1s</td>
    </tr>
    <tr>
      <td>ccx43</td>
      <td>8</td>
      <td>61 GB</td>
      <td>277s</td>
      <td>1s</td>
    </tr>
  </tbody>
</table>

<p>Going from two cores to four is 1.76x faster. Going from four to eight is only 1.29x; the critical path through the dependency graph and the serial link step don’t get faster with more cores. ccx43 costs twice as much as ccx33 and finishes 22% sooner, so ccx33 is my default.</p>

<p>The same build on my laptop took 964s, but that’s not a clean benchmark: a Guix source build was running at the same time. It’s the laptop as it is on a working evening, which is the number I care about, but it says nothing about the hardware.</p>

<h3 id="renting-by-the-hour">Renting by the hour</h3>

<p>Hetzner bills hourly, so I create a box when I need one and delete it when I’m done. The catch, straight from Hetzner’s docs: <strong>powering a server off does not stop billing.</strong> You pay until you delete it.</p>

<p>A small script, <a rel="noopener" href="https://gist.github.com/franzos/eac65a3b712be39e8d3eb40edb563e81"><code class="language-plaintext highlighter-rouge">crunch-server</code></a>, handles that:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>crunch-server up      <span class="c"># create, provision, wire up ssh</span>
<span class="nv">$ </span>crunch-server status  <span class="c"># what's running, and what it's cost so far</span>
<span class="nv">$ </span>crunch-server down    <span class="c"># delete it (this is the part that stops billing)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">up</code> pulls the API token from <code class="language-plaintext highlighter-rouge">pass</code>, uploads my SSH key, creates the server with a cloud-init that installs the toolchain, waits for cloud-init to finish (not just for sshd to answer), and writes a <code class="language-plaintext highlighter-rouge">Host crunch</code> block with the pooling settings into <code class="language-plaintext highlighter-rouge">~/.ssh/config</code>. If a box is already up, it reuses it; each fresh <code class="language-plaintext highlighter-rouge">up</code> starts a new billed hour.</p>

<p>Two things I got wrong the first time:</p>

<ul>
  <li><strong>Host keys.</strong> Every new box gets a recycled IP, so the main <code class="language-plaintext highlighter-rouge">known_hosts</code> throws a host-key warning every time. The script writes to a separate <code class="language-plaintext highlighter-rouge">~/.ssh/known_hosts.crunch</code> and wipes it on teardown.</li>
  <li><strong>Native dependencies.</strong> This project pulls <code class="language-plaintext highlighter-rouge">pq-sys</code> and <code class="language-plaintext highlighter-rouge">openssl-sys</code>, so <code class="language-plaintext highlighter-rouge">libpq-dev</code> and <code class="language-plaintext highlighter-rouge">libssl-dev</code> have to be in the cloud-init, or the build fails twelve minutes in.</li>
</ul>

<h3 id="round-trip">Round trip</h3>

<ul>
  <li><strong>Bring-up: 61 seconds</strong>, from nothing to a box that answers <code class="language-plaintext highlighter-rouge">cargo --version</code></li>
  <li><strong>Teardown: 18 seconds</strong></li>
</ul>

<p>I expected three or four minutes and was ready to build a base snapshot. At a minute, it’s not worth it.</p>

<p>Hetzner rounds usage up to the hour, though. Three short-lived boxes in an afternoon cost three hours; keep one and reuse it.</p>

<h3 id="caveats">Caveats</h3>

<ul>
  <li>One project, one run per configuration. This shows the trend, not precise figures.</li>
  <li>The warm rebuild is a no-op; nothing changed between runs. It confirms the remote cache survives and that syncing 5 MB of source costs nothing, but it’s not the edit-compile loop. That’s the number I still need.</li>
  <li>The first ccx43 attempt failed with <code class="language-plaintext highlighter-rouge">dedicated core limit exceeded</code>. New projects have a cap on dedicated vCPUs; support raises it on request.</li>
  <li>crunch’s author recommends fewer fast cores over many slow ones, and runs a Hetzner AX102 (Ryzen 9 7950X3D). Milan EPYCs at around 2 GHz are the “many slow cores” option, and the weak scaling above four cores matches that.</li>
</ul>

<p>The AX102 is next. For now the build runs somewhere else, my watch loops stay responsive, and the whole experiment cost about a euro.</p>]]></content><author><name></name></author><category term="[&quot;Tools&quot;]" /><category term="rust" /><category term="hetzner" /><category term="guix" /><category term="crunch" /><summary type="html"><![CDATA[Moving cold Rust builds from a busy laptop to a rented Hetzner box: what it costs, and how long the round trip takes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og/my-laptop-is-never-idle.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og/my-laptop-is-never-idle.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">One Press, Two Clicks</title><link href="https://gofranz.com/blog/one-press-two-clicks/" rel="alternate" type="text/html" title="One Press, Two Clicks" /><published>2026-08-12T01:00:00+01:00</published><updated>2026-08-12T01:00:00+01:00</updated><id>https://gofranz.com/blog/one-press-two-clicks</id><content type="html" xml:base="https://gofranz.com/blog/one-press-two-clicks/"><![CDATA[<p>Yesterday my MX Anywhere 2S <a href="/blog/the-headphones-connected-the-mouse-didnt/">wouldn’t reconnect</a>. Today it clicks twice.</p>

<p>A soft press lands as two clicks, or drops a selection halfway through a drag. Press it hard and it behaves. That’s a worn microswitch: the contact bounces open for a few tens of milliseconds right after it closes, and everything upstream reads the bounce as a real release.</p>

<p>libinput debounces buttons already, but only over a 12-25ms window it doesn’t expose. Mine bounces for longer, so the fix has to sit below libinput on the raw evdev stream.</p>

<h2 id="measure-first">Measure first</h2>

<p>libinput’s own debouncing hides the fault, so read the evdev node directly. Being in the <code class="language-plaintext highlighter-rouge">input</code> group is enough; you don’t need root to look:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="s">"/dev/input/event15"</span><span class="p">,</span> <span class="s">"rb"</span><span class="p">)</span> <span class="k">as</span> <span class="n">device</span><span class="p">:</span>
    <span class="n">seconds</span><span class="p">,</span> <span class="n">microseconds</span><span class="p">,</span> <span class="n">eventType</span><span class="p">,</span> <span class="n">code</span><span class="p">,</span> <span class="n">value</span> <span class="o">=</span> <span class="n">struct</span><span class="p">.</span><span class="n">unpack</span><span class="p">(</span><span class="s">"llHHi"</span><span class="p">,</span> <span class="n">device</span><span class="p">.</span><span class="n">read</span><span class="p">(</span><span class="mi">24</span><span class="p">))</span>
</code></pre></div></div>

<p>A minute of normal clicking gave me this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>      1.228s  LEFT   press      1123.9 ms
      1.320s  LEFT   release      92.1 ms
      4.280s  LEFT   press      1256.0 ms
      4.340s  LEFT   release      60.0 ms   &lt;-- bounce
      4.384s  LEFT   press        44.0 ms   &lt;-- and back
      5.380s  LEFT   release     996.1 ms
</code></pre></div></div>

<p>That second click is one physical press: closed, opened again 60ms later, closed 44ms after that, then held for a second while I dragged. Depending on what’s listening, that’s two clicks or an interrupted drag.</p>

<p>What makes it fixable is that the bands don’t overlap:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>press to release</th>
      <th>gap before the next press</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>bounce</td>
      <td>36-60 ms</td>
      <td>44-52 ms</td>
    </tr>
    <tr>
      <td>real click</td>
      <td>92-120 ms</td>
      <td>776 ms and up</td>
    </tr>
  </tbody>
</table>

<p>A bounce always let go inside 60ms; a deliberate click always held for at least 92ms. That’s the whole filter.</p>

<h2 id="why-not-libinput">Why not libinput</h2>

<p>Its window is 12ms for a plain bounce, 25ms once a button has been flagged as bouncy, and the <a rel="noopener" href="https://wayland.freedesktop.org/libinput/doc/latest/button-debouncing.html">documentation</a> is upfront that there’s no knob for it. Sensible default - a configurable debounce is a footgun aimed squarely at people who’d rather turn a dial than replace a switch.</p>

<h2 id="the-rule">The rule</h2>

<p>A release arriving within <code class="language-plaintext highlighter-rouge">--short-ms</code> of its press is suspect, so the filter withholds it for <code class="language-plaintext highlighter-rouge">--hold-ms</code>. Press the button again inside that window and the release gets dropped along with the new press: as far as anything upstream is concerned, the button never came up. Otherwise the release goes out late.</p>

<p>Only <em>short</em> presses get withheld, so an ordinary 100ms click is never delayed. And since a dropped selection and a spurious double-click are the same fault - a phantom release - swallowing it fixes the drag case too.</p>

<h2 id="the-pipeline">The pipeline</h2>

<p>Below libinput means evdev, and that means <a rel="noopener" href="https://gitlab.com/interception/linux/tools">interception-tools</a>. <code class="language-plaintext highlighter-rouge">intercept</code> grabs the device exclusively, the filter transforms the byte stream, <code class="language-plaintext highlighter-rouge">uinput</code> re-emits it on a clone that libinput picks up instead:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>intercept <span class="nt">-g</span> /dev/input/event15 | mouse-debounce | uinput <span class="nt">-d</span> /dev/input/event15
</code></pre></div></div>

<p>Try that the first time on a laptop with a working touchpad. While the device is grabbed, the only pointer you have is the one coming out of the pipe.</p>

<p>It’s 255 lines of C: <code class="language-plaintext highlighter-rouge">read</code>, <code class="language-plaintext highlighter-rouge">poll</code>, <code class="language-plaintext highlighter-rouge">write</code>, and <code class="language-plaintext highlighter-rouge">input_event</code> out of <code class="language-plaintext highlighter-rouge">&lt;linux/input.h&gt;</code>. No allocation, no ownership graph, and <code class="language-plaintext highlighter-rouge">input_event</code> has <code class="language-plaintext highlighter-rouge">time_t</code> fields that C sizes straight off the header - 64-bit on x86-64, 32-bit on armv7.</p>

<h2 id="testing">Testing</h2>

<p>The filter is timer-driven, so there’s nothing meaningful to unit test; the behaviour is “did the next press arrive within 70ms”. The suite spawns the real binary, writes real <code class="language-plaintext highlighter-rouge">input_event</code> structs down the pipe at real wall-clock spacing, and counts the clicks that come out:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>case                                         clicks  want
recorded: bounce during a drag                    1     1
recorded: bounce turning one click into two       1     1
deliberate double-click, 80 ms gap                2     2
undebounced button is left alone                  2     2
</code></pre></div></div>

<p>The recorded cases are traces off my own mouse. Scheduling can stretch a 60ms bounce past the hold window, so each case gets three attempts before it counts as a failure. It runs in the Guix build container too, so the package won’t build if the timing behaviour has drifted.</p>

<h2 id="wiring-it-up">Wiring it up</h2>

<p><code class="language-plaintext highlighter-rouge">udevmon</code> keeps the job on the device across reconnects. This one’s Bluetooth, so it gets a new event node every time it wakes up:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">JOB</span><span class="pi">:</span> <span class="s2">"</span><span class="s">intercept</span><span class="nv"> </span><span class="s">-g</span><span class="nv"> </span><span class="s">$DEVNODE</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">mouse-debounce</span><span class="nv"> </span><span class="s">--hold-ms</span><span class="nv"> </span><span class="s">90</span><span class="nv"> </span><span class="s">--short-ms</span><span class="nv"> </span><span class="s">80</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">uinput</span><span class="nv"> </span><span class="s">-d</span><span class="nv"> </span><span class="s">$DEVNODE"</span>
  <span class="na">DEVICE</span><span class="pi">:</span>
    <span class="na">NAME</span><span class="pi">:</span> <span class="s2">"</span><span class="s">MX</span><span class="nv"> </span><span class="s">Anywhere</span><span class="nv"> </span><span class="s">2S</span><span class="nv"> </span><span class="s">Mouse"</span>
    <span class="na">EVENTS</span><span class="pi">:</span>
      <span class="na">EV_KEY</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">BTN_LEFT</span><span class="pi">]</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">NAME</code> is a full-match regex, not a substring - udevmon uses <code class="language-plaintext highlighter-rouge">std::regex</code> with <code class="language-plaintext highlighter-rouge">regex_match</code>, defaulting to <code class="language-plaintext highlighter-rouge">.*</code>. The <code class="language-plaintext highlighter-rouge">EVENTS</code> clause is what pins the job to the right node: a Logitech mouse presents several under sibling names.</p>

<p>On Guix that’s a package and a service in <a rel="noopener" href="https://codeberg.org/gofranz/panther">my channel</a>, so the config never gets typed into <code class="language-plaintext highlighter-rouge">/etc</code>:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">service</span> <span class="nv">px-mouse-debounce-service-type</span>
         <span class="p">(</span><span class="nf">px-mouse-debounce-configuration</span>
          <span class="p">(</span><span class="nf">device-name</span> <span class="s">"MX Anywhere 2S Mouse"</span><span class="p">)</span>
          <span class="p">(</span><span class="nf">hold-ms</span> <span class="mi">90</span><span class="p">)</span>
          <span class="p">(</span><span class="nf">short-ms</span> <span class="mi">80</span><span class="p">)))</span>
</code></pre></div></div>

<p>The service generates the YAML above with store paths baked into the job line, so there’s no <code class="language-plaintext highlighter-rouge">PATH</code> to get wrong inside udevmon’s <code class="language-plaintext highlighter-rouge">sh -c</code>.</p>

<h2 id="thresholds">Thresholds</h2>

<p>I started at <code class="language-plaintext highlighter-rouge">--hold-ms 70</code>, because the longest bounce gap I’d captured was 52ms. It still slipped, within an hour of real use: sixty seconds of deliberate clicking catches the typical bounce, not the tail. I’m on 90 now.</p>

<p>Raising it is cheap. Sweeping the suite:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>case                                            50    70    90   110   want
recorded: bounce then a long hold                2     1     1     1   1
deliberate double-click, 80 ms gap               2     2     2     2   2
</code></pre></div></div>

<p>Nothing breaks at 90, or at 110. The 80ms double-click survives both because <code class="language-plaintext highlighter-rouge">--short-ms</code> gates the whole mechanism: that trace’s first click is 100ms long, so its release was never withheld in the first place. <code class="language-plaintext highlighter-rouge">--hold-ms</code> only bites when a double-click has <em>both</em> a short first click and a short gap. 50 is where it actually breaks and lets a 52ms bounce through.</p>

<h2 id="limits">Limits</h2>

<p>Two genuine clicks, each shorter than <code class="language-plaintext highlighter-rouge">--short-ms</code> and less than <code class="language-plaintext highlighter-rouge">--hold-ms</code> apart, are the same waveform as a bounce, and the filter will merge them. With my numbers that’s two sub-80ms clicks less than 90ms apart. I can’t produce that deliberately; someone faster might.</p>

<p>And this is a workaround on a switch that’s mechanically failing. The bounce window widens as it wears - I’ve already moved once - and eventually it’ll overlap a real double-click, at which point no threshold separates them. The durable fix is a soldering iron and a new Omron - or a new mouse.</p>

<p>The code is at <a rel="noopener" href="https://github.com/franzos/mouse-debounce">github.com/franzos/mouse-debounce</a> if your switch is going the same way. Measure yours first.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="linux" /><category term="guix" /><category term="wayland" /><category term="tooling" /><summary type="html"><![CDATA[The left switch on my mouse wore out and started registering one click as two. libinput debounces buttons already, but only over 12-25ms and with no knob - so I measured the bounce and wrote a filter that sits below it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og/one-press-two-clicks.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og/one-press-two-clicks.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Headphones Connected, the Mouse Didn’t</title><link href="https://gofranz.com/blog/the-headphones-connected-the-mouse-didnt/" rel="alternate" type="text/html" title="The Headphones Connected, the Mouse Didn’t" /><published>2026-08-11T01:00:00+01:00</published><updated>2026-08-11T01:00:00+01:00</updated><id>https://gofranz.com/blog/the-headphones-connected-the-mouse-didnt</id><content type="html" xml:base="https://gofranz.com/blog/the-headphones-connected-the-mouse-didnt/"><![CDATA[<p>My laptop had been running all night. In the morning the mouse and the keyboard were both blinking away, the way they do when they can’t find the machine they’re paired to. The Nothing headphones connected the moment I took them out of the case.</p>

<p>Something had left the adapter running a permanent LE scan, and an active scan stops the host initiating LE connections - so the mouse and keyboard could advertise all they liked and nothing would ever answer. The headphones speak BR/EDR, classic Bluetooth, which pages the host instead. That path was never touched.</p>

<h2 id="the-first-look">The first look</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>bluetoothctl show | <span class="nb">grep</span> <span class="nt">-E</span> <span class="s1">'Powered|Discovering'</span>
	Powered: <span class="nb">yes
	</span>Discovering: <span class="nb">yes</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Discovering: yes</code>, nothing connected, on a machine I hadn’t asked to scan anything. The device cache had about fifty entries with random addresses - <code class="language-plaintext highlighter-rouge">65:89:74:04:66:FA</code>, <code class="language-plaintext highlighter-rouge">7D:BF:0F:DB:74:1C</code> - which is what a long-running scan in a dense apartment block looks like.</p>

<p><code class="language-plaintext highlighter-rouge">bluetoothctl info</code> on each of my three sorted them out:</p>

<table>
  <thead>
    <tr>
      <th>device</th>
      <th>transport</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>MX Anywhere 2S</td>
      <td>LE only</td>
    </tr>
    <tr>
      <td>Air75 BT5.0</td>
      <td>LE only</td>
    </tr>
    <tr>
      <td>Nothing Ear</td>
      <td>BR/EDR and LE</td>
    </tr>
  </tbody>
</table>

<p>The one device that kept working was the only one with a classic radio path. <code class="language-plaintext highlighter-rouge">connect</code> on either of the LE devices hung - no error, no refusal, twenty seconds and nothing came back.</p>

<h2 id="not-a-wedged-controller">Not a wedged controller</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span><span class="nb">sudo </span>herd restart bluetooth
<span class="nv">$ </span>bluetoothctl power off <span class="o">&amp;&amp;</span> bluetoothctl power on
<span class="nv">$ </span>bluetoothctl show | <span class="nb">grep </span>Discovering
	Discovering: <span class="nb">yes</span>
</code></pre></div></div>

<p>The scan was back within seconds, both times. That’s the useful result even though it fixed nothing: a stale session survives neither, and a power cycle clears adapter state outright. Something alive was re-arming the scan the moment the adapter came back.</p>

<p>Worth knowing on Guix: <code class="language-plaintext highlighter-rouge">hciconfig</code>, <code class="language-plaintext highlighter-rouge">hcitool</code> and <code class="language-plaintext highlighter-rouge">btmgmt</code> aren’t there to reach for. They’re deprecated upstream and Guix doesn’t compile them, so <code class="language-plaintext highlighter-rouge">bluetoothctl power</code> and <code class="language-plaintext highlighter-rouge">rfkill</code> are what you get.</p>

<h2 id="finding-the-client">Finding the client</h2>

<p>BlueZ doesn’t expose which D-Bus client holds a discovery session, and <code class="language-plaintext highlighter-rouge">dbus-monitor</code> gets refused <code class="language-plaintext highlighter-rouge">BecomeMonitor</code> on this box. So work backwards: list every connection on the system bus, resolve each to a PID, and check whether that binary even knows the word <code class="language-plaintext highlighter-rouge">StartDiscovery</code>.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for </span>n <span class="k">in</span> <span class="si">$(</span>dbus-send <span class="nt">--system</span> <span class="nt">--dest</span><span class="o">=</span>org.freedesktop.DBus <span class="nt">--print-reply</span> <span class="se">\</span>
             /org/freedesktop/DBus org.freedesktop.DBus.ListNames |
           <span class="nb">grep</span> <span class="nt">-oE</span> <span class="s1">'":[0-9.]+"'</span> | <span class="nb">tr</span> <span class="nt">-d</span> <span class="s1">'"'</span><span class="si">)</span><span class="p">;</span> <span class="k">do
  </span><span class="nv">pid</span><span class="o">=</span><span class="si">$(</span>dbus-send <span class="nt">--system</span> <span class="nt">--dest</span><span class="o">=</span>org.freedesktop.DBus <span class="nt">--print-reply</span> <span class="se">\</span>
          /org/freedesktop/DBus org.freedesktop.DBus.GetConnectionUnixProcessID <span class="se">\</span>
          string:<span class="s2">"</span><span class="nv">$n</span><span class="s2">"</span> | <span class="nb">awk</span> <span class="s1">'/uint32/{print $2}'</span><span class="si">)</span>
  <span class="nv">exe</span><span class="o">=</span><span class="si">$(</span><span class="nb">readlink</span> <span class="nt">-f</span> /proc/<span class="nv">$pid</span>/exe 2&gt;/dev/null<span class="si">)</span> <span class="o">||</span> <span class="k">continue
  </span><span class="nb">grep</span> <span class="nt">-aq</span> StartDiscovery <span class="s2">"</span><span class="nv">$exe</span><span class="s2">"</span> 2&gt;/dev/null <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"</span><span class="nv">$pid</span><span class="s2"> </span><span class="si">$(</span><span class="nb">basename</span> <span class="s2">"</span><span class="nv">$exe</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span>
<span class="k">done</span> | <span class="nb">sort</span> <span class="nt">-u</span>
</code></pre></div></div>

<p>Two hits worth anything: <code class="language-plaintext highlighter-rouge">chrome</code> and <code class="language-plaintext highlighter-rouge">slack</code>, both Chromium underneath. Waybar came back clean, despite the Bluetooth module in my bar. One gotcha: <code class="language-plaintext highlighter-rouge">bluetoothd</code> matches too, since it’s the process <em>providing</em> <code class="language-plaintext highlighter-rouge">StartDiscovery</code>. Filter it out or you’ll chase your own daemon.</p>

<p>From there it’s a bisect. Quit Slack: still scanning. Quit Chrome:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>bluetoothctl show | <span class="nb">grep </span>Discovering
	Discovering: no
<span class="nv">$ </span>bluetoothctl devices Connected
Device E2:7D:F3:EB:50:11 Air75 BT5.0
Device FD:B6:AB:99:E1:22 MX Anywhere 2S
</code></pre></div></div>

<p>Both devices back, immediately, without touching the adapter.</p>

<p>Starting Chrome again brought the scan straight back, but the mouse and keyboard stayed connected - which explains the timing. The scan doesn’t break a live link, it only blocks a new one from being established. It bites after the peripherals idle out on their own, at which point they’re stuck advertising into a host that can’t answer. Hence “broken overnight”.</p>

<h2 id="dead-ends">Dead ends</h2>

<p>A stored Web Bluetooth grant for <code class="language-plaintext highlighter-rouge">banglejs.com</code> - a watch I last touched six months ago - looked like the answer. Revoked it; Chrome carried on scanning. Extensions were the next guess, since three of mine bundle Web Bluetooth code for Ledger hardware wallets and a background service worker shows up in no tab and no manifest permission; <code class="language-plaintext highlighter-rouge">--disable-extensions</code> scanned too. No site, no extension, no open tab, which left Chrome itself.</p>

<h2 id="the-fix">The fix</h2>

<p>Turn Web Bluetooth off at the policy layer. On Linux that’s one JSON file and no admin infrastructure:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">/etc/opt/chrome/policies/managed/bluetooth.json</span><span class="w">
</span><span class="p">{</span><span class="w"> </span><span class="nl">"DefaultWebBluetoothGuardSetting"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The path is easy to fluff - it’s <code class="language-plaintext highlighter-rouge">/etc/opt/chrome/policies/managed</code>, and I first created it without the <code class="language-plaintext highlighter-rouge">chrome</code> component, where nothing would ever read it. Root-owned and <code class="language-plaintext highlighter-rouge">0644</code> is what you want; the directory must not be writable by your own user, or anything running as you can set your browser policy for you.</p>

<p>Full quit, relaunch, and:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>bluetoothctl show | <span class="nb">grep </span>Discovering
	Discovering: no
</code></pre></div></div>

<p>Chrome running, nothing scanning, all three devices connected. So it was Web Bluetooth after all, with nothing anywhere asking for it: Chrome opens the session on launch and holds it for as long as it runs. <em>Why</em> it does that when no site has requested Bluetooth, I still can’t tell you.</p>

<h2 id="on-guix">On Guix</h2>

<p>The file is declared rather than typed into <code class="language-plaintext highlighter-rouge">/etc</code> by hand:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">simple-service</span> <span class="ss">'chrome-web-bluetooth-policy</span> <span class="nv">etc-service-type</span>
  <span class="p">(</span><span class="nb">list</span> <span class="o">`</span><span class="p">(</span><span class="s">"opt/chrome/policies/managed/bluetooth.json"</span>
          <span class="o">,</span><span class="p">(</span><span class="nf">plain-file</span> <span class="s">"chrome-bluetooth-policy.json"</span>
                       <span class="s">"{ \"DefaultWebBluetoothGuardSetting\": 2 }\n"</span><span class="p">))))</span>
</code></pre></div></div>

<p>Two things to check before trusting that. <code class="language-plaintext highlighter-rouge">etc-service-type</code> symlinks top-level <code class="language-plaintext highlighter-rouge">/etc</code> entries, so <code class="language-plaintext highlighter-rouge">/etc/opt</code> becomes a link into the store - meaning the real <code class="language-plaintext highlighter-rouge">/etc/opt</code> I’d made by hand has to go first. <code class="language-plaintext highlighter-rouge">activate-etc</code> does <code class="language-plaintext highlighter-rouge">rm-f</code> then <code class="language-plaintext highlighter-rouge">symlink</code>, <code class="language-plaintext highlighter-rouge">rm-f</code> can’t delete a non-empty directory, and the <code class="language-plaintext highlighter-rouge">symlink</code> fails with <code class="language-plaintext highlighter-rouge">EEXIST</code>. Reconfigure dies, and the error doesn’t obviously point at the cause.</p>

<p>The second is whether Chrome accepts the file at all. <code class="language-plaintext highlighter-rouge">guix-daemon</code> runs unprivileged here, so store files are owned by the daemon rather than root, and that trap already bites two other things in my config: auditd refuses a config it doesn’t see as root-owned, and <code class="language-plaintext highlighter-rouge">pam_u2f</code> refuses a non-root authfile <em>silently</em>. Chrome doesn’t check. <code class="language-plaintext highlighter-rouge">config_dir_policy_loader.cc</code> enumerates the directory and deserializes whatever JSON it finds, with no ownership, permission or symlink check anywhere, and <code class="language-plaintext highlighter-rouge">FileEnumerator</code> uses <code class="language-plaintext highlighter-rouge">stat()</code> rather than <code class="language-plaintext highlighter-rouge">lstat()</code> unless you ask for <code class="language-plaintext highlighter-rouge">SHOW_SYM_LINKS</code>, so the store symlinks enumerate as plain files.</p>

<h2 id="if-this-is-you">If this is you</h2>

<ol>
  <li><code class="language-plaintext highlighter-rouge">bluetoothctl show | grep Discovering</code>. If that says <code class="language-plaintext highlighter-rouge">yes</code> and you didn’t ask for it, you’ve found it.</li>
  <li>Run the loop above to get your candidates.</li>
  <li>Quit them one at a time. Don’t bother restarting <code class="language-plaintext highlighter-rouge">bluetooth</code> first, like I did - a live client just re-arms the scan.</li>
  <li>If it’s Chrome, drop in the policy file. Quitting the browser every time your keyboard falls off is a workaround; the policy is the fix.</li>
  <li><code class="language-plaintext highlighter-rouge">bluetoothctl trust &lt;mac&gt;</code> on your input devices. Mine were untrusted, and with no Bluetooth agent running there’s nothing around to authorise an untrusted device’s reconnect. That wasn’t this bug, but it’s a second way to end up in the same place.</li>
</ol>

<p>When one Bluetooth device works and another doesn’t, check what transport each is using before you start suspecting the radio. Mine was fine the entire time.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="bluetooth" /><category term="linux" /><category term="guix" /><category term="framework" /><summary type="html"><![CDATA[My mouse and keyboard stopped reconnecting overnight while my headphones were fine. The adapter was stuck in a permanent LE scan that Chrome opens on launch - here's how I found it and the policy file that stops it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og/the-headphones-connected-the-mouse-didnt.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og/the-headphones-connected-the-mouse-didnt.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">687 GiB My SSD Didn’t Know Were Free</title><link href="https://gofranz.com/blog/ssd-trim-on-guix/" rel="alternate" type="text/html" title="687 GiB My SSD Didn’t Know Were Free" /><published>2026-08-05T01:00:00+01:00</published><updated>2026-08-05T01:00:00+01:00</updated><id>https://gofranz.com/blog/ssd-trim-on-guix</id><content type="html" xml:base="https://gofranz.com/blog/ssd-trim-on-guix/"><![CDATA[<p>My monitoring started shouting at me while I was building a Rust project:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Tctl  61C  CRITICAL on CPU_IOWAIT (Min:8.9 Mean:30.5 Max:65.3): cargo, rustc, chrome
</code></pre></div></div>

<p>Chrome would stall mid-scroll. Bluetooth audio would drop out for a second and come back. Everything pointed at IO, and the processes named were the obvious suspects - <code class="language-plaintext highlighter-rouge">cargo</code>, <code class="language-plaintext highlighter-rouge">rustc</code>, <code class="language-plaintext highlighter-rouge">rust-lld</code>.</p>

<p>The short version: TRIM had never run on this machine, not once. The SSD believed all 1.8 TB were in use when 687 GiB were actually free, and it was paying for that on every single write.</p>

<h2 id="it-wasnt-memory">It wasn’t memory</h2>

<p>First thing worth ruling out, because “everything freezes” usually means swap:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ free -h
               total        used        free      shared  buff/cache   available
Mem:            60Gi        10Gi       4.3Gi       972Mi        47Gi        49Gi
</code></pre></div></div>

<p>49 GiB available, and zram had 397 MB in a 24 GB device. Pressure stall info agreed - <code class="language-plaintext highlighter-rouge">/proc/pressure/memory</code> was flat at zero while <code class="language-plaintext highlighter-rouge">/proc/pressure/io</code> sat at <code class="language-plaintext highlighter-rouge">avg300=10.53</code>. So: not memory. Disk.</p>

<h2 id="the-tell">The tell</h2>

<p>This is the measurement that made it obvious. <code class="language-plaintext highlighter-rouge">/proc/diskstats</code> tracks completed IOs and the milliseconds spent on them, so you can get an average latency per operation straight out of it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span><span class="nb">awk</span> <span class="s1">'$3=="nvme0n1" {printf "%.2f ms/read  %.2f ms/write\n", $7/$4, $11/$8}'</span> /proc/diskstats
0.18 ms/read  50.94 ms/write
</code></pre></div></div>

<p>Reads at 0.18 ms are perfectly healthy. Writes at 50 ms are not - that’s roughly what you’d expect from a mechanical drive, on an NVMe SSD that should be doing sub-millisecond.</p>

<p>That asymmetry is diagnostic. A dying drive is slow at everything. A drive that’s fast reading and terrible writing is a drive with no free blocks to write into. SMART confirmed it wasn’t wear:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Available Spare:    100%
Percentage Used:    4%
Data Units Written: 121,437,799 [62.1 TB]
</code></pre></div></div>

<p>4% used. The drive is fine. It just didn’t know where its free space was.</p>

<h2 id="why-trim-had-never-run">Why TRIM had never run</h2>

<p>Two separate things had to be true, and both were.</p>

<p><strong>First</strong>, TRIM has to pass through dm-crypt. On an encrypted root it doesn’t by default, and the option is on the mapped device, not the filesystem:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">mapped-devices</span>
 <span class="p">(</span><span class="nb">list</span> <span class="p">(</span><span class="nf">mapped-device</span>
        <span class="p">(</span><span class="nf">source</span> <span class="p">(</span><span class="nf">uuid</span> <span class="s">"33d48354-afc2-428f-aa2a-0234984a04d8"</span><span class="p">))</span>
        <span class="p">(</span><span class="nf">target</span> <span class="s">"cryptroot"</span><span class="p">)</span>
        <span class="p">(</span><span class="nf">type</span> <span class="nv">luks-device-mapping</span><span class="p">)</span>
        <span class="p">(</span><span class="nf">arguments</span> <span class="o">'</span><span class="p">(</span><span class="o">#</span><span class="nv">:allow-discards?</span> <span class="no">#t</span><span class="p">)))))</span>
</code></pre></div></div>

<p>Worth knowing what you’re trading: <code class="language-plaintext highlighter-rouge">allow-discards</code> leaks a free/used block map to anyone holding the ciphertext. For a laptop-theft threat model that’s fine. For a drive you hand to someone else, think about it.</p>

<p><strong>Second</strong>, and this is the one that actually bit me: I had a weekly mcron job to run <code class="language-plaintext highlighter-rouge">fstrim</code>, scheduled for Sunday at 04:00. My laptop is asleep at 04:00 on a Sunday. mcron has no catch-up for missed jobs - if the machine isn’t awake when the job is due, that run simply doesn’t happen. Ever. The job had been sitting in my config looking correct and had never once fired.</p>

<p>So the fix is two-part. Run it daily, so a miss costs a day rather than a week:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="k">define</span> <span class="nv">mcron-job-fstrim</span>
  <span class="o">#</span><span class="nv">~</span><span class="p">(</span><span class="nf">job</span> <span class="s">"0 4 * * *"</span>
         <span class="o">#</span><span class="nv">$</span><span class="p">(</span><span class="nf">program-file</span> <span class="s">"fstrim-daily"</span>
             <span class="o">#</span><span class="nv">~</span><span class="p">(</span><span class="nf">execl</span> <span class="o">#</span><span class="nv">$</span><span class="p">(</span><span class="nf">file-append</span> <span class="nv">util-linux</span> <span class="s">"/sbin/fstrim"</span><span class="p">)</span>
                      <span class="s">"fstrim"</span> <span class="s">"-v"</span> <span class="s">"-m"</span> <span class="s">"1M"</span> <span class="s">"/"</span><span class="p">))))</span>
</code></pre></div></div>

<p>And run one at boot as well, so a machine that’s always asleep at 4am still gets trimmed:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="k">define</span> <span class="nv">fstrim-at-boot-service</span>
  <span class="p">(</span><span class="nf">shepherd-service</span>
   <span class="p">(</span><span class="nf">provision</span> <span class="o">'</span><span class="p">(</span><span class="nf">fstrim-at-boot</span><span class="p">))</span>
   <span class="p">(</span><span class="nf">requirement</span> <span class="o">'</span><span class="p">(</span><span class="nf">file-systems</span><span class="p">))</span>
   <span class="p">(</span><span class="nf">respawn?</span> <span class="no">#f</span><span class="p">)</span>
   <span class="p">(</span><span class="nf">documentation</span> <span class="s">"Discard unused blocks on / shortly after boot."</span><span class="p">)</span>
   <span class="p">(</span><span class="nf">start</span> <span class="o">#</span><span class="nv">~</span><span class="p">(</span><span class="nf">make-forkexec-constructor</span>
             <span class="p">(</span><span class="nb">list</span> <span class="o">#</span><span class="nv">$</span><span class="p">(</span><span class="nf">program-file</span> <span class="s">"fstrim-at-boot"</span>
                       <span class="o">#</span><span class="nv">~</span><span class="p">(</span><span class="k">begin</span>
                           <span class="p">(</span><span class="nf">sleep</span> <span class="mi">300</span><span class="p">)</span>
                           <span class="p">(</span><span class="nf">execl</span> <span class="o">#</span><span class="nv">$</span><span class="p">(</span><span class="nf">file-append</span> <span class="nv">util-linux</span> <span class="s">"/sbin/fstrim"</span><span class="p">)</span>
                                  <span class="s">"fstrim"</span> <span class="s">"-v"</span> <span class="s">"-m"</span> <span class="s">"1M"</span> <span class="s">"/"</span><span class="p">))))))</span>
   <span class="p">(</span><span class="nf">stop</span> <span class="o">#</span><span class="nv">~</span><span class="p">(</span><span class="nf">make-kill-destructor</span><span class="p">))))</span>
</code></pre></div></div>

<p>The detail that matters there is what I <em>didn’t</em> write. Shepherd has a <code class="language-plaintext highlighter-rouge">one-shot?</code> flag, and using it here would have blocked boot until <code class="language-plaintext highlighter-rouge">fstrim</code> finished. Without it, <code class="language-plaintext highlighter-rouge">make-forkexec-constructor</code> forks and returns immediately, so boot isn’t delayed at all; the <code class="language-plaintext highlighter-rouge">(sleep 300)</code> then keeps the trim out of the login rush. <code class="language-plaintext highlighter-rouge">respawn? #f</code> stops shepherd restarting it when it exits.</p>

<p>Both get registered the usual way:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">simple-service</span> <span class="ss">'fstrim-cron</span> <span class="nv">mcron-service-type</span>
                <span class="p">(</span><span class="nb">list</span> <span class="nv">mcron-job-fstrim</span><span class="p">))</span>
<span class="p">(</span><span class="nf">simple-service</span> <span class="ss">'fstrim-at-boot</span> <span class="nv">shepherd-root-service-type</span>
                <span class="p">(</span><span class="nb">list</span> <span class="nv">fstrim-at-boot-service</span><span class="p">))</span>
</code></pre></div></div>

<h2 id="the-result">The result</h2>

<p>The first run had eight months of debt to clear:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ sudo fstrim -v /
/: 687.4 GiB (738038181888 bytes) trimmed

real    34m12.977s
</code></pre></div></div>

<p>34 minutes, and 840,000-odd discard commands. That’s a one-time cost - the drive now knows what’s free, and subsequent runs only have to deal with what’s been freed since.</p>

<p>Write latency, measured over a 15 second window before and after:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>before</th>
      <th>right after the trim</th>
      <th>settled, a few hours later</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>average write</td>
      <td>57.15 ms</td>
      <td>1.46 ms</td>
      <td>0.11 ms</td>
    </tr>
  </tbody>
</table>

<p>That middle column is the drive still chewing through the aftermath of a 34 minute discard storm. Left alone for a few hours it settled to 0.11 ms, which is where it should have been all along - call it 500x. The IO stalls stopped and Chrome went back to scrolling like a normal application.</p>

<h2 id="a-43ms-stall-for-73-milliwatts">A 43ms stall for 73 milliwatts</h2>

<p>While I was in there I checked the drive’s power states, since “freezes after a moment of idle” has more than one cause:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ sudo nvme id-ctrl /dev/nvme0 | grep -E '^ps '
ps 3 : mp:0.0800W non-operational enlat:1600 exlat:4600
ps 4 : mp:0.0070W non-operational enlat:1600 exlat:43000
</code></pre></div></div>

<p>Those exit latencies are microseconds. State 4 saves 73 milliwatts over state 3 and costs 43 ms to wake up from. On a laptop pulling several watts at idle, that’s not a trade I want. The kernel gates APST on exit latency alone, so a ceiling between the two blocks the deep state and keeps the shallow one:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="s">"nvme_core.default_ps_max_latency_us=5000"</span>
</code></pre></div></div>

<p>I’ll note that 43 ms sits right inside the range of write latencies I was measuring, so some of what I blamed on TRIM may have been this. I can’t separate them cleanly after the fact.</p>

<h2 id="about-that--m-1m">About that <code class="language-plaintext highlighter-rouge">-m 1M</code></h2>

<p>The <code class="language-plaintext highlighter-rouge">-m 1M</code> in those jobs tells <code class="language-plaintext highlighter-rouge">fstrim</code> to skip free extents smaller than 1 MiB, so a routine trim doesn’t turn into its own IO stall. I nearly talked myself out of it - the average extent in that first run was 845 KiB, <em>below</em> the floor, which looked like the daily job would skip most of what it should be trimming and quietly rebuild the backlog.</p>

<p>Measuring it says otherwise. Running both back to back, later the same evening:</p>

<table>
  <thead>
    <tr>
      <th>run</th>
      <th>trimmed</th>
      <th>time</th>
      <th>rate</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>no floor</td>
      <td>131.5 GiB</td>
      <td>5m37s</td>
      <td>0.39 GiB/s</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-m 1M</code></td>
      <td>20.4 GiB</td>
      <td>8.5s</td>
      <td>2.4 GiB/s</td>
    </tr>
  </tbody>
</table>

<p>The floor is about 6x faster per gigabyte, because the sub-1MiB extents are precisely where the cost is. Even a heavy day with 130 GiB to clear finishes in under a minute with the floor on. So it stays, and the boot job costs 8 seconds of background IO rather than anything you’d notice.</p>

<p>Granted, that’s not a clean A/B - the unfloored run went first, so it isn’t a fair measure of how much the floor <em>skips</em>. It’s a fair measure of what the floor <em>costs</em>, which was the thing I actually needed to decide.</p>

<h2 id="what-i-still-dont-know">What I still don’t know</h2>

<p>The 500x is entirely down to TRIM. The APST and <code class="language-plaintext highlighter-rouge">noatime</code> changes need a reconfigure and a reboot, and hadn’t taken effect when I measured any of this. Whatever they’re worth is still to come.</p>

<p>If you’re running an encrypted root on Guix, the two-line version is: check that <code class="language-plaintext highlighter-rouge">#:allow-discards?</code> is actually set, then check that your trim job has ever run. Mine looked right in the config for months and had never executed once.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span><span class="nb">awk</span> <span class="s1">'$3=="nvme0n1" {printf "%.2f ms/read  %.2f ms/write\n", $7/$4, $11/$8}'</span> /proc/diskstats
</code></pre></div></div>

<p>If the second number is much bigger than the first, you have the same problem I did.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="guix" /><category term="framework" /><category term="linux" /><category term="ssd" /><summary type="html"><![CDATA[My laptop was freezing under Rust builds. The SSD was doing 50ms writes because TRIM had never actually run - here's the diagnosis and the Guix config that fixes it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og/ssd-trim-on-guix.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og/ssd-trim-on-guix.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Deploying AI in a business: the AI is the easy part</title><link href="https://gofranz.com/blog/deploying-ai-in-a-business/" rel="alternate" type="text/html" title="Deploying AI in a business: the AI is the easy part" /><published>2026-08-04T01:00:00+01:00</published><updated>2026-08-04T01:00:00+01:00</updated><id>https://gofranz.com/blog/deploying-ai-in-a-business</id><content type="html" xml:base="https://gofranz.com/blog/deploying-ai-in-a-business/"><![CDATA[<p>I spent last weekend wiring AI into a client’s internal processes. The technical part was unremarkable; I’ve built harder things in an afternoon. Everything that made the project worth doing happened around it.</p>

<p>Before I get to what we actually built, some ground rules, because they’re the reason the build looked the way it did.</p>

<h2 id="it-makes-mistakes-and-thats-not-a-bug-you-can-file">It makes mistakes, and that’s not a bug you can file</h2>

<p>An LLM is a non-deterministic process. So is OCR, and so is a human reading a scanned invoice. Nobody deploys OCR at scale without a correction step; everybody knows it’ll misread a 6 as an 8 often enough to matter. LLMs get deployed as if that step were optional.</p>

<p>It’s also a moving target. A new model behaves differently and brings its own quirks. Latency is fine on Tuesday and terrible on Thursday. Requests fail. Sometimes a model seems to shift in character between releases and you’re left re-tuning prompts that worked fine for months.</p>

<p>You can shape the thing without training it. If you need a specific voice, or you want the output constrained to a shape, you can get most of the way there with instructions, examples and validation. Treat all of it as best-effort. It’s a strong tendency, not a hard truth, and anything that depends on it being a hard truth needs a check behind it.</p>

<p>If you need better output, one option is to involve more than one model: one drafts, another reviews against the guidelines, and you look at what survives. That’s not a trick, it’s the same thing you’d do with people when the output actually matters. It helps. It doesn’t get you to zero.</p>

<p>And this part I’ll say flat: today, a professional with experience in their field, a reputation at stake, and some care about what leaves the building will beat the model on quality. Not on speed - it’ll lose that badly. On quality. That’s true as of today; I don’t know what tomorrow looks like.</p>

<p>None of this is an anti-AI position. Humans have the same failure modes; I wrote recently about <a href="/blog/a-1534-line-commit-called-runs/">a 1,534-line commit called “runs”</a> that shipped a hardware wallet generating seeds from a non-cryptographic PRNG, and no model was anywhere near it. The difference is that a person can be told, and a person minds.</p>

<h2 id="the-calculator-problem">The calculator problem</h2>

<p>Here’s the failure I actually worry about in a company: the output looks good, so people trust it.</p>

<p>My calculator tells me 1+1=2 and I trust it, correctly, forever. The LLM arrives on the same screen, on the same machine, in the same tab strip, and produces fluent, confident, well-formatted text. Everything about the packaging says “computer”, and everybody has thirty years of training that computers are right about this kind of thing. They’re not the same category of thing at all, and nothing in the interface tells you that.</p>

<p>Which makes deployment a human matter before it’s a technical one. Staff need training, and they need reminders, because the trust creeps back in. Don’t treat this like a calculator; treat it like something that makes mistakes.</p>

<p>The odds get worse in the directions you’d expect: the longer the output, and the tighter your expectations of it, the more likely you are to get something wrong. A one-line classification is fairly safe. Three pages that have to be right in every particular is a different proposition.</p>

<h2 id="outward-facing-is-where-it-gets-expensive">Outward-facing is where it gets expensive</h2>

<p>That’s for internal processes. Point it at customers and the stakes change.</p>

<p>Think about how a call center actually works. People get trained before they take a call. They’re listened in on. They’re coached, corrected, and scored. The script is tight, and someone owns the script. Nobody hires an agent and then never checks on them again.</p>

<p>An LLM doing the same job gets the same treatment, minus every incentive the human had. Its job isn’t on the line. It has no reputation to protect, no interest in a promotion, no bad feeling about a call that went badly. It is, as it comes out. Set it and forget it isn’t a thing here; it isn’t a thing for the humans either, and we’ve known that for decades.</p>

<h2 id="somebody-has-to-own-it">Somebody has to own it</h2>

<p>So the question every AI project has to answer early: who watches this?</p>

<p>It’s either someone external, on a maintenance and QC arrangement, or someone internal you designate and give the time to actually do it. There’s no third option where nobody does it and it stays good.</p>

<p>What doesn’t work is the fifty-a-month subscription you sign up for and forget. That only holds if you genuinely don’t care about the outcome, and if you don’t care about the outcome, it’s not clear why you started. You want a measurable result. Does it bring in customers? Does support get better, or just faster? Does the process finish earlier? What’s the error rate, and is it going up or down? If you can’t answer those, you don’t have a project, you have a subscription.</p>

<h2 id="what-we-actually-did">What we actually did</h2>

<p>With all that said, the build itself was boring, straight-forward stuff. Roughly:</p>

<ol>
  <li><strong>Found the processes worth automating.</strong> Not all of them; the ones where more throughput, with oversight, genuinely helps. Time-consuming, repetitive, and cheap to check.</li>
  <li><strong>Wrote guidelines.</strong> How the system is to be used, and what to look out for. Think of the checklist a flight crew runs before departure: they’ve done it a thousand times and they still read it out.</li>
  <li><strong><a href="/ai-training/">Trained the assigned staff</a>.</strong> The people who’d own it day to day, on their own workflows, not a generic demo.</li>
  <li><strong>Implemented the guidelines.</strong> Writing them down isn’t implementing them. They had to land in the actual process, with the actual checks in the actual places.</li>
</ol>

<p>Using it is about as hard as using Gmail. The staff picked it up in ten minutes. The interface was never the hard part, and if your AI project’s difficulty is concentrated in the UI, something has gone sideways.</p>

<h2 id="why-nobody-pushed-back">Why nobody pushed back</h2>

<p>There was very little resistance, and I don’t think that’s down to change management. It made previously boring, time-consuming work easier, and it didn’t replace anyone. What it means in practice is that by Friday, the work is actually done.</p>

<p>That’s the whole pitch. It isn’t headcount, it’s the backlog that never quite cleared.</p>

<h2 id="what-happens-next">What happens next</h2>

<p>I’ll check in weekly for a while, then every couple of weeks: collect feedback, look at what’s going wrong, fine-tune the guidelines. At some point it stops being the new thing and becomes how it’s always been done, which is the goal. If it keeps going well, we’ll pick the next process.</p>

<p>That last part is the bit I can’t shortcut and won’t pretend otherwise. AI is fast and it works at any hour, and that means there’s a lot more room for things to go wrong, quietly, at volume.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Opinion&quot;]" /><category term="ai" /><category term="llms" /><category term="business" /><category term="consulting" /><summary type="html"><![CDATA[I spent a weekend putting AI into a client's internal processes. The integration was boring. Picking the right processes, writing the guidelines, training the staff, and deciding who owns it afterwards - that's where the actual work was.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og/deploying-ai-in-a-business.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og/deploying-ai-in-a-business.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">MCP dropped DCR for CIMD - and handed you an SSRF</title><link href="https://gofranz.com/blog/mcp-dcr-deprecated-cimd/" rel="alternate" type="text/html" title="MCP dropped DCR for CIMD - and handed you an SSRF" /><published>2026-08-04T01:00:00+01:00</published><updated>2026-08-04T01:00:00+01:00</updated><id>https://gofranz.com/blog/mcp-dcr-deprecated-cimd</id><content type="html" xml:base="https://gofranz.com/blog/mcp-dcr-deprecated-cimd/"><![CDATA[<p>I’ve been wiring MCP authorization into <a rel="noopener" href="https://github.com/franzos/forseti">Forseti</a> for a while, which meant running an unauthenticated <code class="language-plaintext highlighter-rouge">POST /oauth2/register</code> endpoint in front of Hydra and rate-limiting it hard enough that nobody could fill the database with junk. That endpoint is gone now. The MCP spec revision <a rel="noopener" href="https://blog.modelcontextprotocol.io/posts/2026-07-28/">2026-07-28</a> formally deprecates Dynamic Client Registration in favour of Client ID Metadata Documents, and Forseti 0.2.0 dropped DCR entirely.</p>

<p>The short version: DCR made the authorization server hold state for every client that ever showed up. CIMD flips it - the client hosts a JSON file, the URL of that file <em>is</em> the client ID, and the AS fetches it on demand. Which is better, and also how you get a server-side request forgery hole in your authorization server.</p>

<h2 id="why-dcr-is-going-away">Why DCR is going away</h2>

<p>DCR (RFC 7591) arrived with MCP’s first authorization spec in <code class="language-plaintext highlighter-rouge">2025-03-26</code>, which said implementations <strong>SHOULD</strong> support it and, further down, “strongly recommend” it. For eight months it was the only zero-touch option: an MCP client that’s never met your server POSTs its metadata to <code class="language-plaintext highlighter-rouge">/register</code>, gets back a <code class="language-plaintext highlighter-rouge">client_id</code>, and runs the normal authorization code flow.</p>

<p>CIMD arrived in <code class="language-plaintext highlighter-rouge">2025-11-25</code> (<a rel="noopener" href="https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1296">SEP-991</a>), and the same revision dropped DCR to <strong>MAY</strong>, “included for backwards compatibility with earlier versions of the MCP authorization spec”. <code class="language-plaintext highlighter-rouge">2026-07-28</code> finished the job: the formal <em>Deprecated</em>.</p>

<p>The reasons were laid out well before that, in an August 2025 <a rel="noopener" href="https://blog.modelcontextprotocol.io/posts/client_registration/">writeup on client registration</a> by Paul Carleton - an exploratory post, not the announcement, but it’s where the problems are stated plainly:</p>

<ul>
  <li>“Unbounded database growth: Every time a user connects a client to an MCP server, a new registration is created with the authorization server unless the client already has one.” Registrations are per-install, so the same app on your laptop and your desktop is two rows.</li>
  <li>“There’s no way to tell a client that its ID is invalid without creating an open redirect vulnerability.” So registrations effectively never expire.</li>
  <li>“An unauthenticated <code class="language-plaintext highlighter-rouge">/register</code> endpoint writes to a database within the authorization server, meaning that tenant admins now need to worry about rate limiting or policy controls.” That’s the part that kept me writing rate limiters.</li>
</ul>

<p>The deprecation itself is in the <a rel="noopener" href="https://modelcontextprotocol.io/specification/2026-07-28/changelog">2026-07-28 changelog</a>, and the same revision adopted a formal <a rel="noopener" href="https://modelcontextprotocol.io/community/feature-lifecycle">feature lifecycle policy</a> with a twelve-month minimum deprecation window - shortenable to no less than ninety days, but only for a vulnerability with a published advisory or documented exploitation and no in-place mitigation. So DCR keeps working for at least a year, for backwards compatibility with authorization servers that don’t do CIMD yet. It just isn’t the answer anymore.</p>

<h2 id="the-two-side-by-side">The two side by side</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>DCR (<a rel="noopener" href="https://datatracker.ietf.org/doc/html/rfc7591">RFC 7591</a>)</th>
      <th>CIMD (<a rel="noopener" href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00">draft-ietf-oauth-client-id-metadata-document-00</a>)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">client_id</code></td>
      <td>opaque string the AS mints</td>
      <td>the HTTPS URL of your metadata document</td>
    </tr>
    <tr>
      <td>Registration step</td>
      <td><code class="language-plaintext highlighter-rouge">POST /register</code> before the flow</td>
      <td>none</td>
    </tr>
    <tr>
      <td>Who stores what</td>
      <td>AS writes a row per registration, with no expiry mechanism</td>
      <td>client hosts a JSON file; AS caches it</td>
    </tr>
    <tr>
      <td>Portable across servers</td>
      <td>no - one registration per AS</td>
      <td>yes, the same <code class="language-plaintext highlighter-rouge">client_id</code> everywhere</td>
    </tr>
    <tr>
      <td>Client auth</td>
      <td>client secrets</td>
      <td>public by default; register a JWKS and <code class="language-plaintext highlighter-rouge">private_key_jwt</code> makes it confidential</td>
    </tr>
    <tr>
      <td>Discovery</td>
      <td><code class="language-plaintext highlighter-rouge">registration_endpoint</code> in AS metadata</td>
      <td><code class="language-plaintext highlighter-rouge">client_id_metadata_document_supported: true</code></td>
    </tr>
    <tr>
      <td>Main attack surface</td>
      <td>unauthenticated write endpoint: spam, storage DoS</td>
      <td>outbound fetch of an attacker-chosen URL: SSRF</td>
    </tr>
    <tr>
      <td>Killing a bad client</td>
      <td>can’t signal expiry without an open redirect</td>
      <td>edit or pull the document - though caches lag</td>
    </tr>
    <tr>
      <td>MCP status</td>
      <td>deprecated 2026-07-28, removal no sooner than 12 months</td>
      <td><strong>SHOULD</strong> support, for clients and <em>authorization</em> servers</td>
    </tr>
  </tbody>
</table>

<p>The document itself is simple:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"client_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://app.example.com/oauth/client-metadata.json"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"client_name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Example MCP Client"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"redirect_uris"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"http://127.0.0.1:3000/callback"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"grant_types"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"authorization_code"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"response_types"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"code"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"token_endpoint_auth_method"</span><span class="p">:</span><span class="w"> </span><span class="s2">"none"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">client_id</code> <strong>MUST</strong> equal the URL it was fetched from, exactly. Worth being precise about what that buys you: it stops me hosting a copy of your document at <em>my</em> URL. It does nothing to stop me handing the authorization server <em>your</em> URL and claiming to be you - that attack is alive and well, and I’ll come back to it.</p>

<h2 id="the-part-that-bites-ssrf">The part that bites: SSRF</h2>

<p>Here’s what actually changed for the server operator. Under DCR, an attacker sends you data. Under CIMD, an attacker sends you a <em>URL</em>, and your authorization server - which sits inside your network, holding your signing keys - goes and fetches it. That’s textbook SSRF, and the draft says so in <a rel="noopener" href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#name-server-side-request-forgery">section 6.5</a>: “Authorization servers fetching the client metadata document and resolving URLs located in the metadata document should be aware of possible SSRF attacks.” It follows up with the actual instruction - servers <strong>SHOULD</strong> “avoid fetching any URLs using private or loopback addresses and consider network policies or other measures to prevent making requests to these addresses”, and <strong>SHOULD</strong> watch for non-HTTP URI schemes. (Section numbers are for <code class="language-plaintext highlighter-rouge">-00</code>, the revision MCP normatively cites; in the current <code class="language-plaintext highlighter-rouge">-02</code> the security considerations moved to section 8.)</p>

<p>MCP’s own text wraps that in a MUST-consider (“authorization servers <strong>MUST</strong> consider the security implications detailed in [Section 6]”) while the SSRF bullet itself stays a SHOULD. So the obligation to think about it is hard; the specific defences are yours to pick. This is what Forseti’s fetcher does, and it’s roughly the list I’d want from anything else:</p>

<ul>
  <li><strong>HTTPS only</strong>, with the URL capped at 512 bytes. Plain <code class="language-plaintext highlighter-rouge">http</code> is allowed only when you explicitly flip <code class="language-plaintext highlighter-rouge">allow_private_targets</code> for local development. (The spec also requires the <code class="language-plaintext highlighter-rouge">client_id</code> URL to carry a path component; Forseti doesn’t enforce that one yet.)</li>
  <li><strong>Reject loopback, private, and special-use IPs</strong> before the request goes out - and again at connect time through a guarded DNS resolver, because a DNS answer that’s public on the first lookup and <code class="language-plaintext highlighter-rouge">169.254.169.254</code> on the second is the whole rebinding trick.</li>
  <li><strong>Don’t follow redirects at all.</strong> Forseti sets the redirect policy to none. Following one means re-running every check on the new target; not following is simpler and I haven’t missed it.</li>
  <li><strong>Cap the body and the clock.</strong> 64 KiB and a 5s timeout. The draft suggests 5 KB; I went wider because logo and scope fields add up, and 64 KiB is still far below anything that hurts.</li>
  <li><strong>Cache, with bounds.</strong> Respect HTTP cache headers but clamp the TTL to 60s..24h, single-flight concurrent fetches for the same URL, and serve a last-good document for up to an hour if the fetch fails. Never cache errors or malformed documents.</li>
  <li><strong>Don’t let the client name itself.</strong> <code class="language-plaintext highlighter-rouge">client_name</code> is whatever the client wrote about itself, so on Forseti’s consent page the host of the <code class="language-plaintext highlighter-rouge">client_id</code> URL is the primary identity and the self-asserted name sits underneath it.</li>
</ul>

<p>There’s an optional host allowlist (<code class="language-plaintext highlighter-rouge">[oauth.cimd].allowed_client_hosts</code>) for anyone who wants a closed set. Empty means open, gated by consent, which is the setting that makes MCP work the way it’s supposed to.</p>

<p>The failure mode of over-blocking is real too: <a rel="noopener" href="https://github.com/backstage/backstage/issues/33951">Backstage hit it</a> when a Kubernetes deployment’s own hostname resolved to a <code class="language-plaintext highlighter-rouge">10.x</code> address and the private-IP check rejected a self-referential fetch. Granted, that’s an argument for a deliberate escape hatch, not for loosening the default.</p>

<h2 id="the-impersonation-hole-thats-left">The impersonation hole that’s left</h2>

<p>That consent-screen design covers one attack and misses the more interesting one. Showing the <code class="language-plaintext highlighter-rouge">client_id</code> host is the right answer to a client lying in its <code class="language-plaintext highlighter-rouge">client_name</code>. It does nothing about the attack the spec spells out:</p>

<blockquote>
  <p>Client ID Metadata Documents cannot prevent <code class="language-plaintext highlighter-rouge">localhost</code> URL impersonation by themselves. An attacker can claim to be any client by: 1. Providing the legitimate client’s metadata URL as their <code class="language-plaintext highlighter-rouge">client_id</code> 2. Binding to any <code class="language-plaintext highlighter-rouge">localhost</code> port, and providing that address as the redirect_uri 3. Receiving the authorization code via the redirect when the user approves</p>
</blockquote>

<p>Read that carefully. The <code class="language-plaintext highlighter-rouge">client_id</code> host in that attack <em>is</em> the legitimate one - it’s the real client’s document, fetched from the real client’s domain, and it passes every check in the list above. As the spec puts it: “The server will see the legitimate client’s metadata document and the user will see the legitimate client’s name, making attack detection difficult.” The only field that differs is the redirect URI, which is why the spec’s requirement there is a <strong>MUST</strong>: authorization servers “<strong>MUST</strong> clearly display the redirect URI hostname during authorization.”</p>

<h2 id="where-my-things-stand">Where my things stand</h2>

<p><strong>Forseti</strong> is the authorization-server side, so it took the whole change. Since 0.2.0, MCP clients connect with a URL client ID and no registration step; DCR and initial access tokens are removed rather than deprecated in place. On a first authorization request Forseti fetches and validates the document, then upserts a Hydra client row keyed to that URL, so Hydra keeps doing the OAuth work while the identity stays the client’s own URL. The scope set on that row is a ceiling, never a grant - consent is still the authorization.</p>

<p><strong>Stackpit</strong> needed nothing, and that’s the more interesting half. It’s a resource server: its MCP endpoint is bearer-only, it publishes <code class="language-plaintext highlighter-rouge">/.well-known/oauth-protected-resource/mcp</code> per RFC 9728, and it validates audience, <code class="language-plaintext highlighter-rouge">nbf</code>, and scopes on the token. How the client got its <code class="language-plaintext highlighter-rouge">client_id</code> is entirely the authorization server’s problem. That separation is the quiet win of the RFC 9728 split - a registration mechanism can be deprecated out from under you and your resource servers don’t notice.</p>

<h2 id="should-you-move">Should you move</h2>

<p>If you run an authorization server for MCP clients: yes, but you’ve got a year, and the caveat is that not every client is there yet. Some still hard-require a <code class="language-plaintext highlighter-rouge">registration_endpoint</code> and fall over without one - <a rel="noopener" href="https://github.com/openai/codex/issues/15818">Codex</a> has an open issue exactly on that, and <a rel="noopener" href="https://github.com/anthropics/claude-code/issues/67258">Claude Code</a> has one for a related case where a pre-configured <code class="language-plaintext highlighter-rouge">clientId</code> still triggers a DCR attempt.</p>

<p>I dropped DCR from Forseti anyway because support is catching up fast.</p>

<p>If you run an MCP server that’s only a resource server, this isn’t your problem at all.</p>

<p>And note what you’re building on: an active Internet-Draft, currently at <code class="language-plaintext highlighter-rouge">-02</code> (July 2026), while MCP still normatively cites <code class="language-plaintext highlighter-rouge">-00</code>. Section numbers have already moved between them, so check which one your implementation is reading. Nothing in CIMD attests that the name and logo in a document belong to whoever wrote them either - the draft has suggestions (prefetch the logo so you can moderate it, weigh domain age and reputation, warn on new domains), but they’re all things you build, not things the protocol gives you. It’s a better shape than DCR, not a solved problem.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Opinion&quot;]" /><category term="mcp" /><category term="oauth2" /><category term="oidc" /><category term="security" /><category term="identity" /><summary type="html"><![CDATA[The 2026-07-28 MCP spec deprecates Dynamic Client Registration in favour of Client ID Metadata Documents. What changes, why, and what you have to guard now that your authorization server fetches URLs an attacker picks.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og/mcp-dcr-deprecated-cimd.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og/mcp-dcr-deprecated-cimd.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">A 1,534-line commit called “runs”</title><link href="https://gofranz.com/blog/a-1534-line-commit-called-runs/" rel="alternate" type="text/html" title="A 1,534-line commit called “runs”" /><published>2026-08-02T01:00:00+01:00</published><updated>2026-08-02T01:00:00+01:00</updated><id>https://gofranz.com/blog/a-1534-line-commit-called-runs</id><content type="html" xml:base="https://gofranz.com/blog/a-1534-line-commit-called-runs/"><![CDATA[<p>A Coldcard is a hardware wallet. It has one job: generate a private key nobody can guess, and keep it away from the network. In firmware v4.0.0, it didn’t do the first half.</p>

<p>Somebody wanted to swap MicroPython’s random number generator for their own. The build came back with duplicate symbol errors. Instead of untangling the collision, they turned the other one off:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#define MICROPY_HW_ENABLE_RNG (0)  // We have our own version of this code.
</span></code></pre></div></div>

<p>The error went away. So did the hardware RNG. The replacement was never actually wired up, so <code class="language-plaintext highlighter-rouge">random.bytes(32)</code> fell through to Yasmarang - a tiny, fast, thoroughly non-cryptographic PRNG that MicroPython keeps around for when you don’t have hardware entropy. That’s what generated seeds. <a rel="noopener" href="https://insider.btcpp.dev/p/when-randombytes-runs-but-doesnt">Dusty Daemon’s write-up</a> walks the whole call chain, and it’s worth reading in full.</p>

<p>What stuck with me wasn’t the bug. It was the commit: 1,534 lines changed, message <code class="language-plaintext highlighter-rouge">runs</code>. Five characters. There’s another one in there whose entire message is <code class="language-plaintext highlighter-rouge">x</code>.</p>

<h3 id="nobodys-ai-did-this">Nobody’s AI did this</h3>

<p>Worth saying plainly, because it would be easy to file this under vibe coding: no model wrote that line. A person did, years before any of us had an agent sitting in a terminal. Mistakes come from being rushed, from being sloppy, or from not knowing any better, and humans have had all three well covered for decades.</p>

<p>Which is exactly why I keep pushing back when AI gets sold as a different category of thing. It isn’t. It’s fallible in the same ways we are, just faster and with better grammar. It has no passion and no knowledge; it has statistics. And the comment in that line - “We have our own version of this code” - is the most human artifact in the whole story. Someone believed it. It wasn’t true. Nobody checked.</p>

<h3 id="the-bottleneck-was-never-typing">The bottleneck was never typing</h3>

<p>AI has genuinely sped up how fast code appears. It has not moved the thing that actually protects you, which is somebody competent reading the code and understanding what it does on the real hardware.</p>

<p>A 1,534-line diff is a 1,534-line diff whether a model produced it in ninety seconds or a person produced it over a weekend. If nobody reads it, it’s a liability either way. The difference now is that the ninety-second version arrives more often, and review capacity didn’t grow to match.</p>

<p>So the rule I work by is boring: scale review to the stakes, not to the volume. A marketing site can ship on vibes. Something holding other people’s money or other people’s data gets a second set of eyes that actually knows the domain - not another model summarizing the diff for you, because the model shares the failure mode. It’ll tell you the change looks reasonable. It looked reasonable to the author too.</p>

<p>Go fast where being wrong is cheap. Slow down exactly where it isn’t. That’s most of the discipline, and it’s the same advice whether the code came from a person or a machine - which is sort of the point.</p>

<p>I <a href="/blog/ai-is-the-race-car-im-the-driver/">wrote a while back</a> that AI is the race car and I’m the driver. This is the other half of that: the car doesn’t decide which races are worth losing.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Opinion&quot;]" /><category term="security" /><category term="code-review" /><category term="ai" /><category term="bitcoin" /><summary type="html"><![CDATA[Coldcard shipped firmware that generated wallet seeds from a weak PRNG. No AI was involved. That's the part worth sitting with.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og/a-1534-line-commit-called-runs.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og/a-1534-line-commit-called-runs.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>