<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://bhanuharya.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://bhanuharya.github.io/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-15T01:13:48+07:00</updated><id>https://bhanuharya.github.io/feed.xml</id><title type="html">bhanuharya@sec</title><subtitle>Things Harya has built, tested, and had to fix, mostly in security and self-hosting.</subtitle><author><name>harya</name></author><entry><title type="html">I ported Slay the Spire 2 to iOS (I typed, Claude ported)</title><link href="https://bhanuharya.github.io/blog/slay-the-spire-2-on-android-and-ios/" rel="alternate" type="text/html" title="I ported Slay the Spire 2 to iOS (I typed, Claude ported)" /><published>2026-10-14T00:00:00+07:00</published><updated>2026-10-14T00:00:00+07:00</updated><id>https://bhanuharya.github.io/blog/slay-the-spire-2-on-android-and-ios</id><content type="html" xml:base="https://bhanuharya.github.io/blog/slay-the-spire-2-on-android-and-ios/"><![CDATA[<p>My copy of Slay the Spire 2 now runs on an iPhone and runs better on my Android tablet. I would love to tell you I engineered this. What I actually did was own the game, own the devices, type requests like “can you make an apk release on my fork” into Claude Code, and paste Steam Guard codes into GitHub issues. Claude wrote the code. I’m told this is called “leadership”.</p>

<p>For context, I play Silent for the shivs and Ironclad for the brute force. This project needed both decks, and I mostly brought the Ironclad one.</p>

<p>This post covers what got built, whose work it stands on, and what still doesn’t work.</p>

<h2 id="whose-code-this-is">Whose code this is</h2>

<p>Most of the hard work isn’t mine. Not even Claude’s.</p>

<table>
  <thead>
    <tr>
      <th>Project</th>
      <th>What it is</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="https://github.com/Ekyso/StS2-Launcher">Ekyso/StS2-Launcher</a></td>
      <td>The original Android launcher: the PC game’s .NET code running on Android, adapted with Harmony runtime patches</td>
    </tr>
    <tr>
      <td><a href="https://github.com/iunius612/StS2-Launcher_Mod_Manager">iunius612/StS2-Launcher_Mod_Manager</a></td>
      <td>A fork of that with Steam Workshop mods, a Save Manager, cloud-save fixes and a lot of mobile polish</td>
    </tr>
    <tr>
      <td><a href="https://github.com/bhanuharya/sts-2-launcher-mobile">bhanuharya/sts-2-launcher-mobile</a></td>
      <td>My fork of iunius612’s: an Android performance build and an iOS port</td>
    </tr>
  </tbody>
</table>

<p>The Android launcher itself is iunius612’s code, unchanged. If it logs you into Steam, downloads the game, or loads your mods on Android, thank them. My fork adds two things on top, and “my” is doing a lot of work in that sentence.</p>

<h2 id="division-of-labour">Division of labour</h2>

<p>For transparency:</p>

<table>
  <thead>
    <tr>
      <th>Task</th>
      <th>Me</th>
      <th>Claude</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Owning the game on Steam</td>
      <td>✓</td>
      <td> </td>
    </tr>
    <tr>
      <td>Owning a Xiaomi Pad 5 and an iPhone</td>
      <td>✓</td>
      <td> </td>
    </tr>
    <tr>
      <td>Reading Godot’s engine source to work out how its iOS export really works</td>
      <td> </td>
      <td>✓</td>
    </tr>
    <tr>
      <td>Writing a binary Android manifest editor</td>
      <td> </td>
      <td>✓</td>
    </tr>
    <tr>
      <td>Patching the game’s compiled code with Mono.Cecil</td>
      <td> </td>
      <td>✓</td>
    </tr>
    <tr>
      <td>Writing a Steam downloader on SteamKit2</td>
      <td> </td>
      <td>✓</td>
    </tr>
    <tr>
      <td>Saying “the graphic become much worse”</td>
      <td>✓</td>
      <td> </td>
    </tr>
    <tr>
      <td>Typing Steam Guard codes into a GitHub issue</td>
      <td>✓</td>
      <td> </td>
    </tr>
    <tr>
      <td>Tapping <strong>Approve</strong> right after being told not to</td>
      <td>✓</td>
      <td> </td>
    </tr>
    <tr>
      <td>Playing Silent: a hundred small, precise patches</td>
      <td> </td>
      <td>✓</td>
    </tr>
    <tr>
      <td>Playing Ironclad: pressing <strong>Run workflow</strong> again to see what dies</td>
      <td>✓</td>
      <td> </td>
    </tr>
  </tbody>
</table>

<p>A second Claude session drove adb on the tablet to run benchmarks. I relayed messages between the two like a very slow clipboard.</p>

<h2 id="android-my-tablet-was-the-problem">Android: my tablet was the problem</h2>

<p>The Pad 5 has a 2560×1600 120 Hz screen and a Snapdragon 860. The game ran, but it felt choppy. The first guess was that the GPU couldn’t keep up, so the first fix was a “fast mode” that rendered at a lower resolution. That’s the Ironclad answer: hit the problem as hard as you can. It made the game look much worse and barely helped. The tablet-side measurements showed why: the fast mode kept the GPU at about 91% busy. Android’s own Game Mode downscaling was cheaper, at 74–80%.</p>

<p>The real culprit was boring: the game’s own 30 fps cap. On a 120 Hz panel its frames landed unevenly, and that reads as choppy. A 60 fps cap with vsync on, and the display at 60 Hz, was smooth with the GPU at mid clock.</p>

<p>The fix that worked was a Silent deck: no single big card, just a lot of small ones that add up. That became <strong>StS2 Launcher Perf</strong>, a build of upstream’s release APK with an add-on:</p>

<ul>
  <li>A <strong>Perf settings</strong> panel: FPS cap, vsync, particle amount, render resolution, full-screen effects, and a frame-time log. The defaults change nothing.</li>
  <li>Settings live in a text file the app re-reads every 2 seconds, so the benchmarking session could switch settings over adb mid-fight.</li>
  <li>The launcher’s Korean-only dialogs translated to English, 243 text fragments in all.</li>
  <li>Its own package name, so it installs next to the original.</li>
</ul>

<p>There’s no engine rebuild. A GitHub Actions workflow downloads upstream’s APK and checks its hash. It patches one DLL to load the add-on, edits the binary manifest, and re-signs the result. Four releases so far. The first one taught us that <code class="language-plaintext highlighter-rouge">Assembly.Location</code> is an empty string when the launcher loads a DLL from memory, so the add-on tried to load itself from <code class="language-plaintext highlighter-rouge">/STS2Perf.dll</code>. Apparently “that’s where I’d be if I existed” isn’t a valid path.</p>

<h2 id="ios-no-jit-no-mac">iOS: no JIT, no Mac</h2>

<p>The Android launcher can’t just be recompiled for iOS. It runs the game’s code on a JIT and patches it while it runs, and iOS doesn’t allow JIT for sideloaded apps. So the iOS port turns everything inside out:</p>

<ul>
  <li>The game’s <code class="language-plaintext highlighter-rouge">sts2.dll</code> is patched <strong>at build time</strong> with Mono.Cecil, with the same fixes the Android launcher applies at runtime: skip Steam’s own startup, skip crash reporting, and a few platform guards.</li>
  <li>All of it is compiled to native ARM64 with .NET NativeAOT, through Godot 4.5.1’s iOS export.</li>
  <li>A small native extension mounts <code class="language-plaintext highlighter-rouge">SlayTheSpire2.pck</code> from the app’s Documents folder at startup and checks it matches the build. You copy that file from your own Steam install. The repo has no game files, and the build is only uploaded inside an encrypted zip.</li>
</ul>

<p>I don’t own a Mac, so all of this runs on GitHub’s macOS runners, which are free for public repos. Before touching the real game, the pipeline ran against a fake game: a tiny Godot project shaped like Slay the Spire 2, with the same class names, a fake Steam, and a fake pck. That caught most of the mistakes early. It didn’t catch all of them, which is what fake games are for.</p>

<h3 id="steam-doesnt-trust-london">Steam doesn’t trust London</h3>

<p>To compile the game’s code, the build machine needs the game’s DLLs, so it logs into Steam as me. Steam sent the approval prompt to my phone. I approved it, and got this:</p>

<p><img src="/assets/img/sts2-steam-blocked-sign-in.png" alt="Steam has blocked this sign in: the attempt appears to come from a Steam client near London, England, while your physical location appears to be closer to my city" /></p>

<p>Fair. A server in London asking for my account while I’m on another continent does look like a malicious actor. The usual fix is to log in once on your own PC and give the build a saved token. That’s when I found out I no longer own a Windows PC.</p>

<p>What Claude built instead:</p>

<ol>
  <li>The build opens a GitHub issue that @mentions me and waits.</li>
  <li>I reply with the 5-character code from the Steam app’s Steam Guard tab.</li>
  <li>The build checks the issue every 2 seconds, which is as close as CI gets to a shiv build: small, fast, and a lot of them. It reads only my replies, deletes each one, signs in, and closes the issue.</li>
</ol>

<p>My strategy for getting it working was pure Ironclad: press Run workflow, see what breaks, press it again. It took three runs:</p>

<ul>
  <li><strong>Run 8:</strong> the sign-in prompt popped up again, this time from San Francisco, and I tapped Approve after being told specifically not to. That turned out not to matter. What did matter: my code arrived after about 3 minutes, and Steam had already dropped the connection, because it closes connections that haven’t signed in within about a minute. Fix: wait for the code with no connection open, then connect and send it within seconds.</li>
  <li><strong>Run 10:</strong> while a sign-in is pending, the Steam app shows its Approve/Deny screen on top of the code, so I couldn’t read it. I tapped Deny to get it out of the way, sent the code, and the sign-in worked. Then the patcher failed on the real <code class="language-plaintext highlighter-rouge">sts2.dll</code>: it couldn’t find <code class="language-plaintext highlighter-rouge">GodotSharp.dll</code>, which the game’s <code class="language-plaintext highlighter-rouge">[Export(PropertyHint.Range, ...)]</code> attributes refer to. The fake game had no such attribute. It has one now. Also fixed: the build now asks for the code before it starts any sign-in, so nothing covers it.</li>
  <li><strong>Run 13:</strong> signed in, downloaded about 1.9 GB of depot files, patched, compiled, and produced a 52 MB <code class="language-plaintext highlighter-rouge">StS2.ipa</code> in about 14 minutes.</li>
</ul>

<h3 id="installing-it">Installing it</h3>

<p>The week I tried this, SideStore failed to sign in to my Apple ID with error -80045. According to its issue tracker, everyone was getting the same. Sideloadly on a PC with Apple’s iTunes and iCloud still worked. The app opened to this:</p>

<p><img src="/assets/img/sts2-ios-game-data-not-found.png" alt="The iOS build's startup screen: Game data not found. Copy SlayTheSpire2.pck (game version v0.107.1) into this app's folder in the Files app, then reopen the game." /></p>

<p>After a day of builds, a screen that correctly complains about a missing file is the best thing I’d seen. I copied the pck over, and the game started:</p>

<p><img src="/assets/img/sts2-ios-main-menu.jpg" alt="Slay the Spire 2's main menu running on an iPhone, version v0.107.1 in the corner" /></p>

<p>The version in the top-right corner is v0.107.1, the same as the build, so the app’s version check agreed with the pck.</p>

<p>And of course the first fight was Silent, with Cloak and Dagger in hand. Shivs on iOS, compiled ahead of time:</p>

<p><img src="/assets/img/sts2-ios-silent-fight.jpg" alt="A Silent fight on an iPhone: Prepared+, Strike, Hand Trick+, Neutralize+, Cloak and Dagger and Survivor in hand, against a tentacled yellow enemy" /></p>

<p>One more honest note. Before the real game, Claude built a tiny test app to check the toolchain on my phone. It closed right after the Godot splash. The real game, which is far bigger, did not. Nobody has gone back to find out why.</p>

<h2 id="what-doesnt-work-yet">What doesn’t work yet</h2>

<ul>
  <li><strong>Short freezes.</strong> Every so often the game stops for a moment and then carries on. As Ironclad, I barely notice. Halfway through playing nine shivs, I notice. The next build logs every frame over 100 ms with what happened during it: pipeline compiles, texture loads, or .NET garbage collection.</li>
  <li><strong>No Steam.</strong> No cloud saves or achievements; saves get copied by hand between PC and phone.</li>
  <li><strong>No mods.</strong> Mods are compiled code, and iOS won’t load new code.</li>
  <li><strong>Every 7 days</strong> the free Apple ID signature expires and the app needs re-signing.</li>
  <li><strong>Every game update</strong> needs a new build and a reinstall, for the same no-new-code reason.</li>
</ul>

<p>Steam login with cloud saves inside the iOS app is possible. The Android launcher already does it with SteamKit2, a plain .NET library, so that’s next on the list after the freezes.</p>

<h2 id="what-i-learned">What I learned</h2>

<h3 id="measure-before-fixing">Measure before fixing</h3>

<p>The first Android fix made the game uglier without making it faster. A frame-time log and a GPU busy percentage would have saved a release.</p>

<h3 id="fake-inputs-find-most-bugs-real-inputs-find-the-rest">Fake inputs find most bugs, real inputs find the rest</h3>

<p>The fake game caught nearly everything before a single Steam login. The real game then found exactly one thing the fake game didn’t have.</p>

<h3 id="some-bugs-need-bash-most-need-shivs">Some bugs need Bash, most need shivs</h3>

<p>Rerunning a build is the Ironclad approach, and it worked more often than it should have. The bugs that mattered, the Steam timeout and the missing GodotSharp, needed the Silent approach: a small, precise fix in exactly the right place.</p>

<h3 id="read-the-prompt-before-tapping-approve">Read the prompt before tapping Approve</h3>

<p>Applies to Steam. Also to AI agents.</p>

<p>The code is at <a href="https://github.com/bhanuharya/sts-2-launcher-mobile">bhanuharya/sts-2-launcher-mobile</a>. If you own the game and have a Windows PC or a Mac, the README walks through the iOS build. If you don’t own the game, none of this helps you, by design.</p>

<p>One last thing. Mega Crit <a href="https://thegameslayer.com/news/slay-the-spire-2-mobile-release/">has said</a> an official mobile version of Slay the Spire 2 is coming “at some point”, just not during Early Access. Theirs will be better than mine: no GitHub issue full of Steam Guard codes, no re-signing every 7 days, and probably cloud saves. When it ships, I’ll happily buy it again. I just couldn’t wait. “At some point” is a lot longer than “one more run”, and I’ve been on one more run since June.</p>

<p>Now if you’ll excuse me, I have a Silent run to lose on a six-inch screen.</p>]]></content><author><name>bhanuharya</name></author><category term="ios" /><category term="android" /><category term="godot" /><category term="dotnet" /><category term="github-actions" /><category term="ai-agents" /><summary type="html"><![CDATA[My copy of Slay the Spire 2 now runs on an iPhone and runs better on my Android tablet. I would love to tell you I engineered this. What I actually did was own the game, own the devices, type requests like “can you make an apk release on my fork” into Claude Code, and paste Steam Guard codes into GitHub issues. Claude wrote the code. I’m told this is called “leadership”.]]></summary></entry><entry><title type="html">Building a SAST setup on top of SonarQube</title><link href="https://bhanuharya.github.io/blog/building-a-sast-setup-on-top-of-sonarqube/" rel="alternate" type="text/html" title="Building a SAST setup on top of SonarQube" /><published>2026-10-01T00:00:00+07:00</published><updated>2026-10-01T00:00:00+07:00</updated><id>https://bhanuharya.github.io/blog/building-a-sast-setup-on-top-of-sonarqube</id><content type="html" xml:base="https://bhanuharya.github.io/blog/building-a-sast-setup-on-top-of-sonarqube/"><![CDATA[<p>I wanted a SAST setup for a set of mobile and web repos, mostly Flutter apps and a few web apps. The developers already work in SonarQube. They review hotspots there and their builds wait on its quality gate. So I wanted the security findings in SonarQube too, not in a separate dashboard nobody opens.</p>

<h2 id="what-community-edition-doesnt-cover">What Community Edition doesn’t cover</h2>

<table>
  <thead>
    <tr>
      <th>Gap</th>
      <th>Why it mattered</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Dart security rules</td>
      <td>Flutter apps get very little security coverage out of the box.</td>
    </tr>
    <tr>
      <td>Secrets in git history</td>
      <td>A key deleted last year still lives in the history.</td>
    </tr>
    <tr>
      <td>Vulnerable dependencies</td>
      <td>Dozens of advisories per repo, nowhere to see them.</td>
    </tr>
    <tr>
      <td>Old findings</td>
      <td>First scan of an old repo: hundreds of issues. Nobody fixes hundreds.</td>
    </tr>
  </tbody>
</table>

<p>There are open-source engines for each of these: OpenGrep for code patterns, Gitleaks for secrets, Trivy for dependencies. Running them was quick. Getting their results into SonarQube in a form people would review took most of the time.</p>

<h2 id="first-try-external-issues">First try: external issues</h2>

<p>Sonar can import other tools’ findings as “external issues”, so I started there. The first import: 77 findings exported, zero visible. I assumed the import had failed. It hadn’t. My rule names didn’t match Sonar’s <code class="language-plaintext highlighter-rouge">external_&lt;engine&gt;:&lt;rule&gt;</code> format, so the issues were there and my filter couldn’t see them.</p>

<p>With the names fixed, 249 of 255 findings showed up. The other six were in dotfiles Sonar doesn’t index. Then some quieter problems:</p>

<ul>
  <li>narrow source scopes left out the dependency findings</li>
  <li>scanning the whole tree collided with <code class="language-plaintext highlighter-rouge">sonar.tests</code></li>
  <li>everything landed under Issues, never under Security Hotspots</li>
</ul>

<p>That last one decided it. External issues have no rule page and can’t go through hotspot review, and people scroll past them. So I wrote a plugin that registers the findings as native Sonar rules. The <a href="/blog/dart-security-sonarqube-community/">Dart post</a> covers how that part works.</p>

<h2 id="what-i-ended-up-with">What I ended up with</h2>

<p>SonarQube stays as it was. The missing engines run beside it, and their findings come in as native Sonar issues.</p>

<ul>
  <li><strong><a href="https://github.com/bhanuharya/secure-development-tools"><code class="language-plaintext highlighter-rouge">sdt</code></a></strong>, a small Go CLI, runs the three engines and writes one <code class="language-plaintext highlighter-rouge">findings.json</code>. Every finding gets a fingerprint.</li>
  <li><strong>A SonarQube plugin</strong> imports those findings as vulnerabilities and security hotspots, with descriptions and fix guidance, Dart included. To a developer they look like any other Sonar issue.</li>
  <li><strong>A baseline</strong> built from the target branch’s fingerprints. Anything already there counts as existing, so only what you just added is new.</li>
  <li><strong>Scanner errors fail the build.</strong> If an engine errors, the run exits with code 3. Every run writes a manifest with checksums.</li>
  <li><strong>A Word report</strong> built from Sonar’s data, because that’s what gets attached to a release ticket.</li>
  <li><strong>A Jenkins library</strong>, so one job can scan any repo, either a whole branch or one PR.</li>
</ul>

<p>Sonar still runs its own analysis and quality gate on top.</p>

<h2 id="things-that-bit-me">Things that bit me</h2>

<p><strong>Jenkins env vars are case-insensitive.</strong> My job had a parameter called <code class="language-plaintext highlighter-rouge">branch</code>. The library exported <code class="language-plaintext highlighter-rouge">BRANCH</code> for the scan script, and the script kept failing with <code class="language-plaintext highlighter-rouge">BRANCH: parameter null or not set</code>. Jenkins merges environment names case-insensitively, so <code class="language-plaintext highlighter-rouge">BRANCH</code> got folded into the parameter’s lowercase key, and bash, which <em>is</em> case-sensitive, saw nothing. Same with <code class="language-plaintext highlighter-rouge">pr_id</code> and <code class="language-plaintext highlighter-rouge">PR_ID</code>. The fix: pass them as <code class="language-plaintext highlighter-rouge">SDT_SCAN_*</code> and rename them right before the script runs.</p>

<p><strong>My first PR scan scanned the whole branch.</strong> I passed the base branch to the scanner and assumed it would only look at the diff. On a real PR it reported 117 findings, 115 of them dependency CVEs, from a PR that touched 15 files and no lockfiles. Only secret scanning was limited to the PR’s commits. The baseline fixed it. Scan the target branch first, then the PR:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[sdt] baseline: full scan of origin/main
baseline created: .secure-dev/baseline.json (119 entries)
[sdt] pull request adds 0 of 117 findings (the rest already exist on the target branch)
</code></pre></div></div>

<p>Sonar’s own PR analysis only looks at new code too, so the two now agree. A PR scan takes about twice as long because of the extra target scan.</p>

<p><strong>The default quality gate fails every PR.</strong> Sonar’s default gate wants 80% coverage on new code. My scans don’t send a coverage report, so every PR failed on coverage, and people stop looking at builds that are always red. Next I’m setting up a security-only gate: no new vulnerabilities, and every new hotspot reviewed.</p>

<p><strong>Merged PRs are awkward.</strong> Once a PR is merged, its branch is usually deleted and <code class="language-plaintext highlighter-rouge">main</code> already contains it, so a baseline from <code class="language-plaintext highlighter-rouge">main</code> finds nothing new. Checking a merged PR means comparing the two parents of the merge commit. I haven’t built that yet.</p>

<h2 id="where-it-is-now">Where it is now</h2>

<p>It runs on a local Jenkins and pulls repos from Bitbucket. One job has a dropdown for <code class="language-plaintext highlighter-rouge">branch</code> or <code class="language-plaintext highlighter-rouge">pull-request</code>. Findings go to SonarQube and each build keeps its reports. I still start PR scans by hand, because Bitbucket Cloud can’t reach a Jenkins on localhost.</p>

<p>It’s pre-release and the schema will change. So far it does what I wanted: developers stay in SonarQube, and a PR shows them only the security issues it added.</p>

<p>Code: <a href="https://github.com/bhanuharya/secure-development-tools">github.com/bhanuharya/secure-development-tools</a></p>]]></content><author><name>bhanuharya</name></author><category term="appsec" /><category term="sast" /><category term="sonarqube" /><category term="jenkins" /><category term="flutter" /><summary type="html"><![CDATA[I wanted a SAST setup for a set of mobile and web repos, mostly Flutter apps and a few web apps. The developers already work in SonarQube. They review hotspots there and their builds wait on its quality gate. So I wanted the security findings in SonarQube too, not in a separate dashboard nobody opens.]]></summary></entry><entry><title type="html">Dart security rules for SonarQube Community, with OpenGrep</title><link href="https://bhanuharya.github.io/blog/dart-security-sonarqube-community/" rel="alternate" type="text/html" title="Dart security rules for SonarQube Community, with OpenGrep" /><published>2026-10-01T00:00:00+07:00</published><updated>2026-10-01T00:00:00+07:00</updated><id>https://bhanuharya.github.io/blog/dart-security-sonarqube-community</id><content type="html" xml:base="https://bhanuharya.github.io/blog/dart-security-sonarqube-community/"><![CDATA[<p>Sonar has its own Dart and Flutter analyzer, but it ships in <a href="https://docs.sonarsource.com/sonarqube-server/analyzing-source-code/languages/dart">Developer Edition and up</a>. On the free Community Build you get Dart through <a href="https://github.com/insideapp-oss/sonar-flutter">sonar-flutter</a>, a community plugin. It adds the <code class="language-plaintext highlighter-rouge">dart</code> language and imports <code class="language-plaintext highlighter-rouge">dart analyze</code> results. Those are lints. A Flutter app that turns off TLS certificate checks scans clean.</p>

<p>I wanted real security findings for Flutter apps on Community, showing up the same way Sonar’s own Java or Python security issues do. This post is how I got there with OpenGrep and a small plugin.</p>

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

<table>
  <thead>
    <tr>
      <th>Piece</th>
      <th>Job</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>sonar-flutter</td>
      <td>Registers the <code class="language-plaintext highlighter-rouge">dart</code> language and imports <code class="language-plaintext highlighter-rouge">dart analyze</code> lints</td>
    </tr>
    <tr>
      <td>OpenGrep + Dart rules</td>
      <td>Finds the security problems</td>
    </tr>
    <tr>
      <td><a href="https://github.com/bhanuharya/sonar-opengrep-dart">sonar-opengrep-dart</a></td>
      <td>Turns each OpenGrep rule into a Sonar rule and puts results on it</td>
    </tr>
  </tbody>
</table>

<p>OpenGrep runs in CI and writes JSON. The plugin doesn’t run anything itself. It reads that JSON during <code class="language-plaintext highlighter-rouge">sonar-scanner</code>.</p>

<h2 id="why-i-didnt-use-sarif">Why I didn’t use SARIF</h2>

<p>Sonar can import SARIF or its generic format as “external issues”. External issues show up, but they can’t be Security Hotspots, they don’t count in the CWE / OWASP reports, they have no rule page, and you can’t switch them on or off in a quality profile. In practice people scroll past them.</p>

<p>A plugin can register rules on the server like any language analyzer does. Then a finding is a normal Sonar issue: it has a rule description, a “How to fix it” tab, a CWE link, and hotspots go through the usual review.</p>

<h2 id="a-rule">A rule</h2>

<p>Rules are plain OpenGrep YAML. The <code class="language-plaintext highlighter-rouge">metadata</code> block is what the plugin reads to build the Sonar rule:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">id</span><span class="pi">:</span> <span class="s">scp.dart.tls.disable-certificate-validation</span>
  <span class="na">languages</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">dart</span><span class="pi">]</span>
  <span class="na">severity</span><span class="pi">:</span> <span class="s">ERROR</span>
  <span class="na">message</span><span class="pi">:</span> <span class="s">A badCertificateCallback that always returns </span><span class="no">true</span><span class="s"> disables TLS certificate validation ...</span>
  <span class="na">metadata</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">TLS</span><span class="nv"> </span><span class="s">certificate</span><span class="nv"> </span><span class="s">validation</span><span class="nv"> </span><span class="s">should</span><span class="nv"> </span><span class="s">not</span><span class="nv"> </span><span class="s">be</span><span class="nv"> </span><span class="s">disabled"</span>
    <span class="na">category</span><span class="pi">:</span> <span class="s">security</span>
    <span class="na">cwe</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">CWE-295"</span><span class="pi">]</span>
    <span class="na">owasp</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">A02:2021</span><span class="nv"> </span><span class="s">-</span><span class="nv"> </span><span class="s">Cryptographic</span><span class="nv"> </span><span class="s">Failures"</span><span class="pi">]</span>
    <span class="na">confidence</span><span class="pi">:</span> <span class="s">HIGH</span>
    <span class="na">remediation</span><span class="pi">:</span> <span class="s">Remove the override, or validate the chain against a pinned certificate.</span>
  <span class="na">pattern-either</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">pattern</span><span class="pi">:</span> <span class="s">$CLIENT.badCertificateCallback = ($A, $B, $C) =&gt; </span><span class="no">true</span>
    <span class="pi">-</span> <span class="na">pattern</span><span class="pi">:</span> <span class="s">$CLIENT.badCertificateCallback = ($A, $B, $C) { return </span><span class="no">true</span><span class="s">; }</span>
</code></pre></div></div>

<p>The type comes from the metadata. <code class="language-plaintext highlighter-rouge">confidence: HIGH</code> or a taint rule (<code class="language-plaintext highlighter-rouge">mode: taint</code>) becomes a Vulnerability. Any other security rule becomes a Security Hotspot, since a person has to look at the context. <code class="language-plaintext highlighter-rouge">owasp-mobile</code> and <code class="language-plaintext highlighter-rouge">masvs</code> keys become tags, because Sonar has no mobile standard of its own.</p>

<p>The bundled pack has 18 rules so far. Each one has a vulnerable and a safe fixture for <code class="language-plaintext highlighter-rouge">opengrep scan --test</code>:</p>

<ul>
  <li>TLS validation disabled, MD5/SHA-1, AES-ECB, zero keys, static IVs, insecure random</li>
  <li>SQL injection and untrusted WebView scripts (taint)</li>
  <li><code class="language-plaintext highlighter-rouge">http://</code> URLs, secrets in SharedPreferences or logs, hardcoded API keys</li>
  <li>WebView JavaScript channels, <code class="language-plaintext highlighter-rouge">biometricOnly: false</code>, deep-link handlers with no validation</li>
</ul>

<p>You can point the server at your own rule directories too.</p>

<h2 id="things-i-didnt-expect">Things I didn’t expect</h2>

<p><strong>A plugin can’t add rules to another plugin’s built-in profile.</strong> The default Dart profile belongs to sonar-flutter. My rules can’t go into it, and a project uses one profile per language. So the setup includes a script that copies sonar-flutter’s profile, activates the OpenGrep rules in it, and makes it the default.</p>

<p><strong>sonar-flutter runs <code class="language-plaintext highlighter-rouge">flutter analyze</code> itself by default.</strong> If <code class="language-plaintext highlighter-rouge">flutter</code> isn’t on the scanner’s PATH, the whole analysis fails. In CI I run <code class="language-plaintext highlighter-rouge">dart analyze --format=machine</code> first and point sonar-flutter at that file with <code class="language-plaintext highlighter-rouge">sonar.dart.analyzer.mode=MANUAL</code> and <code class="language-plaintext highlighter-rouge">report.mode=MACHINE</code>.</p>

<p><strong>Zero-length diagnostics crash the import.</strong> Some <code class="language-plaintext highlighter-rouge">dart analyze</code> results come with a length of 0, file-name lints at line 1 for example. sonar-flutter throws on those and the whole scan fails. My scan script rewrites them to a one-character span before handing the file over. (“My” is doing some work in that sentence. Claude wrote it. I found the stack trace.)</p>

<p><strong>Secrets in git history have no line to sit on.</strong> A key deleted two years ago is still in every clone, but the file isn’t in the checkout anymore. Those findings go on the project instead of a file, as their own rule (<code class="language-plaintext highlighter-rouge">sdt:secret-in-history</code>), so they don’t get dropped.</p>

<p>That last one comes from the wider setup: <a href="https://github.com/bhanuharya/secure-development-tools">sdt</a> runs Gitleaks and Trivy next to OpenGrep, and the plugin also imports secrets and vulnerable dependencies as native rules. A dependency nothing imports becomes a hotspot. One that’s imported becomes a vulnerability.</p>

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

<p>This is pattern matching, plus OpenGrep’s taint mode for a couple of rules. It is a lot shallower than a dedicated analyzer. If you have Developer Edition, use Sonar’s own Dart analysis, and treat these rules as an extra layer at most.</p>

<p>The plugin is tested in CI against SonarQube 9.9 LTA, 10.7 and the latest Community Build, with sonar-flutter 0.5.2. It’s early, and the rule pack is small. If you write Flutter on Community and have a rule idea, I’d like to hear it.</p>]]></content><author><name>bhanuharya</name></author><category term="appsec" /><category term="sast" /><category term="sonarqube" /><category term="flutter" /><category term="dart" /><category term="opengrep" /><summary type="html"><![CDATA[Sonar has its own Dart and Flutter analyzer, but it ships in Developer Edition and up. On the free Community Build you get Dart through sonar-flutter, a community plugin. It adds the dart language and imports dart analyze results. Those are lints. A Flutter app that turns off TLS certificate checks scans clean.]]></summary></entry><entry><title type="html">What happens when I revoke an agent’s access</title><link href="https://bhanuharya.github.io/blog/agent-task-scoped-access/" rel="alternate" type="text/html" title="What happens when I revoke an agent’s access" /><published>2026-09-21T05:50:00+07:00</published><updated>2026-09-21T05:50:00+07:00</updated><id>https://bhanuharya.github.io/blog/agent-task-scoped-access</id><content type="html" xml:base="https://bhanuharya.github.io/blog/agent-task-scoped-access/"><![CDATA[<p>I want an agent to compare migration files with staging and report what’s missing. For that job it needs database access. The hard part is stopping it: once a task is revoked, can work already admitted still change the database? I have not tested this yet. This post is the design question and a small lab plan.</p>

<h2 id="three-clocks">Three clocks</h2>

<p>One grant starts three separate timers:</p>

<table>
  <thead>
    <tr>
      <th>Clock</th>
      <th>Example</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Grant</td>
      <td>this task may touch this service until Friday</td>
    </tr>
    <tr>
      <td>Credential</td>
      <td>password or token with its own expiry</td>
    </tr>
    <tr>
      <td>Connection</td>
      <td>TCP session, transaction, query already running</td>
    </tr>
  </tbody>
</table>

<p>They stop separately:</p>

<ul>
  <li>A grant can end while the credential still works.</li>
  <li>A credential can expire while the connection stays open.</li>
  <li>A database finishes a query it already accepted. The result can land after the revoke was reported done.</li>
</ul>

<p>The runtime I have been building tracks this as five separate events:</p>

<p><img src="/assets/img/revoked-lifecycle.svg" alt="What the word revoked hides" /></p>

<p><em>One status word, four separate events. Late effects land in the last box.</em></p>

<p>I trust one sentence here: “admission closed and no sessions remain”. A status line that says revoked packs four open questions into one word.</p>

<h2 id="where-each-control-stops">Where each control stops</h2>

<h3 id="secrets-manager-custody">Secrets manager: custody</h3>

<p>It decides who may read which secret. A Vault-shaped issuer can mint a short-lived database credential per task. That beats a long-lived password in an environment variable.</p>

<p>What it leaves open: the effect. A lease says when a credential dies. It says nothing about the query accepted a second earlier, or whether the task still gets its answer.</p>

<p>One more wrinkle in the same spot. Short-lived credentials for PostgreSQL ride on password authentication. If the fix is to move off password authentication, the clock I relied on may stop being the thing that enforces anything.</p>

<h3 id="gateway-admission">Gateway: admission</h3>

<p>It sees a request, checks policy, forwards or refuses. The one I read checks HTTP method and path against a per-provider allowlist. A permitted host still needs a permitted operation.</p>

<p>The boundary: it decides at admission and the work lands later. Closing the door stops future admissions. Everything already inside keeps going.</p>

<h3 id="sandbox-reachability">Sandbox: reachability</h3>

<p>The strongest setup I have read about: the agent container gets no network interface, drops every Linux capability, and mounts two Unix sockets for tool calls and model calls. Nothing else is reachable. The project aborts startup when the connectivity check fails, so it never slides quietly into a weaker mode.</p>

<p>The edge: the same screen that keeps the agent out of my network keeps it out of my test stack when the fixture resolves to loopback or a private range. The code says so directly. A fixture on loopback needs a specific option before it becomes reachable at all. So the adapter has to live somewhere the declared route permits, and that place can be awkward.</p>

<h2 id="two-escape-hatches">Two escape hatches</h2>

<p>The same project documents one relaxation and ships another in code:</p>

<table>
  <thead>
    <tr>
      <th>Hatch</th>
      <th>What it does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Env flag for trusted environments</td>
      <td>Proxy treats any unknown host as a passthrough tunnel. Applies to all three host checks.</td>
    </tr>
    <tr>
      <td>Test-only option for loopback fixtures</td>
      <td>Settable only by code that builds the proxy itself. Turns the address screen off.</td>
    </tr>
  </tbody>
</table>

<p>With the second one set, any claim that the agent cannot reach an undeclared route stops holding. The failure is quiet: the lab works better, the boundary disappears, results look the same.</p>

<h2 id="checking-the-citation">Checking the citation</h2>

<p>I treat “the code does this, at this file and line” as unverified until I fetch the source and grep the identifier. Here that turned up three kinds of rot:</p>

<ol>
  <li>Line moved. Stale, easy fix.</li>
  <li>Identifier gone or renamed. The mechanism my plan relied on is absent from the version I would pin. Design problem.</li>
  <li>Citation never existed. The expensive one, usually inside an otherwise careful document.</li>
</ol>

<p>The habit that paid off: go to the enforcement site, the place that decides behaviour. A type existing proves nothing. In the gateway I read, the endpoint filter is declared in one file and called in the mitm proxy, on the request path, right before the credential swap. Stopping at the declaration gives a sentence that is true and misleading.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># five mentions in the tree, and one place that decides anything</span>
<span class="nv">$ </span><span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"isEndpointAllowed"</span> src <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.ts"</span> | <span class="nb">wc</span> <span class="nt">-l</span>
5
<span class="nv">$ </span><span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"isEndpointAllowed(provider.config"</span> src <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.ts"</span>
src/docker/mitm-proxy.ts:1105:    <span class="k">if</span> <span class="o">(!</span>isEndpointAllowed<span class="o">(</span>provider.config, method, path<span class="o">))</span> <span class="o">{</span>
</code></pre></div></div>

<p>Five hits. Two are comments and one is an import. Exactly one runs on the request path. That is the one that decides whether the filter means anything.</p>

<h2 id="what-would-settle-it">What would settle it</h2>

<p>Reading gives a hypothesis and seams worth testing. It cannot say whether a stopped grant still lands an effect. That is the question the post turns on.</p>

<p><img src="/assets/img/authority-map.svg" alt="Where the authority sits, and where a stop acts" /></p>

<p><em>The roles and the claim under test. A stop acts at the gateway, the effect lands at the service, the direct attempt from the sandbox is run as a case, and the observer is a separate process the task cannot see.</em></p>

<p>Small lab on purpose. One issuer, one service, one observer. Six rules for whether it can tell me anything:</p>

<ul>
  <li>write down what each case should show before running it</li>
  <li>include a permitted operation that must succeed, so a later denial reads as policy</li>
  <li>keep the four lifecycle events separate</li>
  <li>observe from a process outside the one under test</li>
  <li>mark inconclusive cases as inconclusive, keep them out of the story</li>
  <li>run the direct attempt as a control, since topology is no substitute for watching it fail</li>
</ul>

<p>The gateway earns its place once the small version produces something that needs it. Added first, it spends the budget on plumbing and reaches the interesting question late.</p>

<h2 id="where-this-stands">Where this stands</h2>

<p>I have only read the docs and source at one revision. I have not pinned a release or run the lab, so I do not know whether an admitted operation can land after access is pulled. That is the next thing to test.</p>]]></content><author><name>bhanuharya</name></author><category term="agents" /><category term="security" /><category term="access-control" /><category term="self-hosting" /><summary type="html"><![CDATA[I want an agent to compare migration files with staging and report what’s missing. For that job it needs database access. The hard part is stopping it: once a task is revoked, can work already admitted still change the database? I have not tested this yet. This post is the design question and a small lab plan.]]></summary></entry><entry><title type="html">Can my local 8B do what Jev does</title><link href="https://bhanuharya.github.io/blog/can-my-local-8b-do-what-jev-does/" rel="alternate" type="text/html" title="Can my local 8B do what Jev does" /><published>2026-09-17T00:00:00+07:00</published><updated>2026-09-17T00:00:00+07:00</updated><id>https://bhanuharya.github.io/blog/can-my-local-8b-do-what-jev-does</id><content type="html" xml:base="https://bhanuharya.github.io/blog/can-my-local-8b-do-what-jev-does/"><![CDATA[<p>TypeSafe’s Jev showed up in my feed twice in one day. Both demos used it to pick from a fixed set of answers, not to chat. <a href="https://x.com/nutlope/status/2100426999546184123">One</a> sorted 1,018 research papers into 24 topics for eight cents, at a median 256 ms per paper. <a href="https://x.com/gregpr07/status/2100411066966749359">The other</a> ran a flight search in seven seconds for $0.0039.</p>

<p><a href="https://docs.typesafe.ai/">Jev</a> doesn’t return text. You give it a state, a question and the answers you’ll accept. It returns the option it picked, a probability for every option, and a separate confidence score you’re meant to threshold.</p>

<p>I wanted to know if a local 8B could do the same job. Partly for privacy, since for email triage the input is the email. Partly because a local model has no per-call cost and doesn’t change under me. And partly because picking one of eight queues sounded like something an 8B should manage.</p>

<p>I tested it on synthetic email triage. The local model matched Jev on three of eight queues. The bigger problem was its confidence: it was just as sure of its wrong answers, so I couldn’t use it to decide what to route automatically.</p>

<h2 id="the-local-setup">The local setup</h2>

<p>LFM2.5-8B-A1B, 8B parameters with about 1B active per token, quantised to Q4_K_M (4.9 GB). It runs under llama-server on the ThinkPad T14 that hosts my local lane, CPU only, started by hand when I need it:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./llama-server -m LFM2.5-8B-A1B-Q4_K_M.gguf \
  -c 65536 -t 8 -tb 8 -ngl 0 -fa on \
  --jinja --metrics -a lfm25-8b \
  -n 2048 --repeat-penalty 1.1 --repeat-last-n 256
</code></pre></div></div>

<p>Both sides get the same system prompt. A chat completion gives you none of the three things Jev returns, so most of the work was making the local model answer in the same shape:</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">"model"</span><span class="p">:</span><span class="w"> </span><span class="s2">"lfm25-8b"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"messages"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"&lt;system prompt&gt;"</span><span class="p">,</span><span class="w"> </span><span class="s2">"&lt;the email&gt;"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="nl">"role"</span><span class="p">:</span><span class="w"> </span><span class="s2">"assistant"</span><span class="p">,</span><span class="w"> </span><span class="nl">"content"</span><span class="p">:</span><span class="w"> </span><span class="s2">"{</span><span class="se">\"</span><span class="s2">label</span><span class="se">\"</span><span class="s2">: </span><span class="se">\"</span><span class="s2">"</span><span class="p">}],</span><span class="w">
  </span><span class="nl">"temperature"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
  </span><span class="nl">"max_tokens"</span><span class="p">:</span><span class="w"> </span><span class="mi">8</span><span class="p">,</span><span class="w">
  </span><span class="nl">"grammar"</span><span class="p">:</span><span class="w"> </span><span class="s2">"root ::= (</span><span class="se">\"</span><span class="s2">billing</span><span class="se">\"</span><span class="s2"> | </span><span class="se">\"</span><span class="s2">security</span><span class="se">\"</span><span class="s2"> | ... | </span><span class="se">\"</span><span class="s2">__none__</span><span class="se">\"</span><span class="s2">) </span><span class="se">\"\\\"</span><span class="s2">}</span><span class="se">\"</span><span class="s2">"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"continue_final_message"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"add_generation_prompt"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
  </span><span class="nl">"logprobs"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"top_logprobs"</span><span class="p">:</span><span class="w"> </span><span class="mi">20</span><span class="p">,</span><span class="w">
  </span><span class="nl">"cache_prompt"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<ul>
  <li><strong>The pick.</strong> The assistant turn is pre-filled with <code class="language-plaintext highlighter-rouge">{"label": "</code>, and the grammar lists the allowed queues, so the model can’t answer anything else. With 8 tokens at temperature 0, a decision takes 4 output tokens. Without the grammar the same model used 454.</li>
  <li><strong>The probabilities.</strong> llama-server has no per-option probability, so I rebuilt one from the top 20 token logprobs along the greedy path, then renormalised over the valid options. The renormalising is required: the server reports probabilities from before the grammar mask, so they don’t sum to one. It’s still an approximation. Exact scoring needs one forced call per option, and the completions endpoint returned zero prompt tokens for <code class="language-plaintext highlighter-rouge">echo</code> when I tried it.</li>
  <li><strong>The confidence.</strong> There’s nothing separate to return, so it’s just the probability of the chosen option.</li>
</ul>

<p>For comparison I also ran the model without the grammar or the prefill, with a 512-token cap, and pulled the label out of its answer.</p>

<h2 id="lab-1-94-emails">Lab 1: 94 emails</h2>

<table>
  <thead>
    <tr>
      <th>setup</th>
      <th>accuracy</th>
      <th>ECE</th>
      <th>latency p50</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Jev</td>
      <td>81.9% (77/94)</td>
      <td>0.091</td>
      <td>739 ms</td>
    </tr>
    <tr>
      <td>local 8B, with grammar</td>
      <td>63.8% (60/94)</td>
      <td>0.269</td>
      <td>3,948 ms</td>
    </tr>
    <tr>
      <td>local 8B, no grammar</td>
      <td>52.1% (49/94)</td>
      <td>n/a</td>
      <td>21,850 ms</td>
    </tr>
  </tbody>
</table>

<p>My first conclusion was that the grammar added 11.7 points of accuracy. It didn’t. Nine of the no-grammar answers had no label in them at all, and those nine account for 9.6 of the 11.7 points. On the answers it did give, it was 57.6% against 63.8%, six emails out of 94. That’s noise.</p>

<p>The grammar bought parseable output and speed, 21.8 seconds per email down to 3.9. It didn’t buy accuracy.</p>

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

<p>To automate triage you pick a confidence threshold, route everything above it, and send the rest to a person. The number that matters is how many wrong answers get through.</p>

<table>
  <thead>
    <tr>
      <th>gate</th>
      <th>Jev covers</th>
      <th>Jev wrong, passed</th>
      <th>local covers</th>
      <th>local wrong, passed</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0.6</td>
      <td>89.4%</td>
      <td>9</td>
      <td>93.6%</td>
      <td>29</td>
    </tr>
    <tr>
      <td>0.7</td>
      <td>84.0%</td>
      <td>4</td>
      <td>86.2%</td>
      <td>23</td>
    </tr>
    <tr>
      <td>0.8</td>
      <td>79.8%</td>
      <td>3</td>
      <td>80.9%</td>
      <td>20</td>
    </tr>
    <tr>
      <td>0.9</td>
      <td>62.8%</td>
      <td>0</td>
      <td>71.3%</td>
      <td>15</td>
    </tr>
  </tbody>
</table>

<p>At 0.8, both handle about four fifths of the mailbox. Jev lets 3 wrong answers through out of 94. The local model lets 20 through. Its average confidence on the answers it got right was actually higher than Jev’s, 0.964 against 0.941.</p>

<p><img src="/assets/img/gate-errors-escaped.png" alt="Errors that pass a confidence gate" /></p>

<p><em>Wrong answers that clear the gate, lab 2. The local model covers the same share at every threshold because it has no answers below 90% confidence.</em></p>

<h2 id="lab-2-205-emails">Lab 2: 205 emails</h2>

<p>For lab 2 I changed three things, each aimed at a failure I’d seen in lab 1. Queue definitions were written as actions. The model picked from 25 plain descriptions, which code mapped onto the 8 queues. And the probabilities came from a full expansion over the token distribution, not just the greedy path. The set had 205 emails, 60 of them traps.</p>

<ul>
  <li>Jev went from 84.4% to 85.9% with the descriptions. The local model got 65.4%, up from 63.8% on a harder set, so no real change.</li>
  <li>The local model got slower, 3.9 to 6.7 seconds per email, because the prompt grew.</li>
  <li>The descriptions made Jev worse on traps (43 of 60, down from 48) and raised its output tokens from 15,590 to 65,810.</li>
  <li>The local model’s ECE got worse, 0.339.</li>
</ul>

<p>I tried fixing the calibration with temperature scaling on held-out data. ECE stayed between 0.32 and 0.36. Looking at coverage shows why: it’s 84.9% at 0.7, 0.8 and 0.9 alike. Every answer the local model gives a usable score for sits above 90% confidence, so there’s nothing in the middle to rescale. Another 31 of the 205 answers produced no usable distribution at all.</p>

<p><img src="/assets/img/reliability.png" alt="Stated confidence against observed accuracy" /></p>

<p><em>Confidence against accuracy, lab 2. The local model has no answers below 90% confidence.</em></p>

<p>I don’t think a prompt, a grammar or rescaling can fix a model that never reports doubt.</p>

<h2 id="per-queue">Per queue</h2>

<table>
  <thead>
    <tr>
      <th>true queue</th>
      <th>Jev</th>
      <th>local 8B</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>newsletter</td>
      <td>87%</td>
      <td>97%</td>
    </tr>
    <tr>
      <td>security</td>
      <td>89%</td>
      <td>89%</td>
    </tr>
    <tr>
      <td>meeting</td>
      <td>88%</td>
      <td>88%</td>
    </tr>
    <tr>
      <td>no-fit</td>
      <td>94%</td>
      <td>81%</td>
    </tr>
    <tr>
      <td>internal</td>
      <td>89%</td>
      <td>63%</td>
    </tr>
    <tr>
      <td>billing</td>
      <td>80%</td>
      <td>57%</td>
    </tr>
    <tr>
      <td>personal</td>
      <td>56%</td>
      <td>44%</td>
    </tr>
    <tr>
      <td>complaint</td>
      <td>100%</td>
      <td>4%</td>
    </tr>
  </tbody>
</table>

<p>The local model keeps up on newsletter, security and meeting. Complaints are the disaster: 24 of the 205 emails, and it got 1 of them. That one queue is most of the 19-point gap.</p>

<p>So a split might work: the local model takes its three good queues and everything else goes to Jev or a person.</p>

<h2 id="bugs-in-my-own-harness">Bugs in my own harness</h2>

<p>I found and fixed ten bugs before or during the runs. Three of them would have given me confident, wrong conclusions:</p>

<ul>
  <li>Three trap emails had their correct label pointing at the decoy queue. A phishing email was filed under <code class="language-plaintext highlighter-rouge">billing</code> in the answer key.</li>
  <li>The grammar’s closing brace comes in the same token as the end of the label, so a finished answer never matched an option name, and correct answers were scored as no answer.</li>
  <li>The coverage metric dropped rows with no distribution, which turned 84.9% coverage into 100% in one file while the report’s own table said 84.9%.</li>
</ul>

<p>Four of the other seven:</p>

<ul>
  <li>A typo in the grammar made every request return HTTP 400.</li>
  <li>A scorer read the token after the label instead of the label itself.</li>
  <li>A 64-token cap returned empty answers because the model was still thinking.</li>
  <li>The answer parser matched labels inside negations, so “the subject does not say newsletter” counted as a newsletter answer.</li>
</ul>

<h2 id="what-this-doesnt-show">What this doesn’t show</h2>

<ul>
  <li>The emails are template-generated and 77 to 208 characters long. Real mail will score lower, so every accuracy here is a best case.</li>
  <li>It’s one model, one quantisation, one CPU and one email set. It says nothing about small models in general.</li>
  <li><code class="language-plaintext highlighter-rouge">jev-latest</code> changes over time, so the Jev numbers can’t be reproduced against a fixed version.</li>
  <li>In lab 1 the local model beat Jev on traps, but there were only 14 traps. In lab 2, with 60, Jev won 48 to 31.</li>
  <li>The two demos at the top are self-reported, and neither reports accuracy.</li>
  <li>TypeSafe publishes no price for this, and their pricing page returns 404. The cost estimate below borrows the paper demo’s rate.</li>
</ul>

<h2 id="cost-isnt-the-question">Cost isn’t the question</h2>

<p>At the paper demo’s rate, Jev costs about eight cents per thousand emails. The local model is free per call but handled 909 emails an hour in lab 1 and 536 in lab 2, so 50,000 emails a month would take it 55 to 93 hours. At these volumes the money is small either way. What decides it is how many errors get through the gate.</p>

<h2 id="next">Next</h2>

<ul>
  <li>Two or three examples in the prompt for the queues it confuses: internal against meeting, personal against no-fit.</li>
  <li>Run each email twice with the options in a different order, and gate on whether the two runs agree, since the model’s confidence is no use.</li>
  <li>A small fine-tuned classifier, which takes milliseconds per email on CPU and comes out of training calibrated.</li>
  <li>Pin one server slot so the shared system prompt is only processed once, and give the server all 12 threads. With four unpinned slots it’s probably reprocessed on every call, which would explain why lab 2 got slower.</li>
</ul>

<p>For now I’d test the local model as a prefilter on its three good queues, with everything else going to Jev or a person. I wouldn’t let its confidence decide that split.</p>]]></content><author><name>bhanuharya</name></author><category term="local-llm" /><category term="evals" /><category term="calibration" /><category term="llama.cpp" /><category term="email" /><summary type="html"><![CDATA[TypeSafe’s Jev showed up in my feed twice in one day. Both demos used it to pick from a fixed set of answers, not to chat. One sorted 1,018 research papers into 24 topics for eight cents, at a median 256 ms per paper. The other ran a flight search in seven seconds for $0.0039.]]></summary></entry><entry><title type="html">4B vs 23B on the 3060</title><link href="https://bhanuharya.github.io/blog/putting-a-4b-local-model-up-against-my-23b-moe/" rel="alternate" type="text/html" title="4B vs 23B on the 3060" /><published>2026-09-14T00:00:00+07:00</published><updated>2026-09-14T00:00:00+07:00</updated><id>https://bhanuharya.github.io/blog/putting-a-4b-local-model-up-against-my-23b-moe</id><content type="html" xml:base="https://bhanuharya.github.io/blog/putting-a-4b-local-model-up-against-my-23b-moe/"><![CDATA[<p>My GPU box runs GLM-4.7-Flash as its local model. It’s a 23B mixture of experts with about 3B active per token, pruned and quantised to fit on a 12 GB RTX 3060. It writes well. The pruning and quantisation also make it unreliable on some facts.</p>

<p>Then a 4B model came out as a GGUF, and I wondered if smaller might be better. I ran both on the same machine with the same prompts. The 4B read long prompts about seven times faster and used about half the VRAM. I kept the 23B anyway.</p>

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

<ul>
  <li>Intel i5-12400, 6 cores and 12 threads</li>
  <li>32 GB of RAM</li>
  <li>RTX 3060 with 12 GB of VRAM, running the CUDA build of llama.cpp on Windows 11</li>
</ul>

<p>The 23B takes 11,444 MiB of the card’s 12,288, so the two models can’t be loaded together. Every comparison meant stopping one and starting the other, about ninety seconds each time. Both ran on the same port so my other clients kept working.</p>

<h2 id="the-two-models">The two models</h2>

<p><strong>GLM-4.7-Flash</strong>, the one I already run: 22.9B parameters, REAP-pruned, a community decensored build, quantised to IQ3_S mix at 3.66 bits per weight. 10.18 GB on disk, 131K context.</p>

<p><strong>Spark-X2.5-4B</strong> from XHToken: 4.11B dense, 36 layers, one full attention layer for every three sliding-window ones. Q6_K, 3.37 GB. The most widely linked abliterated version only ships safetensors, so I ran a different abliteration of the same base that comes as a GGUF. With 131K context loaded it uses 6,425 MiB. The model card claims 1M context. I only tested recall up to 96K.</p>

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

<p>Temperature 0, same token caps for both:</p>

<ul>
  <li>“reply with exactly: ok”</li>
  <li>two questions the 23B has gotten wrong before: does ice cream contain cream cheese, and is “icecream” a valid spelling</li>
  <li>a writing prompt of the kind that often gets refused, which is what I mostly use this model for</li>
  <li>speed on a short prompt and an 80K-token prompt, then the long prompt again to hit the cache</li>
  <li>a planted string to find at 41K and 96K tokens</li>
</ul>

<h2 id="results">Results</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>4B</th>
      <th>23B</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>prefill, 80K prompt</td>
      <td>1,689 tok/s</td>
      <td>224 tok/s</td>
    </tr>
    <tr>
      <td>time to first token, 80K prompt</td>
      <td>48 s</td>
      <td>359 s</td>
    </tr>
    <tr>
      <td>generation, 80K prompt</td>
      <td>37 tok/s</td>
      <td>18 tok/s</td>
    </tr>
    <tr>
      <td>cached repeat</td>
      <td>0.6 s</td>
      <td>2.05 s</td>
    </tr>
    <tr>
      <td>VRAM</td>
      <td>6,425 MiB</td>
      <td>11,444 MiB</td>
    </tr>
  </tbody>
</table>

<p>On short prompts both generated about 64 tok/s. Both found the planted string at both depths. Both replied with exactly “ok” and got 17 × 23 right.</p>

<p>The two facts went one each. The 4B got the ice cream question right. The 23B kept mixing up cream and cream cheese until it ran into the token cap. On the spelling question it was the other way round: the 23B got it right, and the 4B decided “icecream” was a valid compound word.</p>

<h2 id="the-4b-thinks-before-it-answers">The 4B thinks before it answers</h2>

<p>It’s a reasoning model. The answer comes back in one field and its reasoning in another. It spent 123 tokens of reasoning to say “ok”, and 1,282 on the spelling question.</p>

<p>The writing prompt came back empty at a 1,500-token cap. It was still empty at 3,000, after 12,249 characters of reasoning. At 6,000 it finished: 14,951 characters of reasoning, then a 5,736-character answer. It never refused. It just hadn’t finished thinking, so a client with a low token cap gets an empty response, not a shorter one.</p>

<p>At temperature 0 both runs were byte-identical, with the same token counts and the same reasoning lengths. Two runs is a small sample, but it means this setup can work as a regression check.</p>

<h2 id="why-i-kept-the-23b">Why I kept the 23B</h2>

<p>It answers directly without spending tokens on reasoning, it knows more, and I know how it behaves on the writing I use it for. The 4B’s one real advantage is reading long prompts fast. It’s on disk, and the swap scripts sit next to the launcher if I need to get through long documents quickly.</p>

<h2 id="what-actually-cost-me-time">What actually cost me time</h2>

<p>Not the benchmark. A model server started from an SSH session dies when that session closes. It passed a health check, then the port went dark, and I spent an hour blaming the model. The lane I actually use is started by a scheduled task, which is why it survives.</p>

<p>It’s also set to fail closed: if the lane is down, requests error out instead of quietly going somewhere else.</p>

<p>If the numbers hold on a third run, I’ll keep the 4B as a fast reader and stop trying to promote it :-)</p>]]></content><author><name>bhanuharya</name></author><category term="local-llm" /><category term="llama.cpp" /><category term="hardware" /><category term="self-hosting" /><category term="testing" /><summary type="html"><![CDATA[My GPU box runs GLM-4.7-Flash as its local model. It’s a 23B mixture of experts with about 3B active per token, pruned and quantised to fit on a 12 GB RTX 3060. It writes well. The pruning and quantisation also make it unreliable on some facts.]]></summary></entry></feed>