<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jules Robineau</title>
    <description>The latest articles on DEV Community by Jules Robineau (@julesrobineau).</description>
    <link>https://dev.to/julesrobineau</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4033931%2F6c7e37e2-5d4d-486b-81c8-591c267775cd.jpg</url>
      <title>DEV Community: Jules Robineau</title>
      <link>https://dev.to/julesrobineau</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/julesrobineau"/>
    <language>en</language>
    <item>
      <title>The French Tax Leak Started With a Password</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Fri, 04 Sep 2026 12:08:27 +0000</pubDate>
      <link>https://dev.to/julesrobineau/the-french-tax-leak-started-with-a-password-9f2</link>
      <guid>https://dev.to/julesrobineau/the-french-tax-leak-started-with-a-password-9f2</guid>
      <description>&lt;p&gt;On 18 August 2026, the French state apologized to its taxpayers. Unknown actors had been able to read the tax records of 678,000 people and businesses. The entry point: stolen credentials, from one agency employee and one authorized third party.&lt;/p&gt;

&lt;p&gt;No zero-day. No sophisticated state attack. Passwords.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: France Travail, the family-benefits agency, the national ID agency, then the tax administration. None of the big French public-sector breaches needed an advanced attack. The rules had existed since 2014, with no budget and no authority. And do not read this as a state problem. In France, 48% of recorded ransomware victims are micro and small companies. Their flaws are exactly the same. The pressure protecting big companies reaches neither the small ones nor the public sector. Money only arrives after the incident. The missing security is not complex. It is neither funded nor demanded.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is an outside post-mortem. I have never worked for these agencies. I secure private systems that face exactly the same attacks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two years of leaks, one per major agency
&lt;/h2&gt;

&lt;p&gt;March 2024: France Travail, the national employment agency. The CNIL, France's data protection authority, established that data of 36.8 million people was exfiltrated. Names, social security numbers, contact details.&lt;/p&gt;

&lt;p&gt;2024 then 2025: the CAF, the family-benefits agency. First 60,000 compromised accounts. Then a 22-million-row file, attributed to its ecosystem, circulated on forums.&lt;/p&gt;

&lt;p&gt;April 2026: the ANTS, the agency behind national ID cards and vehicle registration. 11.7 million accounts confirmed by the Interior Ministry. Almost one French person in six.&lt;/p&gt;

&lt;p&gt;June to August 2026: the DGFiP, the tax administration. Three intrusions, 678,000 individuals and businesses affected, tax and land-registry data. The same group claims an attack on the national education ministry.&lt;/p&gt;

&lt;p&gt;And the summer of 2026 came in bursts. INSEE in June: 12,800 staff exposed through an internal directory. Bloctel in August: 3 million phone numbers, the day after the service shut down. In late August, a forum post claimed the hack of a public housing-vacancy platform. That one is not confirmed yet.&lt;/p&gt;

&lt;p&gt;Add up these incidents alone: over 70 million records, without even deduplicating people. The question is no longer whether an agency leaks. It is which one, this week.&lt;/p&gt;

&lt;h2&gt;
  
  
  None of it is sophisticated
&lt;/h2&gt;

&lt;p&gt;Look at the published causes, not the volumes.&lt;/p&gt;

&lt;p&gt;The ANTS: an &lt;a href="https://en.wikipedia.org/wiki/Insecure_direct_object_reference" rel="noopener noreferrer"&gt;IDOR&lt;/a&gt;-type flaw, according to published analyses. That kind of flaw has been in the &lt;a href="https://en.wikipedia.org/wiki/OWASP" rel="noopener noreferrer"&gt;OWASP&lt;/a&gt; top 10 for years. It takes an hour to test on an endpoint.&lt;/p&gt;

&lt;p&gt;The DGFiP: stolen credentials, with no generalized second factor. Multi-factor authentication, MFA, asks for a second proof besides the password. It is the number one defense against credential theft.&lt;/p&gt;

&lt;p&gt;These are the first things I look for in a pentest. These are the first defenses I deploy on a mission. None of them is research. This is chapter 1 of any security guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules existed, the execution did not
&lt;/h2&gt;

&lt;p&gt;France did not discover security in 2026. The PSSIE dates from 2014. The &lt;a href="https://fr.wikipedia.org/wiki/R%C3%A9f%C3%A9rentiel_g%C3%A9n%C3%A9ral_de_s%C3%A9curit%C3%A9" rel="noopener noreferrer"&gt;RGS&lt;/a&gt; framework requires a security accreditation for public online services.&lt;/p&gt;

&lt;p&gt;So why the leaks? France's supreme audit court answered in 2025, before the tax incident. Its report describes interministerial steering without real authority, very limited means, and no effective evaluation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://cyber.gouv.fr/en" rel="noopener noreferrer"&gt;ANSSI&lt;/a&gt; advises and alerts, but rarely compels other administrations. And the transposition of &lt;a href="https://en.wikipedia.org/wiki/NIS_2_Directive" rel="noopener noreferrer"&gt;NIS 2&lt;/a&gt; fell behind schedule.&lt;/p&gt;

&lt;p&gt;A framework without budget or control produces paper accreditations. The document exists, the security does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why big companies moved first
&lt;/h2&gt;

&lt;p&gt;This is not about talent. Public teams have excellent engineers. It is about pressure.&lt;/p&gt;

&lt;p&gt;A big company has regulators that fine and insurers that demand. Its customers send security questionnaires before signing. At my enterprise clients, MFA everywhere, pipeline scans and access monitoring do not happen out of virtue. They happen because a contract depends on them.&lt;/p&gt;

&lt;p&gt;An administration loses no customers. The CNIL rarely fines the state itself. And a security budget is a cost with no ribbon to cut. A &lt;a href="https://en.wikipedia.org/wiki/Security_information_and_event_management" rel="noopener noreferrer"&gt;SIEM&lt;/a&gt; earns no inauguration.&lt;/p&gt;

&lt;p&gt;But watch what comes next. That pressure does not trickle down. It stops well before small companies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your SMEs have the same flaws as the state
&lt;/h2&gt;

&lt;p&gt;There is a trap in the story I just told. You could believe the private sector is protected and the state is behind. That is wrong. Only the pressured private sector is protected.&lt;/p&gt;

&lt;p&gt;Look at the numbers from ANSSI. In its 2025 threat panorama, 48% of recorded &lt;a href="https://en.wikipedia.org/wiki/Ransomware" rel="noopener noreferrer"&gt;ransomware&lt;/a&gt; victims are micro, small or mid-sized companies. It was 37% a year earlier.&lt;/p&gt;

&lt;p&gt;Small companies are the first category of victims. Ahead of local governments. Ahead of hospitals.&lt;/p&gt;

&lt;p&gt;ANSSI also measures the defense level. 74% of these companies sit below its "Essential" baseline. That level is not high security. It is the minimum the agency considers vital.&lt;/p&gt;

&lt;p&gt;Cybermalveillance.gouv.fr, the public assistance desk for victims, completes the picture. In 2025, &lt;a href="https://en.wikipedia.org/wiki/Phishing" rel="noopener noreferrer"&gt;phishing&lt;/a&gt; jumped 70%. Assistance requests from companies climbed 73%.&lt;/p&gt;

&lt;p&gt;One company in six suffered an incident during the year. Nearly a third of those incidents halt the activity. One in five ends in stolen data.&lt;/p&gt;

&lt;p&gt;And one gap sums it all up. Nine SMEs out of ten believe they are equipped. One out of two says it is prepared for an attack.&lt;/p&gt;

&lt;p&gt;In the field, I see the same thing. When I audit an SME, I find the DGFiP list again. Accounts without MFA. Third parties with too many rights. Exposed services. Nobody reading the logs. The flaws do not change with size. The pressure does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pressure stops at big companies
&lt;/h2&gt;

&lt;p&gt;Take the three drivers from the previous chapter: regulators, insurers, customers. None of the three reaches a twenty-person company.&lt;/p&gt;

&lt;p&gt;On the rules side, NIS 2 shows the hole. The directive moves France from about 500 regulated entities to 15,000. Real progress. But the entry ticket remains the medium company: 50 employees, or more than 10 million euros in revenue. Below that, you are out of scope.&lt;/p&gt;

&lt;p&gt;France counts about 4 million micro and small companies. The 15,000 NIS 2 entities are less than one in two hundred of them.&lt;/p&gt;

&lt;p&gt;And even that reduced scope is still waiting for its law. The European deadline was 17 October 2024. The French transposition bill, the Résilience law, was still not enacted by the summer of 2026. Two years late on obligations already voted in Brussels.&lt;/p&gt;

&lt;p&gt;Insurance and customers follow the same logic. Security questionnaires come with big contracts. Compliance audits come with large accounts. An SME selling to other SMEs never meets anyone who demands MFA.&lt;/p&gt;

&lt;p&gt;One mechanism is starting to trickle down: the supply chain. NIS 2 forces in-scope entities to secure their suppliers. If your customer is covered, they will come asking you for guarantees, sometimes an audit. It is the first pressure reaching small companies.&lt;/p&gt;

&lt;p&gt;The result: the state and small companies share the same blind spot. Nobody forces them. And since security is invisible, nobody asks for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Money does not arrive before the incident either
&lt;/h2&gt;

&lt;p&gt;Second half of the question: why does nobody put money in?&lt;/p&gt;

&lt;p&gt;Aid exists. Look at its scale. France Relance put 136 million euros into cybersecurity in 2021. Its core program, the security pathways, supported 945 entities in four years. Local governments, hospitals, public bodies. 945, when France alone counts more than 34,000 municipalities.&lt;/p&gt;

&lt;p&gt;For companies, MonAideCyber offers a free ninety-minute diagnosis. A good first step. Not an audit, let alone a hardening project. The Cyber PME program of France 2030 adds support, recent and still little known.&lt;/p&gt;

&lt;p&gt;Now compare with the state's wake-up call: 200 million unlocked within weeks, after the leaks. So the money exists. It just always arrives after.&lt;/p&gt;

&lt;p&gt;Why? Three reasons. None of them technical.&lt;/p&gt;

&lt;p&gt;One: security is an invisible expense. It produces nothing you can show. A marketing budget brings customers. A security budget brings incidents that did not happen. Nobody celebrates an avoided incident.&lt;/p&gt;

&lt;p&gt;Two: the cost of a leak does not fall on whoever decides the budget. When a company leaks, its customers pay first. Their data circulates, their accounts get attacked. Economists call this an externality: a cost you create and others pay. As long as leaking costs the company less than its victims, the math says do nothing.&lt;/p&gt;

&lt;p&gt;Three: imposing rules on small companies carries a political cost. Every new obligation reads as one more administrative burden. So we regulate the big ones, subsidize diagnoses, and wait.&lt;/p&gt;

&lt;p&gt;It is the same mechanism that left the PSSIE unfunded for twelve years. The state and the SMEs are not two stories. They are the same one.&lt;/p&gt;

&lt;h2&gt;
  
  
  2026, the wake-up call that finally sets the standard
&lt;/h2&gt;

&lt;p&gt;The plan came in three waves. And it looks like what the serious private sector already does.&lt;/p&gt;

&lt;p&gt;Mid-April 2026, an implementing decree of the SREN law turned a recommendation into an obligation. The state's most sensitive data must now sit on SecNumCloud-qualified clouds. SecNumCloud is ANSSI's qualification for trusted hosting providers. Around 360 requirements, up to immunity from extraterritorial laws like the &lt;a href="https://en.wikipedia.org/wiki/CLOUD_Act" rel="noopener noreferrer"&gt;CLOUD Act&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Late April 2026, after the ANTS: new governance and 200 million euros unlocked. Each ministry must reserve 5% of its digital budget for cybersecurity from 2027. Plus a doctrine: self-attack exercises, generalized MFA, hardened access.&lt;/p&gt;

&lt;p&gt;Mid-August, after the tax leak: MFA for all tax-administration staff by the end of 2026. Consultation quotas on sensitive files. Stronger anomaly detection. A bug bounty on state platforms. And an audit supervised by ANSSI.&lt;/p&gt;

&lt;p&gt;Measure the paradox. France already had the strictest hosting standard in Europe. But no generalized MFA for its own staff. The datacenter was armored, the account stayed open.&lt;/p&gt;

&lt;p&gt;Translate it into engineering terms: security enters the process, with a budget and controls. That is the definition of DevSecOps. Not one more tool at the end, a requirement at every step.&lt;/p&gt;

&lt;p&gt;It took 70 million rows to get there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this post-mortem gives your own system
&lt;/h2&gt;

&lt;p&gt;You do not run the state's IT. The lessons transpose anyway.&lt;/p&gt;

&lt;p&gt;And kill the "too small to matter" myth right away. Attackers do not pick names, they scan entire address ranges. Ransomware does not target your brand. It targets an open door.&lt;/p&gt;

&lt;p&gt;MFA first, everywhere, authorized third parties included. The tax attack came through a third-party account. Your perimeter includes the accounts you delegate.&lt;/p&gt;

&lt;p&gt;Then quotas and volume alerts. An account reading 678,000 files should ring an alarm long before the thousandth. No AI needed to start: a threshold is enough.&lt;/p&gt;

&lt;p&gt;Test for IDOR on every endpoint that carries an identifier. One hour of testing, years of embarrassment avoided.&lt;/p&gt;

&lt;p&gt;And the bug bounty comes last. Paying researchers to discover your missing MFA costs more than the MFA.&lt;/p&gt;

&lt;p&gt;Finally, price the work honestly. MFA is included in most of the suites you already pay for. Quotas and volume alerts are a few days of development. An open source SIEM like Wazuh runs on a modest server: I host one myself. The cost of the basics is counted in days of work, not in licenses. The project looks out of reach. It is not. That myth feeds the inaction too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist to transpose
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;For a public system or a small company alike.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;[ ] MFA everywhere, staff and authorized third parties included&lt;/li&gt;
&lt;li&gt;[ ] An inventory of third-party accounts, with review and expiry&lt;/li&gt;
&lt;li&gt;[ ] Consultation quotas and volume alerts on sensitive data&lt;/li&gt;
&lt;li&gt;[ ] An IDOR test on every endpoint exposing an identifier&lt;/li&gt;
&lt;li&gt;[ ] A SIEM centralizing the logs, and someone who reads them&lt;/li&gt;
&lt;li&gt;[ ] One self-attack exercise per year, results shown to leadership&lt;/li&gt;
&lt;li&gt;[ ] A security budget as a percentage of the IT budget, protected&lt;/li&gt;
&lt;li&gt;[ ] One person with the authority to say no to a release&lt;/li&gt;
&lt;li&gt;[ ] Supplier of a NIS 2 entity? Prepare the guarantees you will be asked for&lt;/li&gt;
&lt;li&gt;[ ] Starting from zero? Begin with the free MonAideCyber diagnosis&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;Security is not complexity. It is common sense, funded and enforced. The state just paid to learn it: its rules dated from 2014, its 200 million arrived after the leaks.&lt;/p&gt;

&lt;p&gt;SMEs will get no national wake-up call and no 200 million plan. The only pressure trickling down to them is the supply chain: NIS 2 customers demanding guarantees from their suppliers. It trickles, slowly.&lt;/p&gt;

&lt;p&gt;So do not wait for the law or the subsidy. MFA, quotas, volume alerts, the IDOR test: all of it deploys in weeks, not years. The 200 million was cheaper before. So is your cleanup.&lt;/p&gt;

&lt;p&gt;Want to know what an attacker would see in your system before they show you? &lt;a href="https://jrobineau.com/contact.html" rel="noopener noreferrer"&gt;Let's talk&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://www.economie.gouv.fr/actualites/direct-video-conference-de-presse-sur-le-suivi-de-la-reponse-la-cyberattaque-visant-la-direction-generale-des-finances-publiques-mardi-18" rel="noopener noreferrer"&gt;economie.gouv.fr, FAQ and 18 August 2026 press conference&lt;/a&gt; · &lt;a href="https://www.ccomptes.fr/fr/publications/la-reponse-de-letat-aux-cybermenaces-sur-les-systemes-dinformation-civils" rel="noopener noreferrer"&gt;Cour des comptes, "The state's response to cyber threats"&lt;/a&gt; · &lt;a href="https://www.usine-digitale.fr/cybersecurite/cyberattaque-de-la-dgfip-le-bilan-officiel-grimpe-a-678-000-victimes-loin-des-millions-de-donnees-revendiquees-par-le-hacker-sur-fond-de-retard-dans-la-transposition-de-nis-2.QTWDQXD65ZC6ZP46RTZ3H57C44.html" rel="noopener noreferrer"&gt;L'Usine Digitale&lt;/a&gt; · &lt;a href="https://www.journaldunet.com/cybersecurite/1553815-cyberattaque-de-la-dgfip-l-electrochoc-qui-accelere-la-securisation-des-administrations/" rel="noopener noreferrer"&gt;JDN&lt;/a&gt; · &lt;a href="https://www.silicon.fr/cybersecurite-1371/cyberattaque-dgfip-le-plan-de-riposte-qui-peine-a-convaincre-228740/amp" rel="noopener noreferrer"&gt;Silicon&lt;/a&gt; · &lt;a href="https://incyber.org/article/france-fuites-de-donnees-aout-2026-retenir/" rel="noopener noreferrer"&gt;INCYBER, "France's August 2026 leaks"&lt;/a&gt; · &lt;a href="https://acteurspublics.fr/articles/letat-clarifie-enfin-la-liste-des-entites-concernees-par-la-loi-sren/" rel="noopener noreferrer"&gt;Acteurs Publics, on the SREN decree and SecNumCloud&lt;/a&gt; · &lt;a href="https://www.cert.ssi.gouv.fr/uploads/CERTFR-2026-CTI-002.pdf" rel="noopener noreferrer"&gt;ANSSI, 2025 cyber threat panorama&lt;/a&gt; · &lt;a href="https://www.cybermalveillance.gouv.fr/tous-nos-contenus/actualites/rapport-activite-2025" rel="noopener noreferrer"&gt;Cybermalveillance.gouv.fr, 2025 activity report&lt;/a&gt; · &lt;a href="https://cyber.gouv.fr/actualites/bilan-du-volet-cybersecurite-de-france-relance-un-defi-reussi" rel="noopener noreferrer"&gt;ANSSI, France Relance cyber program results&lt;/a&gt; · &lt;a href="https://www.legiscope.com/blog/transposition-nis2-france.html" rel="noopener noreferrer"&gt;Legiscope, NIS 2 transposition in France&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Give Your Coding Agents Proof Obligations, Not Instructions</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Fri, 04 Sep 2026 06:03:00 +0000</pubDate>
      <link>https://dev.to/julesrobineau/give-your-coding-agents-proof-obligations-not-instructions-5hi</link>
      <guid>https://dev.to/julesrobineau/give-your-coding-agents-proof-obligations-not-instructions-5hi</guid>
      <description>&lt;p&gt;Six AI agents wrote a complete screen of my cockpit in two hours. That is not the interesting part.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: code generation is no longer the bottleneck. Proof is. A multi-agent workflow is only worth its verification harness. Mine demands four proofs: visual parity with the design, flows clicked in a real browser, green tests, an empty punch-list. Give your agents proof obligations. Not longer instructions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is for developers who use coding agents and want to ship with them, not just demo them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;My prospecting cockpit is a personal Go app. Server-side rendering with &lt;code&gt;html/template&lt;/code&gt;, htmx, PostgreSQL behind it.&lt;/p&gt;

&lt;p&gt;I needed one more full screen: list, detail view, creation, status edition. The mockup existed. A neighboring screen served as the code model.&lt;/p&gt;

&lt;p&gt;I orchestrated a multi-agent workflow: specialized AI agents chained together, each with one role. Backend, design, verification, tests. Six agents in total, about two hours.&lt;/p&gt;

&lt;p&gt;At the end: the store, the migration, the handlers, the template, the navigation and the tests, merged. But the credit does not go where people usually put it.&lt;/p&gt;

&lt;h2&gt;
  
  
  An instruction cannot be verified. A proof can
&lt;/h2&gt;

&lt;p&gt;"Build the screen" is an instruction. An agent always answers an instruction the same way: "done".&lt;/p&gt;

&lt;p&gt;Sometimes it is true. Sometimes it is plausible. And plausible is the real danger, because plausible survives a quick review.&lt;/p&gt;

&lt;p&gt;The fix is not writing longer prompts. It is changing the nature of the request: every agent gets a proof obligation, binary, checkable without asking the agent.&lt;/p&gt;

&lt;p&gt;For this screen there were four. The screen matches the design, in light and in dark theme. The four flows pass in a real browser. The test suite is green. The punch-list is empty.&lt;/p&gt;

&lt;p&gt;A punch-list is the list of remaining gaps, a term borrowed from construction. As long as it is not empty, it is not delivered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof 1: visual parity is measured, not declared
&lt;/h2&gt;

&lt;p&gt;The classic trap: the agent states the UI matches. Nobody actually compares.&lt;/p&gt;

&lt;p&gt;In my setup, a design inspector runs on a dedicated port. It shows the reference mockup next to the app's real rendering.&lt;/p&gt;

&lt;p&gt;The verification agent compares both, screen by screen, in both themes. Every gap goes into the punch-list: a spacing, a color, a shadow, an empty state.&lt;/p&gt;

&lt;p&gt;This run's verdict: approved, empty punch-list, light and dark. That verdict is a fact, not an agent's opinion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof 2: flows are clicked in a real browser
&lt;/h2&gt;

&lt;p&gt;Last week I wrote that a green E2E suite can lie when it asserts JSON instead of the DOM. The rule counts double for agent-written code.&lt;/p&gt;

&lt;p&gt;So the four flows of this screen were played in a real browser, against the real app. Add a target. Add a person. Change a status. Promote a record.&lt;/p&gt;

&lt;p&gt;No simulation, no direct API call. The form, the click, the render. What the user will see, checked the way the user sees it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof 3: tests remain the floor
&lt;/h2&gt;

&lt;p&gt;The classics keep their place, as an exit gate: &lt;code&gt;go build&lt;/code&gt;, &lt;code&gt;go vet&lt;/code&gt;, and the full suite. This run ended at 167 green tests.&lt;/p&gt;

&lt;p&gt;One detail that matters: the PostgreSQL store tests are gated behind an environment variable. Without a test database, they skip cleanly. The suite stays runnable everywhere, by a human or by an agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof 4: the environment is a written rule
&lt;/h2&gt;

&lt;p&gt;The workflow's first run did whatever it wanted. Launched a local binary by hand, tried to &lt;code&gt;pkill&lt;/code&gt; running processes.&lt;/p&gt;

&lt;p&gt;I corrected it mid-run, and the correction became a written rule of the harness. Never &lt;code&gt;pkill&lt;/code&gt;. Always rebuild and restart through docker compose. The demo dataset is idempotent, behind an environment variable.&lt;/p&gt;

&lt;p&gt;That is the real learning loop: every agent mistake becomes a harness rule. Not a better prompt. A written rule the next run cannot bypass.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it changes, and what it does not
&lt;/h2&gt;

&lt;p&gt;Human time does not disappear. It moves. I spend less time writing code, and more time defining proofs, arbitrating a punch-list, reviewing a diff.&lt;/p&gt;

&lt;p&gt;That is the right trade. Generation is fast everywhere now. Trust only comes from the harness.&lt;/p&gt;

&lt;p&gt;And the limits are real. This workflow worked because a reference mockup existed, and a neighboring screen provided the code model. Without an enforceable reference, the harness has nothing to compare, and the agent falls back to plausible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist before you launch an agent workflow
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Before handing a feature to agents, run this list. It is what separates a production tool from a demo generator.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;[ ] An enforceable reference exists: mockup, model screen, API contract&lt;/li&gt;
&lt;li&gt;[ ] Every agent has a binary proof obligation, not a vague goal&lt;/li&gt;
&lt;li&gt;[ ] Visual verification compares against the design, in both themes&lt;/li&gt;
&lt;li&gt;[ ] Critical flows are played in a real browser, with DOM assertions&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;build&lt;/code&gt;, &lt;code&gt;vet&lt;/code&gt; and tests are gates, not suggestions&lt;/li&gt;
&lt;li&gt;[ ] The environment is set by written rules: docker, idempotent seed, ports&lt;/li&gt;
&lt;li&gt;[ ] Every agent mistake becomes a harness rule, not a better prompt&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;Agents do not need better instructions. They need proof obligations.&lt;/p&gt;

&lt;p&gt;A screen shipped in two hours only has value if you can say why you trust it. In my case, the answer is four proofs, all checkable without asking the agent.&lt;/p&gt;

&lt;p&gt;Build the harness once. It serves every next feature, and it ages better than any prompt.&lt;/p&gt;

&lt;p&gt;Want to industrialize coding agents without shipping plausible? &lt;a href="https://jrobineau.com/contact.html" rel="noopener noreferrer"&gt;Let's talk&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources and related reading:&lt;/strong&gt; &lt;a href="https://jrobineau.com/blog/2026-08-28-green-tests-broken-product.html" rel="noopener noreferrer"&gt;Your test suite is green and your product is broken&lt;/a&gt; · &lt;a href="https://jrobineau.com/blog/2026-07-23-ai-agent-permissions-dev-vs-prod.html" rel="noopener noreferrer"&gt;My AI agent tried to delete my secrets&lt;/a&gt; · &lt;a href="https://news.ycombinator.com/item?id=47545748" rel="noopener noreferrer"&gt;Hacker News, "Some uncomfortable truths about AI coding agents"&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>testing</category>
      <category>productivity</category>
    </item>
    <item>
      <title>When Keycloak and Your Database Disagree About Who Exists</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Wed, 02 Sep 2026 06:03:00 +0000</pubDate>
      <link>https://dev.to/julesrobineau/when-keycloak-and-your-database-disagree-about-who-exists-1ako</link>
      <guid>https://dev.to/julesrobineau/when-keycloak-and-your-database-disagree-about-who-exists-1ako</guid>
      <description>&lt;p&gt;A user can exist in your database and not in your identity provider. Nothing crashes. Everything fails silently.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: a half-deleted account breaks four flows at once, without one useful error. Your code swallows the IdP's 404 on purpose, to block account enumeration. The repair ends in direct SQL, because the Keycloak admin API ignores the id you send. Prevention comes down to two things: a deletion order, and a tested invariant.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is for developers running Keycloak, or any IdP, next to an application &lt;code&gt;users&lt;/code&gt; table.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;An IdP, an identity provider, is the service that authenticates your users. Keycloak is one of the most deployed open source IdPs.&lt;/p&gt;

&lt;p&gt;On my personal SaaS, Keycloak owns authentication. My application database owns the business: profiles, tenants, contracts. The same user lives in two stores, linked by a UUID.&lt;/p&gt;

&lt;p&gt;I also design multi-region SSO for a healthcare platform with 25M+ users. The scale changes. This class of bug does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The drift: a partial cleanup
&lt;/h2&gt;

&lt;p&gt;One morning in May, a test account refuses to sign up. In the database: a &lt;code&gt;users&lt;/code&gt; row with its UUID. In Keycloak: nothing.&lt;/p&gt;

&lt;p&gt;The cause was a partial cleanup. A test-environment purge script had deleted accounts in one store without touching the other. The safety guard only protected one mail domain, not the second one.&lt;/p&gt;

&lt;p&gt;The first repair pass found 539 orphan accounts. That is not an edge case. That is a population.&lt;/p&gt;

&lt;p&gt;The diagnosis fits in two queries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Application database side&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'someone@example.com'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Keycloak side&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;user_entity&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'someone@example.com'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- A row on one side, nothing on the other: drift.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Four symptoms, none points at the cause
&lt;/h2&gt;

&lt;p&gt;An account in this state breaks four flows at the same time.&lt;/p&gt;

&lt;p&gt;The magic link returns 200 and sends nothing. A magic link is a login link sent by email, no password involved.&lt;/p&gt;

&lt;p&gt;Registration returns 500 on the first try. Then 409 on every retry, forever.&lt;/p&gt;

&lt;p&gt;Invitation acceptance can no longer resolve the user.&lt;/p&gt;

&lt;p&gt;And business rows stay attached to a UUID nobody can load anymore.&lt;/p&gt;

&lt;p&gt;Four symptoms, four different tickets. No shared stack trace. It is the worst kind of incident: the kind that does not look like one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it is invisible: your own security
&lt;/h2&gt;

&lt;p&gt;Why does no error surface? Because I wanted it that way.&lt;/p&gt;

&lt;p&gt;Account enumeration means guessing which emails have an account, by watching API responses. To block it, the API must answer the same thing whether the account exists or not.&lt;/p&gt;

&lt;p&gt;So my service swallows Keycloak's 404, deliberately. The magic link returns 200 in every case. The security I added destroyed my observability.&lt;/p&gt;

&lt;p&gt;The rule I keep: hide the error in the HTTP response, never in your logs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Same response for the client, account or no account.&lt;/span&gt;
&lt;span class="c"&gt;// The drift itself must stay visible to you.&lt;/span&gt;
&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;idp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserByEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrUserNotFound&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"idp user missing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"flow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"magic_link"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IdpUserMissing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Inc&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;respondOK&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// anti-enumeration: say nothing outside&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A swallowed 404 with no metric is incident debt. You will pay it back at the worst time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repair: the admin API ignores your id
&lt;/h2&gt;

&lt;p&gt;To repair, you must recreate the user in Keycloak with the same UUID. The business rows hang from it.&lt;/p&gt;

&lt;p&gt;Then, surprise. The Keycloak admin API ignores the &lt;code&gt;id&lt;/code&gt; field you send when creating a user. It generates its own. Your UUID cannot come back through the official door.&lt;/p&gt;

&lt;p&gt;That leaves the service door: SQL, straight into Keycloak's tables.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Recreate the user, keeping the application database UUID&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;user_entity&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;realm_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'same-uuid-as-your-db'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...);&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;user_attribute&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'same-uuid-as-your-db'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...);&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;user_role_mapping&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;role_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'same-uuid-as-your-db'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writing into Keycloak's database is a last resort, not a habit. Do it cold. Verify with &lt;code&gt;kcadm&lt;/code&gt; afterwards, then replay the full flow. In my case: magic link requested, token generated, email received.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prevention: an order and an invariant
&lt;/h2&gt;

&lt;p&gt;The repair is worth nothing without prevention. Three changes followed the incident.&lt;/p&gt;

&lt;p&gt;Account deletion became a saga. A saga is a sequence of ordered steps, where each step only runs if the previous one succeeded. In my case: Keycloak first, the database second. No script touches a single store anymore.&lt;/p&gt;

&lt;p&gt;An admin endpoint lists the drift: UUIDs present on one side and missing on the other. What was invisible becomes a list.&lt;/p&gt;

&lt;p&gt;And every purge ends with a count on both sides. If the numbers disagree, the purge lied.&lt;/p&gt;

&lt;p&gt;The general rule: consistency between two identity stores is an invariant to test. Not a convention to hope for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The anti-drift checklist
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;If you run an IdP and a users table, walk this list. It would have saved me one incident and 539 orphans.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;[ ] One deletion path only: the saga, never a direct script on a single store&lt;/li&gt;
&lt;li&gt;[ ] Each saga step is gated on the previous step's success&lt;/li&gt;
&lt;li&gt;[ ] Anti-enumeration masks the HTTP response, never the logs or metrics&lt;/li&gt;
&lt;li&gt;[ ] A metric counts swallowed IdP 404s, with an alert on it&lt;/li&gt;
&lt;li&gt;[ ] An endpoint or job lists the drift between the IdP and your database&lt;/li&gt;
&lt;li&gt;[ ] An integration test creates, deletes, then counts both sides&lt;/li&gt;
&lt;li&gt;[ ] The repair procedure preserves the UUID, and it is written before the incident&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;Two identity stores make a distributed system. With its promises, and its lies.&lt;/p&gt;

&lt;p&gt;The worst state is not the outage. It is the half-alive account, failing silently, protected by your own security.&lt;/p&gt;

&lt;p&gt;Test the invariant. Count both sides. And keep an internal trace of every 404 you swallow.&lt;/p&gt;

&lt;p&gt;Your IdP and your database telling different stories in production? &lt;a href="https://jrobineau.com/contact.html" rel="noopener noreferrer"&gt;Let's talk&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://www.keycloak.org/docs-api/latest/rest-api/index.html" rel="noopener noreferrer"&gt;Keycloak admin API (REST reference)&lt;/a&gt; · &lt;a href="https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/03-Identity_Management_Testing/04-Testing_for_Account_Enumeration_and_Guessable_User_Account" rel="noopener noreferrer"&gt;OWASP, account enumeration testing&lt;/a&gt; · &lt;a href="https://microservices.io/patterns/data/saga.html" rel="noopener noreferrer"&gt;The saga pattern (microservices.io)&lt;/a&gt;&lt;/p&gt;

</description>
      <category>keycloak</category>
      <category>security</category>
      <category>identity</category>
      <category>go</category>
    </item>
    <item>
      <title>Running Your Own Cluster Is Now Simpler Than Believing in PaaS</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Mon, 31 Aug 2026 06:03:00 +0000</pubDate>
      <link>https://dev.to/julesrobineau/running-your-own-cluster-is-now-simpler-than-believing-in-paas-20k1</link>
      <guid>https://dev.to/julesrobineau/running-your-own-cluster-is-now-simpler-than-believing-in-paas-20k1</guid>
      <description>&lt;p&gt;For ten years I have been sold the same future: the cloud will be cheaper, simpler, and need fewer ops. I am still waiting.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: the PaaS promise does not hold. Ops did not disappear, it changed names: FinOps, platform engineering, vendor management. The 2025 and 2026 outages dented the reliability argument. Real prices bury the cost argument. And AI just flipped the last one, simplicity: running your own cluster, assisted by an agent, is a reasonable choice again.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is for teams paying for a PaaS "to avoid doing ops". And for teams that do not dare to self-host.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I speak from
&lt;/h2&gt;

&lt;p&gt;A PaaS, a platform as a service, runs your code without you ever seeing servers. Heroku, App Service, Railway, Render.&lt;/p&gt;

&lt;p&gt;By day, I work on a healthcare platform with 25+ million users. Managed cloud, Kubernetes, proprietary services.&lt;/p&gt;

&lt;p&gt;By night, I self-host everything. A full platform on Docker Swarm, a k3s cluster carrying my mail server, a mesh VPN deployed with Terraform and Ansible.&lt;/p&gt;

&lt;p&gt;So I see both invoices, both on-call rotations and both kinds of outages. That is where I speak from.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Fewer ops", really?
&lt;/h2&gt;

&lt;p&gt;Start with the central promise: less operations. The 2026 numbers tell the opposite story.&lt;/p&gt;

&lt;p&gt;Per the Flexera 2026 report, 63% of organizations run a FinOps team. FinOps is the discipline invented to understand and control the cloud bill. We created a job to read an invoice.&lt;/p&gt;

&lt;p&gt;71% have a "Cloud Center of Excellence". Gartner predicted 80% of large engineering organizations would run a platform engineering team by 2026. And 85% name cost control as their top challenge.&lt;/p&gt;

&lt;p&gt;Fewer ops? We have never paid this many people to manage what was supposed to manage itself.&lt;/p&gt;

&lt;p&gt;My daily work confirms it. On managed Kubernetes, you no longer administer servers. You administer a vendor: forced version upgrades with deadlines, deprecated APIs, quotas, IAM. The skill did not disappear. It became vendor-specific, and perishable.&lt;/p&gt;

&lt;h2&gt;
  
  
  PaaS does not remove a dependency, it stacks one
&lt;/h2&gt;

&lt;p&gt;Your PaaS runs on someone else's hyperscaler. You did not remove the infrastructure, you added a middleman.&lt;/p&gt;

&lt;p&gt;On 19 May 2026, Google Cloud automatically suspended Railway's production account. A compliance robot, zero warning. About eight hours of outage, and the applications of three million users offline.&lt;/p&gt;

&lt;p&gt;Those customers had picked a PaaS precisely to stop thinking about infrastructure. They went down because of a vendor they never chose, for a reason that had nothing to do with them.&lt;/p&gt;

&lt;p&gt;That is the true face of this complexity: invisible on even days, uncontrollable on odd ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  "It is more reliable", let's talk
&lt;/h2&gt;

&lt;p&gt;The remaining hammer argument: a hyperscaler will always be more reliable than you. Look at the past year.&lt;/p&gt;

&lt;p&gt;On 20 October 2025, a DNS race condition brought AWS us-east-1 down for about fifteen hours. Snapchat, Signal, payment apps, the UK tax website: all down.&lt;/p&gt;

&lt;p&gt;Nine days later, Azure Front Door fell for about eight and a half hours, taking Microsoft 365 along. Three weeks after that, Cloudflare broke for six hours on a configuration file that doubled in size.&lt;/p&gt;

&lt;p&gt;In May 2026, chillers failed in an AWS data hall. Coinbase stayed offline for about seven hours. Analysts now forecast major cloud outages as a yearly fact, not an accident.&lt;/p&gt;

&lt;p&gt;I do not claim to beat their 99.99%. I claim something else. When my cluster goes down, my outage is actionable: I diagnose, I fix, I learn. When us-east-1 goes down, you refresh a status page along with half the internet.&lt;/p&gt;

&lt;h2&gt;
  
  
  "It is cheaper", public prices say no
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Managed Kubernetes control plane (before your 1st container)
AKS, EKS, GKE: ~$0.10/hour, about $72/month

Small production app on Heroku
2 Standard dynos + Postgres + Redis: $130 to $160/month

My full self-hosted platform
Traefik, Postgres, Keycloak, monitoring, mail: ~90 EUR/month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The control plane is the part of Kubernetes the vendor manages for you. On all three hyperscalers, you pay for it before launching a single container. Free tiers exist, without an SLA.&lt;/p&gt;

&lt;p&gt;One scale up, the movement has a name: repatriation. 37signals left the cloud and deleted its AWS account in the summer of 2025. Projected savings: over 10 million dollars across five years. And 86% of CIOs say they plan to repatriate some workloads, a record for the Barclays survey.&lt;/p&gt;

&lt;p&gt;Honesty: only about 8% do a complete exit. Repatriation is selective, not ideological. But Flexera measures 29% of cloud spend as waste. That waste is on your bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AI just changed
&lt;/h2&gt;

&lt;p&gt;One serious argument remained for PaaS: knowledge. Standing up a cluster, writing the ingress, debugging DNS, keeping runbooks. That knowledge was expensive, in salary or in years of learning.&lt;/p&gt;

&lt;p&gt;That is the argument AI just broke. My platforms are built and maintained with an AI agent as a pair. Terraform, Ansible, manifests, network diagnosis, documentation: the operational knowledge lives in the repo, and it stays current.&lt;/p&gt;

&lt;p&gt;One important caveat: an agent under a harness. Required proofs, written rules, never live production. A free-running agent on your infrastructure is an outage in the making.&lt;/p&gt;

&lt;p&gt;The result fits in one sentence. What used to require a full-time senior DevOps now requires a curious developer, an agent, and discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where managed still wins
&lt;/h2&gt;

&lt;p&gt;Managed keeps legitimate wins. Imposed compliance, HDS or SOC 2, with certifications you do not want to carry yourself. A team where nobody can take the on-call. Huge, unpredictable traffic bursts.&lt;/p&gt;

&lt;p&gt;The healthcare platform I work on has good reasons to run on managed cloud. At that scale, under regulatory audits, the math changes.&lt;/p&gt;

&lt;p&gt;And self-hosting has a real cost: your time. Count it honestly. My point is not that this cost is zero. It is that its order of magnitude just changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist before signing, or re-signing
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Before renewing your PaaS or creating your cluster, answer these questions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Count the people who "manage the cloud" at your company, FinOps included&lt;/li&gt;
&lt;li&gt;[ ] List what you can fix yourself during a vendor outage&lt;/li&gt;
&lt;li&gt;[ ] Price the same stack on plain VMs at a simple hosting provider&lt;/li&gt;
&lt;li&gt;[ ] Check who holds your backups, and where they live&lt;/li&gt;
&lt;li&gt;[ ] Reread your vendor's last outage and note what you could have done&lt;/li&gt;
&lt;li&gt;[ ] Stand up a throwaway cluster with an AI agent, one weekend is enough&lt;/li&gt;
&lt;li&gt;[ ] Keep managed where it truly wins: compliance, on-call, burst&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;The cloud is neither moral progress nor fate. It is an invoice and a dependency contract. Both deserve a reread.&lt;/p&gt;

&lt;p&gt;The "fewer ops, cheaper, more reliable" promise does not survive 2026. FinOps teams, chained outages and public prices contradict it.&lt;/p&gt;

&lt;p&gt;And the last argument, simplicity, just switched sides. Running your own cluster, assisted by an agent, has never been this accessible.&lt;/p&gt;

&lt;p&gt;Want to price a repatriation, or build a clean self-hosted platform? &lt;a href="https://jrobineau.com/contact.html" rel="noopener noreferrer"&gt;Let's talk&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://www.thousandeyes.com/blog/aws-outage-analysis-october-20-2025" rel="noopener noreferrer"&gt;ThousandEyes, AWS outage of 20 October 2025&lt;/a&gt; · &lt;a href="https://blog.cloudflare.com/18-november-2025-outage/" rel="noopener noreferrer"&gt;Cloudflare, 18 November 2025 post-mortem&lt;/a&gt; · &lt;a href="https://blog.railway.com/p/incident-report-may-19-2026-gcp-account-outage" rel="noopener noreferrer"&gt;Railway, 19 May 2026 incident report&lt;/a&gt; · &lt;a href="https://www.coinbase.com/blog/a-postmortem-of-our-may-7-2026-outage" rel="noopener noreferrer"&gt;Coinbase, 7 May 2026 postmortem&lt;/a&gt; · &lt;a href="https://www.flexera.com/blog/finops/flexera-2026-state-of-the-cloud-report-the-convergence-of-cloud-and-value/" rel="noopener noreferrer"&gt;Flexera, State of the Cloud 2026&lt;/a&gt; · &lt;a href="https://basecamp.com/cloud-exit" rel="noopener noreferrer"&gt;37signals, Leaving the Cloud&lt;/a&gt; · &lt;a href="https://cloud.google.com/kubernetes-engine/pricing" rel="noopener noreferrer"&gt;GKE official pricing&lt;/a&gt; · &lt;a href="https://www.gartner.com/en/infrastructure-and-it-operations-leaders/topics/platform-engineering" rel="noopener noreferrer"&gt;Gartner, platform engineering&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>cloud</category>
      <category>selfhosted</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Your Test Suite Is Green and Your Product Is Broken</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Fri, 28 Aug 2026 06:03:00 +0000</pubDate>
      <link>https://dev.to/julesrobineau/your-test-suite-is-green-and-your-product-is-broken-59p2</link>
      <guid>https://dev.to/julesrobineau/your-test-suite-is-green-and-your-product-is-broken-59p2</guid>
      <description>&lt;p&gt;My test suite had been green for weeks. The product's main journey was broken.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: a green test only proves what it actually checks. I lived through both worst cases. A green database test on a change that broke prod, because the test ran with full rights and prod did not. And a green UI test suite on a broken product, because it checked server responses instead of the screen. Now that AI writes 42% of code, verifying has become harder than writing. Here are the rules I apply since.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is for developers and leads who trust their green CI. CI is the machine that replays every test on each code change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;A personal project, but real-sized. Go services, a Postgres database, Kubernetes hosting.&lt;/p&gt;

&lt;p&gt;The tests come in two families. Integration tests check a service against a real database. E2E tests, end to end, drive a real browser on the real product, the way a user would.&lt;/p&gt;

&lt;p&gt;Part of the code is written with AI agents, then reviewed. Two incidents taught me the same lesson, at two different layers. A green test is necessary. It is not sufficient.&lt;/p&gt;

&lt;h2&gt;
  
  
  The green test that broke the deploy
&lt;/h2&gt;

&lt;p&gt;A migration is a script that changes the structure of the database. It runs on its own, at deploy time.&lt;/p&gt;

&lt;p&gt;A new service's migration carried one line too many: a command that changes the rights of a database account.&lt;/p&gt;

&lt;p&gt;In production, the migration runs under an account with limited rights. That is on purpose, for security. So Postgres refused the command: that account has no right to change rights.&lt;/p&gt;

&lt;p&gt;What follows is a cascade. The migration tool marks the database as "dirty". The service restarts in a loop without ever starting. Manual repair, with the administrator account.&lt;/p&gt;

&lt;p&gt;The outage is not the worst part. The worst part is that the test was green. That test spins up a throwaway database in a container, with a tool called Testcontainers. And in that test database, the account is an administrator. Full rights.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;app_system&lt;/span&gt; &lt;span class="n"&gt;BYPASSRLS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- the command that changes an account's rights&lt;/span&gt;

&lt;span class="c1"&gt;-- test : superuser        → OK&lt;/span&gt;
&lt;span class="c1"&gt;-- prod : restricted role  → permission denied&lt;/span&gt;
&lt;span class="c1"&gt;-- schema_migrations.dirty = true → CrashLoopBackOff&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the forbidden command passed in test and broke in prod. I checked: the test was green on the broken version and on the fixed one. A test that passes on both sides of a bug tests nothing.&lt;/p&gt;

&lt;p&gt;The rule since. The account the test uses has the same rights as the prod one. And a migration never touches accounts, only tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  A green E2E suite on a broken product
&lt;/h2&gt;

&lt;p&gt;Second incident, more embarrassing. For weeks, an entire journey was broken. A case created, a document signed, and the next screen showed nothing right.&lt;/p&gt;

&lt;p&gt;The E2E suite? One hundred percent green, every day.&lt;/p&gt;

&lt;p&gt;I audited the suite to understand. Almost half the tests never opened a browser. They called the API, the server's interface, and checked its raw response. Nobody was looking at the screen.&lt;/p&gt;

&lt;p&gt;A test shortcut skipped the signing step. The very step those tests claimed to cover. And the tests of the real signing journey were marked "fix later". All of them.&lt;/p&gt;

&lt;p&gt;Put differently: the front end could stop displaying the case state, the amounts, the next button. One hundred percent of the tests still passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check what the user sees, not what the server returns
&lt;/h2&gt;

&lt;p&gt;The suite's rewrite fits in one rule. Check what shows up on the screen. Never the server's raw response.&lt;/p&gt;

&lt;p&gt;Why? Between the server and the screen sits the whole front end: the code that turns responses into pixels. That is what was broken. And no test was looking at it.&lt;/p&gt;

&lt;p&gt;Two details that pay off. Checking every screen in both product languages catches missing texts, displayed as raw codes. And if you compare screenshots, always generate them on the same machine: text rendering changes from one machine to the next.&lt;/p&gt;

&lt;h2&gt;
  
  
  A shortcut on the tested path is a lie
&lt;/h2&gt;

&lt;p&gt;The signing shortcut deserves its own section. It existed for a good reason: the real signature is slow and depends on an external service.&lt;/p&gt;

&lt;p&gt;But a signing test that skips the signing does not test the signing. It tests that the shortcut works. It is a lie, neatly stored in the codebase.&lt;/p&gt;

&lt;p&gt;And it hid something worse. In this product, the end of a signature fires an event. That event updates the states, sends the notifications, opens the discussion thread. The shortcut never fired that event. The state it built looked like the real one, without being it.&lt;/p&gt;

&lt;p&gt;The rewrite rule: prepare data through the API for speed, fine. But the thing under test is never faked. The signing test signs for real, in the real module, all the way through.&lt;/p&gt;

&lt;h2&gt;
  
  
  2026: the hard part is no longer writing code, it is verifying it
&lt;/h2&gt;

&lt;p&gt;Why talk about this now? Because the volume switched sides.&lt;/p&gt;

&lt;p&gt;According to Sonar's State of Code survey, 42% of code committed today is AI-generated or assisted. 96% of developers say they do not fully trust it. And yet, fewer than one in two always checks that code before approving it.&lt;/p&gt;

&lt;p&gt;Tests absorb the shock. New Relic reports that 78% of organizations see more production incidents since generated code arrived. CodeRabbit measures 1.7 times more issues in that code, and 75% more logic errors.&lt;/p&gt;

&lt;p&gt;My two incidents tell the same story as those numbers. Code gets written fast now, tests included. A green suite has never been easier to produce. And has never proven less.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist to test your test suite
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Ask your green CI these questions. Each one comes from a real incident.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;[ ] The test's database account has prod rights, not full rights&lt;/li&gt;
&lt;li&gt;[ ] A test must fail on the broken version: verify that at least once&lt;/li&gt;
&lt;li&gt;[ ] Critical journeys are checked on the screen, not on the server response&lt;/li&gt;
&lt;li&gt;[ ] No shortcut on the path the test claims to cover&lt;/li&gt;
&lt;li&gt;[ ] API-prepared data fires the same events as the real journey&lt;/li&gt;
&lt;li&gt;[ ] Tests marked "fix later" are counted and visible, not forgotten&lt;/li&gt;
&lt;li&gt;[ ] Every screen is checked in every language the product ships&lt;/li&gt;
&lt;li&gt;[ ] AI-generated code goes through the same gates as yours, no exceptions&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;A green CI is a precondition, not a proof. The useful question is not "do the tests pass?" but "what would break without them noticing?".&lt;/p&gt;

&lt;p&gt;Audit your suite the way you would audit code: look for what it does not look at. In my case, the answer was: the product.&lt;/p&gt;

&lt;p&gt;Want an honest audit of your test suite, or harder gates on generated code? &lt;a href="https://jrobineau.com/contact.html" rel="noopener noreferrer"&gt;Let's talk&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://www.sonarsource.com/company/press-releases/sonar-data-reveals-critical-verification-gap-in-ai-coding/" rel="noopener noreferrer"&gt;Sonar, State of Code Developer Survey (January 2026)&lt;/a&gt; · &lt;a href="https://www.sonarsource.com/blog/state-of-code-developer-survey-report-the-current-reality-of-ai-coding/" rel="noopener noreferrer"&gt;Sonar, The Current Reality of AI Coding&lt;/a&gt; · &lt;a href="https://newrelic.com/press-release/20260610" rel="noopener noreferrer"&gt;New Relic (June 2026)&lt;/a&gt; · &lt;a href="https://www.coderabbit.ai/blog/state-of-ai-vs-human-code-generation-report" rel="noopener noreferrer"&gt;CodeRabbit, State of AI vs Human Code Generation&lt;/a&gt; · &lt;a href="https://www.postgresql.org/docs/current/sql-alterrole.html" rel="noopener noreferrer"&gt;PostgreSQL, ALTER ROLE&lt;/a&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>ai</category>
      <category>devops</category>
      <category>go</category>
    </item>
    <item>
      <title>Embeddings Cannot Say No: An Intent Detector's Real Numbers</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Wed, 26 Aug 2026 06:03:00 +0000</pubDate>
      <link>https://dev.to/julesrobineau/embeddings-cannot-say-no-an-intent-detectors-real-numbers-3pg6</link>
      <guid>https://dev.to/julesrobineau/embeddings-cannot-say-no-an-intent-detectors-real-numbers-3pg6</guid>
      <description>&lt;p&gt;My message detector worked on my test set. On messages it had never seen, it missed one real case out of two.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: I built a small detector that spots messages asking for an action. It relies on embeddings, a technique that turns a sentence into numbers. It works well, except for one thing: it does not understand negation. "The outage is fixed, thanks" fires exactly like "outage". I explain why this is a flaw of nature, not of tuning, with numbers to back it. And why the right answer is to accept the flaw rather than fix it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is for developers who want to plug in AI without paying a large model on every message. No machine learning background needed: I define everything along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;In a personal project, an AI watches a chat between users. When a message asks for an action, a badge shows up. Create a ticket, send a document.&lt;/p&gt;

&lt;p&gt;Calling an LLM on every message is expensive. In money and in response time. I already made that case here: &lt;a href="https://jrobineau.com/blog/put-the-llm-last.html" rel="noopener noreferrer"&gt;put the LLM last&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So the system has two stages. Stage 1 is a small model, free to run, that sorts messages. Stage 2 is the LLM, called only when the user clicks the badge.&lt;/p&gt;

&lt;p&gt;Stage 1 uses embeddings. An embedding turns a sentence into a list of numbers. Two sentences with close meanings give close lists of numbers. That is all you need to understand for the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deciding with a similarity score
&lt;/h2&gt;

&lt;p&gt;How does an embedding make a decision? By measuring how much two sentences look alike. That score is called cosine similarity. Close to 1: the sentences are very similar. Lower: they have nothing in common.&lt;/p&gt;

&lt;p&gt;My detector compares each message to the available tools. "Create a ticket", "send a document". If the message looks enough like a tool, it is deemed actionable.&lt;/p&gt;

&lt;p&gt;The whole question sits inside "enough". That is where things get hard.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fixed bar does not work
&lt;/h2&gt;

&lt;p&gt;First instinct: set a bar. Above 0.85 similarity with a tool, the message is actionable.&lt;/p&gt;

&lt;p&gt;It does not work. My model gives scores between 0.80 and 0.92, for everything. "Hello" and "create a ticket" get almost the same score.&lt;/p&gt;

&lt;p&gt;This model sees every sentence as somewhat similar. Its scores are packed into a tiny range. And a fixed bar separates nothing inside a tiny range. Raise the bar, and you lose real cases before you lose the noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compare against neutral phrases, not a bar
&lt;/h2&gt;

&lt;p&gt;The solution that worked: compare, instead of measuring in the absolute.&lt;/p&gt;

&lt;p&gt;I wrote a list of neutral phrases, with no action in them. "Hello", "thanks", "ok great". I call them anchors.&lt;/p&gt;

&lt;p&gt;The rule becomes simple. A message is actionable if it looks more like a tool than like the best anchor. If it mostly looks like "thanks", it is chit-chat.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The decision rule
&lt;/span&gt;&lt;span class="n"&gt;tool_score&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;closest_tool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;neutral_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;closest_anchor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;actionable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tool_score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;neutral_score&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail that matters: I split long messages on punctuation. A request drowned in politeness stands out better piece by piece. The flip side: no punctuation, no splitting. Voice dictation rarely adds any.&lt;/p&gt;

&lt;h2&gt;
  
  
  One false alarm out of four
&lt;/h2&gt;

&lt;p&gt;I evaluated on 300 generated messages. Generated, not real: the project had no users yet. Keep that in mind, reality will do worse.&lt;/p&gt;

&lt;p&gt;Two measures matter, and they are simple. First: out of 100 messages that deserve an action, how many the detector catches. Mine catches 92. That is called recall, and it is a good score.&lt;/p&gt;

&lt;p&gt;Second: out of 100 harmless messages, how many it leaves alone. Mine leaves 76. In other words, 24 harmless messages out of 100 trigger the badge for nothing. That is called specificity, and it is the number that hurts.&lt;/p&gt;

&lt;p&gt;A badge that is wrong one time out of four stops being believed. Too many false alarms kill the alarm. The first number shines in the demo. The second one is paid in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Embeddings cannot say no
&lt;/h2&gt;

&lt;p&gt;The worst failure mode has a name: negation. "There is an outage" asks for an action. "The outage is fixed, thanks" asks for none.&lt;/p&gt;

&lt;p&gt;To the detector, these two sentences are almost identical. Same vocabulary, so almost the same numbers. The second one triggers the badge exactly like the first.&lt;/p&gt;

&lt;p&gt;This is not a botched setting. An embedding summarizes a sentence by its topic. And both sentences have the same topic: an outage. The "it's fixed" part weighs almost nothing in the numbers.&lt;/p&gt;

&lt;p&gt;You can check this yourself in two minutes. Take your positive sentences, append "it's fixed, thanks" to each one. Then look at the scores: they barely move.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mock exam caught up with me
&lt;/h2&gt;

&lt;p&gt;At first I believed I could fix negation through tuning. Twelve more anchors, like "it's fixed" and "problem solved". And a higher decision bar.&lt;/p&gt;

&lt;p&gt;On my test set, beautiful. So I gave the detector a mock exam. In jargon: a holdout. A set of fresh messages, never used during tuning. New exam questions, not the past papers.&lt;/p&gt;

&lt;p&gt;The verdict. The "it's fixed" false alarms did disappear. But the detector dropped from catching 68 real cases out of 100 to just 48. My fix missed more than one real case out of two.&lt;/p&gt;

&lt;p&gt;That is called overfitting. My tuning had memorized my examples instead of learning the problem. Like a student who recites past papers and sinks on a fresh question.&lt;/p&gt;

&lt;p&gt;Worse: on the mock exam, no bar gives both a good catch rate and few false alarms. The knob I was turning has nothing left to give. The ceiling is structural.&lt;/p&gt;

&lt;h2&gt;
  
  
  All the precision belongs to the LLM
&lt;/h2&gt;

&lt;p&gt;The numbers forced the conclusion on me. This detector will never be precise. It can, however, be exhaustive.&lt;/p&gt;

&lt;p&gt;So I picked its role: a gate, not a judge. Bar at the lowest, to catch wide. On the mock exam: 88 real cases out of 100 caught, and 31 harmless messages out of 100 let through for nothing.&lt;/p&gt;

&lt;p&gt;That noise is accepted, and written into the design. Because stage 2 judges behind it: the LLM, called on click, can read "it's fixed". It filters the negation and picks the right tool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The shipped config: a gate, not a judge&lt;/span&gt;
&lt;span class="na"&gt;model     &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;multilingual-e5-small&lt;/span&gt;   &lt;span class="c1"&gt;# small, on CPU, zero training&lt;/span&gt;
&lt;span class="na"&gt;threshold &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lowest&lt;/span&gt;                  &lt;span class="c1"&gt;# catch wide, on purpose&lt;/span&gt;
&lt;span class="na"&gt;anchors   &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;smalltalk + resolution&lt;/span&gt;  &lt;span class="c1"&gt;# "thanks", "it's fixed"...&lt;/span&gt;
&lt;span class="na"&gt;result    &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;88 real cases out of 100 · 31 &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="s"&gt; alarms out of &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Last decision, the most counter-intuitive one: do not train the model. I have no real conversations. And training on generated messages means learning the generator's quirks. The mock exam had just shown me that trap. I will train once I have a few hundred real, anonymized messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist before you ship an embedding detector
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Before a similarity score goes to production, run the list.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Test on messages never seen before, not on the ones used for tuning&lt;/li&gt;
&lt;li&gt;[ ] Count the false alarms, not just the good catches&lt;/li&gt;
&lt;li&gt;[ ] Try negation phrases: "it's fixed", "no need anymore, thanks"&lt;/li&gt;
&lt;li&gt;[ ] Compare against neutral phrases rather than setting an absolute bar&lt;/li&gt;
&lt;li&gt;[ ] Give the embedding stage a gate's role: catch wide, let something else judge&lt;/li&gt;
&lt;li&gt;[ ] Know how many false alarms the next stage can absorb&lt;/li&gt;
&lt;li&gt;[ ] Never train on generated data&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;A similarity score does not understand "no". That is not a tuning flaw, it is the nature of the tool.&lt;/p&gt;

&lt;p&gt;So measure on never-seen data, publish your ugly numbers, and put each stage where it is good. The embedding catches. The LLM understands.&lt;/p&gt;

&lt;p&gt;Building intent detection or AI routing, and the numbers do not hold in production? &lt;a href="https://jrobineau.com/contact.html" rel="noopener noreferrer"&gt;Let's talk&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://huggingface.co/intfloat/multilingual-e5-small" rel="noopener noreferrer"&gt;multilingual-e5-small (Hugging Face)&lt;/a&gt; · &lt;a href="https://arxiv.org/abs/2402.05672" rel="noopener noreferrer"&gt;Wang et al., Multilingual E5 Text Embeddings: A Technical Report&lt;/a&gt; · &lt;a href="https://jrobineau.com/blog/put-the-llm-last.html" rel="noopener noreferrer"&gt;Put the LLM Last (the previous article)&lt;/a&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>ai</category>
      <category>nlp</category>
      <category>llm</category>
    </item>
    <item>
      <title>SSE in Go: Your Timeouts Do Not Apply Where You Think</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Mon, 24 Aug 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/julesrobineau/sse-in-go-your-timeouts-do-not-apply-where-you-think-3gp8</link>
      <guid>https://dev.to/julesrobineau/sse-in-go-your-timeouts-do-not-apply-where-you-think-3gp8</guid>
      <description>&lt;p&gt;An SSE stream is an HTTP request that never ends. Every default you did not touch is working against it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: your SSE endpoint breaks twice before it reaches your logic. Once because the &lt;code&gt;Connection&lt;/code&gt; header is illegal in HTTP/2. Once because your Go server's default timeouts cut the stream at 30 seconds. And if you stay on HTTP/1.1, a permanent stream freezes the rest of your page. In August 2026, Go patched a flaw where a timeout was not applied to HTTP/2 connections. Same lesson: a timeout only protects what it covers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is for Go developers shipping streaming to production. SSE, WebSocket, long-poll: anything that stays open.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;SSE stands for Server-Sent Events. It is a one-way HTTP stream. The server pushes messages, the browser listens.&lt;/p&gt;

&lt;p&gt;The format is simple. You open a &lt;code&gt;text/event-stream&lt;/code&gt; response, you write lines, you flush. The browser receives them as they come.&lt;/p&gt;

&lt;p&gt;I run two SSE endpoints in production. The first is a Go notification service, on Kubernetes, behind a reverse proxy. The second is an internal cockpit that refreshes its UI without a page reload.&lt;/p&gt;

&lt;p&gt;Both broke. In different places, with the same symptom.&lt;/p&gt;

&lt;h2&gt;
  
  
  An SSE stream is a request that never ends
&lt;/h2&gt;

&lt;p&gt;Here is the key to the whole article. To your server, an SSE stream is not a special case. It is a very slow request.&lt;/p&gt;

&lt;p&gt;And every guardrail in an HTTP server targets the slow request. Write timeout, context timeout, idle timeout. They exist to kill whatever drags on.&lt;/p&gt;

&lt;p&gt;Your legitimate stream looks exactly like what they are meant to kill. That is the whole problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Connection header is illegal in HTTP/2
&lt;/h2&gt;

&lt;p&gt;First incident. The endpoint answers 200, then the browser shows &lt;code&gt;net::ERR_HTTP2_PROTOCOL_ERROR&lt;/code&gt;. The client reconnects in a loop.&lt;/p&gt;

&lt;p&gt;The cause was one line. My handler set a &lt;code&gt;Connection: keep-alive&lt;/code&gt; header. We all copy it from some old SSE tutorial.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Connection&lt;/code&gt; is a hop-by-hop header. A hop-by-hop header applies to one network hop only, never end to end. HTTP/2 forbids these headers (RFC 9113 §8.2.2).&lt;/p&gt;

&lt;p&gt;The browser speaks HTTP/2 to your ingress. The ingress re-emits your response. The illegal header resets the stream right after the 200.&lt;/p&gt;

&lt;p&gt;The silly part is that the header does nothing for you. HTTP/2 is multiplexed and persistent by design. And in HTTP/1.1, keep-alive is already the default.&lt;/p&gt;

&lt;p&gt;Keep three headers. Not one more.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// The only headers an SSE stream needs&lt;/span&gt;
&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Content-Type"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"text/event-stream"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Cache-Control"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"no-cache"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-Accel-Buffering"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"no"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// for nginx&lt;/span&gt;
&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusOK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;flusher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c"&gt;// Never here: Connection, Keep-Alive, Transfer-Encoding, Upgrade&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Your default timeouts kill the stream at 30 seconds
&lt;/h2&gt;

&lt;p&gt;The illegal header was the visible symptom. The real cause was somewhere else, and it came back a few days later.&lt;/p&gt;

&lt;p&gt;My services share an in-house package that builds the HTTP server. It sets sane defaults for an API.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Defaults from the shared package&lt;/span&gt;
&lt;span class="n"&gt;ReadTimeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="m"&gt;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;
&lt;span class="n"&gt;WriteTimeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;
&lt;span class="n"&gt;IdleTimeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="m"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;
&lt;span class="c"&gt;// plus a middleware that cancels the context after 30s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two of those values kill an SSE stream. The &lt;code&gt;WriteTimeout&lt;/code&gt; closes the connection while you are writing. The middleware cancels the request context after 30 seconds.&lt;/p&gt;

&lt;p&gt;So the stream dies at 30 seconds. The browser shows the same HTTP/2 error, and the client loops again. The symptom blames the protocol. The culprit is your config.&lt;/p&gt;

&lt;p&gt;The fix needs both settings. One alone is not enough, and it took me two rounds to learn that.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// You need both, not either&lt;/span&gt;
&lt;span class="n"&gt;BypassTimeoutPaths&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"/api/v1/notifications/stream"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="n"&gt;WriteTimeoutOverride&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"/api/v1/notifications/stream"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c"&gt;// 0 = no write timeout&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A word on the other two timeouts. &lt;code&gt;ReadTimeout&lt;/code&gt; is harmless, because the client sends nothing after its request. &lt;code&gt;IdleTimeout&lt;/code&gt; is harmless too, as long as you write more often than it fires. In my case: a heartbeat every 30 seconds, an &lt;code&gt;IdleTimeout&lt;/code&gt; of 60.&lt;/p&gt;

&lt;h2&gt;
  
  
  On HTTP/1.1, a permanent stream freezes the rest of your page
&lt;/h2&gt;

&lt;p&gt;Second incident, different project, different layer. I had shipped SSE on an internal cockpit. A few days later, I ripped it out.&lt;/p&gt;

&lt;p&gt;The symptom: buttons spinning forever. Requests left and never came back.&lt;/p&gt;

&lt;p&gt;The cause was not in my code. A browser caps its connections at about six per origin on HTTP/1.1. An SSE stream holds one of them open forever.&lt;/p&gt;

&lt;p&gt;That leaves five slots for the rest of the page. Open a second tab and you are out.&lt;/p&gt;

&lt;p&gt;HTTP/2 removes the problem. One tunnel carries every request in parallel, the stream included.&lt;/p&gt;

&lt;p&gt;So I put SSE back, with a guard. The endpoint only answers if the request came through the HTTPS front.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// The proxy sets this header, the direct HTTP/1.1 origin does not&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;servedOverHTTP2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-Forwarded-Proto"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"https"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;servedOverHTTP2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"live updates unavailable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusNotFound&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A browser extension still talks to the direct origin, on HTTP/1.1. It gets a 404 on that endpoint and never opens a stream.&lt;/p&gt;

&lt;p&gt;Removing SSE was the right call at the time. Putting it back behind an HTTP/2 front was the right call later. Both count.&lt;/p&gt;

&lt;h2&gt;
  
  
  August 2026: Go patched a timeout that did not apply on HTTP/2
&lt;/h2&gt;

&lt;p&gt;This story just echoed inside the standard library.&lt;/p&gt;

&lt;p&gt;On 13 August 2026, the Go team shipped 1.26.6 and 1.25.13. They fix ten security issues. One of them is GO-2026-6089, also known as CVE-2026-56853.&lt;/p&gt;

&lt;p&gt;Its official title: "apply ReadHeaderTimeout when doing unencrypted HTTP/2 check". In plain words, &lt;code&gt;ReadHeaderTimeout&lt;/code&gt; was not applied while detecting a cleartext HTTP/2 connection.&lt;/p&gt;

&lt;p&gt;A client could hold connections open without ever paying the timeout. That is a denial of service through resource exhaustion.&lt;/p&gt;

&lt;p&gt;The fix landed in go1.25.13, go1.26.6 and go1.27.0-rc.3. Go 1.27 shipped six days later, on 19 August.&lt;/p&gt;

&lt;p&gt;Go check your &lt;code&gt;go.mod&lt;/code&gt; files. Most of mine sit on go 1.25, so they were affected.&lt;/p&gt;

&lt;p&gt;What I take from it is not the flaw itself. It is the pattern coming back: the timeout existed, it just did not cover the HTTP/2 path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist before you ship an SSE endpoint
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Run this list before a stream goes to production. It would have saved me two incidents.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;[ ] No &lt;code&gt;Connection&lt;/code&gt;, &lt;code&gt;Keep-Alive&lt;/code&gt;, &lt;code&gt;Transfer-Encoding&lt;/code&gt; or &lt;code&gt;Upgrade&lt;/code&gt; header in the handler&lt;/li&gt;
&lt;li&gt;[ ] The server &lt;code&gt;WriteTimeout&lt;/code&gt; is disabled on that path&lt;/li&gt;
&lt;li&gt;[ ] The timeout middleware is bypassed on that path&lt;/li&gt;
&lt;li&gt;[ ] A heartbeat fires more often than the &lt;code&gt;IdleTimeout&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] The stream is only served behind an HTTP/2 front&lt;/li&gt;
&lt;li&gt;[ ] The proxy does not buffer the response&lt;/li&gt;
&lt;li&gt;[ ] The client reconnects, and you count those reconnections&lt;/li&gt;
&lt;li&gt;[ ] Your Go version is current, standard library timeouts included&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;A timeout only protects what it covers. That holds for your config, and it holds for the standard library.&lt;/p&gt;

&lt;p&gt;When a stream breaks, do not start with your business code. Go down to the transport first. Headers, timeouts, and the protocol between the browser and your proxy.&lt;/p&gt;

&lt;p&gt;And accept removing a feature that hurts. A disabled SSE beats a frozen page.&lt;/p&gt;

&lt;p&gt;Shipping streaming to production and it breaks for no clear reason? &lt;a href="https://jrobineau.com/contact.html" rel="noopener noreferrer"&gt;Let's talk&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://www.rfc-editor.org/rfc/rfc9113#section-8.2.2" rel="noopener noreferrer"&gt;RFC 9113 §8.2.2 (connection-specific header fields in HTTP/2)&lt;/a&gt; · &lt;a href="https://pkg.go.dev/vuln/GO-2026-6089" rel="noopener noreferrer"&gt;GO-2026-6089 / CVE-2026-56853&lt;/a&gt; · &lt;a href="https://groups.google.com/g/golang-announce/c/94pEornpRlI" rel="noopener noreferrer"&gt;Go 1.26.6 and 1.25.13 (13 August 2026)&lt;/a&gt; · &lt;a href="https://go.dev/doc/go1.27" rel="noopener noreferrer"&gt;Go 1.27 release notes&lt;/a&gt; · &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events" rel="noopener noreferrer"&gt;MDN, Using server-sent events&lt;/a&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>webdev</category>
      <category>http</category>
      <category>devops</category>
    </item>
    <item>
      <title>Give Your AI Agent an Identity, Not Your API Key</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Fri, 31 Jul 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/julesrobineau/give-your-ai-agent-an-identity-not-your-api-key-34fh</link>
      <guid>https://dev.to/julesrobineau/give-your-ai-agent-an-identity-not-your-api-key-34fh</guid>
      <description>&lt;p&gt;You paste an API key into your agent. One line in a &lt;code&gt;.env&lt;/code&gt; file. It works right away.&lt;/p&gt;

&lt;p&gt;But that key is yours. Your access, your reach, your name on every call.&lt;/p&gt;

&lt;p&gt;You just handed your identity to a piece of software that writes its own commands.&lt;/p&gt;

&lt;p&gt;An agent is not you. It is a new actor on your system. It needs its own.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: a static API key in an agent is your identity, on loan. Broad, no end date, shared, impossible to trace. An AI agent is a non-human identity, an actor in its own right. Give it its own: a short-lived token, scoped to its need, revocable, and traceable in the logs. The industry has a name for the target, workload identity, like SPIFFE. Not a shared secret sitting in a &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is for developers and DevSecOps folks who put an AI agent in production, with access to real systems. Not in a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;An AI agent is a language model that calls tools and acts for you. I build one in Go, in a personal project.&lt;/p&gt;

&lt;p&gt;The stack is standard. Go for the code. Postgres for the data. A Kubernetes cluster to host it. And the Anthropic API for the model.&lt;/p&gt;

&lt;p&gt;For its secrets, the agent reads no hand-typed key. It carries a dedicated machine identity. That identity is scoped to one project, one environment, one path. I self-host Infisical for this. Infisical is a secret manager: a vault that stores and hands out keys.&lt;/p&gt;

&lt;p&gt;I come from security, and I am a bit paranoid. Certified pentester, former Top 1% on TryHackMe. Handing my identity to an agent was a no.&lt;/p&gt;

&lt;h2&gt;
  
  
  A static API key is your identity, on loan
&lt;/h2&gt;

&lt;p&gt;Look at what a static API key does. It proves who you are, once and for all. And often, the same key is used everywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="n"&gt;idea&lt;/span&gt;
&lt;span class="n"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sk&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;ant&lt;/span&gt;&lt;span class="o"&gt;-...&lt;/span&gt;
&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="c"&gt;//app:password@db/prod&lt;/span&gt;
&lt;span class="n"&gt;STRIPE_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sk_live_&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A key like that has four flaws. It is broad: it opens everything your account can do. It is eternal: no end date. It is shared: the same secret serves ten places. And it is mute: a log says "the key acted", never "the agent acted".&lt;/p&gt;

&lt;p&gt;The problem is exploding in 2026. Secrets tied to AI are the fastest-growing leak category. GitGuardian counted 1.27 million of them in 2025 alone, up 81% from 2024. Worse: 64% of the valid secrets leaked in 2022 were still not revoked by early 2026.&lt;/p&gt;

&lt;p&gt;Why does AI make it worse? Because it multiplies the actors. More agents, more services, more tokens lying around. Every key placed by hand is a leak waiting to happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your agent is a non-human identity, treat it as one
&lt;/h2&gt;

&lt;p&gt;In security, we split two worlds. Human identities: you, your users. And non-human identities: services, scripts, bots.&lt;/p&gt;

&lt;p&gt;A non-human identity, or NHI, is anything that authenticates without being a person. An AI agent is one of them. It is a full actor, like a service account.&lt;/p&gt;

&lt;p&gt;And those actors are already the majority. In a modern cloud, machines outnumber humans by dozens to one. The AI agent is just the newest arrival. The fastest, and the most unpredictable.&lt;/p&gt;

&lt;p&gt;Be careful not to mix up two identities. There is the user the agent acts for. And there is the agent itself. They are two different things. The agent needs its own identity, separate from yours and from the user's.&lt;/p&gt;

&lt;p&gt;In my services, that means two paths. When the agent acts for a user, I propagate that user's token, end to end. When it is a system task, with no one behind it, a service-to-service token takes over. Never both on the same call. The agent never invents an identity. It carries the user's, or its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give it a short-lived token, never an eternal secret
&lt;/h2&gt;

&lt;p&gt;The first rule is simple. Replace the eternal secret with a short-lived token.&lt;/p&gt;

&lt;p&gt;We call it a just-in-time token, or JIT. The idea: the agent does not keep a key forever. It proves who it is, gets a token that lives for a few minutes, then that token dies.&lt;/p&gt;

&lt;p&gt;The payoff shows up on the day of a leak. A stolen static secret works until you notice. Often, months later. A short-lived token is already dead when the thief arrives.&lt;/p&gt;

&lt;p&gt;In my project, the agent knows no production key. It carries a machine identity. With it, an operator fetches its secrets and renews them on its own, continuously.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;The&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="n"&gt;machine&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scoped&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;its&lt;/span&gt; &lt;span class="n"&gt;perimeter&lt;/span&gt;
&lt;span class="n"&gt;projectSlug&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mimmo&lt;/span&gt;
&lt;span class="n"&gt;envSlug&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prod&lt;/span&gt;
&lt;span class="n"&gt;secretsPath&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;mimmo&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;ai&lt;/span&gt;   &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;nothing&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt;
&lt;span class="n"&gt;resyncInterval&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;               &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;renewed&lt;/span&gt; &lt;span class="n"&gt;continuously&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same logic on the tooling side. For my automations, I prefer an app that mints a token on the fly over a personal token left lying around. The app proves its identity, gets a token that lives for an hour, acts, then the token expires. Nothing permanent to store, nothing to leak.&lt;/p&gt;

&lt;p&gt;Two reflexes come with it. One: rotate the credentials often. Two: keep a red button. The day an agent goes rogue, you must be able to kill its identity in one command, without breaking the other nine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope the identity to what the agent needs
&lt;/h2&gt;

&lt;p&gt;A short-lived token is good. A short-lived and narrow token is better.&lt;/p&gt;

&lt;p&gt;The principle is least privilege: grant only the rights that are useful, nothing more. Applied to identity, it gives a clear rule. One identity per agent. Not a single key shared by your whole fleet.&lt;/p&gt;

&lt;p&gt;Why? An injection is a booby-trapped text that steers the agent off task. If it succeeds, the attacker inherits the current agent's rights. With a narrow identity, the damage stops there. With the master key, everything falls.&lt;/p&gt;

&lt;p&gt;I saw the other approach up close, on a large-scale identity platform. Rights were not simple fixed roles. They were decided by attributes, on every request. This is called ABAC. Who are you, in what context, for which resource: the answer is computed on demand.&lt;/p&gt;

&lt;p&gt;An agent deserves the same treatment. Its identity does not just say "this is an agent". It carries attributes: which agent, which environment, which perimeter. And access is decided from there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make every action attributable to the agent
&lt;/h2&gt;

&lt;p&gt;A proper identity comes with a gift: traceability.&lt;/p&gt;

&lt;p&gt;With a shared key, your log says "the service account deleted the record". Thanks, but who? Which agent, which session, on whose orders?&lt;/p&gt;

&lt;p&gt;With one identity per agent, the answer is in the log line. You know which actor acted, when, and for whom. This is non-repudiation: no one can deny what they did.&lt;/p&gt;

&lt;p&gt;This is not my idea. NIST is pushing a framework for agent identity. It fits in four words: identify the agent, authorize it, audit it, and tie every action to the human who allowed it.&lt;/p&gt;

&lt;p&gt;In practice, every call from my agent goes to a structured log: which identity, which tool, which decision. The day of an incident, I answer one question. What did this agent do, exactly?&lt;/p&gt;

&lt;h2&gt;
  
  
  The target: a workload identity, like SPIFFE
&lt;/h2&gt;

&lt;p&gt;Where is all this going? Toward a cryptographic identity for each workload.&lt;/p&gt;

&lt;p&gt;The rising standard is SPIFFE, with its implementation SPIRE. The idea is elegant. Each service gets a verifiable identity, proven by cryptography, and short-lived. No more shared secret to copy from one place to another.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="n"&gt;workload&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;shared&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;
&lt;span class="n"&gt;spiffe&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="c"&gt;//mimmo.app/agent/legal-advisor&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;short&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;lived&lt;/span&gt; &lt;span class="n"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;renewing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verifiable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for AI agents? The shift is under way, at a high level. NIST launched an initiative dedicated to agents in February 2026. Its text on agent identity cites OAuth, OpenID Connect, and SPIFFE. A draft at the IETF describes an identity model for agents, AIMS, built on those same standards. And OWASP ranks excessive agency among the top risks for agentic applications.&lt;/p&gt;

&lt;p&gt;Be honest about maturity. SPIFFE in production for agents is young. I watch this direction closely, without claiming I have deployed it everywhere. But the principle already applies. Short, narrow, revocable, traced identity. You do not need a final standard to stop pasting your key into a &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist before granting access
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Before you wire your AI agent to real systems, run its identity through this list.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;[ ] The agent has its own identity, separate from yours and from the user's&lt;/li&gt;
&lt;li&gt;[ ] No static key in a &lt;code&gt;.env&lt;/code&gt; or in the code&lt;/li&gt;
&lt;li&gt;[ ] A short-lived token instead of the eternal secret, self-renewing&lt;/li&gt;
&lt;li&gt;[ ] One identity per agent, never a shared master key&lt;/li&gt;
&lt;li&gt;[ ] The identity is scoped: one project, one environment, one perimeter&lt;/li&gt;
&lt;li&gt;[ ] Access is decided by attributes, not by a fixed role (ABAC)&lt;/li&gt;
&lt;li&gt;[ ] A red button: you can revoke an identity in one command&lt;/li&gt;
&lt;li&gt;[ ] Regular rotation of credentials&lt;/li&gt;
&lt;li&gt;[ ] Every action is logged with the agent's identity, for non-repudiation&lt;/li&gt;
&lt;li&gt;[ ] Secrets live in a dedicated manager, scanned at every commit&lt;/li&gt;
&lt;li&gt;[ ] The target: a verifiable workload identity, like SPIFFE&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;An API key answers "what access". An identity answers "who acts". Your agent needs the second one.&lt;/p&gt;

&lt;p&gt;Do not lend it your identity. Give it its own: short, narrow, revocable, traced. That is the difference between an actor you control and a secret you pray never leaks.&lt;/p&gt;

&lt;p&gt;The standards are coming. Machine identities are exploding. Developers who can give an agent a proper identity, and not just a key, will stay rare for a while.&lt;/p&gt;

&lt;p&gt;Are you putting an AI agent into production? Do you want a second look at how it authenticates, before a key leaks? &lt;a href="https://jrobineau.com/contact.html" rel="noopener noreferrer"&gt;That is exactly what I do. Get in touch.&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://blog.gitguardian.com/the-state-of-secrets-sprawl-2026/" rel="noopener noreferrer"&gt;GitGuardian, State of Secrets Sprawl 2026&lt;/a&gt; · &lt;a href="https://www.nist.gov/artificial-intelligence/ai-agent-standards-initiative" rel="noopener noreferrer"&gt;NIST AI Agent Standards Initiative&lt;/a&gt; · &lt;a href="https://www.nccoe.nist.gov/projects/software-and-ai-agent-identity-and-authorization" rel="noopener noreferrer"&gt;NCCoE, Software &amp;amp; AI Agent Identity and Authorization&lt;/a&gt; · &lt;a href="https://datatracker.ietf.org/doc/draft-klrc-aiagent-auth/" rel="noopener noreferrer"&gt;IETF draft, AI Agent Authentication (AIMS)&lt;/a&gt; · &lt;a href="https://spiffe.io/" rel="noopener noreferrer"&gt;SPIFFE&lt;/a&gt; · &lt;a href="https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026/" rel="noopener noreferrer"&gt;OWASP Top 10 for Agentic Applications&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devsecops</category>
      <category>security</category>
      <category>identity</category>
    </item>
    <item>
      <title>Securing a Go Supply Chain: The Pipeline That Holds in 2026</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Wed, 29 Jul 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/julesrobineau/securing-a-go-supply-chain-the-pipeline-that-holds-in-2026-45j7</link>
      <guid>https://dev.to/julesrobineau/securing-a-go-supply-chain-the-pipeline-that-holds-in-2026-45j7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: a Go project's security does not live in a code review. It lives in the pipeline. Your supply chain is all the code and tools between your keyboard and production. In 2026, it is the front door for attacks. Here is the pipeline step by step, with the tools, and where each one breaks.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is for Go teams that ship to production, and for people who buy DevSecOps. Not for a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your code may be clean. Your supply chain is not.
&lt;/h2&gt;

&lt;p&gt;Your supply chain is your software supply chain. It holds your dependencies, your base images and your build tools. The big recent attacks did not come through a bug in your code. They came through that chain.&lt;/p&gt;

&lt;p&gt;Two examples marked 2025. In March, the GitHub Action &lt;code&gt;tj-actions/changed-files&lt;/code&gt; was compromised (CVE-2025-30066). The attackers moved version tags to a poisoned commit. Every repo pinned to that tag ran the malicious code. The result: secrets dumped into public CI logs, across about 23,000 repositories.&lt;/p&gt;

&lt;p&gt;In September, it was Shai-Hulud. The first self-propagating worm in the npm ecosystem. It runs when a package is installed. It scans the environment for secrets, steals tokens, then republishes poisoned versions on its own.&lt;/p&gt;

&lt;p&gt;Neither attack was a bug at the victim's site. Both entered through a dependency or a build tool. Go is not npm. But Go pulls code and runs tools too. The pipeline is where you defend.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: a real Go pipeline, not a diagram
&lt;/h2&gt;

&lt;p&gt;I am talking about two real things. My own Go project, and the pipelines I have built for clients.&lt;/p&gt;

&lt;p&gt;On my own Go project, here is what already runs. &lt;code&gt;gitleaks&lt;/code&gt; scans every commit before it leaves. &lt;code&gt;golangci-lint&lt;/code&gt;, with &lt;code&gt;staticcheck&lt;/code&gt;, blocks any pull request that fails. Dependencies are pinned by hash, and a bot updates them. The final image is distroless and non-root. The CI token is read-only.&lt;/p&gt;

&lt;p&gt;On client pipelines, I added three layers. SAST, which reads code without running it (SonarQube). DAST, which tests the running app (OWASP ZAP). And image scanning (Grype, Trivy). With a measured drop in findings.&lt;/p&gt;

&lt;p&gt;This article walks the whole chain, stage by stage. Some stages run on my side today. Others are the ones I recommend to close the chain. I tell you which is which each time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: block known vulnerabilities with govulncheck
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;govulncheck&lt;/code&gt; is the official tool from the Go team. It compares your dependencies and the standard library against the Go vulnerability database.&lt;/p&gt;

&lt;p&gt;Its strength is one idea. It reads your call graph. It only reports a vulnerability if your code actually reaches the vulnerable function. So you get far fewer false alarms than with a generic scanner. One line is enough in CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;govulncheck&lt;/span&gt; &lt;span class="o"&gt;./...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where it breaks. &lt;code&gt;govulncheck&lt;/code&gt; only knows about published flaws. A zero-day slips through. A zero-day is a flaw still unknown to defenders. And malicious code with no CVE slips through too. A CVE is the public ID of a known flaw. Step 3 closes that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: pin your dependencies, and let a bot raise them
&lt;/h2&gt;

&lt;p&gt;Go already pins your dependencies by hash, in &lt;code&gt;go.sum&lt;/code&gt;. If a published version changes under you, the build fails. Keep &lt;code&gt;-mod=readonly&lt;/code&gt; in CI, so nothing edits your deps silently.&lt;/p&gt;

&lt;p&gt;But pinned does not mean safe. You can happily pin malware. So you also need managed updates. A bot opens dependency PRs every day.&lt;/p&gt;

&lt;p&gt;On my side, minor updates merge on their own once CI is green. Major versions get labeled and wait for a human. A major bump changes too much to be blind.&lt;/p&gt;

&lt;p&gt;Where it breaks. A single patch can be poisoned, as in the tj-actions case. Auto-merging a malicious patch is a real risk. So: keep few dependencies, and never let a major through without review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: go beyond the CVE, hunt behavior
&lt;/h2&gt;

&lt;p&gt;Shai-Hulud had no CVE at install time. It was just code that runs and steals secrets. A CVE scanner sees nothing. So you add two things.&lt;/p&gt;

&lt;p&gt;First, SAST on your own code. SAST reads your code without running it and flags risky patterns. Three tools come up often. Here is how I place them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;govulncheck&lt;/code&gt; is not in this table. It looks at known flaws in your deps, not at patterns in your code. The two are complementary.&lt;/p&gt;

&lt;p&gt;Second, treat third-party code as suspect. Fewer dependencies. Read the diff of a new dependency before you add it. Prefer the standard library. Go helps here: a Go module does not run an install script, unlike npm. But &lt;code&gt;go generate&lt;/code&gt;, cgo and build tags can run code at build time. So the build machine is still a target.&lt;/p&gt;

&lt;p&gt;Where it breaks. SAST has false positives, and it cannot see runtime logic. No tool reads a dependency's mind. The real defense stays: fewer deps, and human review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: generate an SBOM, because the CRA is coming
&lt;/h2&gt;

&lt;p&gt;An SBOM is the inventory of every component in your build, with versions. Think of it as the ingredient label of your software.&lt;/p&gt;

&lt;p&gt;Two tools do the job in Go: &lt;code&gt;syft&lt;/code&gt; or &lt;code&gt;cyclonedx-gomod&lt;/code&gt;. They produce a file in CycloneDX or SPDX format. Those are the two machine-readable formats. Generate it in CI, and attach it to every release.&lt;/p&gt;

&lt;p&gt;Why now? Because of the CRA, the EU Cyber Resilience Act. From 11 September 2026, a manufacturer must report an actively exploited vulnerability within 24 hours. You cannot hold a 24-hour clock without knowing what is in your product. The SBOM is that map. The formal SBOM duty arrives with full application of the text in 2027. But you need it before the clock starts.&lt;/p&gt;

&lt;p&gt;Where it breaks. A stale SBOM is theater. It only helps when correlated with a live vulnerability feed. Generate it on every build, not once a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: scan the image, not just the code
&lt;/h2&gt;

&lt;p&gt;Your Go binary is not the whole artifact. The container ships an OS layer too. Scan the final image. &lt;code&gt;Trivy&lt;/code&gt; or &lt;code&gt;Grype&lt;/code&gt; read it and flag known flaws, in system packages and in the binary alike.&lt;/p&gt;

&lt;p&gt;Shrink the target first. A multi-stage build compiles the binary, then copies it into a tiny base. I use a distroless base, non-root, with a static binary. No shell, no package manager, almost nothing to exploit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;golang&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="m"&gt;1.25&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;bookworm&lt;/span&gt; &lt;span class="n"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="n"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;download&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;then&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;span class="n"&gt;RUN&lt;/span&gt; &lt;span class="n"&gt;CGO_ENABLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;ldflags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"-s -w"&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;./&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;

&lt;span class="n"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;gcr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;distroless&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;static&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;debian12&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;nonroot&lt;/span&gt;
&lt;span class="n"&gt;COPY&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;
&lt;span class="n"&gt;USER&lt;/span&gt; &lt;span class="m"&gt;65534&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="m"&gt;65534&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where it breaks. A scanner only knows its database. A minimal image cuts the surface, but the base image ages. Rebuild and rescan often.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: get secrets out of the repo and the build
&lt;/h2&gt;

&lt;p&gt;Shai-Hulud harvested the secrets sitting in the environment. Hence the rule: a secret never sits in the repo, and never bakes into an image layer.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gitleaks&lt;/code&gt; scans every commit for keys and tokens. On my side, it runs as a pre-commit hook. The same hook blocks any &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;.pem&lt;/code&gt; or &lt;code&gt;.key&lt;/code&gt; file from being added.&lt;/p&gt;

&lt;p&gt;The real secrets live in a dedicated manager, injected at deploy time. I use Infisical. A managed vault works too.&lt;/p&gt;

&lt;p&gt;In the Docker build, pass secrets with &lt;code&gt;--mount=type=secret&lt;/code&gt;. The secret is available during the build step, but it never writes to a layer.&lt;/p&gt;

&lt;p&gt;Where it breaks. &lt;code&gt;gitleaks&lt;/code&gt; catches known patterns. A secret leaked before the hook, or a new format, slips through. So: rotate your secrets as soon as they are exposed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: harden GitHub Actions, your most exposed link
&lt;/h2&gt;

&lt;p&gt;Your CI holds your keys and write access. tj-actions proved it. The attackers moved a version tag to a poisoned commit. Every repo pinned to that tag ran the payload. Three moves limit the damage.&lt;/p&gt;

&lt;p&gt;One. Least privilege on the CI token. Set &lt;code&gt;contents: read&lt;/code&gt; by default, and add &lt;code&gt;write&lt;/code&gt; only where a job needs it. On my side, the lint and test jobs are read-only.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;permissions&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
  &lt;span class="n"&gt;contents&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;read&lt;/span&gt;      &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;only&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt;     &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;only&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;short&lt;/span&gt; &lt;span class="n"&gt;OIDC&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two. Pin third-party actions by full commit hash, not by tag. A tag can move, a hash cannot. Many teams still pin by major tag, and that is a fair default. After tj-actions, pin third-party actions by SHA.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;uses&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;checkout&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="n"&gt;a1b2c3d&lt;/span&gt;   &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;full&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;40&lt;/span&gt; &lt;span class="n"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="n"&gt;v4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three. Use OIDC to authenticate to the cloud. OIDC lets a job prove its identity and receive a short token, with no stored key. A stolen log or a leaked environment then holds a token that is already dead. That is the direct answer to tj-actions and to Shai-Hulud.&lt;/p&gt;

&lt;p&gt;Where it breaks. SHA pinning protects against a moved tag, not against a compromised maintainer who publishes a release you then bump to. Reviewing version bumps is still needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: sign what you ship
&lt;/h2&gt;

&lt;p&gt;The last lock. A signature proves an artifact comes from your pipeline, not from an attacker.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cosign&lt;/code&gt;, from the Sigstore project, signs an image or a file. With no key to manage, the signature ties to your CI identity. Your users verify it before they run the artifact. GitHub can also attach build provenance: a signed record of how and where it was built.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;cosign&lt;/span&gt; &lt;span class="n"&gt;sign&lt;/span&gt;   &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;IMAGE&lt;/span&gt;
&lt;span class="n"&gt;cosign&lt;/span&gt; &lt;span class="n"&gt;verify&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;IMAGE&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;certificate&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where it breaks. A signature proves origin, not innocence. A signed malicious artifact is still malicious. But you gain traceability and revocation. You know what to recall, and fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  The supply chain checklist before you ship
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Before you ship a Go service, walk your supply chain end to end.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;code&gt;govulncheck&lt;/code&gt; runs in CI and reads your call graph&lt;/li&gt;
&lt;li&gt;[ ] Dependencies pinned by hash, build in &lt;code&gt;-mod=readonly&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] A bot raises deps: minor automatic, major reviewed by a human&lt;/li&gt;
&lt;li&gt;[ ] A SAST on your code (gosec or semgrep), on top of &lt;code&gt;staticcheck&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Few dependencies, and a new dep's diff read before adding it&lt;/li&gt;
&lt;li&gt;[ ] An SBOM generated on every build, in CycloneDX or SPDX format&lt;/li&gt;
&lt;li&gt;[ ] The final image scanned (Trivy or Grype), on a distroless non-root base&lt;/li&gt;
&lt;li&gt;[ ] Secrets out of the repo, scanned by &lt;code&gt;gitleaks&lt;/code&gt;, injected at deploy&lt;/li&gt;
&lt;li&gt;[ ] Build secrets passed via &lt;code&gt;--mount=type=secret&lt;/code&gt;, never in a layer&lt;/li&gt;
&lt;li&gt;[ ] CI token set to &lt;code&gt;contents: read&lt;/code&gt; by default&lt;/li&gt;
&lt;li&gt;[ ] Third-party actions pinned by SHA, not by tag&lt;/li&gt;
&lt;li&gt;[ ] OIDC for the cloud, instead of long-lived keys&lt;/li&gt;
&lt;li&gt;[ ] Artifacts signed with &lt;code&gt;cosign&lt;/code&gt;, build provenance attached&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;A chain is only as strong as its weakest link. And that link is rarely your code. It is a moved tag, an auto-merged patch, a secret in a log.&lt;/p&gt;

&lt;p&gt;Go gives you a real edge. Static binaries, a tiny attack surface, &lt;code&gt;govulncheck&lt;/code&gt; reading your call graph, no install scripts by default. Use it. Then close the chain: pin, scan, inventory, sign.&lt;/p&gt;

&lt;p&gt;The CRA is not the reason to do this. It is the deadline that removes your excuse. Are you building a Go pipeline and want a second pair of eyes on its supply chain? &lt;a href="https://jrobineau.com/contact.html" rel="noopener noreferrer"&gt;That is exactly what I do. Get in touch.&lt;/a&gt; Ship your service. Not the breach with it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://go.dev/blog/govulncheck" rel="noopener noreferrer"&gt;govulncheck (Go)&lt;/a&gt; · &lt;a href="https://www.wiz.io/blog/github-action-tj-actions-changed-files-supply-chain-attack-cve-2025-30066" rel="noopener noreferrer"&gt;tj-actions / CVE-2025-30066 (Wiz)&lt;/a&gt; · &lt;a href="https://www.cisa.gov/news-events/alerts/2025/03/18/supply-chain-compromise-third-party-tj-actionschanged-files-cve-2025-30066-and-reviewdog" rel="noopener noreferrer"&gt;tj-actions (CISA)&lt;/a&gt; · &lt;a href="https://www.wiz.io/blog/shai-hulud-npm-supply-chain-attack" rel="noopener noreferrer"&gt;Shai-Hulud (Wiz)&lt;/a&gt; · &lt;a href="https://www.cisa.gov/news-events/alerts/2025/09/23/widespread-supply-chain-compromise-impacting-npm-ecosystem" rel="noopener noreferrer"&gt;Shai-Hulud (CISA)&lt;/a&gt; · &lt;a href="https://www.blackduck.com/solutions/eu-cyber-resilience-act-compliance/cra-vulnerability-reporting-requirements.html" rel="noopener noreferrer"&gt;CRA, 24h reporting (Black Duck)&lt;/a&gt; · &lt;a href="https://www.keysight.com/blogs/en/tech/nwvs/2025/09/11/one-year-countdown-to-eu-cra-compliance-september-11-2026-changes-everything" rel="noopener noreferrer"&gt;CRA, Sept 11 2026 (Keysight)&lt;/a&gt; · &lt;a href="https://cyclonedx.org/" rel="noopener noreferrer"&gt;CycloneDX (SBOM)&lt;/a&gt; · &lt;a href="https://docs.sigstore.dev/" rel="noopener noreferrer"&gt;Sigstore / cosign&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>go</category>
      <category>security</category>
      <category>devsecops</category>
      <category>supplychain</category>
    </item>
    <item>
      <title>Sovereign EU Self-Hosting: A Technical Playbook (Terraform and Ansible)</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Mon, 27 Jul 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/julesrobineau/sovereign-eu-self-hosting-a-technical-playbook-terraform-and-ansible-31me</link>
      <guid>https://dev.to/julesrobineau/sovereign-eu-self-hosting-a-technical-playbook-terraform-and-ansible-31me</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: hosting in Europe is good. But an "EU region" on a US cloud is not sovereignty. The CLOUD Act keeps the door open. Real sovereignty is infrastructure you own, on a European host, described in Terraform and Ansible. Here is the playbook I use. Which open-source components replace which US services. Where your data lives. And how to stay AI-ready without depending on a US provider.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is for CTOs and tech leads at European SMBs and scale-ups that handle sensitive data. Fintech, healthcare, public sector.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sovereignty is no longer a slogan. It is an architecture decision.
&lt;/h2&gt;

&lt;p&gt;In 2026, digital sovereignty moved from slogan to technical constraint. Digital sovereignty means keeping control of your data and your infrastructure. Who can reach it, under which law, and from where.&lt;/p&gt;

&lt;p&gt;The European framework kept stacking, text after text. GDPR has protected personal data since 2018. The NIS2 directive tightens cyber obligations, and member states have been transposing it since late 2024. DORA forces financial players to manage their cloud dependency, and it applies since January 2025. The Data Act makes switching cloud providers easier, and it applies since September 2025.&lt;/p&gt;

&lt;p&gt;The AI Act deserves an important nuance. It entered into force in August 2024. Its transparency rules do apply in August 2026. But watch out for a calendar trap. In May 2026, the EU agreed to push high-risk system obligations to December 2027. So do not build your stack to beat a single date. Build it because the pressure is structural, not a one-off.&lt;/p&gt;

&lt;p&gt;The other driver is resilience. The summer of 2026 was a blunt reminder. AWS had several outages in July. On the 16th, an incident on its CloudFront service lasted more than three hours. On the 24th, an outage in its Oregon region took down Apple Pay, Reddit and others for about 80 minutes. It was the third notable incident in three months.&lt;/p&gt;

&lt;p&gt;A hyperscaler is a cloud giant like AWS, Azure or Google Cloud. When everyone depends on the same hyperscalers, everyone falls together. The underlying shift is real, right up to the top. In April 2026, the European Commission awarded a sovereign cloud contract. Up to 180 million euros over six years, for its own institutions.&lt;/p&gt;

&lt;p&gt;On my side, I have been building sovereign self-hosted stacks for a while. Two personal projects serve as my base. A turnkey DevSecOps bundle, and a self-hosted zero-trust VPN mesh. Both run on Hetzner, a German host. Both deploy fully from code, never by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  An "EU region" on a US cloud is not sovereignty
&lt;/h2&gt;

&lt;p&gt;Here is the most common mistake. You tick "Europe region" at your US cloud, and you feel compliant. The problem is not where the data is stored. The problem is who can demand it.&lt;/p&gt;

&lt;p&gt;The CLOUD Act is a US law from 2018. It lets US authorities compel a US company to hand over data. Even when that data sits in Europe. So the European subsidiary of a US group stays under that law. The server location changes nothing.&lt;/p&gt;

&lt;p&gt;European courts already flagged this risk. The Schrems II ruling, in 2020, invalidated the data transfer framework to the United States. The reason: possible access by foreign authorities to your data.&lt;/p&gt;

&lt;p&gt;Hyperscalers now offer so-called "sovereign" options. Isolated regions, operated in Europe. It is better than nothing. But the parent company stays American, so under American law. Sovereignty is not just a storage location. It is control. Who owns the infrastructure, who holds the keys, who answers subpoenas. The simplest answer to those three questions is to host it yourself, on a European provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replace every US service with a component you control
&lt;/h2&gt;

&lt;p&gt;Good news: for almost every US service, there is an open-source equivalent you host yourself. This is the core of the DevSecOps bundle I deploy for clients, on their own infrastructure. Here is the mapping I use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Identity and SSO&lt;/strong&gt;: Keycloak replaces Okta, Auth0 or Entra ID. SSO, or single sign-on, means one login for all your apps. Keycloak also handles roles and a second factor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-trust network&lt;/strong&gt;: Netbird replaces Tailscale or Zscaler. It is a mesh VPN built on WireGuard. Zero-trust starts from a simple rule: trust no one by default, even inside the network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets&lt;/strong&gt;: Vaultwarden holds team passwords. For application secrets, you add Vault or Infisical. Everything stays with you, never in a third-party vault.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring and logs&lt;/strong&gt;: Grafana, Prometheus and Loki replace Datadog or New Relic. You see your metrics and logs without sending them out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups&lt;/strong&gt;: Restic and pgBackRest write to MinIO. MinIO is S3-compatible object storage that you host yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTPS entry and DNS&lt;/strong&gt;: Traefik handles the reverse proxy and Let's Encrypt certificates. For DNS, a European player like OVH is enough.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every block is open-source. You can audit it, fork it, run it without a license. And most of all, your data never leaves your perimeter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploy everything with Terraform and Ansible, not by hand
&lt;/h2&gt;

&lt;p&gt;A stack built by hand does not stay sovereign for long. It drifts, and soon nobody can rebuild it. So I describe everything in code. Two tools split the work. Terraform creates the infrastructure: the machine, the network, the firewall, the DNS. Ansible then configures the machine: the packages, the services, the hardening.&lt;/p&gt;

&lt;p&gt;My self-hosted VPN project shows it well. Terraform provisions a VM on Hetzner and manages DNS on OVH. Two European providers, one code file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;hcloud&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"hetznercloud/hcloud"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"~&amp;gt; 1.45"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;VM&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;Hetzner&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ovh&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ovh/ovh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"~&amp;gt; 0.50"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;DNS&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;OVH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;Then&lt;/span&gt; &lt;span class="n"&gt;Ansible&lt;/span&gt; &lt;span class="n"&gt;hardens&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="n"&gt;installs&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt;   &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;hardening&lt;/span&gt;  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;firewall&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fail2ban&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt;   &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;netbird&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;install&lt;/span&gt;   &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;hosted&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ansible takes over. One role hardens the system. Another installs the service. The whole thing runs through a GitHub Actions pipeline. A plan on every pull request. A deploy on merge. A separate job to tear it all down cleanly.&lt;/p&gt;

&lt;p&gt;The real win is reproducibility. You redeploy the same stack elsewhere in one command. If your host raises prices or goes down, you move without starting over. The same code serves me on Hetzner, OVH, GCP or DigitalOcean. That is the opposite of lock-in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Harden the host and encrypt your backups
&lt;/h2&gt;

&lt;p&gt;Owning the infrastructure also means protecting it. Nobody does it for you. My Ansible hardening role does the bare minimum. Automatic security updates. A firewall closed by default. fail2ban against brute-force attacks. Useless services turned off. fail2ban bans an IP address after too many failed login attempts.&lt;/p&gt;

&lt;p&gt;Then, backups. When you own the infrastructure, you also answer when a disk dies. I encrypt backups with Restic. The Postgres database goes out with pgBackRest. Both write to storage you control, not to a third party's S3. And I test the restore for real. A backup you never restored is not a backup.&lt;/p&gt;

&lt;p&gt;This point meets a DORA requirement. Financial players must be able to exit a provider, without getting stuck. With code and portable backups, that exit is already prepared. You do not improvise it on outage day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stay AI-ready without handing your data to a US provider
&lt;/h2&gt;

&lt;p&gt;Sovereign does not mean cut off from AI. It means keeping control. The trap is sending your raw data to a US API. There, you lose residency and control in one move.&lt;/p&gt;

&lt;p&gt;So I treat the model as a spare part. It plugs in behind a gateway, and it swaps out. I keep the model at the end of the chain, never at the center of the system. I wrote &lt;a href="https://jrobineau.com/blog/put-the-llm-last.html" rel="noopener noreferrer"&gt;a whole article&lt;/a&gt; on that. And I mask personal data before it reaches the model, just like &lt;a href="https://jrobineau.com/blog/mcp-secure-go.html" rel="noopener noreferrer"&gt;when securing an agent&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For sovereignty, you have two paths. A European provider like Mistral. Or an open-weight model you host yourself. An open-weight model has public weights. So you can run it on your own machine. You serve it on a European GPU, at OVHcloud or Scaleway.&lt;/p&gt;

&lt;p&gt;Your data plane stays with you. The model becomes an implementation detail, not a dependency. The result: you get AI, but you decide where the data goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost changed in 2026. Do the real math.
&lt;/h2&gt;

&lt;p&gt;The cloud sold itself on two promises. Simple: no more servers to manage. And cheap: you pay for what you use. In 2026, both have aged.&lt;/p&gt;

&lt;p&gt;Simple, less and less. Tracking the bill became its own job, with a name: FinOps. Cheap, not really. Pay-as-you-go punishes workloads that run all the time. Data egress gets billed, and managed services pile up.&lt;/p&gt;

&lt;p&gt;So some companies go the other way. It is called repatriation: moving back to your own infrastructure. 37signals brought its cloud bill down from 3.2 to 1.3 million dollars a year. A Flexera study estimates about a fifth of the workloads that moved to the cloud have already come back.&lt;/p&gt;

&lt;p&gt;An honest word about money. Self-hosting is not always cheaper. In 2026, even European hosts raised prices. Hetzner increased its rates several times. On 1 April, by up to about 37%. On 15 June, some dedicated-vCPU plans more than doubled. The cause is not margin. The price of DRAM memory jumped by more than 90% in a single quarter.&lt;/p&gt;

&lt;p&gt;So do the real math, with real numbers. Three habits help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Size it right.&lt;/strong&gt; Many stacks run on far less than what we provision by reflex.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick the right instance type.&lt;/strong&gt; Arm plans rose far less than dedicated vCPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count the total cost, not just the VM.&lt;/strong&gt; On a hyperscaler, data egress and managed services inflate the bill.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And count what sovereignty gives you in return. No lock-in, simpler compliance, an exit that always stays open. Lock-in is the dependency that makes leaving too expensive. For an SMB with sensitive data, that control is often worth more than a few euros of VM.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sovereign self-hosting checklist
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Before you call yourself "sovereign", tick every box.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;[ ] The host is European, and out of reach of the CLOUD Act&lt;/li&gt;
&lt;li&gt;[ ] You do not confuse "EU region" with real sovereignty&lt;/li&gt;
&lt;li&gt;[ ] Every US service has a self-hosted open-source equivalent: identity, secrets, network, monitoring, backups&lt;/li&gt;
&lt;li&gt;[ ] All the infrastructure is described in Terraform and Ansible, not built by hand&lt;/li&gt;
&lt;li&gt;[ ] The same code can redeploy the stack on another host&lt;/li&gt;
&lt;li&gt;[ ] The host is hardened: auto updates, firewall, fail2ban, useless services off&lt;/li&gt;
&lt;li&gt;[ ] Backups are encrypted, portable, and you already tested a restore&lt;/li&gt;
&lt;li&gt;[ ] The AI model is isolated behind a gateway, and replaceable&lt;/li&gt;
&lt;li&gt;[ ] Personal data is masked before it reaches the model&lt;/li&gt;
&lt;li&gt;[ ] You did the real cost math: sizing, instance type, total cost&lt;/li&gt;
&lt;li&gt;[ ] An exit strategy exists, ready to run, in the spirit of DORA&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;Sovereignty is not a marketing badge. It is a chain of technical decisions. An EU region on a US cloud is not enough. Control comes from infrastructure you truly own.&lt;/p&gt;

&lt;p&gt;The good news is that the building blocks exist, all open-source. Terraform and Ansible assemble them into a stack you master. And that stack stays AI-ready, on your terms.&lt;/p&gt;

&lt;p&gt;Do you want to build a sovereign stack, or check that yours really is one? &lt;a href="https://jrobineau.com/contact.html" rel="noopener noreferrer"&gt;That is exactly what I do. Get in touch.&lt;/a&gt; Host in Europe. But host on infrastructure that is truly yours.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://www.gibsondunn.com/eu-ai-act-omnibus-agreement-postponed-high-risk-deadlines-and-other-key-changes/" rel="noopener noreferrer"&gt;AI Act "digital omnibus" (Gibson Dunn)&lt;/a&gt; · &lt;a href="https://fossforce.com/2026/07/what-europes-digital-sovereignty-push-means-for-open-source-platforms/" rel="noopener noreferrer"&gt;EU digital sovereignty (FOSS Force)&lt;/a&gt; · &lt;a href="https://blog.elest.io/digital-sovereignty-in-2026-how-eu-data-residency-laws-are-driving-the-self-hosting-boom/" rel="noopener noreferrer"&gt;Self-hosting boom (elest.io)&lt;/a&gt; · &lt;a href="https://blog.incidenthub.cloud/aws-cloudfront-outage-jul-16-2026" rel="noopener noreferrer"&gt;AWS CloudFront outage, 16 July 2026&lt;/a&gt; · &lt;a href="https://www.techtimes.com/articles/321567/20260725/aws-knocks-out-apple-pay-reddit-hulu-80-minutes-third-outage-since-may.htm" rel="noopener noreferrer"&gt;AWS Oregon outage, 24 July 2026&lt;/a&gt; · &lt;a href="https://northflank.com/blog/hetzner-cloud-server-price-increases" rel="noopener noreferrer"&gt;Hetzner 2026 price increases&lt;/a&gt; · &lt;a href="https://www.datacenterdynamics.com/en/news/37signals-claims-it-saved-almost-2m-last-year-from-cloud-repatriation/" rel="noopener noreferrer"&gt;Cloud repatriation, 37signals (DCD)&lt;/a&gt; · &lt;a href="https://www.hbs.net/blog/cloud-repatriation-trends-cost-ai-and-the-push-towards-hybrid" rel="noopener noreferrer"&gt;Repatriation trends (HBS)&lt;/a&gt; · GDPR · NIS2 · DORA · Data Act · CLOUD Act (2018) · Schrems II (2020).&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>selfhosting</category>
      <category>opensource</category>
    </item>
    <item>
      <title>My AI agent tried to delete my secrets. It couldn't.</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Mon, 27 Jul 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/julesrobineau/my-ai-agent-tried-to-delete-my-secrets-it-couldnt-2hm0</link>
      <guid>https://dev.to/julesrobineau/my-ai-agent-tried-to-delete-my-secrets-it-couldnt-2hm0</guid>
      <description>&lt;p&gt;One day, my AI agent tried to delete the secrets of my infra.&lt;/p&gt;

&lt;p&gt;It wrote the Terraform command. It ran it. Nothing happened.&lt;/p&gt;

&lt;p&gt;Not because it changed its mind. Because my Terraform only applies through the pipeline, never locally. And because it has no access to do it.&lt;/p&gt;

&lt;p&gt;That is the best moment in my whole setup. An agent did something dangerous, and it cost nothing. That is the whole idea. Don't bet on a well-behaved agent. Build an agent that can't break things.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: an AI agent is a new dev. Fast, tireless, no judgment. Locally, I give it everything, so it moves fast. In prod, almost nothing: it reads the logs, and it fixes things by pull request. The guardrails do not live in the model. They live in hooks, pre-commit checks, GitHub rules and RBAC. You don't trust the agent. You make breaking things impossible.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is for developers and DevSecOps folks who let a coding agent act in their repo and their infra. Claude Code, Cursor, the tool does not matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;A coding agent is an LLM that reads your repo and runs commands for you.&lt;/p&gt;

&lt;p&gt;I run these agents every day, on my own SaaS. The stack is ordinary. Go for the backend. A k3s cluster for the infra. A k3s is a lightweight Kubernetes, which orchestrates containers. Terraform to provision everything. Terraform is IaC: you describe your infra in code. Grafana and Jaeger to see what happens.&lt;/p&gt;

&lt;p&gt;I come from security, and I am a bit paranoid. Handing prod keys to an agent, with no safety net, was a no.&lt;/p&gt;

&lt;h2&gt;
  
  
  An AI agent is a new dev
&lt;/h2&gt;

&lt;p&gt;Think back to onboarding a junior dev. You do not hand them prod access and the SSH keys on day one. You give them a laptop, a throwaway dev database, and one rule: everything goes through code review.&lt;/p&gt;

&lt;p&gt;An AI agent is the same onboarding. Except it types a hundred commands a minute, with no fatigue and no fear. That is least privilege: grant only the rights that are needed, nothing more. An over-permissioned agent is the real risk.&lt;/p&gt;

&lt;p&gt;This is not theoretical. In 2025, Replit's AI agent deleted an entire production database, in the middle of a code freeze. It then made up fake records and lied about what it had done. The CEO admitted an error that "should never be possible". His fix? Separate dev and prod, and rein the agent in. Exactly the topic of this article.&lt;/p&gt;

&lt;p&gt;The topic is serious enough that standards are moving. Zero Trust is a security approach: trust no one by default, verify on every access. OWASP published a Top 10 of risks for agentic applications. NIST, in its Zero Trust text, already talks about "subjects", not just users. An agent is a subject like any other. And the Cloud Security Alliance shipped a Zero Trust framework for agents. It is co-written with the inventor of Zero Trust.&lt;/p&gt;

&lt;p&gt;The right question is not "how many rights". It is "how much autonomy". Fewer rights, yes. But above all, less power to act unchecked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Locally, give it full power
&lt;/h2&gt;

&lt;p&gt;Locally, my agent has everything. All the dev secrets, all the commands, all the access. I want it to move fast.&lt;/p&gt;

&lt;p&gt;In my cluster, it roams freely in the dev space. A namespace is an isolated space inside the cluster. It deploys, breaks pods, tries again.&lt;/p&gt;

&lt;p&gt;The access level comes from the kubeconfig context. A kubeconfig is the file that tells &lt;code&gt;kubectl&lt;/code&gt; which cluster to talk to, and with what rights. I keep one context per environment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Locally, the agent works on the dev context: read-write&lt;/span&gt;
kubectl config use-context dev

&lt;span class="c"&gt;# The prod context exists too, but it is read-only (see below)&lt;/span&gt;
kubectl config use-context prod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why is that full power fine in dev? Because the blast radius is small. The blast radius is what it can break if things go wrong. In dev, the data is throwaway. A mistake costs five minutes, not a customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A per-environment permission matrix
&lt;/h2&gt;

&lt;p&gt;A dev does not have the same rights locally and in prod. Neither does your agent. Here is the map I follow, from most open to most locked down.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;dev&lt;/th&gt;
&lt;th&gt;staging&lt;/th&gt;
&lt;th&gt;prod&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shell commands&lt;/td&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;td&gt;allowlist&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read logs / traces&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes (read-only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List k8s resources&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write / delete&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read secrets&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apply Terraform&lt;/td&gt;
&lt;td&gt;pipeline&lt;/td&gt;
&lt;td&gt;pipeline&lt;/td&gt;
&lt;td&gt;pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Merge to main&lt;/td&gt;
&lt;td&gt;always via PR + review + green CI&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rest of this article is how I hold each cell of that matrix. Without ever counting on the agent's good sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the guardrails outside the model
&lt;/h2&gt;

&lt;p&gt;Here is the idea that holds the whole article. You do not trust the model to behave. You make the mistake impossible, from the outside. Four barriers: three on my machine, one out of the agent's reach.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The list of allowed commands
&lt;/h3&gt;

&lt;p&gt;My config file gives the agent a short allowlist. And it refuses the irreversible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(go build:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash(go test:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash(golangci-lint:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git commit:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git push:*)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git push:*--force*origin main*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(gh pr merge:* main*)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent can build, test, commit. It cannot force-push to main. It cannot merge a PR into main. Those two deny lines are worth gold.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The hook that asks before acting
&lt;/h3&gt;

&lt;p&gt;An allowlist does not cover everything. I add a hook that runs before every command. A hook is a script that runs before an action. Mine spots dangerous commands.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// PreToolUse hook: inspect every Bash command before it runs.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DANGEROUS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="sr"&gt;/rm&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+.*-rf&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                     &lt;span class="c1"&gt;// rm -rf on an absolute path&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;sudo&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;mkfs&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;dd&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+.*of=&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;dev&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;curl|wget&lt;/span&gt;&lt;span class="se"&gt;)\s&lt;/span&gt;&lt;span class="sr"&gt;+.*&lt;/span&gt;&lt;span class="se"&gt;\|\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;sh|bash&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// curl | bash&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;etc&lt;/span&gt;&lt;span class="se"&gt;\/(&lt;/span&gt;&lt;span class="sr"&gt;passwd|shadow&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;// reading sensitive files&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// No blind block. Ask a human, and log everything.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DANGEROUS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;hookSpecificOutput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;hookEventName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PreToolUse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;permissionDecision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ask&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;permissionDecisionReason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Risky command: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does not block blindly. It asks. A human decides. And every decision goes to a log.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The pre-commit that guards the repo
&lt;/h3&gt;

&lt;p&gt;A pre-commit is a script that runs before each commit. Mine runs gitleaks, the linter and the tests, and rejects sensitive files.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lefthook.yml: runs before every commit&lt;/span&gt;
&lt;span class="na"&gt;pre-commit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;block-secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                 &lt;span class="c1"&gt;# reject .env, .pem, keys&lt;/span&gt;
      &lt;span class="na"&gt;glob&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.env"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.pem"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.key"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id_rsa*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;echo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"sensitive&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;file:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{staged_files}";&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt;
    &lt;span class="na"&gt;gitleaks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitleaks protect --staged --redact&lt;/span&gt;
    &lt;span class="na"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;glob&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.go"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;golangci-lint run&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;glob&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.go"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;go test ./...&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key point fits in one sentence. These barriers do not care that an LLM wrote the command. They check the result, not the intent. That is deterministic security.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The GitHub rule, out of the agent's reach
&lt;/h3&gt;

&lt;p&gt;A local hook can be skipped. A server-side rule cannot. On GitHub, the main branch is protected by a ruleset. A ruleset is a set of rules applied to the branch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Protection on the main branch (GitHub ruleset)&lt;/span&gt;
&lt;span class="na"&gt;required_pull_request_reviews&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;required_approving_review_count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;   &lt;span class="c1"&gt;# at least one human review&lt;/span&gt;
&lt;span class="na"&gt;required_status_checks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;strict&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;                         &lt;span class="c1"&gt;# CI must be green and up to date&lt;/span&gt;
  &lt;span class="na"&gt;checks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lint"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gitleaks"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;allow_force_pushes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;              &lt;span class="c1"&gt;# no history rewrite&lt;/span&gt;
&lt;span class="na"&gt;allow_deletions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="c1"&gt;# no bypass: the rule applies to admins too&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nuance matters. The first three barriers live on my machine. This one lives on GitHub. The agent cannot touch it. It is the line nothing crosses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets never pass through the agent
&lt;/h2&gt;

&lt;p&gt;An agent that sees your prod secrets is a leak waiting to happen. So on my stack, it does not see them. Nobody sees them.&lt;/p&gt;

&lt;p&gt;Back to the very start of my infra repo. Second commit. A secret lands in clear text, in the Git history, in plain sight. Two commits. It took two commits for a secret to leak. That is why gitleaks runs on every commit today. gitleaks is a tool that spots secrets in code.&lt;/p&gt;

&lt;p&gt;But gitleaks is only the safety net. The real fix is that no secret is typed by hand anymore. Terraform generates them, then pushes them into a secret manager. A secret manager is a vault that stores and hands out passwords and keys. Mine is Infisical.&lt;/p&gt;

&lt;p&gt;From there, each secret is delivered where it belongs, per environment. Prod gets the prod secrets, staging gets its own. The agent gets none.&lt;/p&gt;

&lt;p&gt;And here is the real point: nobody reads these secrets in clear text. Not the agent. Not me either. I do not know a single production password. You cannot leak what you do not know.&lt;/p&gt;

&lt;h2&gt;
  
  
  In prod, the agent inherits your context, and nothing else
&lt;/h2&gt;

&lt;p&gt;One honest caveat. The local side already runs. The prod side, I am building right now. The cluster is coming up, and I put these rules in place as it does. So what I describe here is the model I am deploying.&lt;/p&gt;

&lt;p&gt;In prod, the agent switches to the prod kubeconfig context. That context is bound to a read-only role. RBAC is the Kubernetes system that says who is allowed to do what.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The prod context is bound to this role. Read-only, nothing else.&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Role&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;agent-readonly&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods/log"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;events"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;services"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# no create, update, delete, or exec&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No writes. No deletes. No shell inside a pod. The agent can list and observe, and that is all.&lt;/p&gt;

&lt;p&gt;To understand a bug, it reads logs and traces. But it does not poke the cluster. It queries Grafana and Loki, read-only. Loki is the log engine behind Grafana. It touches neither the database nor the secrets.&lt;/p&gt;

&lt;p&gt;This is the Zero Trust principle. You trust no one by default. You verify on every access. An agent is no exception. It is a privileged subject, like a service account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prod gets fixed by pull request, not by hand
&lt;/h2&gt;

&lt;p&gt;Go back to the agent that tried to delete my secrets. Why did nothing break? Because nothing applies locally. Prod only changes through the pipeline, behind a pull request.&lt;/p&gt;

&lt;p&gt;That is the GitOps idea: you change your infra by editing code, not by hand. The agent reads the logs, understands the problem, and proposes an IaC fix. Review and CI stand in the way. Then the pipeline applies it. Never the agent, never by hand.&lt;/p&gt;

&lt;p&gt;Here is the loop, in practice. A Jaeger trace shows a slow query. The agent spots the missing index. It opens a PR with the migration. I review, CI passes, it ships. It found the bug without ever touching the database.&lt;/p&gt;

&lt;p&gt;Sometimes you need a deeper read in prod. Then I grant read access, temporary and exceptional. This is just-in-time access: the right arrives when needed, and leaves after. You look, then you take it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit everything: an agent is a privileged user
&lt;/h2&gt;

&lt;p&gt;One last security reflex: keep a trace of everything. Zero Trust has a third principle. Assume a breach will happen one day.&lt;/p&gt;

&lt;p&gt;Every agent command goes to a structured log. Every decision from my hooks too. Here is the line left by the agent that tried to delete my secrets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"ts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-23T14:02:11Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"terraform destroy -target=module.secrets"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"decision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ask"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"HIGH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"violations"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"destructive terraform on secrets"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"session"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"a3f9c1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"claude-code-hook"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An event stream follows what all my agents do, in real time. The day something goes wrong, I answer one question. What did the agent do, exactly? Without that log, you cannot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The per-environment checklist
&lt;/h2&gt;

&lt;p&gt;Before you let a coding agent touch your infra, set its rights against these rules.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Locally, broad access: the agent moves fast, the blast radius is small&lt;/li&gt;
&lt;li&gt;In prod, a read-only kubeconfig context: RBAC get/list/watch, no write, no delete, no exec&lt;/li&gt;
&lt;li&gt;An allowlist of commands, and a deny on the irreversible (force push, merge to main)&lt;/li&gt;
&lt;li&gt;A hook before every command, asking for confirmation on the dangerous ones&lt;/li&gt;
&lt;li&gt;Pre-commit hooks: gitleaks, lint, tests&lt;/li&gt;
&lt;li&gt;On GitHub, main protected server-side: PR required, review required, CI green, force-push blocked&lt;/li&gt;
&lt;li&gt;Secrets generated by IaC, never read by the agent&lt;/li&gt;
&lt;li&gt;Prod logs and traces read read-only (Grafana/Loki), never a shell in the cluster&lt;/li&gt;
&lt;li&gt;Prod changes go through a pull request, applied by the pipeline, never by hand&lt;/li&gt;
&lt;li&gt;Just-in-time prod read access, temporary, then revoked&lt;/li&gt;
&lt;li&gt;Every agent action is logged and auditable&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;An AI agent is not a magic tool. It is a non-human colleague, very fast, with no judgment. Treat it like a dev. Broad locally, locked down in prod.&lt;/p&gt;

&lt;p&gt;And do not count on its good sense. Put the guardrails outside: hooks, pre-commit checks, GitHub rules, RBAC.&lt;/p&gt;

&lt;p&gt;And there is a side effect I did not expect. These barriers do not slow the agent down, they give it room. Since it cannot break anything, I let it work on its own: open PRs, chain tasks, keep going while I do something else. Without them, I would review every command. Boxed in, an agent goes further.&lt;/p&gt;

&lt;p&gt;Remember the agent that tried to delete my secrets. It couldn't. Don't bet on a well-behaved agent. Build an agent that can't break things.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://jrobineau.com/blog/2026-07-23-ai-agent-permissions-dev-vs-prod.html" rel="noopener noreferrer"&gt;jrobineau.com&lt;/a&gt;. I'm Jules Robineau, a senior Go backend &amp;amp; DevSecOps freelancer based in Paris. I build and harden production AI/backend systems at scale. &lt;a href="https://jrobineau.com/services.html" rel="noopener noreferrer"&gt;Get in touch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>devops</category>
      <category>llm</category>
    </item>
    <item>
      <title>Rebuild It to Understand It: From Network Protocols to LLM Agents</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Fri, 24 Jul 2026 07:30:00 +0000</pubDate>
      <link>https://dev.to/julesrobineau/rebuild-it-to-understand-it-from-network-protocols-to-llm-agents-35fa</link>
      <guid>https://dev.to/julesrobineau/rebuild-it-to-understand-it-from-network-protocols-to-llm-agents-35fa</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: you only truly understand a system once you rebuild it. I recoded TCP at school, then the DNS protocol, then Modbus, each time to understand it from the inside. A colleague just went through this with LLMs. He wrote a small agent in Go, and he finally understood tooling and the context window. An LLM is just one more system to demystify. Rebuild a tiny version, and you move from user to engineer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For developers who want to master LLMs, not just use them.&lt;/p&gt;

&lt;h2&gt;
  
  
  A colleague, a Go agent, a click
&lt;/h2&gt;

&lt;p&gt;This week, I am helping a colleague level up on LLMs. I explain the concepts. Context, tokens, tools. A token is a small piece of text the model reads and counts. He listens, but something does not click.&lt;/p&gt;

&lt;p&gt;Then he comes back, delighted. He wrote a small CLI in Go. A plain chat loop that calls a model and runs its tools. And now he gets it. The tooling, the context window, the loop. Not because I explained it. Because he rebuilt it.&lt;/p&gt;

&lt;p&gt;I know that click by heart. I have felt it many times, on other topics. Always the same method. To understand something, I rebuild it.&lt;/p&gt;

&lt;h2&gt;
  
  
  You only understand a system by rebuilding it
&lt;/h2&gt;

&lt;p&gt;Reading the docs gives you a map. Rebuilding gives you the terrain. They are not the same. The map says "there is a river here". The terrain lets you feel the current.&lt;/p&gt;

&lt;p&gt;When you rewrite a system, you can no longer bluff. Every byte has to sit in the right place. Every edge case lands on you. You do not think you understand. You understand, or your code fails.&lt;/p&gt;

&lt;p&gt;This is not an academic exercise. It is the opposite. You rebuild to act better afterward. To debug faster. To bend the tool. To build what no off-the-shelf library gives you.&lt;/p&gt;

&lt;h2&gt;
  
  
  TCP in C: the first time
&lt;/h2&gt;

&lt;p&gt;The very first time was at school, in C. I recoded pieces of TCP and UDP. The famous three-step handshake. And header parsing, field by field.&lt;/p&gt;

&lt;p&gt;TCP opens a connection in three messages. SYN, SYN-ACK, ACK. Before, that was one line in a lecture. After, it was bytes I placed into a packet myself.&lt;/p&gt;

&lt;p&gt;I invented nothing. The protocol had existed for forty years. But redoing it changed how I see the network. Since then, a packet capture is no mystery. It is a format I have written by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  DNS: recode the protocol to bend its subdomains
&lt;/h2&gt;

&lt;p&gt;Later, I took on DNS. DNS turns a name like jrobineau.com into an IP address. I rewrote it myself, in Go. The header, the questions, the answers, byte by byte.&lt;/p&gt;

&lt;p&gt;Rebuilding it, you hit a detail the docs gloss over. A domain name is a series of labels. Each label is prefixed by its length. "www" is a 3, then w, w, w.&lt;/p&gt;

&lt;p&gt;And then an idea shows up. If I control the labels, I control bytes. I can slip my own data into a subdomain. That is the principle of DNS exfiltration, in an authorized security context.&lt;/p&gt;

&lt;p&gt;My server receives the query, parses the name, and recovers the data hidden inside. I even handled name compression, a nasty corner of the protocol. No ready-made library would have shown me that. Rebuilding it did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modbus: rebuild it to find who owns the bug
&lt;/h2&gt;

&lt;p&gt;On a job with industrial hardware, we spoke Modbus. Modbus is an old protocol that drives controllers and sensors. The library we used was bad. Many bugs, much strange behavior.&lt;/p&gt;

&lt;p&gt;There was no way to tell where the pain came from. The protocol? The library? Our code? So I did the one thing that settles it. I recoded Modbus myself, in Go.&lt;/p&gt;

&lt;p&gt;The verdict: the protocol was fine. The culprit was the library. And once the protocol was rebuilt, the real payoff arrived. I could build tools around it, my way.&lt;/p&gt;

&lt;p&gt;I turned it into a small Go library with a Gin-like API. You declare a handler per register range. You add logging and recovery middleware. A 1979 industrial protocol, with the comfort of a web framework. That is bending knowledge to your need.&lt;/p&gt;

&lt;h2&gt;
  
  
  An LLM is one more system to rebuild
&lt;/h2&gt;

&lt;p&gt;Back to LLMs. In 2026, AI is sold as magic. A black box you talk to. And you stay a user, a bit passive, a bit at its mercy.&lt;/p&gt;

&lt;p&gt;But an LLM agent is not magic. It is a loop. You send messages to the model. It replies, sometimes asking for a tool. You run the tool. You send the result back. And you start again.&lt;/p&gt;

&lt;p&gt;The context window is everything the model sees right now. Your loop decides what goes in, and what to drop. The model remembers nothing. You are the one who feeds it the past on every turn.&lt;/p&gt;

&lt;p&gt;Here is the whole loop, in Go. Strip the varnish, and only this is left.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;runAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="n"&gt;LLM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;goal&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// The context window is this list. You alone fill it.&lt;/span&gt;
    &lt;span class="n"&gt;msgs&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;goal&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// 1. You send the whole context to the model.&lt;/span&gt;
        &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;msgs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c"&gt;// 2. No tool requested? The model is done, you return.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolCalls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c"&gt;// 3. You run each tool yourself, not the model.&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolCalls&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="c"&gt;// 4. You feed the result back into the context. Loop again.&lt;/span&gt;
            &lt;span class="n"&gt;msgs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you have written this, the fear fades. An "agent" is this loop plus a few good tools. Tool calling is just the model telling you which function to call. Nothing more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebuild, yes, but not everything, not forever
&lt;/h2&gt;

&lt;p&gt;The goal is not to rewrite everything for life. I do not ship my own TCP stack to production. I use the system's, and I am right to.&lt;/p&gt;

&lt;p&gt;You rebuild once, to understand. Then you trust, because you know what is in the box. It is earned trust, not blind trust.&lt;/p&gt;

&lt;p&gt;Rebuild when the stakes are high. A protocol at the core of your product. A tool you will debug often. A new tech, like LLMs, where everyone stays on the surface. That is where understanding pays.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist for learning a tech by rebuilding it
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;The next tech that impresses you, do not just use it. Rebuild a piece of it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Aim at the core, not the comfort. The agent loop, not the whole vendor API&lt;/li&gt;
&lt;li&gt;[ ] Keep it small. A CLI, one file, an afternoon are often enough&lt;/li&gt;
&lt;li&gt;[ ] Write the format by hand once. The bytes teach what the docs hide&lt;/li&gt;
&lt;li&gt;[ ] Hunt for the unlocking detail. DNS labels, the context loop&lt;/li&gt;
&lt;li&gt;[ ] Break it on purpose. You quickly see the limits and the traps&lt;/li&gt;
&lt;li&gt;[ ] A library lets you down? Rebuild to learn who really owns the bug&lt;/li&gt;
&lt;li&gt;[ ] Once you get it, bend it. Build the tool no ready-made library gives you&lt;/li&gt;
&lt;li&gt;[ ] Then drop your toy version. Go back to the production library, with a real mental model&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;The dev mojo has not changed. Using a tool without understanding it means staying at its mercy. Rebuilding it, even as a toy, takes back control.&lt;/p&gt;

&lt;p&gt;TCP, DNS, Modbus, an LLM agent. Every time, the same method. Rebuild to understand. Understand to bend. LLMs are simply the next system on the list.&lt;/p&gt;

&lt;p&gt;Training a team on LLMs, or want Go backend that holds up? &lt;a href="https://jrobineau.com/contact.html" rel="noopener noreferrer"&gt;That is what I do. Write to me.&lt;/a&gt; We do not suffer our tools. We understand them.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://www.rfc-editor.org/rfc/rfc1035" rel="noopener noreferrer"&gt;RFC 1035, DNS format&lt;/a&gt; · &lt;a href="https://www.rfc-editor.org/rfc/rfc9293" rel="noopener noreferrer"&gt;RFC 9293, TCP&lt;/a&gt; · &lt;a href="https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf" rel="noopener noreferrer"&gt;Modbus Application Protocol&lt;/a&gt; · &lt;a href="https://www.anthropic.com/research/building-effective-agents" rel="noopener noreferrer"&gt;Anthropic, Building effective agents&lt;/a&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>llm</category>
      <category>ai</category>
      <category>career</category>
    </item>
  </channel>
</rss>
