<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en_US"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://www.albertsikkema.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://www.albertsikkema.com/" rel="alternate" type="text/html" hreflang="en_US" /><updated>2026-02-25T08:51:47+00:00</updated><id>https://www.albertsikkema.com/feed.xml</id><title type="html">Albert Sikkema - Building Production AI Systems</title><subtitle>Production-ready AI implementation, software engineering best practices, and enterprise AI systems development. Building scalable AI solutions with Claude, OpenAI, and engineering discipline for enterprise and government.</subtitle><author><name>Albert Sikkema</name></author><entry><title type="html">Bijna de helft van alle bushaltes in Nederland is ontoegankelijk, dus bouwde ik een tool om er wat aan te doen</title><link href="https://www.albertsikkema.com/accessibility/open-source/civic-tech/2026/02/24/bushalte-toegankelijkheid-nederland.html" rel="alternate" type="text/html" title="Bijna de helft van alle bushaltes in Nederland is ontoegankelijk, dus bouwde ik een tool om er wat aan te doen" /><published>2026-02-24T00:00:00+00:00</published><updated>2026-02-24T00:00:00+00:00</updated><id>https://www.albertsikkema.com/accessibility/open-source/civic-tech/2026/02/24/bushalte-toegankelijkheid-nederland</id><content type="html" xml:base="https://www.albertsikkema.com/accessibility/open-source/civic-tech/2026/02/24/bushalte-toegankelijkheid-nederland.html"><![CDATA[<figure>
  <img src="/assets/images/busstop-fail.jpg" alt="Een ontoegankelijke bushalte in Nederland: geen verhoogd perron, geen geleidelijnen" />
  <figcaption>Een bushalte een paar kilometer van mijn huis. Geen verhoogd perron, geen geleidelijnen — ontoegankelijk voor veel reizigers.</figcaption>
</figure>

<p><em>This post is in Dutch — a first for this blog. It covers a topic specific to the Netherlands: I built an open-source tool that maps all 20,277 inaccessible bus stops in the country and lets citizens email the responsible authority with a legally grounded request for improvements. Apologies to my English-speaking readers; normal service will resume next post.</em></p>

<hr />

<p>Vandaag publiceerde de <a href="https://nos.nl/artikel/2603791-veel-bushaltes-niet-toegankelijk-voor-mensen-met-een-beperking">NOS</a> dat voor veel mensen als een verrassing kwam: ongeveer de helft van de Nederlandse bushaltes is niet of nauwelijks toegankelijk voor mensen met een beperking. Zes op de tien haltes missen goede voorzieningen voor blinden en slechtzienden. Bijna de helft is slecht ingericht voor rolstoelgebruikers. In sommige gemeenten ligt het percentage ontoegankelijke haltes boven de 90%.</p>

<p>Afgelopen jaren ben ik intensief bezig geweest met toegankelijkheid, onder andere als developer van de <a href="https://bba.nl/">Beter Bereikbaar Applicatie - BBA</a>, en dit bevestigt alle verhalen die ik hoorde. De data is openbaar beschikbaar, maar dat meer dan de helft van de bushaltes in Nederland slecht bereikbaar zijn is hoger dan ik zelf had verwacht. Dus heb ik de mooie kaarten bekeken, en toen dacht ik: wat nu? De gemiddelde persoon zal het getalletje zien, en of zijn schouders ophalen of het voor kennis geving aannemen. Wat moet je er immers mee? Dus toen dacht ik: wat kan ik er mee? Zou het niet mooi zijn als je een kaart zou hebben waar je deze data op ziet en die je in staat stelt om dit onder de aandacht te brengen bij de desbetreffende instantie (vaak een gemeente, soms een provincie en heel soms een waterschap) Dus bouwde ik er iets voor.</p>

<h2 id="de-onzichtbare-data">De onzichtbare data</h2>

<p>Het <a href="https://dova.nu">Centraal Haltebestand</a> — beheerd door DOVA, het samenwerkingsverband van OV-autoriteiten — bevat gedetailleerde informatie over elke bushalte in Nederland. Van elke halte is bekend hoe hoog de stoeprand is, hoe breed het perron, of er geleidelijnen liggen, of de halte obstakels heeft. Al die data is openbaar.</p>

<p>Maar “openbaar” is niet hetzelfde als “zichtbaar”. De data zit in de <a href="https://halteviewer.ov-data.nl">Halteviewer</a>, een tool voor professionals. Je moet weten dat die bestaat, je moet weten hoe je erin zoekt. Voor een gemeenteraadslid of burger die willen weten hoeveel haltes in hun gemeente niet op orde zijn, is dat een doodlopende weg.</p>

<h2 id="20277-haltes-die-niet-voldoen">20.277 haltes die niet voldoen</h2>

<p>Ik schreef een datapipeline die alle haltedata ophaalt en toetst aan de <a href="https://www.crow.nl">CROW-normen</a> voor toegankelijkheid. Een halte voldoet niet als de stoeprand lager is dan 18 centimeter, het perron smaller dan 1,50 meter, er geen geleidelijnen liggen, of er geen obstakelvrije looproute is.</p>

<p>De cijfers:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Aantal</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Actieve bushaltes in Nederland</td>
      <td>42.068</td>
    </tr>
    <tr>
      <td>Voldoet niet aan CROW-normen</td>
      <td>20.277 (48%)</td>
    </tr>
    <tr>
      <td>Verantwoordelijke wegbeheerders</td>
      <td>384</td>
    </tr>
  </tbody>
</table>

<p>Die 384 wegbeheerders — dat zijn 345 gemeenten, 12 provincies, 5 waterschappen, 7 kantoren van Rijkswaterstaat, en nog een handvol private partijen. Allemaal afzonderlijk verantwoordelijk voor hun eigen haltes.</p>

<p>De wegbeheerders met de meeste ontoegankelijke haltes:</p>

<ul>
  <li><strong>Rotterdam</strong>: 416</li>
  <li><strong>Provincie Overijssel</strong>: 397</li>
  <li><strong>Amsterdam</strong>: 393</li>
  <li><strong>Provincie Gelderland</strong>: 367</li>
  <li><strong>Provincie Drenthe</strong>: 268</li>
</ul>

<h2 id="de-tool-toegankelijke-bushaltes">De tool: Toegankelijke Bushaltes</h2>

<p><a href="https://nietmetdebus.nl/"><strong>Niet met de bus?</strong></a> is een interactieve kaart die alle 20.277 ontoegankelijke haltes toont, gegroepeerd per wegbeheerder. In de zijbalk klik je op je gemeente (of provincie, waterschap, etc.) en je ziet direct welke haltes niet voldoen. Je kunt inzoomen, haltes aanklikken, en zien welke haltes niet voldoen aan de eisen. En het belangrijkste: je kunt met één klik een e-mail genereren naar de verantwoordelijke wegbeheerder.</p>

<figure>
  <a href="https://nietmetdebus.nl/"><img src="/assets/images/nietmetdebus-screenshot.jpg" alt="Screenshot van nietmetdebus.nl: een interactieve kaart met ontoegankelijke bushaltes in Nederland" /></a>
  <figcaption>De interactieve kaart op nietmetdebus.nl toont alle ontoegankelijke bushaltes per wegbeheerder.</figcaption>
</figure>

<h2 id="de-e-mail-goed-onderbouwd-klaar-om-te-versturen">De e-mail: goed onderbouwd, klaar om te versturen</h2>

<p>De gegenereerde e-mail is geen vaag verzoekje. Hij bevat:</p>

<ul>
  <li>Het exacte aantal ontoegankelijke haltes van die wegbeheerder</li>
  <li>Een verwijzing naar het <strong>VN-verdrag inzake de rechten van personen met een handicap</strong> (artikelen 9 en 20) — dat Nederland in 2016 heeft geratificeerd</li>
  <li>Een verwijzing naar de <strong>Wet gelijke behandeling op grond van handicap of chronische ziekte</strong> (Wgbh/cz, artikelen 2 en 3)</li>
  <li>Een verwijzing naar het <strong>Bestuursakkoord Toegankelijkheid OV 2022-2032</strong> — waarin overheden zelf hebben afgesproken om alle haltes toegankelijk te maken</li>
</ul>

<p>Je hoeft geen jurist te zijn. Je hoeft geen expert te zijn in OV-wetgeving. Je klikt, je past de mail aan naar hoe jij het wilt, en je verstuurt hem. Dat is het.</p>

<h2 id="waarom-dit-ertoe-doet">Waarom dit ertoe doet</h2>

<p>Peter Waalboer, belangenbehartiger voor mensen met een beperking, zei het treffend in het NOS-artikel: <em>“Het openbaar vervoer is een publieke voorziening. Die moet voor iedereen toegankelijk zijn — daar is geen discussie over mogelijk.”</em></p>

<p>Helemaal waar, maar de realiteit is weerbarstig: gemeenten hebben beperkte budgetten en bushaltes aanpassen kost geld — een enkele halte kan al duizenden euro’s kosten. Er bestaan subsidies van OV-autoriteiten, en het Bestuursakkoord zet ambities neer, maar naleving is vrijwillig. Zonder druk van inwoners verschuift “toegankelijkheid” makkelijk naar de onderkant van de prioriteitenlijst.</p>

<p>Wat er ontbreekt is niet wetgeving of goede bedoelingen — het is zichtbaarheid. Als een raadslid niet weet dat 60% van de haltes in haar gemeente niet voldoet, gaat ze er niet naar vragen. Als een dorpsgenoot niet weet dat zijn halte ongeschikt is voor zijn buurvrouw in een rolstoel, mist hij het signaal. Data die onzichtbaar is, leidt niet tot actie.</p>

<p>Deze tool maakt die data zichtbaar en actionable. In een paar klikken kun je zien wat er aan de hand is en de verantwoordelijke partij aanspreken — met een juridisch onderbouwd verzoek.</p>

<h2 id="open-source-voor-iedereen">Open source, voor iedereen</h2>

<p>De tool is volledig open source. De broncode staat op <a href="https://github.com/albertsikkema/niet-toegankelijke-bushaltes">GitHub</a>. Technisch is het bewust simpel gehouden: een datapipeline in Node.js die de DOVA- en Allmanak-data ophaalt, en een statische frontend met vanilla HTML, CSS en JavaScript — met een Leaflet-kaart en marker clustering. Geen frameworks en geen build-stappen. De data is verversbaar door de pipeline opnieuw te draaien.</p>

<h2 id="de-timing-gemeenteraadsverkiezingen-op-18-maart">De timing: gemeenteraadsverkiezingen op 18 maart</h2>

<p>Op 18 maart 2026 zijn de gemeenteraadsverkiezingen. Dat maakt dit hét moment om actie te ondernemen. Kandidaat-raadsleden en zittende politici zijn nu extra ontvankelijk voor signalen van inwoners. Stuur die e-mail nu — vóór de verkiezingen. Vraag aan je lokale partijen wat zij gaan doen aan de ontoegankelijke haltes in jouw gemeente. Toegankelijkheid hoort in elk verkiezingsprogramma, niet als voetnoot maar als prioriteit.</p>

<h2 id="wat-kun-jij-doen">Wat kun jij doen?</h2>

<ol>
  <li><strong>Ga naar <a href="https://nietmetdebus.nl/">nietmetdebus.nl</a></strong> en zoek je eigen gemeente op</li>
  <li><strong>Bekijk welke haltes niet voldoen</strong> — misschien is het die halte bij jou om de hoek</li>
  <li><strong>Genereer een e-mail</strong> en stuur die naar je wegbeheerder — liefst vóór 18 maart</li>
  <li><strong>Deel de tool</strong> met je gemeenteraad, je lokale belangenorganisatie, je buren</li>
  <li><strong>Stel het aan de orde</strong> bij verkiezingsdebatten en inspraakavonden in je gemeente</li>
  <li><strong>Heb je suggesties of wil je bijdragen?</strong> Open een issue op <a href="https://github.com/albertsikkema/niet-toegankelijke-bushaltes">GitHub</a></li>
</ol>

<p>Toegankelijkheid is geen gunst. Het is een recht. En soms begint verandering met een simpele e-mail.</p>]]></content><author><name>Albert Sikkema</name></author><category term="accessibility" /><category term="open-source" /><category term="civic-tech" /><summary type="html"><![CDATA[Een open-source tool die alle 20.277 ontoegankelijke bushaltes in Nederland zichtbaar maakt en burgers helpt actie te ondernemen.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.albertsikkema.com/assets/images/busstop-fail.jpg" /><media:content medium="image" url="https://www.albertsikkema.com/assets/images/busstop-fail.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Let the AI Pick React</title><link href="https://www.albertsikkema.com/ai/development/2026/02/13/let-the-ai-pick-react.html" rel="alternate" type="text/html" title="Let the AI Pick React" /><published>2026-02-13T00:00:00+00:00</published><updated>2026-02-13T00:00:00+00:00</updated><id>https://www.albertsikkema.com/ai/development/2026/02/13/let-the-ai-pick-react</id><content type="html" xml:base="https://www.albertsikkema.com/ai/development/2026/02/13/let-the-ai-pick-react.html"><![CDATA[<figure>
  <img src="/assets/images/ai-react-convergence.jpg" alt="Aerial view of highway lanes converging into a single interchange, representing framework standardisation" />
  <figcaption>Photo by <a href="https://unsplash.com/@dnevozhai">Denys Nevozhai</a> on <a href="https://unsplash.com">Unsplash</a></figcaption>
</figure>

<p>There is a self-reinforcing cycle forming in frontend development, and it is making people nervous.</p>

<p>React has the most code in LLM training data. LLMs therefore <a href="https://www.200oksolutions.com/blog/github-copilot-vs-chatgpt-vs-claude-frontend/">generate better React code</a> than anything else. More React code gets written — by both humans and AI — feeding future training data. Repeat. The flywheel spins, and React’s dominance compounds with every prompt.</p>

<p>The evidence is hard to miss: give an LLM a vague prompt like “build me a web app” and you will almost invariably get React + Tailwind + shadcn/ui. Tools like <a href="https://v0.dev">v0</a>, <a href="https://lovable.dev">Lovable</a>, and <a href="https://bolt.new">Bolt.new</a> all default to this stack. v0 technically supports Vue and Svelte, but by Vercel’s own admission it “really works best using React, Tailwind and shadcn/ui.”</p>

<p>The usual reaction to this is concern. It is called <a href="https://maximilian-schwarzmueller.com/articles/the-problem-with-the-default-ai-stack/">the problem with the default AI stack</a>. Others warn about stifled innovation, outdated patterns being perpetuated at scale, and a knowledge barrier for newcomers who never discover alternatives because the AI never suggests them.</p>

<p>I see it differently.</p>

<h2 id="the-fragmentation-problem-nobody-talks-about">The Fragmentation Problem Nobody Talks About</h2>

<p>Frontend development has been drowning in choice for a long time. React, Next, Vue, Svelte, Solid, Angular, Qwik, Astro, Lit, Preact, Marko, Alpine, Htmx — and many more, and that is just frameworks. Each comes with its own ecosystem of state management libraries, routing solutions, meta-frameworks, and component libraries. Every combination produces a slightly different mental model, a different set of conventions, a different way to do the same thing.</p>

<p>This fragmentation has real costs. Teams spend weeks evaluating frameworks. Developers switching jobs need ramp-up time to learn the local flavour. Hiring becomes framework-specific. Knowledge sharing across projects is harder than it should be. The industry has been paying a quiet tax on all this optionality, and for what? For 95% of use cases, any of these frameworks would do the job just fine.</p>

<p>What AI is doing — accidentally, through the cold logic of training data statistics — is pushing the community toward standardisation. And standardisation, when the standard is good enough, is not a loss. It is a relief.</p>

<h2 id="good-enough-wins">Good Enough Wins</h2>

<p>React is not the best framework. I will say that plainly. If I had to write code by hand — really sit down and build components line by line — I would pick Svelte. It is cleaner, less verbose, and gives me a better overview of what is happening. The developer experience is genuinely superior when you are the one typing.</p>

<p>But I am not the one typing.</p>

<p>I wrote about this shift in my post on <a href="/2026/02/05/vibe-coding-quality-democratisation.html">vibe coding, product quality and democratisation</a>: the value equation has changed. When AI generates 80-90% of the code, my personal preference for a framework’s syntax becomes almost irrelevant. What matters is whether the AI can produce correct, functional code — and right now, it produces better React code than anything else. That is not ideology. It is a measurable quality gap rooted in training data volume.</p>

<p>React is not the best. But it is good enough for the vast majority of what gets built. And “good enough + excellent AI support” beats “technically superior + mediocre AI support” in every practical scenario I can think of.</p>

<p>I learned this first-hand when I was using a new Svelte version with GitHub Copilot — a long time ago it seems — when the training data had not included that version yet. Not a fun experience, having to reinstruct the LLM every time.</p>

<h2 id="the-time-argument">The Time Argument</h2>

<p>Every hour I do not spend fighting an AI tool’s weaker output in a less-supported framework is an hour I can spend on what actually matters: the product, the user experience, the business logic, the security model.</p>

<p>The cost savings are real. When Lovable or Claude Code can scaffold a working application in half an hour using React, the overhead of choosing a different framework — debugging AI-generated code that is slightly off, filling in gaps where training data is thin, manually correcting patterns the model has not seen enough of — becomes a luxury most projects cannot justify.</p>

<p>This is the argument that makes the monoculture concerns less relevant for most teams: time saved is money saved. And time is the <a href="https://www.youtube.com/watch?v=AR9hMvlOZCo">final currency</a>.</p>

<h2 id="when-i-would-not-do-this">When I Would Not Do This</h2>

<p>I am not saying React is the answer to everything. There are clear cases where another approach is justified:</p>

<p><strong>Security-critical applications.</strong> When a project demands the highest level of security assurance, I want to understand every line of code. AI-generated code — in any framework — adds a layer of uncertainty that might be unacceptable. In those cases, the framework choice should serve the security model, not the AI tooling.</p>

<p><strong>Performance as a hard requirement.</strong> If a client needs the absolute smallest bundle size or the fastest possible rendering, Svelte or Solid or plain Javascript will outperform React. When performance is a specification, not a nice-to-have, the technical choice should win over the AI convenience.</p>

<p><strong>Simplicity as a constraint.</strong> Some projects need to be small, understandable, and maintainable by non-specialists. A simple static site does not need React’s complexity. The right tool here might be vanilla Javascript, Alpine, or something deliberately minimal.</p>

<p>These are the 5% cases. They exist, they matter, and they require deliberate technical choices. But they are the exception, not the rule.</p>

<h2 id="what-about-innovation">What About Innovation?</h2>

<p>The strongest counterargument is that a React monoculture stifles innovation. If future LLMs are trained mostly on React, the reasoning goes, new frameworks will never gain enough traction to compete.</p>

<p>Here is how I see it: we have not actually seen real innovation in frontend frameworks for a long time. Frontend is complicated — genuinely, deeply complicated. And so far, none of the alternatives have found a definitive answer. They are variations. <a href="https://svelte.dev/">Svelte’s</a> compile step, <a href="https://www.solidjs.com/">Solid’s</a> fine-grained reactivity, <a href="https://astro.build/">Astro’s</a> island architecture — these are smart ideas, well-built tools, and genuine improvements in specific areas. But they are also steps back in others. They are not the next paradigm shift. They are refinements.</p>

<p>Meanwhile, the industry runs on a multi-year cycle that keeps repackaging older ideas under new names. Server-side rendering <a href="https://daily.dev/blog/server-side-rendering-renaissance">is back</a>. Signals — <a href="https://www.builder.io/blog/history-of-reactivity">called observables in Knockout.js back in 2010</a> — are back. The pendulum swings, and we call it progress.</p>

<p>A lot of developers see their framework of choice as real innovation. I understand that attachment — I feel it with Svelte. But in the grander scheme, these are variations on the same fundamental approach to building UIs. If something truly new comes along — something that genuinely changes how we think about frontend development — it will break through regardless of what LLMs default to. That kind of innovation does not need training data momentum. It needs to be undeniably better.</p>

<p>Until that happens, we are better off accepting what we have and building with it.</p>

<h2 id="the-accidental-standard">The Accidental Standard</h2>

<p>React did not plan this advantage. No committee decided it should be the AI default. It happened because React was the most popular framework when the training data was collected — a decade of documentation, tutorials, Stack Overflow answers, and open-source projects.</p>

<p>But planned or not, it gives developers a common language. It gives teams a safe default. It gives non-developers building their first app through vibe coding a foundation that actually works. And it gives the rest of us more time to spend on what we are actually building instead of debating what to build it with.</p>

<p>React apparently is not that bad.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://maximilian-schwarzmueller.com/articles/the-problem-with-the-default-ai-stack/">The Problem with the Default AI Stack</a> — Maximilian Schwarzmüller</li>
  <li><a href="https://www.200oksolutions.com/blog/github-copilot-vs-chatgpt-vs-claude-frontend/">GitHub Copilot vs ChatGPT vs Claude for Frontend Development</a> — 200ok Solutions</li>
  <li><a href="https://www.techradar.com/pro/best-vibe-coding-tools">Best Vibe Coding Tools</a> — TechRadar</li>
  <li><a href="https://thealphaspot.com/articles/is-react-still-the-best-choice-in-2025/">Is React Still the Best Choice in 2025?</a> — The Alpha Spot</li>
  <li><a href="https://www.smashingmagazine.com/2025/01/svelte-5-future-frameworks-chat-rich-harris/">Svelte 5 and the Future of Frameworks: A Chat with Rich Harris</a> — Smashing Magazine</li>
  <li><a href="https://thenewstack.io/dhh-on-ai-vibe-coding-and-the-future-of-programming/">DHH on AI, Vibe Coding, and the Future of Programming</a> — The New Stack</li>
</ul>

<h2 id="further-reading">Further Reading</h2>

<ul>
  <li><a href="/2026/02/05/vibe-coding-quality-democratisation.html">Vibe Coding: Product Quality and Democratisation</a> — my earlier post on vibe coding and when personal tools become products</li>
  <li><a href="/2026/02/01/securing-claude-code-hooks.html">Securing YOLO Mode: How I Stop Claude Code from Nuking My System</a> — on guardrails for AI-assisted development</li>
</ul>]]></content><author><name>Albert Sikkema</name></author><category term="ai" /><category term="development" /><summary type="html"><![CDATA[The AI-React reinforcement loop is creating a monoculture. That might be exactly what frontend development needs.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.albertsikkema.com/assets/images/ai-react-convergence.jpg" /><media:content medium="image" url="https://www.albertsikkema.com/assets/images/ai-react-convergence.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Vibe Coding: Product Quality and Democratisation</title><link href="https://www.albertsikkema.com/ai/development/2026/02/05/vibe-coding-quality-democratisation.html" rel="alternate" type="text/html" title="Vibe Coding: Product Quality and Democratisation" /><published>2026-02-05T00:00:00+00:00</published><updated>2026-02-05T00:00:00+00:00</updated><id>https://www.albertsikkema.com/ai/development/2026/02/05/vibe-coding-quality-democratisation</id><content type="html" xml:base="https://www.albertsikkema.com/ai/development/2026/02/05/vibe-coding-quality-democratisation.html"><![CDATA[<p>I read a few articles the last few weeks about the state of SaaS products and the changes in making and selling software because of the emergence of LLM use among a greater customer base. Some are calling it the SaaSpocalypse: customers can now build their own tools, so why would they keep paying for yours? (<a href="https://en.wikipedia.org/wiki/Software_as_a_service">SaaS</a>: Software as a Service)</p>

<p>And combine that with increases in searches for “vibe coding”: a jump of 6,700% in 2025. By late 2025, 84% of developers were using or planning to use AI coding tools.</p>

<p>So that seems to be quite a threat for a lot of software businesses. Bad times are coming. Or are they? There are multiple facets to the story, let’s look at two of them: product quality and social implications.</p>

<h2 id="product-quality">Product Quality</h2>

<p>Most of the vibe-coded applications I have seen are quite bad. A few weeks ago I tried to contribute to an open-source project that was clearly vibe-coded. It was a great idea, I started with enthusiasm, but ran away within an hour.</p>

<p>The code looked fine at first glance: clean-ish syntax, impressive number of files. But the moment I tried to understand what it actually did, I hit a wall. Fragile abstractions, bad architecture and bugs, so many small bugs.</p>

<p>Now I believe that bad code is not a problem per se, just as ‘good’ code is not a goal in and of itself, just for the sake of writing good code (however you want to define that). (The lack of a proper definition is what keeps me from pursuing good code among other things.) I am much more pragmatic: does the code solve a problem? Yes? Then it is good code; it fulfills its purpose. However, take into account that the purpose is not just to solve the problem then and there, but also in the future. And the cost it takes to maintain the solution for the problem has by definition to be lower than the (perceived) costs the problem causes. Costs can be money, time, assets, reputation, failing to comply etc. If the costs are low enough (and good software drives down the total cost, by reducing maintenance time etc) it is a good solution.</p>

<p>So the main reason to write good code is to drive down the cost of the solution to below the cost of the problem. That also means that good code is not a fixed outcome or definition, it depends on the situation. That also means that for personal use a badly coded vibed app can be perfect: who cares that it is bad? It solves my problem! (Good for you, power to the people etc.)</p>

<p>And that touches on what for me is one enormously important aspect: democratisation of software. No-code and low-code platforms took the first steps here, making it possible to build without writing code at all. Now AI-assisted coding takes it further: you can create actual software, not just what fits within a platform’s constraints. Suddenly you do not need a masters degree to do complicated things with your computer: you can do all sorts of stuff!</p>

<p>I really am wondering what this will mean in terms of social improvement, equality and human rights. The keys to writing software were always in the hands of white, middle-aged, rich men with a predisposition for details and light autism, who kept their trades securely guarded and often were and are not able to properly communicate with their customers. If that is no longer the case, what will happen?</p>

<figure>
  <img src="/assets/images/vibe-coding-democratisation.jpg" alt="Group of people with different skin tones collaborating around a laptop" />
  <figcaption>The best "diverse" stock photo I could find. Note how everyone is still conventionally attractive. We have a way to go. Photo by <a href="https://unsplash.com/@silverkblack">Vitaly Gariev</a> on <a href="https://unsplash.com/photos/8gAbl776pc0">Unsplash</a>.</figcaption>
</figure>

<p>Of course there is still a barrier: LLM costs. Right now, using these tools at scale costs real money, and that creates its own form of gatekeeping. But I expect this to come down significantly in the coming years through economies of scale, efficiency gains in model architectures, reduced energy consumption, and the rise of smaller models that perform nearly as well as the large ones.</p>

<p>But getting back to product quality: if writing the software is no longer the hard part (no more writing ‘print’ in javascript after working with python for weeks and making some changes in a javascript project), what will change?</p>

<h2 id="the-bottleneck-shift">The Bottleneck Shift</h2>

<p>Something strange happens when you can generate code instantly: you lose the thinking time that was hidden in the coding process.</p>

<p>Before AI tools, writing code forced a pace. You’d sit with a problem for days, turning it over in your mind while your fingers typed. The friction of implementation gave you space to contemplate, to look at the problem from different angles, to notice that your initial understanding was wrong.</p>

<p>Now? You describe what you want and get code in seconds.</p>

<p>The bottleneck shifts: building becomes trivial, but <em>understanding the problem correctly</em> becomes the new hard part, and you have less time to figure it out.</p>

<p><a href="https://arxiv.org/abs/2512.11922">Research on vibe coding</a> calls this the “flow-debt tradeoff”: the speed you gain now, you pay back later in technical debt. The AI doesn’t maintain a unified architectural vision across prompts, nobody documents why the code is the way it is, and the problems only surface during maintenance and scaling. The productivity gains are real, but they’re borrowed time.</p>

<p>This is where personal use and business use diverge, because for your own tools “it works” is enough (who cares if it’s messy, it solves your problem), but the moment software becomes a product, something others depend on and that needs to work next year, the equation changes completely. Building was always only about 10% of the work anyway, while the other 90% (compliance, security, support, maintenance, edge cases) doesn’t go away just because building got easier.</p>

<p>So yes, the SaaS products that were basically CRUD apps with a subscription are in trouble, but that’s not an AI problem, that’s a product problem. You were always one motivated developer away from obsolescence; AI just made that developer faster.</p>

<p>That said, vibe coding does have a legitimate place in professional software development. In our workflow we now use it as a scratch pad: instead of going through an entire UI design process, business research, endless meetings and iteration cycles, we vibe-code the idea first and test it with the customer. Does it answer the problem? Great, now we know what to build properly. This saves so much time that would otherwise go into design documents and alignment meetings.</p>

<p>The challenge is managing expectations. You have to make clear to the customer that this is just a draft to explore the idea, and the real thing will cost time and money to build. That can be difficult to grasp: “But you already built it? Why does it take so much time and cost so much?” The answer is everything we discussed above: the 90% that isn’t building.</p>

<h2 id="democratisation">Democratisation</h2>

<p>Back to the social angle: what happens when software creation is widely accessible? Will we see tools built by and for communities that Silicon Valley never understood? Will existing power dynamics be rearranged? Hopefully yes to both.</p>

<p>The complaint that vibe-coded apps are “bad” misses the point entirely: if someone who couldn’t build software before can now solve their own problem, messy code and all, that’s a win. The quality bar for personal tools is “does it work for me?” and nothing more. The trouble comes when personal tools try to become products, when someone thinks “this works for me, I should sell it.” That’s when understanding your own problem (relatively easy) becomes understanding someone else’s problem (hard), and maybe it’s even harder now because building no longer forces you to slow down and think.</p>

<p>So is the time of building and selling SaaS products over? Some think so, but the narrative that “customers will just build their own” underestimates what it takes to maintain and be accountable for software others depend on. Meanwhile, go vibe-code something for yourself, seriously. That’s the democratisation promise and it’s real, just have fun building!</p>

<hr />

<p><em>Thinking about vibe coding, democratisation, or the future of software products? I’d love to hear your perspective. <a href="#" onclick="task1(); return false;">Get in touch</a> to share your thoughts.</em></p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://nmn.gl/blog/ai-killing-b2b-saas">AI is Killing B2B SaaS</a></li>
  <li><a href="https://arxiv.org/abs/2512.11922">Vibe Coding in Practice: Flow, Technical Debt, and Guidelines</a></li>
</ul>

<h2 id="further-reading">Further Reading</h2>

<p>On democratisation and its implications:</p>

<ul>
  <li><a href="https://www.ibm.com/think/insights/democratizing-ai">Democratizing AI (IBM)</a></li>
  <li><a href="https://www.tandfonline.com/doi/full/10.1080/13510347.2024.2338852">Democratization in the age of artificial intelligence (Taylor &amp; Francis)</a></li>
  <li><a href="https://verdict.justia.com/2025/02/24/the-democratization-of-ai-a-pivotal-moment-for-innovation-and-regulation">The Democratization of AI: A Pivotal Moment (Justia)</a></li>
  <li><a href="https://dorik.com/blog/how-no-code-platforms-are-democratizing-software-development">How No-code Platforms are Democratizing Software Development</a></li>
</ul>

<p>On gatekeeping in tech:</p>

<ul>
  <li><a href="https://bernardoamc.com/gatekeeping-software-development/">Gatekeeping in the software industry</a></li>
  <li><a href="https://soatok.blog/2021/03/04/no-gates-no-keepers/">No Gates, No Keepers</a></li>
</ul>]]></content><author><name>Albert Sikkema</name></author><category term="ai" /><category term="development" /><summary type="html"><![CDATA[Vibe coding changes who can build software and what happens when they do. On product quality, democratisation, and when personal tools become products.]]></summary></entry><entry><title type="html">Securing YOLO Mode: How I Stop Claude Code from Nuking My System</title><link href="https://www.albertsikkema.com/ai/security/development/tools/2026/02/01/securing-claude-code-hooks-best-practices.html" rel="alternate" type="text/html" title="Securing YOLO Mode: How I Stop Claude Code from Nuking My System" /><published>2026-02-01T00:00:00+00:00</published><updated>2026-02-01T00:00:00+00:00</updated><id>https://www.albertsikkema.com/ai/security/development/tools/2026/02/01/securing-claude-code-hooks-best-practices</id><content type="html" xml:base="https://www.albertsikkema.com/ai/security/development/tools/2026/02/01/securing-claude-code-hooks-best-practices.html"><![CDATA[<p>I always run Claude Code in YOLO mode. I have <code class="language-plaintext highlighter-rouge">cly</code> aliased to <code class="language-plaintext highlighter-rouge">claude --dangerously-skip-permissions</code> in my <code class="language-plaintext highlighter-rouge">.zshrc</code> because I want Claude to just get things done without asking me to approve every file write.</p>

<p>This works great for productivity, but it also means Claude has free rein to do whatever it wants. Format my hard disk? Sure. Leak my <code class="language-plaintext highlighter-rouge">.env</code> secrets to some random API? Why not. Force push to main? Go for it.</p>

<p>Obviously, I’d prefer to avoid those outcomes. The main thing here is: I don’t let Claude run unsupervised for hours on my system, and I add plenty of other guardrails too. If you do want to experiment with that, please do it on a Raspberry Pi or a VPS, with nothing special on it. But that’s not the subject here.</p>

<p>This post is about hooks: one specific defense layer I researched (again) while updating my claude workflow. Hooks let you intercept and block dangerous operations before they execute, even in YOLO mode. This post documents what I learned. Maybe it helps you too.</p>

<p>Hooks are also fun to use for alerts. This afternoon I added audio phrases from Command &amp; Conquer and Red Alert to some of my hooks. Adding those sounds brought back a lot of fun memories of hours of playing. “Well done, Commander!”</p>

<figure>
  <img src="/assets/images/claude-code-hooks-red-alert.jpg" alt="Command and Conquer Red Alert 2 artwork featuring Soviet and Allied forces" />
  <figcaption>Well done, Commander! Your hooks are ready. (Source: <a href="https://wallpapercave.com/w/wp10090474">Wallpaper Cave</a>, uploaded by kallie)</figcaption>
</figure>

<p>From here on, this has nothing to do with Red Alert or Command &amp; Conquer. But it <em>is</em> about defending your computer and software, so there is some sort of match there :-)</p>

<p><strong>Reader warning:</strong> This is a long and boring post. Only read if you’re interested in securing your Claude Code setup with hooks, blocking dangerous commands, preventing path traversal attacks, or protecting sensitive files. Use it as a reference, but never trust it blindly. Don’t say I didn’t warn you!</p>

<h2 id="table-of-contents">Table of Contents</h2>

<ul>
  <li><a href="#why-hooks-matter-for-security">Why Hooks Matter for Security</a></li>
  <li><a href="#the-hook-lifecycle">The Hook Lifecycle</a></li>
  <li><a href="#blocking-dangerous-commands">Blocking Dangerous Commands</a></li>
  <li><a href="#input-validation-best-practices">Input Validation Best Practices</a></li>
  <li><a href="#path-traversal-prevention">Path Traversal Prevention</a></li>
  <li><a href="#protecting-sensitive-files">Protecting Sensitive Files</a></li>
  <li><a href="#complete-real-world-implementation">Complete Real-World Implementation</a></li>
  <li><a href="#permissionrequest-hooks">PermissionRequest Hooks</a></li>
  <li><a href="#known-cves-and-vulnerabilities">Known CVEs and Vulnerabilities</a></li>
  <li><a href="#exit-code-reference">Exit Code Reference</a></li>
  <li><a href="#comprehensive-security-checklist">Comprehensive Security Checklist</a></li>
  <li><a href="#defense-in-depth">Defense in Depth</a></li>
  <li><a href="#final-thoughts">Final Thoughts</a></li>
  <li><a href="#sources-and-further-reading">Sources and Further Reading</a></li>
</ul>

<h2 id="why-hooks-matter-for-security">Why Hooks Matter for Security</h2>

<p>Hooks are user-defined shell commands or LLM prompts that execute automatically at specific points in Claude Code’s lifecycle. They execute with your full user permissions, meaning they can read, modify, or delete any file your account can access.</p>

<p>This is both the opportunity and the risk. Without proper controls, Claude Code could:</p>
<ul>
  <li>Execute destructive shell commands like <code class="language-plaintext highlighter-rouge">rm -rf</code></li>
  <li>Access sensitive files containing credentials</li>
  <li>Modify system configurations</li>
  <li>Expose secrets in logs or outputs</li>
</ul>

<p>Hooks let you build guardrails that operate deterministically: unlike CLAUDE.md instructions that are “parsed by LLM, weighed against other context, maybe followed,” hooks execute regardless of what Claude thinks it should do.</p>

<h2 id="the-hook-lifecycle">The Hook Lifecycle</h2>

<p>Understanding when hooks fire is essential for effective security. Here are the most relevant events for security purposes (Claude Code has additional events like <code class="language-plaintext highlighter-rouge">Notification</code>, <code class="language-plaintext highlighter-rouge">SubagentStart</code>, <code class="language-plaintext highlighter-rouge">SubagentStop</code>, and <code class="language-plaintext highlighter-rouge">PreCompact</code>):</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Event</th>
      <th style="text-align: left">When it fires</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">SessionStart</code></td>
      <td style="text-align: left">When a session begins or resumes</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">UserPromptSubmit</code></td>
      <td style="text-align: left">When you submit a prompt, before Claude processes it</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">PreToolUse</code></td>
      <td style="text-align: left">Before a tool call executes: can block it</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">PermissionRequest</code></td>
      <td style="text-align: left">When a permission dialog appears</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">PostToolUse</code></td>
      <td style="text-align: left">After a tool call succeeds</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">PostToolUseFailure</code></td>
      <td style="text-align: left">After a tool call fails</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">Stop</code></td>
      <td style="text-align: left">When Claude finishes responding</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">SessionEnd</code></td>
      <td style="text-align: left">When a session terminates</td>
    </tr>
  </tbody>
</table>

<p>For security, <code class="language-plaintext highlighter-rouge">PreToolUse</code> is your primary defense: it runs before dangerous operations execute and can block them entirely.</p>

<h2 id="blocking-dangerous-commands">Blocking Dangerous Commands</h2>

<p>The most common security use case is blocking destructive shell commands. The examples below show the concepts step by step. If you want to skip ahead to a complete, production-ready implementation, jump to <a href="#complete-real-world-implementation">Complete Real-World Implementation</a>.</p>

<p>Here’s a practical implementation:</p>

<h3 id="basic-configuration">Basic Configuration</h3>

<p>Add this to your <code class="language-plaintext highlighter-rouge">.claude/settings.json</code>:</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">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"PreToolUse"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="p">{</span><span class="w">
        </span><span class="nl">"matcher"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Bash"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
          </span><span class="p">{</span><span class="w">
            </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"command"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">".claude/hooks/block-dangerous.sh"</span><span class="w">
          </span><span class="p">}</span><span class="w">
        </span><span class="p">]</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="the-blocking-script">The Blocking Script</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="c"># .claude/hooks/block-dangerous.sh</span>

<span class="nv">COMMAND</span><span class="o">=</span><span class="si">$(</span>jq <span class="nt">-r</span> <span class="s1">'.tool_input.command'</span> &lt; /dev/stdin<span class="si">)</span>

<span class="c"># Block rm -rf variants (handles -rf, -fr, -r -f, etc.)</span>
<span class="k">if </span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$COMMAND</span><span class="s2">"</span> | <span class="nb">grep</span> <span class="nt">-qE</span> <span class="s1">'rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-rf|-fr)\b'</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">echo</span> <span class="s2">"Blocked: rm -rf commands are not allowed"</span> <span class="o">&gt;</span>&amp;2
  <span class="nb">exit </span>2
<span class="k">fi</span>

<span class="c"># Block force pushes to main/master</span>
<span class="k">if </span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$COMMAND</span><span class="s2">"</span> | <span class="nb">grep</span> <span class="nt">-qE</span> <span class="s1">'git\s+push.*--force.*(main|master)'</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">echo</span> <span class="s2">"Blocked: Force push to main/master not allowed"</span> <span class="o">&gt;</span>&amp;2
  <span class="nb">exit </span>2
<span class="k">fi</span>

<span class="c"># Block sudo rm</span>
<span class="k">if </span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$COMMAND</span><span class="s2">"</span> | <span class="nb">grep</span> <span class="nt">-qE</span> <span class="s1">'sudo\s+rm'</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">echo</span> <span class="s2">"Blocked: sudo rm commands require manual approval"</span> <span class="o">&gt;</span>&amp;2
  <span class="nb">exit </span>2
<span class="k">fi</span>

<span class="c"># Block chmod 777</span>
<span class="k">if </span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$COMMAND</span><span class="s2">"</span> | <span class="nb">grep</span> <span class="nt">-qE</span> <span class="s1">'chmod\s+777'</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">echo</span> <span class="s2">"Blocked: chmod 777 is a security risk"</span> <span class="o">&gt;</span>&amp;2
  <span class="nb">exit </span>2
<span class="k">fi

</span><span class="nb">exit </span>0  <span class="c"># Allow the command</span>
</code></pre></div></div>

<p>Exit code 2 tells Claude Code to block the operation and feed the error message back to Claude, who can then explain the issue and suggest alternatives.</p>

<h3 id="configurable-safety-levels">Configurable Safety Levels</h3>

<p>For more flexibility, consider implementing configurable safety levels:</p>

<ul>
  <li><strong>critical</strong>: Block only catastrophic operations (rm -rf ~, fork bombs, dd to disk)</li>
  <li><strong>high</strong>: Add risky operations (force push main, secrets exposure, git reset –hard)</li>
  <li><strong>strict</strong>: Add cautionary items (any force push, sudo rm, docker prune)</li>
</ul>

<h2 id="input-validation-best-practices">Input Validation Best Practices</h2>

<p>Hook input arrives via JSON on stdin. Never trust it blindly. Here are essential validation patterns:</p>

<h3 id="always-quote-variables">Always Quote Variables</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Bad - breaks with spaces or special characters</span>
<span class="nv">FILE_PATH</span><span class="o">=</span><span class="nv">$TOOL_INPUT</span>

<span class="c"># Good - handles all path types</span>
<span class="nv">FILE_PATH</span><span class="o">=</span><span class="s2">"</span><span class="nv">$TOOL_INPUT</span><span class="s2">"</span>
</code></pre></div></div>

<h3 id="validate-before-processing">Validate Before Processing</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="nv">INPUT</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span><span class="si">)</span>

<span class="c"># Extract with fallbacks</span>
<span class="nv">FILE_PATH</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$INPUT</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.tool_input.file_path // empty'</span><span class="si">)</span>

<span class="c"># Check if value exists</span>
<span class="k">if</span> <span class="o">[</span> <span class="nt">-z</span> <span class="s2">"</span><span class="nv">$FILE_PATH</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">exit </span>0  <span class="c"># No file path to validate</span>
<span class="k">fi</span>

<span class="c"># Validate the path looks reasonable (don't check existence - file might be new)</span>
<span class="c"># Add your validation logic here</span>
</code></pre></div></div>

<h3 id="check-tool-availability">Check Tool Availability</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="o">!</span> <span class="nb">command</span> <span class="nt">-v</span> prettier &amp;&gt; /dev/null<span class="p">;</span> <span class="k">then
  </span><span class="nb">exit </span>0  <span class="c"># Tool not available, skip gracefully</span>
<span class="k">fi</span>
</code></pre></div></div>

<h2 id="path-traversal-prevention">Path Traversal Prevention</h2>

<p>Path traversal attacks like <code class="language-plaintext highlighter-rouge">../../etc/passwd</code> are a significant risk. Here’s how to prevent them:</p>

<h3 id="simple-detection">Simple Detection</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="nv">INPUT</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span><span class="si">)</span>
<span class="nv">FILE_PATH</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$INPUT</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.tool_input.file_path // empty'</span><span class="si">)</span>

<span class="c"># Block obvious path traversal</span>
<span class="k">if </span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$FILE_PATH</span><span class="s2">"</span> | <span class="nb">grep</span> <span class="nt">-q</span> <span class="s1">'\.\.'</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">echo</span> <span class="s1">'{"decision":"block","reason":"Path traversal detected"}'</span>
  <span class="nb">exit </span>0
<span class="k">fi</span>

<span class="c"># Ensure path is within project (CVE-2025-54794: use trailing separator)</span>
<span class="nv">PROJECT_DIR</span><span class="o">=</span><span class="s2">"</span><span class="nv">$CLAUDE_PROJECT_DIR</span><span class="s2">"</span>
<span class="nv">RESOLVED_PATH</span><span class="o">=</span><span class="si">$(</span><span class="nb">realpath</span> <span class="nt">-m</span> <span class="s2">"</span><span class="nv">$FILE_PATH</span><span class="s2">"</span> 2&gt;/dev/null<span class="si">)</span>

<span class="c"># Add trailing slash to prevent /project matching /project_malicious</span>
<span class="k">if</span> <span class="o">[[</span> <span class="o">!</span> <span class="s2">"</span><span class="nv">$RESOLVED_PATH</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"</span><span class="nv">$PROJECT_DIR</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> <span class="o">!</span> <span class="s2">"</span><span class="nv">$RESOLVED_PATH</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"</span><span class="nv">$PROJECT_DIR</span><span class="s2">/"</span><span class="k">*</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">echo</span> <span class="s1">'{"decision":"block","reason":"Path is outside project directory"}'</span>
  <span class="nb">exit </span>0
<span class="k">fi

</span><span class="nb">exit </span>0
</code></pre></div></div>

<h3 id="python-implementation">Python Implementation</h3>

<p>For more robust validation, use Python:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#!/usr/bin/env python3
</span><span class="kn">import</span> <span class="nn">json</span>
<span class="kn">import</span> <span class="nn">sys</span>
<span class="kn">import</span> <span class="nn">os</span>
<span class="kn">from</span> <span class="nn">pathlib</span> <span class="kn">import</span> <span class="n">Path</span>

<span class="n">input_data</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="n">load</span><span class="p">(</span><span class="n">sys</span><span class="p">.</span><span class="n">stdin</span><span class="p">)</span>
<span class="n">file_path</span> <span class="o">=</span> <span class="n">input_data</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'tool_input'</span><span class="p">,</span> <span class="p">{}).</span><span class="n">get</span><span class="p">(</span><span class="s">'file_path'</span><span class="p">,</span> <span class="s">''</span><span class="p">)</span>

<span class="k">if</span> <span class="ow">not</span> <span class="n">file_path</span><span class="p">:</span>
    <span class="n">sys</span><span class="p">.</span><span class="nb">exit</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>

<span class="n">project_dir</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'CLAUDE_PROJECT_DIR'</span><span class="p">,</span> <span class="n">os</span><span class="p">.</span><span class="n">getcwd</span><span class="p">())).</span><span class="n">resolve</span><span class="p">()</span>
<span class="n">target_path</span> <span class="o">=</span> <span class="p">(</span><span class="n">project_dir</span> <span class="o">/</span> <span class="n">file_path</span><span class="p">).</span><span class="n">resolve</span><span class="p">()</span>

<span class="c1"># CVE-2025-54794: Must check with trailing separator to prevent
# /project matching /project_malicious
</span><span class="n">project_str</span> <span class="o">=</span> <span class="nb">str</span><span class="p">(</span><span class="n">project_dir</span><span class="p">)</span>
<span class="n">target_str</span> <span class="o">=</span> <span class="nb">str</span><span class="p">(</span><span class="n">target_path</span><span class="p">)</span>

<span class="k">if</span> <span class="ow">not</span> <span class="p">(</span><span class="n">target_str</span> <span class="o">==</span> <span class="n">project_str</span> <span class="ow">or</span> <span class="n">target_str</span><span class="p">.</span><span class="n">startswith</span><span class="p">(</span><span class="n">project_str</span> <span class="o">+</span> <span class="n">os</span><span class="p">.</span><span class="n">sep</span><span class="p">)):</span>
    <span class="k">print</span><span class="p">(</span><span class="n">json</span><span class="p">.</span><span class="n">dumps</span><span class="p">({</span>
        <span class="s">"decision"</span><span class="p">:</span> <span class="s">"block"</span><span class="p">,</span>
        <span class="s">"reason"</span><span class="p">:</span> <span class="s">"Path is outside project directory"</span>
    <span class="p">}))</span>
    <span class="n">sys</span><span class="p">.</span><span class="nb">exit</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>

<span class="n">sys</span><span class="p">.</span><span class="nb">exit</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="protecting-sensitive-files">Protecting Sensitive Files</h2>

<p>Create a blocklist for files that should never be accessed:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="nv">INPUT</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span><span class="si">)</span>
<span class="nv">FILE_PATH</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$INPUT</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.tool_input.file_path // empty'</span><span class="si">)</span>

<span class="c"># Sensitive file patterns</span>
<span class="nv">SENSITIVE_PATTERNS</span><span class="o">=(</span>
  <span class="s2">"</span><span class="se">\.</span><span class="s2">env$"</span>
  <span class="s2">"</span><span class="se">\.</span><span class="s2">env</span><span class="se">\.</span><span class="s2">"</span>
  <span class="s2">"</span><span class="se">\.</span><span class="s2">pem$"</span>
  <span class="s2">"</span><span class="se">\.</span><span class="s2">key$"</span>
  <span class="s2">"</span><span class="se">\.</span><span class="s2">p12$"</span>
  <span class="s2">"credentials</span><span class="se">\.</span><span class="s2">json"</span>
  <span class="s2">"secrets</span><span class="se">\.</span><span class="s2">yaml"</span>
  <span class="s2">"</span><span class="se">\.</span><span class="s2">git/config$"</span>
  <span class="s2">"id_rsa"</span>
  <span class="s2">"id_ed25519"</span>
<span class="o">)</span>

<span class="k">for </span>pattern <span class="k">in</span> <span class="s2">"</span><span class="k">${</span><span class="nv">SENSITIVE_PATTERNS</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
  if </span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$FILE_PATH</span><span class="s2">"</span> | <span class="nb">grep</span> <span class="nt">-qE</span> <span class="s2">"</span><span class="nv">$pattern</span><span class="s2">"</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s2">"Blocked: Access to sensitive file not allowed"</span> <span class="o">&gt;</span>&amp;2
    <span class="nb">exit </span>2
  <span class="k">fi
done

</span><span class="nb">exit </span>0
</code></pre></div></div>

<h2 id="complete-real-world-implementation">Complete Real-World Implementation</h2>

<p>The snippets above are useful for understanding individual concepts, but here’s the actual Python hook I use. It combines all the security checks into a single, comprehensive PreToolUse hook:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#!/usr/bin/env python3
</span><span class="s">"""
PreToolUse security hook that blocks dangerous operations.

Checks for:
- Dangerous rm commands
- Fork bombs
- Dangerous git commands (push to main/master, force push)
- Disk write attacks (dd to /dev/)
- Sensitive file access (.env, .pem, .key, credentials, etc.)
- Path traversal attacks
- Project directory escape

Set CLAUDE_HOOKS_DEBUG=1 to enable debug logging.
"""</span>

<span class="kn">from</span> <span class="nn">__future__</span> <span class="kn">import</span> <span class="n">annotations</span>

<span class="kn">import</span> <span class="nn">json</span>
<span class="kn">import</span> <span class="nn">os</span>
<span class="kn">import</span> <span class="nn">re</span>
<span class="kn">import</span> <span class="nn">sys</span>
<span class="kn">from</span> <span class="nn">pathlib</span> <span class="kn">import</span> <span class="n">Path</span>

<span class="c1"># Debug mode for troubleshooting
</span><span class="n">DEBUG</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'CLAUDE_HOOKS_DEBUG'</span><span class="p">,</span> <span class="s">''</span><span class="p">).</span><span class="n">lower</span><span class="p">()</span> <span class="ow">in</span> <span class="p">(</span><span class="s">'1'</span><span class="p">,</span> <span class="s">'true'</span><span class="p">)</span>

<span class="c1"># Pre-compiled regex patterns for performance
</span><span class="n">DANGEROUS_RM_PATTERNS</span> <span class="o">=</span> <span class="p">[</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\brm\s+.*-[a-z]*r[a-z]*f'</span><span class="p">),</span>  <span class="c1"># rm -rf, rm -fr, rm -Rf, etc.
</span>    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\brm\s+.*-[a-z]*f[a-z]*r'</span><span class="p">),</span>  <span class="c1"># rm -fr variations
</span>    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\brm\s+--recursive\s+--force'</span><span class="p">),</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\brm\s+--force\s+--recursive'</span><span class="p">),</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\brm\s+-r\s+.*-f'</span><span class="p">),</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\brm\s+-f\s+.*-r'</span><span class="p">),</span>
<span class="p">]</span>

<span class="n">DANGEROUS_RM_PATH_PATTERNS</span> <span class="o">=</span> <span class="p">[</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\s/$'</span><span class="p">),</span>          <span class="c1"># Root directory
</span>    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\s/\*'</span><span class="p">),</span>         <span class="c1"># Root with wildcard
</span>    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\s~/?'</span><span class="p">),</span>         <span class="c1"># Home directory
</span>    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\s\$HOME'</span><span class="p">),</span>      <span class="c1"># Home environment variable
</span>    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\s\.\./?'</span><span class="p">),</span>      <span class="c1"># Parent directory references
</span>    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\s\.$'</span><span class="p">),</span>         <span class="c1"># Current directory
</span><span class="p">]</span>

<span class="n">RM_RECURSIVE_PATTERN</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\brm\s+.*-[a-z]*r'</span><span class="p">)</span>

<span class="n">FORK_BOMB_PATTERNS</span> <span class="o">=</span> <span class="p">[</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">':\(\)\s*\{\s*:\|:&amp;\s*\}\s*;:'</span><span class="p">),</span>  <span class="c1"># Classic bash fork bomb
</span>    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\.\/\w+\s*&amp;\s*\.\/\w+'</span><span class="p">),</span>  <span class="c1"># Self-replicating pattern
</span>    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'while\s+true.*fork'</span><span class="p">,</span> <span class="n">re</span><span class="p">.</span><span class="n">IGNORECASE</span><span class="p">),</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'fork\s*\(\s*\)\s*while'</span><span class="p">,</span> <span class="n">re</span><span class="p">.</span><span class="n">IGNORECASE</span><span class="p">),</span>
<span class="p">]</span>

<span class="n">DANGEROUS_GIT_PATTERNS</span> <span class="o">=</span> <span class="p">[</span>
    <span class="c1"># Block ALL pushes to main/master (including regular push)
</span>    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'git\s+push\s+.*\b(main|master)\b'</span><span class="p">),</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'git\s+push\s+origin\s+(main|master)'</span><span class="p">),</span>
    <span class="c1"># Block force push without explicit branch (might be on main)
</span>    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'git\s+push\s+.*--force'</span><span class="p">),</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'git\s+push\s+.*-f\b'</span><span class="p">),</span>
    <span class="c1"># Other dangerous commands
</span>    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'git\s+reset\s+--hard\s+origin/'</span><span class="p">),</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'git\s+clean\s+-fd'</span><span class="p">),</span>  <span class="c1"># Force delete untracked files
</span><span class="p">]</span>

<span class="n">DANGEROUS_DISK_PATTERNS</span> <span class="o">=</span> <span class="p">[</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\bdd\s+.*of=/dev/'</span><span class="p">),</span>  <span class="c1"># dd to device
</span>    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\bmkfs\.'</span><span class="p">),</span>  <span class="c1"># Format filesystem
</span>    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'&gt;\s*/dev/sd'</span><span class="p">),</span>  <span class="c1"># Write to disk device
</span><span class="p">]</span>

<span class="n">ENV_ACCESS_PATTERNS</span> <span class="o">=</span> <span class="p">[</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\bcat\s+[^\|]*\.env\b(?!\.sample|\.example|\.template)'</span><span class="p">),</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\bless\s+[^\|]*\.env\b(?!\.sample|\.example|\.template)'</span><span class="p">),</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\bhead\s+[^\|]*\.env\b(?!\.sample|\.example|\.template)'</span><span class="p">),</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\btail\s+[^\|]*\.env\b(?!\.sample|\.example|\.template)'</span><span class="p">),</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'&gt;\s*[^\s]*\.env\b(?!\.sample|\.example|\.template)'</span><span class="p">),</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\bcp\s+[^\|]*\.env\b(?!\.sample|\.example|\.template)'</span><span class="p">),</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\bmv\s+[^\|]*\.env\b(?!\.sample|\.example|\.template)'</span><span class="p">),</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\bsource\s+[^\|]*\.env\b(?!\.sample|\.example|\.template)'</span><span class="p">),</span>
    <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\.\s+[^\|]*\.env\b(?!\.sample|\.example|\.template)'</span><span class="p">),</span>
<span class="p">]</span>

<span class="n">SENSITIVE_FILE_PATTERNS</span> <span class="o">=</span> <span class="p">[</span>
    <span class="p">(</span><span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\.pem$'</span><span class="p">),</span> <span class="s">'PEM certificate/key file'</span><span class="p">),</span>
    <span class="p">(</span><span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\.key$'</span><span class="p">),</span> <span class="s">'Key file'</span><span class="p">),</span>
    <span class="p">(</span><span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\.p12$'</span><span class="p">),</span> <span class="s">'PKCS12 certificate'</span><span class="p">),</span>
    <span class="p">(</span><span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\.pfx$'</span><span class="p">),</span> <span class="s">'PFX certificate'</span><span class="p">),</span>
    <span class="p">(</span><span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'credentials\.(json|yaml|yml|xml|ini|conf)$'</span><span class="p">),</span> <span class="s">'Credentials file'</span><span class="p">),</span>
    <span class="p">(</span><span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'secrets?\.(json|yaml|yml|xml|ini|conf)$'</span><span class="p">),</span> <span class="s">'Secrets file'</span><span class="p">),</span>
    <span class="p">(</span><span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\.kube/config'</span><span class="p">),</span> <span class="s">'Kubernetes config'</span><span class="p">),</span>
    <span class="p">(</span><span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\.aws/credentials'</span><span class="p">),</span> <span class="s">'AWS credentials'</span><span class="p">),</span>
    <span class="p">(</span><span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\.ssh/'</span><span class="p">),</span> <span class="s">'SSH directory'</span><span class="p">),</span>
    <span class="p">(</span><span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\.gnupg/'</span><span class="p">),</span> <span class="s">'GPG directory'</span><span class="p">),</span>
    <span class="p">(</span><span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\.netrc'</span><span class="p">),</span> <span class="s">'Netrc file'</span><span class="p">),</span>
    <span class="p">(</span><span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\.npmrc'</span><span class="p">),</span> <span class="s">'NPM config with tokens'</span><span class="p">),</span>
    <span class="p">(</span><span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">'\.pypirc'</span><span class="p">),</span> <span class="s">'PyPI config with tokens'</span><span class="p">),</span>
<span class="p">]</span>

<span class="n">SENSITIVE_FILES</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s">'.env'</span><span class="p">,</span> <span class="s">'.env.local'</span><span class="p">,</span> <span class="s">'.env.production'</span><span class="p">,</span> <span class="s">'.env.development'</span><span class="p">,</span>
    <span class="s">'id_rsa'</span><span class="p">,</span> <span class="s">'id_ed25519'</span><span class="p">,</span> <span class="s">'id_ecdsa'</span><span class="p">,</span> <span class="s">'id_dsa'</span><span class="p">,</span>
<span class="p">}</span>

<span class="n">ALLOWED_ENV_FILES</span> <span class="o">=</span> <span class="p">{</span><span class="s">'.env.sample'</span><span class="p">,</span> <span class="s">'.env.example'</span><span class="p">,</span> <span class="s">'.env.template'</span><span class="p">}</span>


<span class="k">def</span> <span class="nf">debug_log</span><span class="p">(</span><span class="n">message</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="s">"""Log debug message if debug mode is enabled."""</span>
    <span class="k">if</span> <span class="n">DEBUG</span><span class="p">:</span>
        <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"[DEBUG] </span><span class="si">{</span><span class="n">message</span><span class="si">}</span><span class="s">"</span><span class="p">,</span> <span class="nb">file</span><span class="o">=</span><span class="n">sys</span><span class="p">.</span><span class="n">stderr</span><span class="p">)</span>


<span class="k">def</span> <span class="nf">is_dangerous_rm_command</span><span class="p">(</span><span class="n">command</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="s">"""Detect dangerous rm commands."""</span>
    <span class="n">normalized</span> <span class="o">=</span> <span class="s">' '</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">command</span><span class="p">.</span><span class="n">lower</span><span class="p">().</span><span class="n">split</span><span class="p">())</span>

    <span class="k">for</span> <span class="n">pattern</span> <span class="ow">in</span> <span class="n">DANGEROUS_RM_PATTERNS</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">pattern</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="n">normalized</span><span class="p">):</span>
            <span class="k">return</span> <span class="bp">True</span>

    <span class="k">if</span> <span class="n">RM_RECURSIVE_PATTERN</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="n">normalized</span><span class="p">):</span>
        <span class="k">for</span> <span class="n">pattern</span> <span class="ow">in</span> <span class="n">DANGEROUS_RM_PATH_PATTERNS</span><span class="p">:</span>
            <span class="k">if</span> <span class="n">pattern</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="n">normalized</span><span class="p">):</span>
                <span class="k">return</span> <span class="bp">True</span>
    <span class="k">return</span> <span class="bp">False</span>


<span class="k">def</span> <span class="nf">is_fork_bomb</span><span class="p">(</span><span class="n">command</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="s">"""Detect fork bomb patterns."""</span>
    <span class="k">for</span> <span class="n">pattern</span> <span class="ow">in</span> <span class="n">FORK_BOMB_PATTERNS</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">pattern</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="n">command</span><span class="p">):</span>
            <span class="k">return</span> <span class="bp">True</span>
    <span class="k">return</span> <span class="bp">False</span>


<span class="k">def</span> <span class="nf">is_dangerous_git_command</span><span class="p">(</span><span class="n">command</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="s">"""Detect dangerous git commands."""</span>
    <span class="n">normalized</span> <span class="o">=</span> <span class="s">' '</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">command</span><span class="p">.</span><span class="n">lower</span><span class="p">().</span><span class="n">split</span><span class="p">())</span>
    <span class="k">for</span> <span class="n">pattern</span> <span class="ow">in</span> <span class="n">DANGEROUS_GIT_PATTERNS</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">pattern</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="n">normalized</span><span class="p">):</span>
            <span class="k">return</span> <span class="bp">True</span>
    <span class="k">return</span> <span class="bp">False</span>


<span class="k">def</span> <span class="nf">is_dangerous_disk_write</span><span class="p">(</span><span class="n">command</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="s">"""Detect dangerous disk write operations."""</span>
    <span class="n">normalized</span> <span class="o">=</span> <span class="s">' '</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">command</span><span class="p">.</span><span class="n">lower</span><span class="p">().</span><span class="n">split</span><span class="p">())</span>
    <span class="k">for</span> <span class="n">pattern</span> <span class="ow">in</span> <span class="n">DANGEROUS_DISK_PATTERNS</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">pattern</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="n">normalized</span><span class="p">):</span>
            <span class="k">return</span> <span class="bp">True</span>
    <span class="k">return</span> <span class="bp">False</span>


<span class="k">def</span> <span class="nf">is_sensitive_file</span><span class="p">(</span><span class="n">file_path</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">bool</span><span class="p">,</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span><span class="p">]:</span>
    <span class="s">"""Check if file path points to sensitive files."""</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">file_path</span><span class="p">:</span>
        <span class="k">return</span> <span class="bp">False</span><span class="p">,</span> <span class="bp">None</span>

    <span class="n">path_lower</span> <span class="o">=</span> <span class="n">file_path</span><span class="p">.</span><span class="n">lower</span><span class="p">()</span>
    <span class="n">basename</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">basename</span><span class="p">(</span><span class="n">path_lower</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">basename</span> <span class="ow">in</span> <span class="n">ALLOWED_ENV_FILES</span><span class="p">:</span>
        <span class="k">return</span> <span class="bp">False</span><span class="p">,</span> <span class="bp">None</span>

    <span class="k">if</span> <span class="n">basename</span> <span class="ow">in</span> <span class="n">SENSITIVE_FILES</span><span class="p">:</span>
        <span class="k">return</span> <span class="bp">True</span><span class="p">,</span> <span class="sa">f</span><span class="s">"Access to </span><span class="si">{</span><span class="n">basename</span><span class="si">}</span><span class="s"> files is prohibited"</span>

    <span class="k">for</span> <span class="n">pattern</span><span class="p">,</span> <span class="n">description</span> <span class="ow">in</span> <span class="n">SENSITIVE_FILE_PATTERNS</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">pattern</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="n">path_lower</span><span class="p">):</span>
            <span class="k">return</span> <span class="bp">True</span><span class="p">,</span> <span class="sa">f</span><span class="s">"Access to </span><span class="si">{</span><span class="n">description</span><span class="si">}</span><span class="s"> is prohibited"</span>

    <span class="k">return</span> <span class="bp">False</span><span class="p">,</span> <span class="bp">None</span>


<span class="k">def</span> <span class="nf">is_path_escape</span><span class="p">(</span><span class="n">file_path</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">project_dir</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">bool</span><span class="p">,</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span><span class="p">]:</span>
    <span class="s">"""Check if path escapes the project directory."""</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">file_path</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">project_dir</span><span class="p">:</span>
        <span class="k">return</span> <span class="bp">False</span><span class="p">,</span> <span class="bp">None</span>

    <span class="k">try</span><span class="p">:</span>
        <span class="n">abs_path</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="n">file_path</span><span class="p">).</span><span class="n">resolve</span><span class="p">()</span>
        <span class="n">abs_project</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="n">project_dir</span><span class="p">).</span><span class="n">resolve</span><span class="p">()</span>

        <span class="c1"># CVE-2025-54794: Must check with trailing separator to prevent
</span>        <span class="c1"># /project matching /project_malicious
</span>        <span class="n">project_str</span> <span class="o">=</span> <span class="nb">str</span><span class="p">(</span><span class="n">abs_project</span><span class="p">)</span>
        <span class="n">path_str</span> <span class="o">=</span> <span class="nb">str</span><span class="p">(</span><span class="n">abs_path</span><span class="p">)</span>

        <span class="k">if</span> <span class="ow">not</span> <span class="p">(</span><span class="n">path_str</span> <span class="o">==</span> <span class="n">project_str</span> <span class="ow">or</span> <span class="n">path_str</span><span class="p">.</span><span class="n">startswith</span><span class="p">(</span><span class="n">project_str</span> <span class="o">+</span> <span class="n">os</span><span class="p">.</span><span class="n">sep</span><span class="p">)):</span>
            <span class="k">return</span> <span class="bp">True</span><span class="p">,</span> <span class="s">"Path is outside project directory"</span>

        <span class="k">if</span> <span class="s">'..'</span> <span class="ow">in</span> <span class="n">file_path</span><span class="p">:</span>
            <span class="k">return</span> <span class="bp">True</span><span class="p">,</span> <span class="s">"Path traversal attempt detected"</span>

    <span class="k">except</span> <span class="p">(</span><span class="nb">ValueError</span><span class="p">,</span> <span class="nb">OSError</span><span class="p">):</span>
        <span class="k">return</span> <span class="bp">True</span><span class="p">,</span> <span class="s">"Invalid path"</span>

    <span class="k">return</span> <span class="bp">False</span><span class="p">,</span> <span class="bp">None</span>


<span class="k">def</span> <span class="nf">check_bash_command</span><span class="p">(</span><span class="n">command</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span><span class="p">:</span>
    <span class="s">"""Check bash command for dangerous patterns."""</span>
    <span class="k">if</span> <span class="n">is_dangerous_rm_command</span><span class="p">(</span><span class="n">command</span><span class="p">):</span>
        <span class="k">return</span> <span class="s">"Dangerous rm command detected"</span>
    <span class="k">if</span> <span class="n">is_fork_bomb</span><span class="p">(</span><span class="n">command</span><span class="p">):</span>
        <span class="k">return</span> <span class="s">"Fork bomb detected"</span>
    <span class="k">if</span> <span class="n">is_dangerous_git_command</span><span class="p">(</span><span class="n">command</span><span class="p">):</span>
        <span class="k">return</span> <span class="s">"Dangerous git command detected (push to main/master or force push)"</span>
    <span class="k">if</span> <span class="n">is_dangerous_disk_write</span><span class="p">(</span><span class="n">command</span><span class="p">):</span>
        <span class="k">return</span> <span class="s">"Dangerous disk write operation detected"</span>

    <span class="k">for</span> <span class="n">pattern</span> <span class="ow">in</span> <span class="n">ENV_ACCESS_PATTERNS</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">pattern</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="n">command</span><span class="p">):</span>
            <span class="k">return</span> <span class="s">"Access to .env files is prohibited"</span>

    <span class="k">return</span> <span class="bp">None</span>


<span class="k">def</span> <span class="nf">check_file_operation</span><span class="p">(</span><span class="n">tool_name</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">tool_input</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">project_dir</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span><span class="p">:</span>
    <span class="s">"""Check file operations for security issues."""</span>
    <span class="n">file_path</span> <span class="o">=</span> <span class="n">tool_input</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'file_path'</span><span class="p">,</span> <span class="s">''</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">tool_name</span> <span class="o">==</span> <span class="s">'Grep'</span><span class="p">:</span>
        <span class="n">file_path</span> <span class="o">=</span> <span class="n">tool_input</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'path'</span><span class="p">,</span> <span class="s">''</span><span class="p">)</span> <span class="ow">or</span> <span class="n">file_path</span>
    <span class="k">if</span> <span class="n">tool_name</span> <span class="o">==</span> <span class="s">'Glob'</span><span class="p">:</span>
        <span class="n">file_path</span> <span class="o">=</span> <span class="n">tool_input</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'path'</span><span class="p">,</span> <span class="s">''</span><span class="p">)</span> <span class="ow">or</span> <span class="n">file_path</span>

    <span class="k">if</span> <span class="ow">not</span> <span class="n">file_path</span><span class="p">:</span>
        <span class="k">return</span> <span class="bp">None</span>

    <span class="n">is_sensitive</span><span class="p">,</span> <span class="n">reason</span> <span class="o">=</span> <span class="n">is_sensitive_file</span><span class="p">(</span><span class="n">file_path</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">is_sensitive</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">reason</span>

    <span class="k">if</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">isabs</span><span class="p">(</span><span class="n">file_path</span><span class="p">)</span> <span class="ow">or</span> <span class="s">'..'</span> <span class="ow">in</span> <span class="n">file_path</span><span class="p">:</span>
        <span class="n">is_escape</span><span class="p">,</span> <span class="n">reason</span> <span class="o">=</span> <span class="n">is_path_escape</span><span class="p">(</span><span class="n">file_path</span><span class="p">,</span> <span class="n">project_dir</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">is_escape</span><span class="p">:</span>
            <span class="k">return</span> <span class="n">reason</span>

    <span class="k">return</span> <span class="bp">None</span>


<span class="k">def</span> <span class="nf">main</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="n">input_data</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="n">load</span><span class="p">(</span><span class="n">sys</span><span class="p">.</span><span class="n">stdin</span><span class="p">)</span>
        <span class="n">tool_name</span> <span class="o">=</span> <span class="n">input_data</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'tool_name'</span><span class="p">,</span> <span class="s">''</span><span class="p">)</span>
        <span class="n">tool_input</span> <span class="o">=</span> <span class="n">input_data</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'tool_input'</span><span class="p">,</span> <span class="p">{})</span>
        <span class="n">project_dir</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'CLAUDE_PROJECT_DIR'</span><span class="p">,</span> <span class="n">os</span><span class="p">.</span><span class="n">getcwd</span><span class="p">())</span>

        <span class="n">debug_log</span><span class="p">(</span><span class="sa">f</span><span class="s">"Checking tool: </span><span class="si">{</span><span class="n">tool_name</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>

        <span class="k">if</span> <span class="n">tool_name</span> <span class="o">==</span> <span class="s">'Bash'</span><span class="p">:</span>
            <span class="n">command</span> <span class="o">=</span> <span class="n">tool_input</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'command'</span><span class="p">,</span> <span class="s">''</span><span class="p">)</span>
            <span class="n">error</span> <span class="o">=</span> <span class="n">check_bash_command</span><span class="p">(</span><span class="n">command</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">error</span><span class="p">:</span>
                <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"BLOCKED: </span><span class="si">{</span><span class="n">error</span><span class="si">}</span><span class="s">"</span><span class="p">,</span> <span class="nb">file</span><span class="o">=</span><span class="n">sys</span><span class="p">.</span><span class="n">stderr</span><span class="p">)</span>
                <span class="n">sys</span><span class="p">.</span><span class="nb">exit</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span>

        <span class="k">if</span> <span class="n">tool_name</span> <span class="ow">in</span> <span class="p">[</span><span class="s">'Read'</span><span class="p">,</span> <span class="s">'Edit'</span><span class="p">,</span> <span class="s">'MultiEdit'</span><span class="p">,</span> <span class="s">'Write'</span><span class="p">,</span> <span class="s">'Glob'</span><span class="p">,</span> <span class="s">'Grep'</span><span class="p">]:</span>
            <span class="n">error</span> <span class="o">=</span> <span class="n">check_file_operation</span><span class="p">(</span><span class="n">tool_name</span><span class="p">,</span> <span class="n">tool_input</span><span class="p">,</span> <span class="n">project_dir</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">error</span><span class="p">:</span>
                <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"BLOCKED: </span><span class="si">{</span><span class="n">error</span><span class="si">}</span><span class="s">"</span><span class="p">,</span> <span class="nb">file</span><span class="o">=</span><span class="n">sys</span><span class="p">.</span><span class="n">stderr</span><span class="p">)</span>
                <span class="n">sys</span><span class="p">.</span><span class="nb">exit</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span>

        <span class="n">sys</span><span class="p">.</span><span class="nb">exit</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>

    <span class="k">except</span> <span class="n">json</span><span class="p">.</span><span class="n">JSONDecodeError</span><span class="p">:</span>
        <span class="n">sys</span><span class="p">.</span><span class="nb">exit</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>  <span class="c1"># Fail open on parse errors
</span>    <span class="k">except</span> <span class="nb">Exception</span><span class="p">:</span>
        <span class="n">sys</span><span class="p">.</span><span class="nb">exit</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>  <span class="c1"># Fail open on unexpected errors
</span>

<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="s">'__main__'</span><span class="p">:</span>
    <span class="n">main</span><span class="p">()</span>
</code></pre></div></div>

<p>Save this as <code class="language-plaintext highlighter-rouge">.claude/hooks/pre_tool_use.py</code> and configure it in your settings:</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">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"PreToolUse"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="p">{</span><span class="w">
        </span><span class="nl">"matcher"</span><span class="p">:</span><span class="w"> </span><span class="s2">"*"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
          </span><span class="p">{</span><span class="w">
            </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"command"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"python3 </span><span class="se">\"</span><span class="s2">$CLAUDE_PROJECT_DIR/.claude/hooks/pre_tool_use.py</span><span class="se">\"</span><span class="s2">"</span><span class="w">
          </span><span class="p">}</span><span class="w">
        </span><span class="p">]</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Key design decisions in this implementation:</p>

<ol>
  <li><strong>Pre-compiled regex patterns</strong> for better performance on repeated checks</li>
  <li><strong>Fail-open on errors</strong> (<code class="language-plaintext highlighter-rouge">sys.exit(0)</code>) so parsing failures don’t break your workflow</li>
  <li><strong>Debug mode</strong> via <code class="language-plaintext highlighter-rouge">CLAUDE_HOOKS_DEBUG=1</code> for troubleshooting</li>
  <li><strong>CVE-2025-54794 fix</strong> with proper path prefix checking using <code class="language-plaintext highlighter-rouge">os.sep</code></li>
  <li><strong>Allows safe variants</strong> like <code class="language-plaintext highlighter-rouge">.env.sample</code> and <code class="language-plaintext highlighter-rouge">.env.example</code></li>
  <li><strong>Covers multiple tools</strong> including Bash, Read, Edit, Write, Glob, and Grep</li>
</ol>

<h2 id="permissionrequest-hooks">PermissionRequest Hooks</h2>

<p>The <code class="language-plaintext highlighter-rouge">PermissionRequest</code> hook (v2.0.45+) triggers when Claude Code displays a permission dialog, allowing automatic approve/deny decisions:</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">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"PermissionRequest"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="p">{</span><span class="w">
        </span><span class="nl">"matcher"</span><span class="p">:</span><span class="w"> </span><span class="s2">"*"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
          </span><span class="p">{</span><span class="w">
            </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"command"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">".claude/hooks/permission-handler.sh"</span><span class="w">
          </span><span class="p">}</span><span class="w">
        </span><span class="p">]</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="auto-approve-safe-operations">Auto-Approve Safe Operations</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="nv">INPUT</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span><span class="si">)</span>
<span class="nv">TOOL_NAME</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$INPUT</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.tool_name // empty'</span><span class="si">)</span>
<span class="nv">COMMAND</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$INPUT</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.tool_input.command // empty'</span><span class="si">)</span>

<span class="c"># Auto-approve read-only tools</span>
<span class="k">if</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$TOOL_NAME</span><span class="s2">"</span> <span class="o">=</span>~ ^<span class="o">(</span>Read|Glob|Grep<span class="o">)</span><span class="nv">$ </span><span class="o">]]</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">echo</span> <span class="s1">'{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'</span>
  <span class="nb">exit </span>0
<span class="k">fi</span>

<span class="c"># Auto-approve safe npm commands</span>
<span class="k">if</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$TOOL_NAME</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"Bash"</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$COMMAND</span><span class="s2">"</span> <span class="o">=</span>~ ^npm<span class="se">\ </span><span class="o">(</span><span class="nb">test</span>|run<span class="se">\ </span>lint|run<span class="se">\ </span>build<span class="o">)</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">echo</span> <span class="s1">'{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'</span>
  <span class="nb">exit </span>0
<span class="k">fi</span>

<span class="c"># Deny dangerous patterns</span>
<span class="k">if </span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$COMMAND</span><span class="s2">"</span> | <span class="nb">grep</span> <span class="nt">-qE</span> <span class="s1">'rm\s+-rf'</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">echo</span> <span class="s1">'{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Destructive command blocked"}}}'</span>
  <span class="nb">exit </span>0
<span class="k">fi</span>

<span class="c"># Default: show permission prompt</span>
<span class="nb">exit </span>0
</code></pre></div></div>

<p>Note: <code class="language-plaintext highlighter-rouge">PermissionRequest</code> hooks do not fire in non-interactive mode (<code class="language-plaintext highlighter-rouge">-p</code>). Use <code class="language-plaintext highlighter-rouge">PreToolUse</code> hooks for automated permission decisions.</p>

<h2 id="known-cves-and-vulnerabilities">Known CVEs and Vulnerabilities</h2>

<p>Several vulnerabilities have been discovered in Claude Code over time. If you’re running a recent version (2.1.12 at time of writing), these are all patched. They’re listed here to illustrate the types of attacks that hooks can help defend against:</p>

<table>
  <thead>
    <tr>
      <th>CVE</th>
      <th>Issue</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>CVE-2025-54794</td>
      <td>Path restriction bypass via matching directory prefixes</td>
    </tr>
    <tr>
      <td>CVE-2025-54795</td>
      <td>Command injection via improper input sanitization</td>
    </tr>
    <tr>
      <td>CVE-2025-52882</td>
      <td>WebSocket authentication bypass allowing remote code execution</td>
    </tr>
    <tr>
      <td>CVE-2025-66032</td>
      <td><a href="https://flatt.tech/research/posts/pwning-claude-code-in-8-different-ways/">8 different command execution bypasses</a> (led to blocklist → allowlist redesign)</td>
    </tr>
  </tbody>
</table>

<p>Check <a href="https://github.com/anthropics/claude-code/releases">Anthropic’s releases</a> for the latest patched versions.</p>

<h3 id="general-mitigation">General Mitigation</h3>

<ul>
  <li>Never execute development tools in untrusted directories</li>
  <li>Use strong sandboxing and isolation</li>
  <li>Treat Claude Code output as unverified</li>
  <li>Keep prompts precise and exclude sensitive data</li>
</ul>

<h2 id="exit-code-reference">Exit Code Reference</h2>

<p>Understanding exit codes is essential:</p>

<table>
  <thead>
    <tr>
      <th>Exit Code</th>
      <th>Meaning</th>
      <th>Effect</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td>Success</td>
      <td>Allow operation; JSON on stdout is processed</td>
    </tr>
    <tr>
      <td>2</td>
      <td>Blocking error</td>
      <td>Block operation; stderr shown to Claude</td>
    </tr>
    <tr>
      <td>Other</td>
      <td>Non-blocking error</td>
      <td>Continue; stderr shown to user only</td>
    </tr>
  </tbody>
</table>

<p>Choose one approach per hook, either exit codes alone or exit 0 with JSON output. Don’t mix them; Claude Code ignores JSON when you exit 2.</p>

<h2 id="comprehensive-security-checklist">Comprehensive Security Checklist</h2>

<p>Before deploying hooks in production:</p>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Validate all input from stdin</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Quote all file paths and variables</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Use absolute paths for scripts (via <code class="language-plaintext highlighter-rouge">$CLAUDE_PROJECT_DIR</code>)</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Block sensitive files (.env, <em>.key, .git/</em>)</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Handle missing tools gracefully</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Set reasonable timeout (default 60s)</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Log errors to stderr or log file</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Test with edge cases (spaces, Unicode, missing files)</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Test hooks before deploying (see below)</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Consider disabling hooks when not needed</li>
</ul>

<h3 id="testing-your-hooks">Testing Your Hooks</h3>

<p>Before deploying, test your hooks with sample input:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Test dangerous command blocking</span>
<span class="nb">echo</span> <span class="s1">'{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}'</span> | python3 .claude/hooks/pre_tool_use.py
<span class="nb">echo</span> <span class="nv">$?</span>  <span class="c"># Should be 2 (blocked)</span>

<span class="c"># Test safe command</span>
<span class="nb">echo</span> <span class="s1">'{"tool_name":"Bash","tool_input":{"command":"ls -la"}}'</span> | python3 .claude/hooks/pre_tool_use.py
<span class="nb">echo</span> <span class="nv">$?</span>  <span class="c"># Should be 0 (allowed)</span>

<span class="c"># Test sensitive file blocking</span>
<span class="nb">echo</span> <span class="s1">'{"tool_name":"Read","tool_input":{"file_path":".env"}}'</span> | python3 .claude/hooks/pre_tool_use.py
<span class="nb">echo</span> <span class="nv">$?</span>  <span class="c"># Should be 2 (blocked)</span>
</code></pre></div></div>

<h2 id="defense-in-depth">Defense in Depth</h2>

<p>Implement security at multiple layers:</p>

<ol>
  <li><strong>UserPromptSubmit</strong> - Validate prompts before Claude processes them</li>
  <li><strong>PreToolUse</strong> - Block dangerous operations before execution</li>
  <li><strong>PermissionRequest</strong> - Auto-approve safe operations, deny dangerous ones</li>
  <li><strong>PostToolUse</strong> - Validate results and provide feedback</li>
  <li><strong>Deny lists</strong> - Add explicit permission denials in settings</li>
</ol>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"permissions"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"deny"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="s2">"Bash(rm -rf:*)"</span><span class="p">,</span><span class="w">
      </span><span class="s2">"Bash(terraform destroy:*)"</span><span class="p">,</span><span class="w">
      </span><span class="s2">"Bash(docker system prune:*)"</span><span class="w">
    </span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="final-thoughts">Final Thoughts</h2>

<p>Claude Code hooks provide powerful security controls, but they require careful implementation. The key principles:</p>

<ol>
  <li><strong>Hooks execute deterministically</strong> - Unlike CLAUDE.md rules, hooks cannot be bypassed</li>
  <li><strong>Validate everything</strong> - Never trust input data</li>
  <li><strong>Exit code 2 blocks</strong> - Use it for security violations</li>
  <li><strong>Defense in depth</strong> - Implement multiple security layers</li>
  <li><strong>Stay updated</strong> - Patch promptly when vulnerabilities are discovered</li>
</ol>

<p>Security isn’t about preventing all possible risks: it’s about reducing attack surface while maintaining productivity. Well-designed hooks let you work confidently with AI assistance while protecting your systems from accidental or malicious damage.</p>

<h2 id="sources-and-further-reading">Sources and Further Reading</h2>

<h3 id="official-documentation">Official Documentation</h3>

<ul>
  <li><a href="https://code.claude.com/docs/en/hooks">Hooks Reference - Claude Code Docs</a> - The official reference documentation</li>
  <li><a href="https://code.claude.com/docs/en/hooks-guide">Automate Workflows with Hooks Guide</a> - Quickstart guide with examples</li>
  <li><a href="https://www.anthropic.com/engineering/claude-code-best-practices">Claude Code Best Practices</a> - Anthropic’s official best practices</li>
  <li><a href="https://platform.claude.com/docs/en/agent-sdk/hooks">Agent SDK Hooks</a> - Hooks in the Agent SDK</li>
</ul>

<h3 id="security-resources">Security Resources</h3>

<ul>
  <li><a href="https://www.backslash.security/blog/claude-code-security-best-practices">Claude Code Security Best Practices - Backslash</a></li>
  <li><a href="https://www.eesel.ai/blog/security-claude-code">A Deep Dive into Security for Claude Code - Eesel</a></li>
  <li><a href="https://www.mintmcp.com/blog/claude-code-security">Claude Code Security: Enterprise Best Practices - MintMCP</a></li>
  <li><a href="https://prpm.dev/blog/claude-hooks-best-practices">Claude Hooks Best Practices - PRPM</a></li>
  <li><a href="https://skywork.ai/blog/ai-agent/claude-skills-security-threat-model-permissions-best-practices-2025/">Are Claude Skills Secure? Threat Model &amp; Permissions - Skywork</a></li>
</ul>

<h3 id="cve-details-and-security-advisories">CVE Details and Security Advisories</h3>

<ul>
  <li><a href="https://cymulate.com/blog/cve-2025-547954-54795-claude-inverseprompt/">CVE-2025-54795: InversePrompt - Cymulate</a></li>
  <li><a href="https://securitylabs.datadoghq.com/articles/claude-mcp-cve-2025-52882/">CVE-2025-52882: WebSocket Authentication Bypass - Datadog Security Labs</a></li>
  <li><a href="https://www.redguard.ch/blog/2025/12/19/advisory-anthropic-claude-code/">Arbitrary Code Execution Advisory - Redguard</a></li>
  <li><a href="https://gbhackers.com/claude-ai-flaws/">Claude AI Flaws: Unauthorized Commands - GBHackers</a></li>
</ul>

<h3 id="tutorials-and-guides">Tutorials and Guides</h3>

<ul>
  <li><a href="https://www.datacamp.com/tutorial/claude-code-hooks">Claude Code Hooks: A Practical Guide - DataCamp</a></li>
  <li><a href="https://dev.to/holasoymalva/the-ultimate-claude-code-guide-every-hidden-trick-hack-and-power-feature-you-need-to-know-2l45">The Ultimate Claude Code Guide - DEV Community</a></li>
  <li><a href="https://stevekinney.com/courses/ai-development/claude-code-hook-examples">Claude Code Hook Examples - Steve Kinney</a></li>
  <li><a href="https://perrotta.dev/2025/12/claude-code-block-dangerous-commands/">Block Dangerous Commands - Perrotta.dev</a></li>
  <li><a href="https://www.letanure.dev/blog/2025-08-06--claude-code-part-8-hooks-automated-quality-checks">Hooks for Automated Quality Checks - Luiz Tanure</a></li>
  <li><a href="https://paddo.dev/blog/claude-code-hooks-guardrails/">Claude Code Hooks: Guardrails That Work - Paddo.dev</a></li>
  <li><a href="https://claude.com/blog/how-to-configure-hooks">How to Configure Hooks - Claude Blog</a></li>
</ul>

<h3 id="github-repositories">GitHub Repositories</h3>

<ul>
  <li><a href="https://github.com/disler/claude-code-hooks-mastery">claude-code-hooks-mastery</a> - Comprehensive hooks examples and patterns</li>
  <li><a href="https://github.com/RoaringFerrum/claude-code-bash-guardian">claude-code-bash-guardian</a> - Automated security layer for Bash hooks</li>
  <li><a href="https://github.com/hesreallyhim/awesome-claude-code">awesome-claude-code</a> - Curated list of skills, hooks, and plugins</li>
  <li><a href="https://github.com/karanb192/claude-code-hooks">claude-code-hooks</a> - Collection of useful hooks</li>
  <li><a href="https://github.com/disler/claude-code-damage-control">claude-code-damage-control</a> - Safety hooks</li>
  <li><a href="https://github.com/affaan-m/everything-claude-code">everything-claude-code</a> - Complete configuration collection</li>
  <li><a href="https://github.com/anthropics/claude-code-security-review">claude-code-security-review</a> - AI-powered security review GitHub Action</li>
</ul>

<h3 id="community-resources">Community Resources</h3>

<ul>
  <li><a href="https://claudelog.com/mechanics/hooks/">ClaudeLog - Hooks Documentation</a></li>
  <li><a href="https://dotclaude.com/hooks">.claude Directory - Hooks</a></li>
  <li><a href="https://shipyard.build/blog/claude-code-cheat-sheet/">Claude Code CLI Cheatsheet - Shipyard</a></li>
  <li><a href="https://docs.gitbutler.com/features/ai-integration/claude-code-hooks">GitButler - Claude Code Hooks</a></li>
  <li><a href="https://claudefa.st/blog/tools/hooks/permission-hook-guide">Permission Hook Guide - Claude Fast</a></li>
</ul>

<hr />

<p><em>Working with Claude Code in production? I’d love to hear about your security patterns and hook implementations. <a href="#" onclick="task1(); return false;">Get in touch</a> to share your experiences.</em></p>]]></content><author><name>Albert Sikkema</name></author><category term="ai" /><category term="security" /><category term="development" /><category term="tools" /><summary type="html"><![CDATA[Comprehensive guide to securing Claude Code with hooks, covering PreToolUse validation, dangerous command blocking, path traversal prevention, and practical security patterns.]]></summary></entry><entry><title type="html">Reverse-Engineering Figma Make: Extracting React Apps from Binary Files</title><link href="https://www.albertsikkema.com/ai/development/tools/reverse-engineering/2026/01/23/reverse-engineering-figma-make-files.html" rel="alternate" type="text/html" title="Reverse-Engineering Figma Make: Extracting React Apps from Binary Files" /><published>2026-01-23T00:00:00+00:00</published><updated>2026-01-23T00:00:00+00:00</updated><id>https://www.albertsikkema.com/ai/development/tools/reverse-engineering/2026/01/23/reverse-engineering-figma-make-files</id><content type="html" xml:base="https://www.albertsikkema.com/ai/development/tools/reverse-engineering/2026/01/23/reverse-engineering-figma-make-files.html"><![CDATA[<p>A client came to me with a beautiful UI. They’d built it in Figma Make, the AI-powered prototyping tool. The app looked stunning, animations were smooth. Just one problem: I couldn’t get to the actual code. Normally you can use the api or export design files, but not with Make. The Figma API Returns <code class="language-plaintext highlighter-rouge">{"status":400,"err":"File type not supported by this endpoint"}</code> for Make files.</p>

<p>Why would you not be able to do that? The wonders of corporate lock-in, I suppose.</p>

<p>That’s a problem: no design file. What now? So on to searching for ways to extract the code.</p>

<h2 id="update---january-29-2026">Update - January 29, 2026</h2>

<p>A few useful tips from readers:</p>

<ul>
  <li><strong>Use the <a href="https://man7.org/linux/man-pages/man1/file.1.html"><code class="language-plaintext highlighter-rouge">file</code></a> command first</strong>: Before reaching for <code class="language-plaintext highlighter-rouge">xxd</code>, running <code class="language-plaintext highlighter-rouge">file ClientApp.make</code> would have identified it as a ZIP archive immediately. Good reminder to start with the obvious tools.</li>
  <li><strong>Try <a href="https://github.com/ReFirmLabs/binwalk"><code class="language-plaintext highlighter-rouge">binwalk</code></a> for unknown binaries</strong>: It scans for known file signatures and can identify embedded files within a single binary. Worth trying early in the process.</li>
</ul>

<p>Now let’s get into it.</p>

<h2 id="the-discovery-its-just-a-zip-file">The Discovery: It’s Just a ZIP File</h2>

<p>So I started clicking around the Figma interface, looking for anything useful. That’s when I noticed you can download the <code class="language-plaintext highlighter-rouge">.make</code> file itself. Download. A file, interesting!</p>

<p>But I can not read it. Hmmm, what now? Rule number one: never trust the extension. A <code class="language-plaintext highlighter-rouge">.make</code> file could be anything. After a while I figured out it was a ZIP file. And in it was in essence a React application.</p>

<figure>
  <img src="/assets/images/figma-make-binary-reverse-engineering.jpg" alt="Binary code digits flowing across screen representing file format reverse engineering" />
  <figcaption>Photo by <a href="https://www.pexels.com/photo/binary-options-trading-18536263/">Markus Winkler</a></figcaption>
</figure>

<h2 id="the-discovery-its-just-a-zip-file-1">The Discovery: It’s Just a ZIP File</h2>

<p>First thing I did was look at the raw bytes:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>xxd <span class="nt">-l</span> 4 <span class="s2">"ClientApp.make"</span>
<span class="c"># Output: 504b 0304</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">504b</code> is <code class="language-plaintext highlighter-rouge">PK</code> in ASCII. That’s the ZIP file signature. The whole mysterious <code class="language-plaintext highlighter-rouge">.make</code> file is just a ZIP archive with a different extension. Learned something new today (about <code class="language-plaintext highlighter-rouge">504b</code>).</p>

<p>Unzip it:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ClientApp.make (ZIP)
├── canvas.fig          # 2.3 MB binary - the interesting part
├── meta.json           # Project metadata
├── ai_chat.json        # 34 MB of AI conversation history
├── thumbnail.png       # Preview image
├── images/             # 110 image assets (hash-named, no extensions)
└── blob_store/         # Additional binary data
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">ai_chat.json</code> was massive. Every prompt, every response, every iteration the client went through while building the app. Not very useful, and not what I needed. (although i did try to extract design tokens from it at first, before turning to the binary file).</p>

<p>The <code class="language-plaintext highlighter-rouge">canvas.fig</code> file held the actual code. 2.3 MB of binary data. That extension sounds more like a standard Figma design file. How to read that and see if it contains anything useful? Time to dig deeper.</p>

<h2 id="decoding-the-binary-format">Decoding the Binary Format</h2>

<p>Looking at the header:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>xxd <span class="nt">-l</span> 20 canvas.fig
<span class="c"># Output: 6669 672d 6d61 6b65 65... (fig-makee)</span>
</code></pre></div></div>

<p>Standard Figma design files use <code class="language-plaintext highlighter-rouge">fig-kiwi</code> as their magic header. Make files use <code class="language-plaintext highlighter-rouge">fig-makee</code>. Different format, same general approach.</p>

<p>The structure:</p>

<table>
  <thead>
    <tr>
      <th>Offset</th>
      <th>Size</th>
      <th>Content</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td>9 bytes</td>
      <td>Magic: <code class="language-plaintext highlighter-rouge">fig-makee</code></td>
    </tr>
    <tr>
      <td>9</td>
      <td>3 bytes</td>
      <td>Padding</td>
    </tr>
    <tr>
      <td>12</td>
      <td>4 bytes</td>
      <td>Chunk 1 size (little-endian)</td>
    </tr>
    <tr>
      <td>16</td>
      <td>N bytes</td>
      <td>Chunk 1 data</td>
    </tr>
    <tr>
      <td>16+N</td>
      <td>4 bytes</td>
      <td>Chunk 2 size</td>
    </tr>
    <tr>
      <td>20+N</td>
      <td>M bytes</td>
      <td>Chunk 2 data</td>
    </tr>
  </tbody>
</table>

<p>Two chunks. Two different compression algorithms. Thanks for some AI help here, LLMs are great at spotting patterns in binary data.</p>

<h2 id="the-compression-puzzle">The Compression Puzzle</h2>

<p>First attempt: zlib decompression on both chunks.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Error: Invalid stored block lengths
</code></pre></div></div>

<p>The first chunk decompressed fine with zlib/deflate. The second chunk refused. Checking its magic bytes: <code class="language-plaintext highlighter-rouge">28 B5 2F FD</code>. That’s Zstandard compression. Different algorithm entirely.</p>

<p>So:</p>
<ul>
  <li><strong>Chunk 1 (Schema)</strong>: Deflate compressed, 24KB → 59KB decompressed</li>
  <li><strong>Chunk 2 (Data)</strong>: Zstandard compressed, 2.2MB → 29MB decompressed</li>
</ul>

<p>Three npm packages made this work: <code class="language-plaintext highlighter-rouge">pako</code> for deflate, <code class="language-plaintext highlighter-rouge">fzstd</code> for Zstandard, and <code class="language-plaintext highlighter-rouge">kiwi-schema</code> for decoding the binary data format. Again, LLMs to the rescue. I know a bit about compression, but this would have taken me a lot of time to figure out alone. And probably would have given up (rewards vs time invested).</p>

<h2 id="kiwi-schema-figmas-binary-format">Kiwi Schema: Figma’s Binary Format</h2>

<p>Figma uses the Kiwi binary schema format. It’s compact but has no schema definition included. Fortunately, Chunk 1 contains exactly that: the schema.</p>

<p>The schema had 534 type definitions. Nodes, colors, vectors, transforms, fonts, and the one I cared about: <code class="language-plaintext highlighter-rouge">CODE_FILE</code>.</p>

<p>Decoding the message data revealed a tree structure with 159 nodes:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span>
  <span class="dl">"</span><span class="s2">nodeChanges</span><span class="dl">"</span><span class="p">:</span> <span class="p">[</span>
    <span class="p">{</span>
      <span class="dl">"</span><span class="s2">type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">DOCUMENT</span><span class="dl">"</span><span class="p">,</span>
      <span class="dl">"</span><span class="s2">children</span><span class="dl">"</span><span class="p">:</span> <span class="p">[...]</span>
    <span class="p">},</span>
    <span class="p">{</span>
      <span class="dl">"</span><span class="s2">type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">CODE_FILE</span><span class="dl">"</span><span class="p">,</span>
      <span class="dl">"</span><span class="s2">name</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">App.tsx</span><span class="dl">"</span><span class="p">,</span>
      <span class="dl">"</span><span class="s2">sourceCode</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">import React from 'react'...</span><span class="dl">"</span>
    <span class="p">},</span>
    <span class="c1">// ... 157 more nodes</span>
  <span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Every React component, utility function and CSS file were there in the <code class="language-plaintext highlighter-rouge">sourceCode</code> property. Progress! Now how to get to the content, the actual files?</p>

<figure>
  <img src="/assets/images/figma-make-file-structure.png" alt="Diagram showing Figma Make canvas.fig binary structure: header, deflate-compressed schema chunk, and zstandard-compressed data chunk that decodes to React source files" />
  <figcaption>Structure of canvas.fig and how it decodes to source code</figcaption>
</figure>

<h2 id="extracting-the-content">Extracting the Content</h2>

<p>Finding <code class="language-plaintext highlighter-rouge">CODE_FILE</code> nodes with source code was straightforward:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">codeFiles</span> <span class="o">=</span> <span class="nx">nodeChanges</span><span class="p">.</span><span class="nx">filter</span><span class="p">(</span>
  <span class="nx">node</span> <span class="o">=&gt;</span> <span class="nx">node</span><span class="p">.</span><span class="nx">type</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">CODE_FILE</span><span class="dl">'</span> <span class="o">&amp;&amp;</span> <span class="nx">node</span><span class="p">.</span><span class="nx">sourceCode</span>
<span class="p">);</span>
</code></pre></div></div>

<p>96 source files extracted:</p>
<ul>
  <li>React components (<code class="language-plaintext highlighter-rouge">.tsx</code>)</li>
  <li>TypeScript utilities (<code class="language-plaintext highlighter-rouge">.ts</code>)</li>
  <li>CSS files including <code class="language-plaintext highlighter-rouge">globals.css</code></li>
  <li>Data files</li>
  <li>React hooks</li>
</ul>

<p>But having files isn’t the same as having a working app. Now to piece it all together, in a working structure:</p>

<h2 id="from-files-to-running-app">From Files to Running App</h2>

<p>The extracted code had some quirks:</p>

<p><strong>Versioned package imports</strong>: Figma Make embeds version numbers directly in import statements.</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// What Figma Make generates</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">Dialog</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@radix-ui/react-dialog@1.1.6</span><span class="dl">'</span>

<span class="c1">// What actually works</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">Dialog</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@radix-ui/react-dialog</span><span class="dl">'</span>
</code></pre></div></div>

<p><strong>Custom asset imports</strong>: Images use a proprietary format.</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// What Figma Make generates</span>
<span class="k">import</span> <span class="nx">heroImage</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">figma:asset/a1b2c3d4.png</span><span class="dl">'</span>

<span class="c1">// What actually works</span>
<span class="kd">const</span> <span class="nx">heroImage</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">/images/a1b2c3d4.png</span><span class="dl">'</span>
</code></pre></div></div>

<p><strong>Missing file extensions</strong>: All images in the <code class="language-plaintext highlighter-rouge">images/</code> folder were hash-named with no extensions. The browser needs <code class="language-plaintext highlighter-rouge">.png</code> to serve them correctly.</p>

<p><strong>Tailwind CSS v4 changes</strong>: The PostCSS integration changed between versions. Needed <code class="language-plaintext highlighter-rouge">@tailwindcss/postcss</code> instead of using <code class="language-plaintext highlighter-rouge">tailwindcss</code> directly.</p>

<p><strong>React StrictMode breaking animations</strong>: The original code used Framer Motion. StrictMode double-mounts components, which breaks timers and animations. Removing StrictMode fixed it.</p>

<p>I wrote scripts to handle all of this automatically. Analyze imports, determine folder structure (based on import references in the files), fix paths, copy images with proper extensions, generate <code class="language-plaintext highlighter-rouge">package.json</code>, Vite config, and TypeScript config.</p>

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

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd </span>make_extraction
./run-all.sh ../ClientApp.make
<span class="nb">cd </span>output/react_app
npm <span class="nb">install</span> <span class="nt">--legacy-peer-deps</span>
npm run dev
</code></pre></div></div>

<p>A working React application on localhost:5173. Same components, same styling, same animations. No manual conversion work. No information lost.</p>

<h2 id="design-token-extraction">Design Token Extraction</h2>

<p>Beyond source code, I also extracted design tokens using regex patterns:</p>

<ul>
  <li>Hex colors: <code class="language-plaintext highlighter-rouge">#1a1a1a</code>, <code class="language-plaintext highlighter-rouge">#ffffff</code></li>
  <li>RGBA values: <code class="language-plaintext highlighter-rouge">rgba(0, 0, 0, 0.5)</code></li>
  <li>HSL colors: <code class="language-plaintext highlighter-rouge">hsl(220, 14%, 96%)</code></li>
  <li>CSS variables: <code class="language-plaintext highlighter-rouge">--primary-color: #3b82f6</code></li>
  <li>Google Fonts: <code class="language-plaintext highlighter-rouge">Inter</code>, <code class="language-plaintext highlighter-rouge">Roboto</code></li>
</ul>

<p>All exported to <code class="language-plaintext highlighter-rouge">design-tokens.json</code> for easy reference or migration to a design system.</p>

<h2 id="lessons-learned">Lessons Learned</h2>

<p><strong>Don’t assume compression types</strong>: The same file can use multiple compression algorithms for different chunks. Check the magic bytes.</p>

<p><strong>Figma’s internal format is consistent</strong>: Whether it’s a design file or a code project, the underlying structure uses Kiwi schemas. Different content, same parsing approach.</p>

<p><strong>React StrictMode has side effects</strong>: It’s great for catching bugs during development, but it can break production code that relies on mount/unmount timing.</p>

<p><strong>The original code is usually correct</strong>: I wasted time “fixing” fonts that weren’t broken. The extraction was accurate; my assumptions weren’t.</p>

<p>And most interesting: Figma Make is a great tool, but under the hood it is straightforward React with Radix UI and Tailwind CSS. No magic, just well-structured code generation. (and i have to give it to them, the code quality is pretty good for AI-generated code!).</p>

<p>So after spending 3 hours reverse-engineering and an hour writing this post, I can now finally get to work on the actual app :-) Have a great day!</p>

<h2 id="get-the-code">Get the Code</h2>

<p>The extraction scripts are available here:</p>

<p><strong>Repository</strong>: <a href="https://github.com/albertsikkema/figma-make-extractor">github.com/albertsikkema/figma-make-extractor</a></p>

<p>Quick start:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/albertsikkema/figma-make-extractor.git
<span class="nb">cd </span>figma-make-extractor/make_extraction
./run-all.sh ../YourApp.make
<span class="nb">cd </span>output/react_app
npm <span class="nb">install</span> <span class="nt">--legacy-peer-deps</span>
npm run dev
</code></pre></div></div>

<p>The scripts handle:</p>
<ol>
  <li>Unzipping the <code class="language-plaintext highlighter-rouge">.make</code> archive</li>
  <li>Decoding the <code class="language-plaintext highlighter-rouge">canvas.fig</code> binary</li>
  <li>Extracting source files</li>
  <li>Extracting design tokens</li>
  <li>Creating a runnable React/Vite project</li>
</ol>

<h2 id="when-would-you-need-this">When Would You Need This?</h2>

<ul>
  <li>Client hands you a Figma Make prototype but not the design file</li>
  <li>You want to audit AI-generated code before deployment</li>
  <li>You need to migrate away from Figma Make to a different stack</li>
  <li>You want to extract design tokens for your design system</li>
  <li>Pure curiosity about how Figma structures its data</li>
</ul>

<h2 id="resources">Resources</h2>

<p><strong>CLI Tools for Binary Analysis:</strong></p>
<ul>
  <li><a href="https://man7.org/linux/man-pages/man1/file.1.html">file</a> - Identify file types from magic bytes</li>
  <li><a href="https://github.com/ReFirmLabs/binwalk">binwalk</a> - Scan for embedded files and compression signatures</li>
</ul>

<p><strong>Binary Format Analysis:</strong></p>
<ul>
  <li><a href="https://github.com/nicbarker/kiwi">Kiwi Schema Format</a></li>
  <li><a href="https://facebook.github.io/zstd/">Zstandard Compression</a></li>
</ul>

<p><strong>npm Packages Used:</strong></p>
<ul>
  <li><a href="https://github.com/nodeca/pako">pako</a> - Deflate compression</li>
  <li><a href="https://github.com/101arrowz/fzstd">fzstd</a> - Zstandard for JavaScript</li>
  <li><a href="https://github.com/nicbarker/kiwi">kiwi-schema</a> - Kiwi binary format decoder</li>
</ul>

<hr />

<p><em>Have a Figma Make file you can’t crack? Questions about the binary format? <a href="#" onclick="task1(); return false;">Get in touch</a> or open an issue on <a href="https://github.com/albertsikkema/figma-make-extractor">GitHub</a>.</em></p>]]></content><author><name>Albert Sikkema</name></author><category term="AI" /><category term="development" /><category term="tools" /><category term="reverse-engineering" /><summary type="html"><![CDATA[How I reverse-engineered Figma Make's binary format to extract complete React applications. A deep dive into ZIP archives, Kiwi schemas, and dual compression algorithms.]]></summary></entry><entry><title type="html">Dictator: A Push-to-Talk Speech-to-Text App for macOS</title><link href="https://www.albertsikkema.com/python/development/macos/productivity/local-ai/2026/01/17/dictator-speech-to-text-macos-app.html" rel="alternate" type="text/html" title="Dictator: A Push-to-Talk Speech-to-Text App for macOS" /><published>2026-01-17T00:00:00+00:00</published><updated>2026-01-17T00:00:00+00:00</updated><id>https://www.albertsikkema.com/python/development/macos/productivity/local-ai/2026/01/17/dictator-speech-to-text-macos-app</id><content type="html" xml:base="https://www.albertsikkema.com/python/development/macos/productivity/local-ai/2026/01/17/dictator-speech-to-text-macos-app.html"><![CDATA[<p>I built <a href="https://github.com/albertsikkema/dictator">Dictator</a>, a simple push-to-talk speech-to-text app for macOS. Hold a key, speak, release, and the transcribed text gets pasted wherever your cursor is.</p>

<p>The name is a play on words. “Dictation” because that’s what it does. “Dictator” because that’s how I sometimes view my relationship with this computer—I tell it what to do. And maybe a small nod to the times we live in.</p>

<p>I know, there are a lot of speech-to-text solutions out there, but I wanted something that was:</p>

<ul>
  <li><strong>100% Local</strong>: All processing happens on-device using whisper.cpp—your audio never leaves your Mac</li>
  <li><strong>No Internet Required</strong>: Works completely offline once installed</li>
  <li><strong>Fast</strong>: Metal GPU acceleration for near-instant transcription on Apple Silicon</li>
  <li><strong>Free</strong>: no hidden costs, no subscriptions</li>
  <li><strong>Lightweight</strong>: Minimal resource usage, stays out of your way</li>
  <li><strong>Non-obtrusive</strong>: Lives quietly in your menu bar, not the dock</li>
  <li><strong>Easy to Use</strong>: Just hold a hotkey to record, release to transcribe and paste</li>
  <li><strong>Push-to-talk</strong>: Natural workflow—hold to speak, release to transcribe</li>
  <li><strong>Visual Feedback</strong>: Icon animates (red → orange → yellow) based on audio level</li>
  <li><strong>Configurable</strong>: Choose your preferred hotkey (Right Option, Right Command, Left Option, or Left Command)</li>
  <li><strong>Auto-start</strong>: Option to launch at login</li>
  <li><strong>Self-contained</strong>: Model bundled in the app (no external dependencies)</li>
</ul>

<p>And also important: I built it for myself to improve my productivity when writing code.</p>

<p>And since I was working on a client app that needs very sophisticated audio input handling, I figured why not use what I just learned to make something useful for myself and others?</p>

<figure>
  <img src="/assets/images/sm7-side.jpg" alt="Shure SM7 microphone, a classic dynamic microphone used for voice recording" />
  <figcaption>The venerable classic Shure SM7... Great microphone, however I do not use it. Just my inbuilt Macbook mics.</figcaption>
</figure>

<h2 id="how-it-works">How It Works</h2>

<ol>
  <li>Hold your configured hotkey (default: Right Option)</li>
  <li>Speak</li>
  <li>Release the hotkey</li>
  <li>Text appears at your cursor</li>
</ol>

<p>The menu bar icon animates based on your voice volume so you know it’s hearing you. All transcription happens locally using <a href="https://github.com/ggerganov/whisper.cpp">whisper.cpp</a> with Metal acceleration—no cloud, no API keys.</p>

<p><strong>Note:</strong> English only for now.</p>

<h2 id="installation">Installation</h2>

<p>Grab the <code class="language-plaintext highlighter-rouge">.dmg</code> from the <a href="https://github.com/albertsikkema/dictator/releases">releases page</a>, drag the app to Applications, right-click and select “Open”. Grant microphone and accessibility permissions when prompted, and you’re set.</p>

<h3 id="macos-security">macOS Security</h3>

<p>This app is not signed with an Apple Developer certificate, so macOS will show security warnings. This is normal for open-source apps distributed outside the App Store.</p>

<p>The app is safe—you can review the source code yourself. All audio processing happens locally on your Mac. No data is sent to any servers.</p>

<p>If right-click → Open doesn’t work:</p>

<ol>
  <li>Go to <strong>System Settings → Privacy &amp; Security</strong></li>
  <li>Scroll down to the Security section</li>
  <li>You’ll see a message about Dictator being blocked—click <strong>Open Anyway</strong></li>
  <li>Confirm by clicking <strong>Open</strong> in the dialog</li>
</ol>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/albertsikkema/dictator">Dictator on GitHub</a></li>
  <li><a href="https://github.com/ggerganov/whisper.cpp">whisper.cpp</a></li>
</ul>

<hr />

<p><em>Questions or feedback? <a href="#" onclick="task1(); return false;">Get in touch</a>.</em></p>]]></content><author><name>Albert Sikkema</name></author><category term="python" /><category term="development" /><category term="macos" /><category term="productivity" /><category term="local-ai" /><summary type="html"><![CDATA[Dictator is a lightweight macOS menu bar app that converts speech to text using whisper.cpp with GPU acceleration. Push-to-talk, instant paste, no cloud required.]]></summary></entry><entry><title type="html">Rethinking Claude Flow: From Per-Repo Chaos to Global App</title><link href="https://www.albertsikkema.com/ai/development/productivity/python/2026/01/13/rethinking-claude-flow-from-per-repo-chaos-to-global-app.html" rel="alternate" type="text/html" title="Rethinking Claude Flow: From Per-Repo Chaos to Global App" /><published>2026-01-13T00:00:00+00:00</published><updated>2026-01-13T00:00:00+00:00</updated><id>https://www.albertsikkema.com/ai/development/productivity/python/2026/01/13/rethinking-claude-flow-from-per-repo-chaos-to-global-app</id><content type="html" xml:base="https://www.albertsikkema.com/ai/development/productivity/python/2026/01/13/rethinking-claude-flow-from-per-repo-chaos-to-global-app.html"><![CDATA[<p>A few weeks ago I mentioned I was building <a href="/development/productivity/python/2026/01/04/choosing-development-ports-that-dont-conflict.html">Claude Flow</a>—a kanban board UI for my <a href="https://github.com/albertsikkema/claude-config-template">claude-config-template</a>. The basic idea: visual task tracking for Claude workflows, hooks that update tasks in real-time, and a cleaner way to manage slash commands. Of course, I had to build it myself because no existing tool fit the bill. Manly because I wanted tight integration with the commands I was already using, and I learned to trust.</p>

<p>The initial implementation worked. Each repo got its own Claude Flow instance with a random port and local database. This created an immediate problem: hooks broke constantly because they couldn’t find the server. Dynamic ports meant hooks had no stable target. Multiple databases meant no central view of tasks across projects.</p>

<p>So long story short: I rebuilt it into a central app with multi-repo support. One server on a fixed port, one database with repo-aware schema, and hooks that always work. Here’s how I did it, the trade-offs, and what I learned.</p>

<figure>
  <img src="/assets/images/claude-flow.png" alt="Claude Flow kanban board interface showing task columns with multi-repo architecture" />
  <figcaption>Claude-Flow in use</figcaption>
</figure>

<h2 id="the-per-repo-problem">The Per-Repo Problem</h2>

<p>My first attempt followed the per repo pattern: install Claude Flow in each repo. Launch it per-repo with an available port. And store tasks in a local SQLite database inside the repo.</p>

<p>This seemed reasonable initially:</p>
<ul>
  <li>Isolated: Each project has its own task database</li>
  <li>Simple: No coordination between repos needed</li>
  <li>Portable: run the install script, everything comes with it</li>
</ul>

<p>In practice, it was a mess:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>repo-A/.claude/claude-flow/  (port 52341)
repo-B/.claude/claude-flow/  (port 52387)
repo-C/.claude/claude-flow/  (port 51993)
</code></pre></div></div>

<p>The hooks need to post updates to the backend—new task created, task completed, artifact saved. But which port? The hooks run in <code class="language-plaintext highlighter-rouge">.claude/hooks/</code> and get triggered by Claude Code. They have no idea what port the UI launched on. I tried writing the port to a file, reading it from hooks. That did not work!</p>

<p>Also: switching between repos meant launching different instances, losing context. Want to see all your tasks across projects? Too bad. Each database only knows about one repo.</p>

<h2 id="the-global-app-solution">The Global App Solution</h2>

<p>The fix was obvious from the beginning, but I was hoping not to go their, afraid of too much complexity. But in retrospect this was the right step: stop treating Claude Flow as a per-repo tool. Make it a global system-level app.</p>

<p>New architecture:</p>
<ul>
  <li><strong>One app</strong> running on fixed port 9118</li>
  <li><strong>One database</strong> at <code class="language-plaintext highlighter-rouge">~/Library/Application Support/claude-flow/database.db</code></li>
  <li><strong>Repo-aware schema</strong> with <code class="language-plaintext highlighter-rouge">repo_id</code> column on every task</li>
  <li><strong>Hooks always target</strong> <code class="language-plaintext highlighter-rouge">localhost:9118</code> (why? See my previous post on <a href="/development/productivity/python/2026/01/04/choosing-development-ports-that-dont-conflict.html">choosing development ports that don’t conflict</a>)</li>
</ul>

<p>Now hooks just work. They don’t need to discover ports. The backend accepts tasks from any repo, tags them with <code class="language-plaintext highlighter-rouge">repo_id</code>, and stores everything centrally. The frontend shows a repo selector dropdown—pick which project you want to view.</p>

<h2 id="implementation-details">Implementation Details</h2>

<p>The refactor touched a lot. These were the main changes:</p>

<h3 id="database-schema">Database Schema</h3>

<p>Added <code class="language-plaintext highlighter-rouge">repo_id</code> to track which repo each task belongs to:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">TaskDB</span><span class="p">(</span><span class="n">Base</span><span class="p">):</span>
    <span class="n">__tablename__</span> <span class="o">=</span> <span class="s">"tasks"</span>

    <span class="nb">id</span> <span class="o">=</span> <span class="n">Column</span><span class="p">(</span><span class="n">Integer</span><span class="p">,</span> <span class="n">primary_key</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">index</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">repo_id</span> <span class="o">=</span> <span class="n">Column</span><span class="p">(</span><span class="n">String</span><span class="p">(</span><span class="mi">500</span><span class="p">),</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="n">index</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">title</span> <span class="o">=</span> <span class="n">Column</span><span class="p">(</span><span class="n">String</span><span class="p">(</span><span class="mi">500</span><span class="p">),</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">description</span> <span class="o">=</span> <span class="n">Column</span><span class="p">(</span><span class="n">Text</span><span class="p">)</span>
    <span class="n">status</span> <span class="o">=</span> <span class="n">Column</span><span class="p">(</span><span class="n">String</span><span class="p">(</span><span class="mi">50</span><span class="p">),</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="c1"># ... other fields
</span></code></pre></div></div>

<p>Also added a <code class="language-plaintext highlighter-rouge">RepoDB</code> table for tracking registered repositories (name, path, last active timestamp).</p>

<h3 id="api-endpoints">API Endpoints</h3>

<p>All task endpoints now filter by <code class="language-plaintext highlighter-rouge">repo_id</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">router</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"/tasks"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">get_tasks</span><span class="p">(</span><span class="n">repo_id</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="n">Query</span><span class="p">(</span><span class="bp">None</span><span class="p">),</span> <span class="n">db</span><span class="p">:</span> <span class="n">Session</span> <span class="o">=</span> <span class="n">Depends</span><span class="p">(</span><span class="n">get_db</span><span class="p">)):</span>
    <span class="n">query</span> <span class="o">=</span> <span class="n">db</span><span class="p">.</span><span class="n">query</span><span class="p">(</span><span class="n">TaskDB</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">repo_id</span><span class="p">:</span>
        <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nb">filter</span><span class="p">(</span><span class="n">TaskDB</span><span class="p">.</span><span class="n">repo_id</span> <span class="o">==</span> <span class="n">repo_id</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">query</span><span class="p">.</span><span class="n">order_by</span><span class="p">(</span><span class="n">TaskDB</span><span class="p">.</span><span class="n">created_at</span><span class="p">.</span><span class="n">desc</span><span class="p">()).</span><span class="nb">all</span><span class="p">()</span>
</code></pre></div></div>

<p>New repo management endpoints let the frontend list repos, add new ones, remove old ones. The backend auto-registers repos when hooks first post from them—reduces setup friction.</p>

<h3 id="fixed-port">Fixed Port</h3>

<p>The desktop app now always uses port 9118:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">port</span> <span class="o">=</span> <span class="mi">9118</span>  <span class="c1"># Fixed for hook compatibility
</span>
<span class="c1"># Check if already running
</span><span class="k">try</span><span class="p">:</span>
    <span class="n">response</span> <span class="o">=</span> <span class="n">requests</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="sa">f</span><span class="s">"http://localhost:</span><span class="si">{</span><span class="n">port</span><span class="si">}</span><span class="s">/health"</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">response</span><span class="p">.</span><span class="n">status_code</span> <span class="o">==</span> <span class="mi">200</span><span class="p">:</span>
        <span class="n">webbrowser</span><span class="p">.</span><span class="nb">open</span><span class="p">(</span><span class="sa">f</span><span class="s">"http://localhost:</span><span class="si">{</span><span class="n">port</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
        <span class="k">return</span>
<span class="k">except</span><span class="p">:</span>
    <span class="k">pass</span>

<span class="c1"># Start server
</span><span class="n">uvicorn</span><span class="p">.</span><span class="n">run</span><span class="p">(</span><span class="n">app</span><span class="p">,</span> <span class="n">host</span><span class="o">=</span><span class="s">"0.0.0.0"</span><span class="p">,</span> <span class="n">port</span><span class="o">=</span><span class="n">port</span><span class="p">)</span>
</code></pre></div></div>

<p>If you launch it twice, the second instance detects the running server and just opens your browser to the existing app. Nice UX, avoids port conflicts. Or you can just launch the ‘Claude Flow’ from applications.</p>

<h3 id="desktop-app-wrapper">Desktop App Wrapper</h3>

<p>Claude Flow runs as a native desktop app using <a href="https://pywebview.flowrl.com/">pywebview</a>. Even though <a href="https://www.electronjs.org/">Electron</a> was the obvious choice—it’s what VS Code, Slack, and Discord use—I went with pywebview for one reason: bundle size.</p>

<p>Electron ships an entire Chromium browser and Node.js runtime. That’s 100-200MB minimum, even for a simple app. PyWebView uses your system’s native webview (WebKit on macOS, Edge WebView2 on Windows, GTK on Linux), adding only ~5MB. Smalll, fast –&gt; Good enough for me.</p>

<p>Instead of opening tabs in your default browser, pywebview creates a standalone window that feels like a proper application:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">webview</span>
<span class="kn">import</span> <span class="nn">threading</span>

<span class="c1"># Start FastAPI in background thread
</span><span class="n">server_thread</span> <span class="o">=</span> <span class="n">threading</span><span class="p">.</span><span class="n">Thread</span><span class="p">(</span><span class="n">target</span><span class="o">=</span><span class="n">start_server</span><span class="p">,</span> <span class="n">daemon</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
<span class="n">server_thread</span><span class="p">.</span><span class="n">start</span><span class="p">()</span>

<span class="c1"># Create native window
</span><span class="n">webview</span><span class="p">.</span><span class="n">create_window</span><span class="p">(</span><span class="s">"Claude Flow"</span><span class="p">,</span> <span class="sa">f</span><span class="s">"http://localhost:</span><span class="si">{</span><span class="n">port</span><span class="si">}</span><span class="s">"</span><span class="p">,</span> <span class="n">width</span><span class="o">=</span><span class="mi">1200</span><span class="p">,</span> <span class="n">height</span><span class="o">=</span><span class="mi">800</span><span class="p">)</span>
<span class="n">webview</span><span class="p">.</span><span class="n">start</span><span class="p">()</span>
</code></pre></div></div>

<p>This gives you:</p>
<ul>
  <li><strong>Native window controls</strong>: Minimize, maximize, close work as expected</li>
  <li><strong>Menu bar integration</strong>: On macOS, it appears in the dock like any other app</li>
  <li><strong>No browser chrome</strong>: No address bar, bookmarks, or extensions cluttering the interface</li>
  <li><strong>Better resource isolation</strong>: Separate from your browser’s memory footprint</li>
</ul>

<p>PyWebView uses your system’s native webview (WebKit on macOS, Edge WebView2 on Windows, GTK on Linux), so it’s lightweight and platform-appropriate. The FastAPI backend runs in a background thread while the window displays the React frontend.</p>

<h3 id="frontend-repo-selector">Frontend Repo Selector</h3>

<p>Added a dropdown to the header that loads repos from <code class="language-plaintext highlighter-rouge">/api/repos</code>, lets you switch between them, and filters task display:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">fetchRepos</span> <span class="o">=</span> <span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">API_BASE</span><span class="p">}</span><span class="s2">/api/repos`</span><span class="p">);</span>
  <span class="k">return</span> <span class="k">await</span> <span class="nx">response</span><span class="p">.</span><span class="nx">json</span><span class="p">();</span>
<span class="p">};</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">fetchTasks</span> <span class="o">=</span> <span class="k">async</span> <span class="p">(</span><span class="nx">repoId</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">API_BASE</span><span class="p">}</span><span class="s2">/api/tasks?repo_id=</span><span class="p">${</span><span class="nx">repoId</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
  <span class="k">return</span> <span class="k">await</span> <span class="nx">response</span><span class="p">.</span><span class="nx">json</span><span class="p">();</span>
<span class="p">};</span>
</code></pre></div></div>

<p>When you add a new repo through the UI, Claude Flow does something useful: it automatically scaffolds the <code class="language-plaintext highlighter-rouge">.claude/</code> directory structure, helper functions, and <code class="language-plaintext highlighter-rouge">thoughts/</code> folder into that repo. If they already exist, you can update them to the latest version with one click. This makes onboarding new projects trivial—add the repo path, Claude Flow sets up the structure, and hooks start working immediately.</p>

<p>No more copying configuration between repos or forgetting to add the hooks directory. The UI handles it, and if I update the template structure later, existing repos can pull in changes without manual file copying. “Latest iteration of my madness” as a service.</p>

<h3 id="global-config-location">Global Config Location</h3>

<p>The <code class="language-plaintext highlighter-rouge">.env</code> file moved from per-repo to global:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/Library/Application Support/claude-flow/.env
</code></pre></div></div>

<p>Set your <code class="language-plaintext highlighter-rouge">OPENAI_API_KEY</code> once, works everywhere.</p>

<h2 id="trade-offs">Trade-offs</h2>

<p>This architecture isn’t perfect. Some downsides:</p>

<p><strong>Shared state</strong>: All tasks in one database. Deleted database means losing task history across all repos. Regular backups matter more. Then again, this is only for local development tasks, not a shared Jira instance. So no real problem.</p>

<p><strong>Port conflicts</strong>: If something else uses 9118, you’re stuck. Could make it configurable, but haven’t needed to yet.</p>

<p>The benefits outweigh these. Hooks work reliably. Single source of truth for tasks. One app to manage, not one per repo.</p>

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

<p><strong>Pick your architecture early, but do not let it hold you back. You can always refactor</strong>: I started with per-repo because that was easy. Later I ran into problems and adjusted. Could I have foreseen this? Yes. Would I have finished this if I had thought all steps through? Probably not.</p>

<p><strong>Fixed infrastructure beats dynamic discovery</strong>: Random ports felt clever—avoid conflicts automatically! But they add complexity everywhere downstream. Fixed port 9118 is simpler, more reliable, and conflicts are rare. So if you can simplify, do it.</p>

<p><strong>Multi-repo support isn’t that hard</strong>: Adding <code class="language-plaintext highlighter-rouge">repo_id</code> filtering throughout the codebase was straightforward. <a href="https://www.sqlalchemy.org/">SQLAlchemy</a> made schema changes painless. <a href="https://fastapi.tiangolo.com/">FastAPI</a>’s dependency injection kept endpoint code clean. Good tools make refactors easier.</p>

<p><strong>Auto-registration reduces friction</strong>: When hooks first POST from a new repo, the backend registers it automatically. No manual setup. Removes a step the user (as in I) would forget and then get confused about.</p>

<h2 id="try-it-if-you-dare">Try It (if you dare)</h2>

<p>Claude Flow isn’t released yet—still in the “works on my machine” phase. I do not know if there will be an official release: the goal is to help me and my specific ideas on how to work with LLM in development. I do not plan on making it easy and accessible. There will documentation, but not polished user experience.</p>

<p>For now, the architecture lessons apply broadly: global apps with multi-tenant filtering often beat per-instance isolation. Fixed infrastructure beats dynamic discovery. Auto-registration beats manual setup.</p>

<p>If you’re building tools that integrate with Claude Code or other AI-assisted development workflows, consider these patterns. They saved me from per-repo chaos.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/BloopAI/vibe-kanban">Vibe Kanban</a> - Inspiration for Claude integration</li>
  <li><a href="https://github.com/your-repo/auto-claude">Auto Claude</a> - Automated Claude integration tool</li>
  <li><a href="https://docs.sqlalchemy.org/en/20/orm/">SQLAlchemy ORM</a> - Python database toolkit</li>
  <li><a href="https://fastapi.tiangolo.com/">FastAPI Documentation</a> - Modern Python web framework</li>
  <li><a href="https://www.anthropic.com/engineering/claude-code-best-practices">Claude Code Best Practices</a> - Official engineering guide</li>
</ul>

<hr />

<p><em>Building tools for AI-assisted development? I’d love to hear what patterns you’ve found useful—<a href="#" onclick="task1(); return false;">get in touch</a>.</em></p>]]></content><author><name>Albert Sikkema</name></author><category term="AI" /><category term="development" /><category term="productivity" /><category term="python" /><summary type="html"><![CDATA[How I refactored Claude Flow from per-repo instances with broken hooks to a single global app with multi-repo support. FastAPI, SQLAlchemy, and lessons learned about architecture decisions.]]></summary></entry><entry><title type="html">Nginx Login Rate Limiting: Stop Brute-Force Attacks Without Breaking UX</title><link href="https://www.albertsikkema.com/security/nginx/devops/best-practices/2026/01/05/nginx-login-rate-limiting-brute-force-protection.html" rel="alternate" type="text/html" title="Nginx Login Rate Limiting: Stop Brute-Force Attacks Without Breaking UX" /><published>2026-01-05T00:00:00+00:00</published><updated>2026-01-05T00:00:00+00:00</updated><id>https://www.albertsikkema.com/security/nginx/devops/best-practices/2026/01/05/nginx-login-rate-limiting-brute-force-protection</id><content type="html" xml:base="https://www.albertsikkema.com/security/nginx/devops/best-practices/2026/01/05/nginx-login-rate-limiting-brute-force-protection.html"><![CDATA[<figure>
  <img src="/assets/images/padlock_gate.jpg" alt="Padlock on a metal gate representing controlled access and rate limiting for login protection" />
  <figcaption>This is what AI thinks an image for this blog should look like... Photo by <a href="https://www.pexels.com/photo/closeup-photography-of-white-gate-with-brass-colored-padlock-846288/">B. Tran</a></figcaption>
</figure>

<p>While setting up self-hosted analytics and error tracking (<a href="https://umami.is/">Umami</a> and <a href="https://glitchtip.com/">GlitchTip</a>) on my NixOS server, I needed to protect login endpoints from brute-force attacks. The standard approach is straightforward: add nginx rate limiting.</p>

<h2 id="the-simple-solution-rate-limit-everything">The Simple Solution: Rate Limit Everything</h2>

<p>So to protect your site’s login, a first step is something like this—a general zone that limits all requests:</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">limit_req_zone</span> <span class="nv">$binary_remote_addr</span> <span class="s">zone=general:10m</span> <span class="s">rate=30r/s</span><span class="p">;</span>

<span class="k">location</span> <span class="n">/</span> <span class="p">{</span>
    <span class="kn">limit_req</span> <span class="s">zone=general</span> <span class="s">burst=20</span> <span class="s">nodelay</span><span class="p">;</span>
    <span class="kn">proxy_pass</span> <span class="s">http://backend</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This works fine for general protection, however there is a balance. This setting is too strict for normal use (‘/’ means all pages and endpoints are subjected to this rate limit regime). So we need more (a lot) room for regular traffic but that means login pages are allowed to be called way more as well, we need stricter limits specifically for login endpoints. A brute-force attacker making for instance 30 requests per second can try 1,800 passwords per minute. That’s way too permissive.</p>

<p>So the next step was to create a stricter zone for <code class="language-plaintext highlighter-rouge">/login</code>:</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">limit_req_zone</span> <span class="nv">$binary_remote_addr</span> <span class="s">zone=login:10m</span> <span class="s">rate=5r/m</span><span class="p">;</span>

<span class="k">location</span> <span class="n">/login</span> <span class="p">{</span>
    <span class="kn">limit_req</span> <span class="s">zone=login</span> <span class="s">burst=3</span> <span class="s">nodelay</span><span class="p">;</span>
    <span class="kn">proxy_pass</span> <span class="s">http://backend</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Five requests per minute. That should stop brute-force attacks cold. Combined with fail2ban to block persistent offenders, this is a solid defense.</p>

<h2 id="the-problem-you-just-broke-the-login-page">The Problem: You Just Broke the Login Page</h2>

<p>Here’s what that strict rate limiting actually does:</p>

<figure>
  <img src="/assets/images/nginx-rate-limiting-simple.png" alt="Flowchart showing simple rate limiting where ALL HTTP methods hit the strict 5r/m login zone, causing GET requests to be rate limited too" />
  <figcaption>The naive approach: all requests hit the strict limit—including GET requests to view the login form</figcaption>
</figure>

<p>Every request to <code class="language-plaintext highlighter-rouge">/login</code> counts against the limit. User visits the login page? That’s one. Types wrong password and page reloads? That’s two. Hits refresh because the page looks stuck? Three, four, five—<strong>blocked</strong>.</p>

<p>Now your legitimate users are locked out of even <em>seeing</em> the login form. You’ve stopped brute-force attacks by making the login page unusable for everyone.</p>

<h2 id="the-fix-only-rate-limit-what-matters">The Fix: Only Rate Limit What Matters</h2>

<p>Login pages handle two distinct operations:</p>

<ol>
  <li><strong>GET</strong> - Display the login form (no credentials transmitted)</li>
  <li><strong>POST</strong> - Submit credentials (the actual attack vector)</li>
</ol>

<p>Attackers don’t care about loading the form—they’re hammering POST requests with credential combinations. GET requests are harmless. So why rate limit them at all? (Users should be able to refresh the login page as much as they want, within reason.)</p>

<p>The solution uses nginx’s <a href="https://nginx.org/en/docs/http/ngx_http_map_module.html">map directive</a> to create a conditional rate limit key:</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">map</span> <span class="nv">$request_method</span> <span class="nv">$login_limit_key</span> <span class="p">{</span>
    <span class="kn">GET</span>     <span class="s">""</span><span class="p">;</span>                      <span class="c1"># Empty key = no rate limit</span>
    <span class="kn">default</span> <span class="nv">$binary_remote_addr</span><span class="p">;</span>     <span class="c1"># Rate limit all other methods</span>
<span class="p">}</span>
<span class="k">limit_req_zone</span> <span class="nv">$login_limit_key</span> <span class="s">zone=login:10m</span> <span class="s">rate=5r/m</span><span class="p">;</span>
</code></pre></div></div>

<p>When the key is empty, nginx skips rate limiting entirely. Users can refresh the login page as much as they want. But POST requests (and PUT/PATCH/DELETE for defense in depth) get limited to 5 per minute per IP.</p>

<p>Here’s the complete flow with HTTP method detection:</p>

<figure>
  <img src="/assets/images/nginx-rate-limiting-flow.png" alt="Flowchart showing nginx rate limiting logic: GET requests go through the general zone at 30r/s to show the login form, POST requests hit the strict login zone at 5r/m, rejected requests return 429, and persistent violators get blocked by fail2ban" />
  <figcaption>The improved approach: GET uses the lenient general zone, POST/PATCH/etc. hit the strict login zone</figcaption>
</figure>

<h2 id="why-not-just-post">Why Not Just POST?</h2>

<p>You could limit only POST requests specifically. But I prefer limiting all methods except GET—a “deny by default” approach that aligns with zero-trust security principles:</p>

<ul>
  <li><strong>Security-first</strong>: Block everything, then explicitly allow what’s safe (GET). If you forget to block a method, it’s already blocked</li>
  <li><strong>Future-proofing</strong>: If your app adds alternative auth methods (PUT for API tokens, PATCH for password updates), they’re automatically protected</li>
  <li><strong>Non-standard clients</strong>: Some HTTP clients behave unexpectedly</li>
  <li><strong>Zero overhead</strong>: The performance difference is negligible</li>
</ul>

<p>The worst case with POST-only limiting is an attacker using PUT instead—and slipping through. With “deny by default,” you’ve already blocked it.</p>

<h2 id="the-full-nixos-configuration">The Full NixOS Configuration</h2>

<p>Here’s my actual configuration running in production:</p>

<div class="language-nix highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">services</span><span class="o">.</span><span class="nv">nginx</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nv">appendHttpConfig</span> <span class="o">=</span> <span class="s2">''</span><span class="err">
</span><span class="s2">    # Standard rate limiting zone for general API protection</span><span class="err">
</span><span class="s2">    limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;</span><span class="err">
</span><span class="s2">    limit_req_status 429;  # Return 429 instead of 503</span><span class="err">

</span><span class="s2">    # Login endpoint rate limiting - all methods EXCEPT GET</span><span class="err">
</span><span class="s2">    map $request_method $login_limit_key {</span><span class="err">
</span><span class="s2">      GET     "";</span><span class="err">
</span><span class="s2">      default $binary_remote_addr;</span><span class="err">
</span><span class="s2">    }</span><span class="err">
</span><span class="s2">    limit_req_zone $login_limit_key zone=login:10m rate=5r/m;</span><span class="err">
</span><span class="s2">  ''</span><span class="p">;</span>

  <span class="nv">virtualHosts</span><span class="o">.</span><span class="s2">"app.example.com"</span> <span class="o">=</span> <span class="p">{</span>
    <span class="c"># Stricter rate limiting for login endpoint</span>
    <span class="nv">locations</span><span class="o">.</span><span class="s2">"/api/auth/login"</span> <span class="o">=</span> <span class="p">{</span>
      <span class="nv">proxyPass</span> <span class="o">=</span> <span class="s2">"http://127.0.0.1:8080"</span><span class="p">;</span>
      <span class="nv">extraConfig</span> <span class="o">=</span> <span class="s2">''</span><span class="err">
</span><span class="s2">        # Nginx locations are mutually exclusive - this location won't inherit</span><span class="err">
</span><span class="s2">        # rate limits from "/". We need both zones here.</span><span class="err">
</span><span class="s2">        limit_req zone=login burst=3 nodelay;  # Strict for POST/PUT/PATCH/DELETE</span><span class="err">
</span><span class="s2">        limit_req zone=api burst=20 nodelay;   # Standard limit for GET</span><span class="err">
</span><span class="s2">        limit_conn conn_limit 5;</span><span class="err">
</span><span class="s2">      ''</span><span class="p">;</span>
    <span class="p">};</span>

    <span class="c"># General API rate limiting for all other endpoints</span>
    <span class="nv">locations</span><span class="o">.</span><span class="s2">"/"</span> <span class="o">=</span> <span class="p">{</span>
      <span class="nv">proxyPass</span> <span class="o">=</span> <span class="s2">"http://127.0.0.1:8080"</span><span class="p">;</span>
      <span class="nv">extraConfig</span> <span class="o">=</span> <span class="s2">''</span><span class="err">
</span><span class="s2">        limit_req zone=api burst=100 nodelay;</span><span class="err">
</span><span class="s2">        limit_conn conn_limit 50;</span><span class="err">
</span><span class="s2">      ''</span><span class="p">;</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">};</span>
</code></pre></div></div>

<h3 id="watch-out-location-mutual-exclusivity">Watch Out: Location Mutual Exclusivity</h3>

<p>Here’s something to keep in mind: nginx locations are <strong>mutually exclusive</strong>. When a request matches <code class="language-plaintext highlighter-rouge">/login</code>, it does NOT inherit rate limits from the <code class="language-plaintext highlighter-rouge">/</code> location. If you have a general rate limit on <code class="language-plaintext highlighter-rouge">/</code> and expect it to apply to <code class="language-plaintext highlighter-rouge">/login</code> too, that is not how it works.</p>

<p>From the <a href="https://nginx.org/en/docs/http/ngx_http_core_module.html#location">nginx documentation</a>: once a location is selected, only that location’s directives apply. This means login endpoints need their own complete rate limiting configuration—both the strict login zone AND any general API rate limiting you want.</p>

<p>The key settings:</p>

<table>
  <thead>
    <tr>
      <th>Setting</th>
      <th>Value</th>
      <th>Why</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">rate=5r/m</code></td>
      <td>5 requests/minute</td>
      <td>Strict but allows retries for typos</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">burst=3</code></td>
      <td>3 extra requests</td>
      <td>Buffer for legitimate quick retries</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">nodelay</code></td>
      <td>Immediate rejection</td>
      <td>Don’t queue—fail fast</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">limit_req_status 429</code></td>
      <td>HTTP 429</td>
      <td>Proper “Too Many Requests” response</td>
    </tr>
  </tbody>
</table>

<p>The <code class="language-plaintext highlighter-rouge">nodelay</code> option is important. Without it, nginx queues excess requests and processes them at the rate limit. With <code class="language-plaintext highlighter-rouge">nodelay</code>, requests beyond the burst are immediately rejected. For login endpoints, you want fast feedback—don’t make attackers wait.</p>

<h2 id="finding-the-right-endpoint">Finding the Right Endpoint</h2>

<p>Do make sure you are limiting the correct endpoint, so avoid rate limiting <code class="language-plaintext highlighter-rouge">/login</code> when the actual authentication happens at <code class="language-plaintext highlighter-rouge">/api/auth/login</code>.</p>

<p>Different applications use different endpoints:</p>

<table>
  <thead>
    <tr>
      <th>Application</th>
      <th>Actual Login Endpoint</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Umami</strong></td>
      <td><code class="language-plaintext highlighter-rouge">/api/auth/login</code></td>
    </tr>
    <tr>
      <td><strong>GlitchTip</strong></td>
      <td><code class="language-plaintext highlighter-rouge">/login</code></td>
    </tr>
    <tr>
      <td><strong>Django (allauth)</strong></td>
      <td><code class="language-plaintext highlighter-rouge">/accounts/login/</code></td>
    </tr>
    <tr>
      <td><strong>FastAPI (typical)</strong></td>
      <td><code class="language-plaintext highlighter-rouge">/api/auth/login</code> or <code class="language-plaintext highlighter-rouge">/login</code></td>
    </tr>
  </tbody>
</table>

<p>So test if you have the right endpoint.</p>

<h2 id="layered-defense-with-fail2ban">Layered Defense with fail2ban</h2>

<p>Rate limiting is your first line of defense. But what about persistent attackers who keep trying after hitting the limit?</p>

<p>Enter <a href="https://github.com/fail2ban/fail2ban">fail2ban</a>. It monitors nginx’s error log for rate limit violations and bans repeat offenders at the firewall level:</p>

<div class="language-nix highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">services</span><span class="o">.</span><span class="nv">fail2ban</span><span class="o">.</span><span class="nv">jails</span><span class="o">.</span><span class="nv">nginx-limit-req</span> <span class="o">=</span> <span class="s2">''</span><span class="err">
</span><span class="s2">  enabled = true</span><span class="err">
</span><span class="s2">  port = http,https</span><span class="err">
</span><span class="s2">  backend = systemd</span><span class="err">
</span><span class="s2">  journalmatch = _SYSTEMD_UNIT=nginx.service + _COMM=nginx</span><span class="err">
</span><span class="s2">  maxretry = 5</span><span class="err">
</span><span class="s2">  bantime = 1h</span><span class="err">
</span><span class="s2">''</span><span class="p">;</span>
</code></pre></div></div>

<p>The escalation path:</p>
<ol>
  <li>First few violations → nginx returns 429</li>
  <li>5+ violations → fail2ban blocks the IP entirely for an hour</li>
</ol>

<p>This prevents attackers from even consuming nginx resources after repeated attempts.</p>

<h2 id="the-websocket-exception">The WebSocket Exception</h2>

<p>One gotcha: some applications use WebSocket for authentication. <a href="https://github.com/louislam/uptime-kuma">Uptime Kuma</a>, for example, uses Socket.io for its login flow. nginx HTTP rate limiting can’t protect WebSocket connections.</p>

<p>For these cases:</p>
<ul>
  <li><strong>Enable 2FA</strong> (essential—this is your primary protection)</li>
  <li>Use connection limiting (<code class="language-plaintext highlighter-rouge">limit_conn conn_limit 5</code>)</li>
  <li>Strong, unique passwords</li>
  <li>fail2ban if the application logs failed attempts</li>
</ul>

<h2 id="testing-your-setup">Testing Your Setup</h2>

<p>Before congratulating yourself, verify it actually works:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Should see 401/403 for first ~4 requests, then 429</span>
<span class="k">for </span>i <span class="k">in</span> <span class="o">{</span>1..8<span class="o">}</span><span class="p">;</span> <span class="k">do
  </span><span class="nv">code</span><span class="o">=</span><span class="si">$(</span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code}"</span> <span class="nt">-X</span> POST <span class="se">\</span>
    https://app.example.com/api/auth/login <span class="se">\</span>
    <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
    <span class="nt">-d</span> <span class="s1">'{"username":"test","password":"test"}'</span><span class="si">)</span>
  <span class="nb">echo</span> <span class="s2">"Request </span><span class="nv">$i</span><span class="s2">: HTTP </span><span class="nv">$code</span><span class="s2">"</span>
<span class="k">done</span>
</code></pre></div></div>

<p>Expected output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Request 1: HTTP 401
Request 2: HTTP 401
Request 3: HTTP 401
Request 4: HTTP 401
Request 5: HTTP 429
Request 6: HTTP 429
...
</code></pre></div></div>

<p>And verify GET isn’t affected:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Should never see 429</span>
<span class="k">for </span>i <span class="k">in</span> <span class="o">{</span>1..10<span class="o">}</span><span class="p">;</span> <span class="k">do
  </span><span class="nv">code</span><span class="o">=</span><span class="si">$(</span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code}"</span> <span class="nt">-X</span> GET https://app.example.com/login<span class="si">)</span>
  <span class="nb">echo</span> <span class="s2">"GET </span><span class="nv">$i</span><span class="s2">: HTTP </span><span class="nv">$code</span><span class="s2">"</span>
<span class="k">done</span>
</code></pre></div></div>

<h2 id="when-not-to-use-this">When Not to Use This</h2>

<p>This pattern works well for traditional form-based login, but consider alternatives for:</p>

<ul>
  <li><strong>Shared IP environments</strong>: This setup limits by IP address. If many users share the same IP (corporate networks, universities, mobile carriers with CGNAT), one user’s failed attempts can lock out everyone. For public-facing apps with diverse users, consider rate limiting by username instead, or use a combination of both.</li>
  <li><strong>API token authentication</strong>: Rate limit the token generation endpoint, but tokens themselves should be validated per-request without rate limiting</li>
  <li><strong>OAuth flows</strong>: The redirect dance makes simple rate limiting tricky—consider rate limiting the callback URL instead</li>
  <li><strong>High-traffic public APIs</strong>: You’ll need more sophisticated rate limiting (by user, by endpoint, tiered limits)</li>
</ul>

<p>For self-hosted applications where you control the user base, IP-based limiting works well. For public apps with users behind corporate firewalls or mobile networks, you’ll want something smarter.</p>

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

<p>My Umami analytics and GlitchTip error tracking now have proper brute-force protection. Legitimate users can refresh the login page freely. Attackers get rate limited after 5 attempts, and blocked entirely after persistent abuse.</p>

<p>Total configuration: about 20 lines of NixOS config. Time to implement: 30 minutes.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://blog.nginx.org/blog/rate-limiting-nginx">nginx Rate Limiting</a> - Official NGINX blog with detailed examples</li>
  <li><a href="https://nginx.org/en/docs/http/ngx_http_limit_req_module.html">ngx_http_limit_req_module</a> - Official module documentation</li>
  <li><a href="https://github.com/fail2ban/fail2ban/blob/master/config/filter.d/nginx-limit-req.conf">fail2ban nginx-limit-req filter</a> - Built-in filter for nginx rate limit violations</li>
  <li><a href="https://nginx.org/en/docs/http/ngx_http_map_module.html">nginx map directive</a> - For conditional rate limiting</li>
  <li><a href="https://umami.is/">Umami</a> - Privacy-focused, self-hosted web analytics</li>
  <li><a href="https://glitchtip.com/">GlitchTip</a> - Open-source error tracking and uptime monitoring</li>
</ul>

<hr />

<p><em>How do you handle login rate limiting? Found edge cases I didn’t cover? I’d love to hear about it—<a href="#" onclick="task1(); return false;">get in touch</a>.</em></p>]]></content><author><name>Albert Sikkema</name></author><category term="security" /><category term="nginx" /><category term="devops" /><category term="best-practices" /><summary type="html"><![CDATA[Protect your login endpoints from brute-force attacks using nginx rate limiting. Learn which HTTP methods to limit, how to configure fail2ban integration, and NixOS examples.]]></summary></entry><entry><title type="html">Choosing Development Ports That Don’t Conflict</title><link href="https://www.albertsikkema.com/development/productivity/python/2026/01/04/choosing-development-ports-that-dont-conflict.html" rel="alternate" type="text/html" title="Choosing Development Ports That Don’t Conflict" /><published>2026-01-04T00:00:00+00:00</published><updated>2026-01-04T00:00:00+00:00</updated><id>https://www.albertsikkema.com/development/productivity/python/2026/01/04/choosing-development-ports-that-dont-conflict</id><content type="html" xml:base="https://www.albertsikkema.com/development/productivity/python/2026/01/04/choosing-development-ports-that-dont-conflict.html"><![CDATA[<figure>
  <img src="/assets/images/ports.jpg" alt="Close-up of network switch ports with glowing LED indicators and connected ethernet cables" />
  <figcaption>This is what AI thinks an image for this blog should look like... Photo by <a href="https://www.pexels.com/photo/close-up-photo-of-network-switch-2881227/">Brett Sayles</a></figcaption>
</figure>

<p>I’ve been working on extending my <a href="https://github.com/albertsikkema/claude-config-template">claude-config-template</a> with a simple UI. The idea was to make it easier to manage Claude workflows - visual task boards, quick command access, that sort of thing. I looked at existing solutions like <a href="https://github.com/AndyMik90/Auto-Claude">Auto-Claude</a> and <a href="https://github.com/BloopAI/vibe-kanban">Vibe Kanban</a>, and while they’re interesting projects, neither quite fit what I needed.</p>

<p>Auto-Claude focuses on autonomous multi-agent orchestration - running a lot of agents in parallel with isolated workspaces. Interesting, but in my experience handling more than 1 complicated and 1 simple process at the same time is taxing my capabilities. Also it is quite new, so there was some trouble getting it to work (I failed). I looked at the code and the errors and there is a lot of AI generated stuff in there, including the common slop that seems to hinder a lot of current projects. So then Vibe Kanban came on my radar: it is a kanban board for managing AI coding agents. Also great, but I wanted something that integrated tightly with my existing slash commands and documentation structure without adopting a new paradigm.</p>

<p>So I started building my own. A few hours later, I had a FastAPI backend and a simple frontend working. Then I got on a sidequest: choosing ports for the backend and frontend servers that wouldn’t conflict with anything else on my machine. This is surprisingly tricky, so I decided to document my findings.</p>

<h2 id="the-port-problem">The Port Problem</h2>

<p>My backend defaulted to 8000. Fastapi uses <code class="language-plaintext highlighter-rouge">uvicorn</code> which defaults to port 8000. So far no problem. But the idea is to import and install this project in other environments as well, so I wanted to avoid common ports. Quite annoying if you install this and first you’ll have to sort a port conflict. Same goes for 5173 (Vite) or 3000 (React, Next.js). And there are many more common ports that developers use for various frameworks and databases. So what is a safe choice? Lets dive in.</p>

<h2 id="the-three-port-ranges">The Three Port Ranges</h2>

<p>Ports aren’t just random numbers. They’re <a href="https://www.iana.org/assignments/service-names-port-numbers">organized by IANA</a> (Internet Assigned Numbers Authority):</p>

<table>
  <thead>
    <tr>
      <th>Range</th>
      <th>Name</th>
      <th>Use</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0-1023</td>
      <td>Well-Known</td>
      <td>System services (HTTP, SSH). Require root.</td>
    </tr>
    <tr>
      <td>1024-49151</td>
      <td>Registered</td>
      <td>Can be registered with IANA. Safe for dev.</td>
    </tr>
    <tr>
      <td>49152-65535</td>
      <td>Dynamic/Ephemeral</td>
      <td>Temporary connections. Avoid for servers.</td>
    </tr>
  </tbody>
</table>

<p>Conclusion 1: stick to the 1024-49151 range for development servers. Seems plenty of room there.</p>

<h2 id="ports-you-should-avoid">Ports You Should Avoid</h2>

<p>Then the ports that are commonly used by a lot of stuff: these were out of the question, since they <em>will</em> conflict with something on your machine:</p>

<figure>
  <img src="/assets/images/common-dev-ports.png" alt="Diagram showing common development ports organized by category: Development Frameworks (3000-8080), Databases (3306-27017), and Other Services (5672-9092)" />
  <figcaption>Common development ports you'll want to avoid when picking your own</figcaption>
</figure>

<p>The macOS AirPlay thing on port 5000 was new to me (never use airplay or Flask) but i found quite some stuff about it. Apparently you’ll see “address already in use” and spend 20 minutes debugging your Flask app before realizing it’s your laptop’s screen sharing feature. It was some time ago I used Flask, but do not remember this conflict back then. Anyway, avoid 5000 on macOS.</p>

<h2 id="so-what-should-you-use">So What Should You Use?</h2>

<p>After going through <a href="https://www.iana.org/assignments/service-names-port-numbers">IANA registrations</a> and <a href="https://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers">Wikipedia’s comprehensive list</a> (especially the Wikipedia page is a treasure trove, I ran into protocols and games I only heard once of a long time ago), I learned something important: there’s no such thing as a truly “free” port. Every port in the registered range has <em>something</em> using it somewhere.</p>

<p>Take the memorable patterns I initially gravitated toward:</p>
<ul>
  <li><strong>5678</strong> is the default for <a href="https://n8n.io/">n8n</a>, the workflow automation tool</li>
  <li><strong>8765</strong> is used by <a href="https://gun.eco/">GUN</a> relay peers—decentralized storage that the Internet Archive uses for censorship-resistant mirroring</li>
</ul>

<p>But here’s the thing: how many developers run n8n or GUN relay peers locally during active development? Probably fewer than those running React, Vite, or a database. The goal isn’t finding a port that’s never been used by anything—it’s finding one that’s <em>unlikely</em> to conflict with tools in your daily workflow.</p>

<p><strong>An even safer option</strong> (slightly lower chance of conflict):</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Backend:  9118
Frontend: 8119
</code></pre></div></div>
<p>Technically these aren’t completely free either: 9118 is the <a href="https://github.com/prometheus/prometheus/wiki/Default-port-allocations">Prometheus Jenkins exporter</a>, and 8119 is used by <a href="https://ports.macports.org/port/macos-fortress-proxy/details/">macos-fortress-proxy</a> for CSS blocking. But these are monitoring tools, not something you’d typically run on a development machine. Less memorable than the sequential patterns, but about as safe as you’ll find.</p>

<p><strong>Truly unused</strong> (if you want zero documented uses):</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Backend:  9143
Frontend: 6234
</code></pre></div></div>
<p>To find these, I searched <a href="https://www.iana.org/assignments/service-names-port-numbers">IANA’s registry</a> for unassigned port ranges, then verified each candidate against <a href="https://www.speedguide.net/">SpeedGuide’s port database</a> and Google to check for unofficial uses. Port 9143 is in the unassigned 9132-9159 range, and 6234 is in the unassigned 6223-6240 range. Neither has any registered service—official or unofficial—that I could find. Not memorable at all, but if you want guaranteed zero conflicts, here you go.</p>

<p>I’m sticking with 9118/8119—the memorable patterns are worth the tiny risk (and I do run <a href="https://n8n.io/">n8n</a> locally sometimes). But now please do not pick these ports for <em>your</em> projects, otherwise we’ll have conflicts again!</p>

<h2 id="what-im-building">What I’m Building</h2>

<p>Back to the actual project: a simple UI layer for my <a href="https://github.com/albertsikkema/claude-config-template">claude-config-template</a>. The idea is straightforward - a local web interface that makes it easier to manage Claude workflows without memorizing slash commands or digging through documentation.</p>

<p>The backend (FastAPI on port 8765) handles the orchestration logic I already built for the template. The frontend (Vite on port 5678) provides a visual interface for:</p>
<ul>
  <li>Launching slash commands with a click instead of typing</li>
  <li>Viewing task progress and agent output in real-time</li>
  <li>Managing the thoughts directory and documentation structure</li>
  <li>Quick access to plans, research, and project context</li>
</ul>

<p>Nothing fancy. No multi-agent swarms or parallel execution complexity. Just a thin UI layer that makes my existing workflow more accessible. Sometimes simple tools that fit your workflow beat sophisticated ones that don’t.</p>

<p>I’ll write more about the implementation once it’s stable enough to share. The repo is archived for now while I clean things up, but I’ll unarchive it soon with a proper release post. For now, at least the ports won’t conflict with anything.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://www.iana.org/assignments/service-names-port-numbers">IANA Service Name and Transport Protocol Port Number Registry</a> - The official list</li>
  <li><a href="https://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers">List of TCP and UDP port numbers - Wikipedia</a> - More readable reference</li>
  <li><a href="https://www.speedguide.net/ports.php">SpeedGuide Port Database</a> - Search by port number</li>
</ul>

<hr />

<p><em>What ports do you use for development? Found other conflicts I didn’t mention? I’d love to hear about it—<a href="#" onclick="task1(); return false;">get in touch</a>.</em></p>]]></content><author><name>Albert Sikkema</name></author><category term="development" /><category term="productivity" /><category term="python" /><summary type="html"><![CDATA[A practical guide to selecting port numbers for development servers that won't clash with common services. Avoid port 3000, 5000, and 8000 - here's what to use instead.]]></summary></entry><entry><title type="html">The Orchestrator: Automating Full Claude Code Workflows</title><link href="https://www.albertsikkema.com/ai/llm/development/productivity/2025/11/21/orchestrator-automating-claude-code-workflows.html" rel="alternate" type="text/html" title="The Orchestrator: Automating Full Claude Code Workflows" /><published>2025-11-21T00:00:00+00:00</published><updated>2025-11-21T00:00:00+00:00</updated><id>https://www.albertsikkema.com/ai/llm/development/productivity/2025/11/21/orchestrator-automating-claude-code-workflows</id><content type="html" xml:base="https://www.albertsikkema.com/ai/llm/development/productivity/2025/11/21/orchestrator-automating-claude-code-workflows.html"><![CDATA[<p>I’ve been using my <a href="https://github.com/albertsikkema/claude-config-template">claude-config-template</a> for several months now, and one friction point kept bothering me: manually running slash commands in sequence. Research, plan, implement, review - it’s the right workflow, but it’s tedious when you know exactly what needs to happen. So I built an orchestrator.</p>

<h2 id="why-now">Why Now?</h2>

<p>I knew this kind of automation was possible from the start, but I deliberately waited. The underlying tools - the slash commands, the agents, the workflows - needed to prove themselves first. After give or take six months of daily use, testing edge cases, fixing bugs, and refining prompts, I trust them enough to let them run without supervision.</p>

<p>Are they perfect? Far from it. But they’re reliable enough for routine tasks. And that’s the threshold for automation: not perfection, but predictable behavior. When I know what the tools will do in common scenarios, I can confidently chain them together.</p>

<figure>
  <img src="/assets/images/orchestrator1.webp" alt="Orchestrator automating Claude Code workflow" />
  <figcaption>This is what AI thinks an image for this blog should look like...</figcaption>
</figure>

<h2 id="the-problem-with-manual-orchestration">The Problem with Manual Orchestration</h2>

<p>If you’ve read my <a href="/ai/tools/productivity/2025/10/14/supercharge-claude-code-with-custom-configuration.html">previous post about the config template</a>, you know I use a structured workflow: research the codebase, create a plan, implement it, then review. This pattern works well, but it requires babysitting. Each slash command might ask questions. You need to wait for completion. Copy file paths between steps.</p>

<p>For routine tasks - adding a feature you’ve spec’d out, fixing a well-understood bug - this manual coordination adds unnecessary overhead. What I wanted was to say “do this task” and come back to reviewed code.</p>

<h2 id="the-nesting-problem">The Nesting Problem</h2>

<p>Here’s the challenge: Claude Code already has subagents. So why not just create an orchestrator agent that spawns other agents? Because of a fundamental limitation in Claude’s architecture: <strong>subagents cannot spawn other subagents</strong>.</p>

<p>This is probably by design, to limit resource usage. But it means you can’t build a meta-agent in Claude that coordinates other agents which might themselves need to spawn agents. The codebase-researcher agent, for instance, uses multiple specialized subagents internally. An orchestrator built as a Claude agent couldn’t call it without breaking the nesting rule.</p>

<p>The solution? Use an external LLM as the orchestrator. It doesn’t live inside Claude’s context, so it can spawn Claude Code instances without violating any nesting constraints.</p>

<h2 id="how-it-works">How It Works</h2>

<p>The orchestrator (<code class="language-plaintext highlighter-rouge">claude-helpers/orchestrator.py</code>) is a Python script that chains Claude Code slash commands together:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv run claude-helpers/orchestrator.py <span class="s2">"Add user authentication with JWT tokens"</span>
</code></pre></div></div>

<p>This kicks off five sequential operations:</p>

<ol>
  <li><strong><code class="language-plaintext highlighter-rouge">/index_codebase</code></strong> - Map out the project structure</li>
  <li><strong><code class="language-plaintext highlighter-rouge">/research_codebase</code></strong> - Investigate existing patterns and relevant code</li>
  <li><strong><code class="language-plaintext highlighter-rouge">/create_plan</code></strong> - Generate an implementation plan</li>
  <li><strong><code class="language-plaintext highlighter-rouge">/implement_plan</code></strong> - Execute the plan</li>
  <li><strong><code class="language-plaintext highlighter-rouge">/code_reviewer</code></strong> - Review the changes</li>
</ol>

<p>Each step feeds into the next. The orchestrator extracts file paths from Claude’s output and passes them as context to subsequent commands. When Claude asks questions (which it does, especially during planning), the orchestrator uses OpenAI’s API to generate appropriate responses.</p>

<h2 id="why-openai">Why OpenAI?</h2>

<p>You might wonder why not use Claude for the orchestration decisions too. Two reasons:</p>

<ol>
  <li><strong>No nesting constraint</strong> - OpenAI doesn’t have the same architectural limitation, so it can coordinate without restrictions</li>
  <li><strong>Different strengths</strong> - The orchestrator needs to parse Claude’s output and make quick decisions about how to respond. It doesn’t need Claude’s deep reasoning or large context window</li>
</ol>

<p>The orchestrator supports both OpenAI and Azure OpenAI. It reads configuration from <code class="language-plaintext highlighter-rouge">.env.claude</code> and auto-detects which service to use based on which environment variables are set.</p>

<h2 id="practical-usage">Practical Usage</h2>

<p>For a straightforward feature:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv run claude-helpers/orchestrator.py <span class="s2">"Add rate limiting to the API endpoints"</span>
</code></pre></div></div>

<p>For exploratory work where you want to review the plan before committing:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv run claude-helpers/orchestrator.py <span class="nt">--no-implement</span> <span class="s2">"Redesign the caching layer"</span>
</code></pre></div></div>

<p>For CI/CD integration:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv run claude-helpers/orchestrator.py <span class="nt">--json</span> <span class="s2">"Fix the failing payment tests"</span> <span class="o">&gt;</span> result.json
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">--json</code> flag structures the output for parsing. The <code class="language-plaintext highlighter-rouge">--no-implement</code> flag stops after the planning phase - useful when you want human review before the LLM touches code.</p>

<h2 id="implementation-details">Implementation Details</h2>

<p>The script is built with <code class="language-plaintext highlighter-rouge">uv</code> for dependency management. First run automatically installs dependencies - no setup required. It uses subprocess to spawn Claude Code instances and streams output in real-time to stderr, so you see progress as it happens.</p>

<p>One design choice: the orchestrator is intentionally simple. It doesn’t try to be clever about which steps to skip or how to parallelize. It runs the full workflow every time. Why? Because the overhead of making smart decisions often exceeds the cost of just doing the work. And consistency matters - I always want research before planning, always want review after implementation.</p>

<h2 id="when-not-to-use-it">When Not to Use It</h2>

<p>The orchestrator is for routine tasks where the workflow is predictable. For exploratory work, complex debugging, or tasks requiring back-and-forth discussion, you still want to run commands manually. The human in the loop isn’t just about reviewing output - it’s about steering the process when things get complicated.</p>

<p>Also, be aware of cost. Running five Claude Code commands per task adds up. For small fixes, it’s probably overkill. For substantial features where you’d run those commands anyway, it saves time without changing cost much.</p>

<h2 id="whats-next">What’s Next</h2>

<p>The main question now is reliability. How often does the orchestrator produce usable results without intervention? I need to track this systematically - not just “it works” but failure modes, edge cases, and where human review catches issues the automation missed.</p>

<p>The code review step is particularly interesting. As I wrote in my <a href="/ai/llm/development/best-practices/2025/11/14/human-in-the-loop-ai-code-review.html">previous post about human-in-the-loop review</a>, LLM-generated reviews miss certain classes of problems - especially meta-level issues like tests that test whether tests exist. A colleague spotted that instantly; multiple LLM passes missed it entirely. So how do I make the automated review more effective? Different prompting? Multiple review passes with different perspectives?</p>

<p>I’m hesitant to add complexity before understanding the baseline. The value is in automation, not optimization. A simple tool that runs reliably beats a complex one that sometimes does the wrong thing.</p>

<h2 id="try-it-out">Try It Out</h2>

<p>The orchestrator is included in the latest version of <a href="https://github.com/albertsikkema/claude-config-template">claude-config-template</a>. After installation:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Make sure you have uv installed</span>
curl <span class="nt">-LsSf</span> https://astral.sh/uv/install.sh | sh

<span class="c"># Set up your OpenAI (or Azure OpenAI) credentials in .env.claude</span>
<span class="nb">echo</span> <span class="s2">"OPENAI_API_KEY=your-key"</span> <span class="o">&gt;&gt;</span> .env.claude

<span class="c"># Run it</span>
uv run claude-helpers/orchestrator.py <span class="s2">"Your task description"</span>
</code></pre></div></div>

<p>The pattern of research→plan→implement→review has served me well. Now it runs without me having to shepherd each step. That’s the kind of automation that actually helps.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://docs.anthropic.com/en/docs/claude-code/sub-agents">Claude Code Subagents Documentation</a> - Official docs on subagent capabilities and limitations</li>
  <li><a href="https://www.anthropic.com/engineering/claude-code-best-practices">Claude Code Best Practices</a> - Anthropic’s engineering guide</li>
  <li><a href="https://github.com/albertsikkema/claude-config-template">claude-config-template</a> - The full configuration system</li>
  <li><a href="https://cookbook.openai.com/">OpenAI Cookbook</a> - Examples and guides for building with OpenAI</li>
  <li><a href="https://ai.pydantic.dev/">PydanticAI</a> - Agent framework for building production-ready AI applications</li>
</ul>

<hr />

<p><em>Are you automating your AI-assisted workflows? What patterns have you found useful? I’d love to hear about your experiences—<a href="#" onclick="task1(); return false;">get in touch</a>.</em></p>]]></content><author><name>Albert Sikkema</name></author><category term="AI" /><category term="LLM" /><category term="development" /><category term="productivity" /><summary type="html"><![CDATA[Automate complete AI-assisted development workflows with an orchestrator that chains Claude Code commands. Learn how to build production-ready AI agent orchestration systems.]]></summary></entry></feed>