{"data":{"allMarkdownRemark":{"nodes":[{"id":"d77b122c-9c83-5b54-b2f8-b83a3ca4db1a","html":"<p>It was almost midnight and I was thirty seconds from closing the laptop.</p>\n<p>I’d been poking at <code class=\"language-text\">$COMPANY</code>’s creator dashboard for about an hour, not because I expected to find anything, but because it was the kind of tired where you keep clicking things instead of going to bed. I’d made a test campaign with a nonsense headline — <code class=\"language-text\">october-proof-6f4d9</code>, the sort of string you generate when you want to be able to grep for it later — and I opened a private window to check something completely unrelated.</p>\n<p>The homepage loaded. My nonsense string was sitting in the middle of it.</p>\n<p>My first thought wasn’t <em>I found something</em>. My first thought was that I’d screwed up my own test setup. That’s usually what it is. You think you’re in a clean browser profile and you’re not, or you’ve got a service worker caching something stale, or you left a devtools override on from two hours ago. I’ve embarrassed myself this way before.</p>\n<p>So I checked. Different browser: still there. Then I dropped to curl, because curl doesn’t have opinions:</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\"><span class=\"token request-line\"><span class=\"token method property\">GET</span> <span class=\"token request-target url\">/</span> <span class=\"token http-version property\">HTTP/1.1</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Host</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">www.$COMPANY.example</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">User-Agent</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">curl/8.4.0</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Accept</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">text/html</span></span></code></pre></div>\n<p>No cookies. No session. Nothing that could possibly identify me. And there it was in the server-rendered HTML:</p>\n<div class=\"gatsby-highlight\" data-language=\"html\"><pre class=\"language-html\"><code class=\"language-html\"><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>section</span> <span class=\"token attr-name\">data-module</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>creator-spotlight<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span>\n  <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>h2</span><span class=\"token punctuation\">></span></span>october-proof-6f4d9<span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>h2</span><span class=\"token punctuation\">></span></span>\n<span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>section</span><span class=\"token punctuation\">></span></span></code></pre></div>\n<p>I scrolled up to the response headers, and that’s the point where I actually sat back in the chair.</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\"><span class=\"token header\"><span class=\"token header-name keyword\">Cache-Control</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">public, s-maxage=300, stale-while-revalidate=60</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Age</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">19</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">X-Cache</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">HIT</span></span></code></pre></div>\n<p>My text wasn’t just on the homepage. It had been <em>cached</em> there. Some CDN node had taken a copy and was handing it out to whoever asked.</p>\n<p>The one thing keeping me calm was that the string was inert. Plain text, nothing else. I hadn’t put a script in a globally visible campaign, and by that point in the night I understood enough about what I was looking at to know that I never would. Because the other half of what I’d found — the half I’d stumbled into twenty minutes earlier and hadn’t fully processed yet — was that the homepage rendered campaign headlines as raw HTML.</p>\n<p>I ended up proving the two halves separately, and never together:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Test A — global placement\n  audience: ALL\n  content: plain text only\n  result: marker appeared on anonymous homepage and CDN cache\n\nTest B — script execution\n  audience: two accounts I controlled\n  content: redacted execution canary\n  result: JavaScript ran in the real homepage origin\n\nNever performed\n  audience: ALL\n  content: executable payload</code></pre></div>\n<p>A global placement primitive and a stored XSS primitive lived inside the same object. I didn’t need to combine them to know what the combination meant: anyone who loaded the homepage could have run attacker-controlled JavaScript under <code class=\"language-text\">$COMPANY</code>’s origin.</p>\n<p>The session cookie was <code class=\"language-text\">HttpOnly</code>. I’ll come back to how little that helped.</p>\n<h2 id=\"it-started-with-a-card\" style=\"position:relative;\"><a href=\"#it-started-with-a-card\" aria-label=\"it started with a card permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>It started with a card</h2>\n<p>Some context on the product. <code class=\"language-text\">$COMPANY</code> is a consumer platform — profiles, messages, public collections, the usual shape — and it has enough traffic that its homepage has quietly become a product in its own right rather than just a door.</p>\n<p>Most signed-in users never actually type the URL. They arrive from a notification or a shared link or the mobile app. But anonymous visitors do, and search traffic does, and logged-out users do, and anyone opening the site on a desktop after not using it for a week does. All of that funnels through the same discovery page, which is assembled from a stack of server-driven modules:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Top stories\nContinue where you left off\nPopular near you\nCreator spotlight\nRecommended collections</code></pre></div>\n<p>The contents shift by locale, experiment bucket, and whether you’re signed in. The HTML shell around them is cached hard.</p>\n<p>I hadn’t gone looking at the creator dashboard for any of this. I was there for something else entirely — an unrelated idea about image uploads that went nowhere. But the campaign editor caught my eye on the way past. It let a creator build a little promotional card for their own profile: headline, short description, image, button.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">┌──────────────────────────────────────────────┐\n│ Notes from building small language models   │\n│ A short collection of experiments.          │\n│                                  Read more   │\n└──────────────────────────────────────────────┘</code></pre></div>\n<p>Nothing about it looked dangerous. I typed some HTML into the headline field out of habit, the way you do, and it came out the other side as visible text. React escaping it, exactly as you’d hope.</p>\n<p>So I made a draft and watched the request go by:</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\"><span class=\"token request-line\"><span class=\"token method property\">POST</span> <span class=\"token request-target url\">/api/creator/v2/campaigns</span> <span class=\"token http-version property\">HTTP/1.1</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Host</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">www.$COMPANY.example</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Content-Type</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">application/json</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Cookie</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">__Host-session=&lt;redacted></span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">X-CSRF-Token</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">&lt;redacted></span></span>\n<span class=\"token application-json\">\n<span class=\"token punctuation\">{</span>\n  <span class=\"token string-property property\">\"headline\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"October systems notes\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"description\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"A small collection of experiments.\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"image_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"img_test_17\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"cta\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token string-property property\">\"label\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"Read more\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token string-property property\">\"url\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"/u/researcher/notes\"</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"surface\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"CREATOR_PROFILE\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"audience\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token string-property property\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"FOLLOWERS\"</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"status\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"DRAFT\"</span>\n<span class=\"token punctuation\">}</span></span></code></pre></div>\n<p>Two fields didn’t belong:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token property\">\"surface\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"CREATOR_PROFILE\"</span>\n<span class=\"token property\">\"audience\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span> <span class=\"token property\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"FOLLOWERS\"</span> <span class=\"token punctuation\">}</span></code></pre></div>\n<p>The dashboard had never offered me a choice about either. There was no placement dropdown, no audience selector. And yet the client was dutifully sending both values on every request, which almost always means the same thing: the server is accepting a generic object shared by more than one frontend, and this frontend just happens to only fill in the boring parts.</p>\n<p>That’s the moment where a boring evening turns into a late one.</p>\n<p>I pulled down the JavaScript bundle and searched for <code class=\"language-text\">CREATOR_PROFILE</code>. It had company:</p>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token keyword\">const</span> CampaignSurface <span class=\"token operator\">=</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token constant\">CREATOR_PROFILE</span><span class=\"token operator\">:</span> <span class=\"token string\">\"CREATOR_PROFILE\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token constant\">FOLLOWING_FEED</span><span class=\"token operator\">:</span> <span class=\"token string\">\"FOLLOWING_FEED\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token constant\">HOME_DISCOVERY</span><span class=\"token operator\">:</span> <span class=\"token string\">\"HOME_DISCOVERY\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token constant\">HOME_HERO</span><span class=\"token operator\">:</span> <span class=\"token string\">\"HOME_HERO\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token constant\">SEARCH_EMPTY_STATE</span><span class=\"token operator\">:</span> <span class=\"token string\">\"SEARCH_EMPTY_STATE\"</span>\n<span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>And a second enum right below it:</p>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token keyword\">const</span> CampaignAudience <span class=\"token operator\">=</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token constant\">FOLLOWERS</span><span class=\"token operator\">:</span> <span class=\"token string\">\"FOLLOWERS\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token constant\">USER_IDS</span><span class=\"token operator\">:</span> <span class=\"token string\">\"USER_IDS\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token constant\">LOCALE</span><span class=\"token operator\">:</span> <span class=\"token string\">\"LOCALE\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token constant\">ALL</span><span class=\"token operator\">:</span> <span class=\"token string\">\"ALL\"</span>\n<span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>Reading between the lines, these looked like they existed for an internal marketing console and for QA previews. Somebody’s editorial team needs to put a card on the homepage; somebody’s QA needs to target two specific accounts. Reasonable features. The creator dashboard was built on the same API types and simply chose not to render the dangerous options.</p>\n<p>Which left one question, and it’s the question I now ask about basically every product with more than one client: <strong>does the server know the difference between a creator and an editor, or does it only know the difference between the UIs?</strong></p>\n<h2 id=\"the-field-the-ui-never-sent\" style=\"position:relative;\"><a href=\"#the-field-the-ui-never-sent\" aria-label=\"the field the ui never sent permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The field the UI never sent</h2>\n<p>I kept this deliberately small. One field changed, campaign left in draft, audience restricted to two accounts I owned:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"headline\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"october-proof-6f4d9\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"description\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"plain text placement test\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"image_id\"</span><span class=\"token operator\">:</span> <span class=\"token null keyword\">null</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"cta\"</span><span class=\"token operator\">:</span> <span class=\"token null keyword\">null</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"surface\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"HOME_DISCOVERY\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"audience\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token property\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"USER_IDS\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token property\">\"ids\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span><span class=\"token string\">\"user_test_a\"</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"user_test_b\"</span><span class=\"token punctuation\">]</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"status\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"DRAFT\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>I genuinely expected a 403. Instead:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"cmp_01HCG4Q5DAJ2\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"owner_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"user_test_a\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"surface\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"HOME_DISCOVERY\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"audience\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token property\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"USER_IDS\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token property\">\"ids\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span><span class=\"token string\">\"user_test_a\"</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"user_test_b\"</span><span class=\"token punctuation\">]</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"status\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"DRAFT\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"review_required\"</span><span class=\"token operator\">:</span> <span class=\"token boolean\">false</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>I read <code class=\"language-text\">review_required: false</code> three or four times.</p>\n<p>There <em>was</em> an authorization check. It worked. It confirmed that I owned this campaign, which I did. What it never asked was whether I was allowed to point the campaign at the homepage. The backend was doing something along these lines:</p>\n<div class=\"gatsby-highlight\" data-language=\"typescript\"><pre class=\"language-typescript\"><code class=\"language-typescript\"><span class=\"token keyword\">async</span> <span class=\"token keyword\">function</span> <span class=\"token function\">createCampaign</span><span class=\"token punctuation\">(</span>user<span class=\"token punctuation\">,</span> input<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token function\">requireCreatorAccount</span><span class=\"token punctuation\">(</span>user<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">return</span> db<span class=\"token punctuation\">.</span>campaigns<span class=\"token punctuation\">.</span><span class=\"token function\">insert</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span>\n    ownerId<span class=\"token operator\">:</span> user<span class=\"token punctuation\">.</span>id<span class=\"token punctuation\">,</span>\n    <span class=\"token operator\">...</span>input<span class=\"token punctuation\">,</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Ownership enforced. Field authority not enforced at all.</p>\n<p>The logic underneath is seductive and wrong in a way I’ve now seen in a dozen codebases. A creator is allowed to create campaigns, therefore the campaign input is creator-controlled, therefore every field on it is creator-controlled. But <code class=\"language-text\">surface</code>, <code class=\"language-text\">audience</code>, <code class=\"language-text\">reviewRequired</code>, and the scheduling behaviour weren’t content. They were distribution policy. They decided who the object was aimed at, not what it said.</p>\n<p>The UI hid them. The API trusted them.</p>\n<p>I activated it for my two test accounts and opened the real homepage, signed in as each one. There was my marker, sitting in the Creator spotlight module on the actual production discovery page.</p>\n<p>At that point I thought I had a decent authorization bug with a limited blast radius. Worth reporting, not worth losing sleep over. Then I looked at how the module was rendering the headline.</p>\n<h2 id=\"the-html-shell\" style=\"position:relative;\"><a href=\"#the-html-shell\" aria-label=\"the html shell permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The HTML shell</h2>\n<p>On the creator profile, the campaign rendered the way you’d expect:</p>\n<div class=\"gatsby-highlight\" data-language=\"jsx\"><pre class=\"language-jsx\"><code class=\"language-jsx\"><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>h2</span><span class=\"token punctuation\">></span></span><span class=\"token punctuation\">{</span>campaign<span class=\"token punctuation\">.</span>headline<span class=\"token punctuation\">}</span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>h2</span><span class=\"token punctuation\">></span></span></code></pre></div>\n<p>Text is text. Angle brackets stay angle brackets. This is why my earlier HTML-in-the-headline poke had come back inert — I’d tested the safe surface and drawn a conclusion about the whole system.</p>\n<p>The homepage used a completely different pipeline. Its API didn’t return a headline and a description. It returned a server-driven component with an HTML fragment inside it:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"creator_spotlight\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"campaign_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"cmp_01HCG4Q5DAJ2\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"html\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"&lt;div class=\\\"spotlight-copy\\\">&lt;h2>october-proof-6f4d9&lt;/h2>&lt;/div>\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"tracking\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token property\">\"surface\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"home_discovery\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token property\">\"experiment\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"spotlight_v3\"</span>\n  <span class=\"token punctuation\">}</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>And the React side was, near enough:</p>\n<div class=\"gatsby-highlight\" data-language=\"jsx\"><pre class=\"language-jsx\"><code class=\"language-jsx\"><span class=\"token keyword\">function</span> <span class=\"token function\">ServerDrivenModule</span><span class=\"token punctuation\">(</span><span class=\"token parameter\"><span class=\"token punctuation\">{</span> module <span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">return</span> <span class=\"token punctuation\">(</span>\n    <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>section</span>\n      <span class=\"token attr-name\">data-module</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span>module<span class=\"token punctuation\">.</span>type<span class=\"token punctuation\">}</span></span>\n      <span class=\"token attr-name\">dangerouslySetInnerHTML</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span> <span class=\"token literal-property property\">__html</span><span class=\"token operator\">:</span> module<span class=\"token punctuation\">.</span>html <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span>\n    <span class=\"token punctuation\">/></span></span>\n  <span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>The React team named that property well. It’s hard to type it by accident.</p>\n<p>But here’s the part that made this interesting rather than merely bad: <strong>the team had not blindly trusted creator input.</strong> There was a sanitizer. Someone had thought about this. The problem was where they’d put it.</p>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token keyword\">const</span> template <span class=\"token operator\">=</span> DOMPurify<span class=\"token punctuation\">.</span><span class=\"token function\">sanitize</span><span class=\"token punctuation\">(</span><span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">\n  &lt;div class=\"spotlight-copy\">\n    &lt;h2>{{{headline_html}}}&lt;/h2>\n    &lt;p>{{description}}&lt;/p>\n  &lt;/div>\n</span><span class=\"token template-punctuation string\">`</span></span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n<span class=\"token keyword\">const</span> html <span class=\"token operator\">=</span> Mustache<span class=\"token punctuation\">.</span><span class=\"token function\">render</span><span class=\"token punctuation\">(</span>template<span class=\"token punctuation\">,</span> campaign<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>The template gets sanitized. The template is safe — of course it is, it’s a hardcoded string written by an employee. The untrusted data isn’t in it yet.</p>\n<p>Then Mustache runs. Triple braces mean “insert without escaping.” The sanitizer had already looked at a harmless placeholder, given it a thumbs up, and gone home. Mustache then swapped that placeholder for whatever a creator had typed.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">sanitize(template)\n        ↓\ninsert(untrusted_data)\n        ↓\ninnerHTML</code></pre></div>\n<p>Which needed to be, at absolute minimum:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">insert(data)\n        ↓\nsanitize(final_html)\n        ↓\ninnerHTML</code></pre></div>\n<p>Or better, and this is the version I argued for later:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">structured data\n        ↓\nReact elements\n        ↓\nno raw HTML sink</code></pre></div>\n<p>I tested the mildest thing I could think of first:</p>\n<div class=\"gatsby-highlight\" data-language=\"html\"><pre class=\"language-html\"><code class=\"language-html\">October <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>em</span> <span class=\"token attr-name\">data-proof</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>6f4d9<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span>systems<span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>em</span><span class=\"token punctuation\">></span></span> notes</code></pre></div>\n<p>On the creator profile it displayed as literal text with the tags visible. On the homepage, “systems” came out in italics, and the <code class=\"language-text\">data-proof</code> attribute was sitting there in the DOM when I inspected it.</p>\n<p>My data had crossed the line from text into markup. That’s not the same as script execution — HTML injection and XSS are cousins, not twins, and I’ve seen people conflate them in reports and get downgraded for it. But it meant the boundary was gone, and everything after it was a question of degree.</p>\n<p>Then I noticed the CSP:</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\">Content-Security-Policy-Report-Only:\n  default-src 'self' https: data: blob:;\n  script-src 'self' 'unsafe-inline' https:;\n  report-uri /csp/report</code></pre></div>\n<p>Report-only. It would file a complaint. It would not stop anything.</p>\n<h2 id=\"the-part-where-i-had-to-decide-something\" style=\"position:relative;\"><a href=\"#the-part-where-i-had-to-decide-something\" aria-label=\"the part where i had to decide something permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The part where I had to decide something</h2>\n<p>This is the section I’ve rewritten the most times, because it’s the part that actually mattered and it’s the part that’s easiest to make sound self-congratulatory.</p>\n<p>I had two things. I could put text on a globally cached homepage. I could get HTML into a sink that would execute it. Combining them was a single JSON field away — change <code class=\"language-text\">USER_IDS</code> to <code class=\"language-text\">ALL</code>, hit activate, and I’d have the most complete proof-of-concept anyone could ask for.</p>\n<p>I want to be honest that the thought was appealing for about four seconds. Not because I wanted to hurt anyone, but because an airtight demo is easier to report than an argument. You don’t have to convince a triager of anything if you can just show them.</p>\n<p>But the demo would have run in strangers’ browsers. Real people, on a real homepage, on a Tuesday night, having done nothing but open a website. There is no version of “it was only a harmless canary” that survives contact with the fact that I would have been executing code in other people’s sessions without their knowledge. And the cache meant I couldn’t take it back — I’d have had to wait for a purge I didn’t control.</p>\n<p>So I split it, and did each half in the smallest way I could think of.</p>\n<p><strong>Execution first, audience of two.</strong> I kept the campaign restricted to the accounts I owned and replaced the italics with a redacted browser-execution canary. It did exactly two things: wrote a random token into a <code class=\"language-text\">data-xss-proof</code> attribute on the page, and requested a one-time image URL from a server I control containing the same token. It didn’t touch cookies, local storage, messages, profile data, or any API response. It didn’t persist. It didn’t modify an account. The working markup isn’t in this post; it went to <code class=\"language-text\">$COMPANY</code> privately and nowhere else.</p>\n<p>I opened the homepage as <code class=\"language-text\">user_test_b</code> and looked at the root element:</p>\n<div class=\"gatsby-highlight\" data-language=\"html\"><pre class=\"language-html\"><code class=\"language-html\"><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>html</span> <span class=\"token attr-name\">data-xss-proof</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>9b7e2c1a<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span></code></pre></div>\n<p>And my server log:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">00:18:43  GET /xss-proof/9b7e2c1a.gif HTTP/1.1\n           Referer: https://www.$COMPANY.example/\n           User-Agent: Mozilla/5.0 ... Chrome/118.0 ...</code></pre></div>\n<p>I did it again in Firefox with a fresh token, because one browser is an anecdote. Then I deleted the campaign and confirmed both test accounts were getting clean homepage HTML again.</p>\n<p><strong>Placement second, inert content.</strong> New campaign, plain text only:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">october-global-placement-14c8</code></pre></div>\n<p>Audience <code class=\"language-text\">ALL</code>. Activate, load the anonymous homepage once, deactivate. The whole window was under thirty seconds and I had the deactivation request queued up before I sent the activation.</p>\n<p>The marker showed up in:</p>\n<ul>\n<li>a logged-out Chrome profile</li>\n<li>a private Firefox window</li>\n<li>a cookie-less curl request</li>\n<li>a request from a second network location</li>\n<li>the server-rendered homepage HTML</li>\n<li>a cached response with a non-zero <code class=\"language-text\">Age</code></li>\n</ul>\n<p>And then — this is the bit that made my stomach turn slightly — it kept showing up after I’d deleted the campaign. The source object was gone. The rendered copies weren’t. They stayed until the CDN objects expired or got purged, and neither of those was something I could trigger.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">ordinary creator account\n        │\n        │ client-controlled surface + audience\n        ▼\nglobal homepage campaign\n        │\n        │ raw HTML inserted after sanitization\n        ▼\nstored XSS in www origin\n        │\n        │ public CDN cache\n        ▼\nevery browser receiving that homepage object</code></pre></div>\n<p>Global placement: proven with inert text. Script execution: proven with a private audience. The combination was no longer a technical question, and turning it into one would have been an unsafe act rather than a research step.</p>\n<p>So I stopped, and started writing.</p>\n<h2 id=\"the-cookie-was-doing-its-job-perfectly\" style=\"position:relative;\"><a href=\"#the-cookie-was-doing-its-job-perfectly\" aria-label=\"the cookie was doing its job perfectly permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The cookie was doing its job perfectly</h2>\n<p>While I was drafting, I checked the session cookie, half-hoping for something that would soften the impact.</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\"><span class=\"token header\"><span class=\"token header-name keyword\">Set-Cookie</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">__Host-session=&lt;redacted>;\n  Path=/;\n  Secure;\n  HttpOnly;\n  SameSite=Lax</span></span></code></pre></div>\n<p>That’s a well-configured cookie. <code class=\"language-text\">__Host-</code> prefix, <code class=\"language-text\">Secure</code>, <code class=\"language-text\">HttpOnly</code>, <code class=\"language-text\">SameSite</code>. Somebody read the guidance and followed it. My canary had confirmed the session value simply wasn’t present in the script-visible cookie string.</p>\n<p>It helped much less than it looks like it should.</p>\n<p>The injected script wasn’t running <em>near</em> <code class=\"language-text\">https://www.$COMPANY.example/</code>. It was running <em>as part of</em> it. Which means when it made a request to any other path on that origin, the browser attached the session cookie by itself, without asking anyone, exactly as designed. And because the response was same-origin, the script could read it.</p>\n<p>The cookie stayed secret. Its authority was available on request.</p>\n<p>In deliberately non-exfiltrating pseudocode:</p>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token comment\">// Blocked by HttpOnly:</span>\n<span class=\"token keyword\">const</span> rawSession <span class=\"token operator\">=</span> document<span class=\"token punctuation\">.</span>cookie<span class=\"token punctuation\">;</span>\n\n<span class=\"token comment\">// Still fully authenticated by the browser:</span>\n<span class=\"token keyword\">const</span> response <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> <span class=\"token function\">fetch</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"/api/account/me\"</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">const</span> myOwnTestAccount <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> response<span class=\"token punctuation\">.</span><span class=\"token function\">json</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>I ran the second one exactly once, against my own account, and the response contained a user ID that was already rendered on the page in front of me. I logged it to the console and it went nowhere. That was enough to establish the model:</p>\n<blockquote>\n<p>XSS doesn’t need to steal an HttpOnly cookie when it can make the victim’s browser use it.</p>\n</blockquote>\n<p><code class=\"language-text\">SameSite=Lax</code> was similarly beside the point. SameSite governs whether cookies ride along with <em>cross-site</em> requests. This script wasn’t cross-site — it was executing on the application’s own origin, which is the one place SameSite has nothing to say about.</p>\n<p>CSRF tokens weren’t a boundary either. A script inside the origin can read tokens out of the DOM or out of an API response and then submit requests in exactly the shape the real application uses. There’s nothing to forge when you’re already inside.</p>\n<p>The cookie flags eliminated one category of theft. They did nothing about the authority of code that’s already in the page.</p>\n<h2 id=\"what-it-could-have-reached\" style=\"position:relative;\"><a href=\"#what-it-could-have-reached\" aria-label=\"what it could have reached permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>What it could have reached</h2>\n<p>I want to be precise here, because this is where reports get sloppy and where I think researchers do real damage — both to their credibility and occasionally to users.</p>\n<p>I tested one read-only request against one account I owned, and then stopped. I did not go endpoint-hunting. So the report separated what I’d actually confirmed from what a reasonable person should assume follows:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Confirmed\n- Any ordinary creator account could choose a homepage surface.\n- The same account could choose an ALL audience.\n- Active homepage campaigns entered a public CDN cache.\n- Campaign headline HTML was inserted after sanitization.\n- JavaScript executed in the production www origin.\n- Same-origin authenticated requests succeeded as an owned test user.\n\nNot tested\n- Reading private messages or saved content\n- Changing email, password, or recovery settings\n- Creating access tokens\n- Accessing payment information\n- Impersonating users in comments or messages\n- Persistence through service workers or other browser storage\n- Targeting staff or administrator accounts</code></pre></div>\n<p>The plausible impact, subject to whatever authorization and reauthentication each endpoint enforces, is the standard same-origin script list: read what the current user can read, do what the current session can do, read any non-<code class=\"language-text\">HttpOnly</code> browser storage for the origin, rewrite the page to harvest credentials or anything else typed into it, post as the user, and — the one that matters most — catch whichever privileged employee happened to open the homepage that morning.</p>\n<p>That last point is why placement mattered more than the injection itself.</p>\n<p>Stored XSS on a creator profile needs the victim to visit that profile. Stored XSS in a DM needs them to open the conversation. Stored XSS in a homepage module needs them to <em>use the product normally</em>. There’s no suspicious link to squint at, no attachment, no click to regret. The delivery mechanism is the page they trust most.</p>\n<h2 id=\"the-cache-made-it-bigger\" style=\"position:relative;\"><a href=\"#the-cache-made-it-bigger\" aria-label=\"the cache made it bigger permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The cache made it bigger</h2>\n<p>The campaign service fired an event on activation:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">campaign.activated\n  → home-composer consumes event\n  → locale-specific home document regenerated\n  → CDN caches rendered document</code></pre></div>\n<p>And the homepage cache key looked roughly like:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">home:v4:en-US:anonymous\nhome:v4:en-US:signed-in</code></pre></div>\n<p>Locale plus a coarse auth state. No campaign owner in the key — and that’s correct, because the content was <em>supposed</em> to be shared. A public homepage should be cacheable. This wasn’t a cache bug.</p>\n<p>What the cache did was change the shape of the vulnerability:</p>\n<ol>\n<li>The attacker didn’t need to stick around after publishing.</li>\n<li>Deleting the source campaign didn’t remove the copies already at the edge.</li>\n<li>Anonymous users got it before a session existed.</li>\n<li>Signed-in users got it with a live session.</li>\n<li>Edge nodes replicated one stored object across regions.</li>\n</ol>\n<p>The browser had no way to know the HTML started life in a creator dashboard. The CDN had no way to know it contained user-controlled markup. The home renderer had no way to know the creator should never have been able to select its surface.</p>\n<p>Every component received something that looked completely valid coming from the component directly upstream. The bug lived in a sentence nobody had written down anywhere:</p>\n<blockquote>\n<p>A creator-owned campaign may not become trusted homepage HTML.</p>\n</blockquote>\n<h2 id=\"sending-it\" style=\"position:relative;\"><a href=\"#sending-it\" aria-label=\"sending it permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Sending it</h2>\n<p>I filed at 01:07 UTC, which explains some of the sentence construction in the original draft.</p>\n<p>Title:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Critical: ordinary creator can publish stored XSS to globally cached homepage</code></pre></div>\n<p>I’ve learned to make the summary fit on one screen, because the first person to read it is triaging on a phone:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Precondition\n  Any account eligible to create a creator campaign.\n\nAuthorization failure\n  POST /api/creator/v2/campaigns accepts internal-only `surface` and\n  `audience` values from the creator client without a server-side allowlist.\n\nXSS sink\n  Homepage renderer sanitizes the Mustache template before inserting\n  `headline_html` through triple braces, then returns the result to a React\n  `dangerouslySetInnerHTML` sink.\n\nDistribution\n  HOME_DISCOVERY + audience ALL is rendered into anonymous and signed-in\n  homepage documents and cached publicly at the CDN.\n\nImpact\n  Attacker-controlled JavaScript can execute for homepage visitors under\n  the primary application origin. HttpOnly prevents direct cookie reads,\n  but the script can still issue and read same-origin authenticated API\n  requests with the victim's session.\n\nSafety\n  Global distribution was tested with plain text only. Script execution\n  was tested only against two owned accounts. The two conditions were never\n  combined. No user data was accessed.</code></pre></div>\n<p>Attached: account IDs, campaign IDs for both proofs, full request and response pairs, the redacted payload in a private file, screen recordings, CDN headers from two regions, timestamps for every create/activate/deactivate and how long the cache held on afterwards, a suggested emergency kill switch, and remediation notes.</p>\n<p>Acknowledged at 01:19. Then at 01:31, an engineer asked the question I’d been expecting and slightly dreading:</p>\n<blockquote>\n<p>Did you ever make executable content visible to audience ALL?</p>\n</blockquote>\n<p>I appreciated that they asked directly instead of dancing around it. I answered:</p>\n<blockquote>\n<p>No. ALL received a random plain-text marker only. Executable content was limited to two user IDs I control. The report demonstrates each required property independently.</p>\n</blockquote>\n<p>That was the whole exchange. No follow-up, no suspicion. But it’s the reason I structured the testing the way I did — so that when someone eventually asked, the answer could be one paragraph instead of a negotiation.</p>\n<p>01:46, marked critical. 02:03, creator campaigns vanished from the homepage. I went to bed around three.</p>\n<h2 id=\"the-kill-switch\" style=\"position:relative;\"><a href=\"#the-kill-switch\" aria-label=\"the kill switch permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The kill switch</h2>\n<p>The first fix didn’t go anywhere near the sanitizer, which I thought was the right instinct.</p>\n<p>They killed the Creator spotlight module at the home-composer layer and purged the associated CDN keys. The vulnerable campaign objects still existed in the database, but the highest-impact sink stopped consuming them. You cut the delivery path first and argue about root cause in the morning.</p>\n<p>The API hotfix landed alongside it — the server simply stopped listening to the client on anything that counted:</p>\n<div class=\"gatsby-highlight\" data-language=\"typescript\"><pre class=\"language-typescript\"><code class=\"language-typescript\"><span class=\"token keyword\">const</span> campaign <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> <span class=\"token function\">createCreatorCampaign</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span>\n  ownerId<span class=\"token operator\">:</span> user<span class=\"token punctuation\">.</span>id<span class=\"token punctuation\">,</span>\n  headline<span class=\"token operator\">:</span> input<span class=\"token punctuation\">.</span>headline<span class=\"token punctuation\">,</span>\n  description<span class=\"token operator\">:</span> input<span class=\"token punctuation\">.</span>description<span class=\"token punctuation\">,</span>\n  imageId<span class=\"token operator\">:</span> input<span class=\"token punctuation\">.</span>image_id<span class=\"token punctuation\">,</span>\n  cta<span class=\"token operator\">:</span> input<span class=\"token punctuation\">.</span>cta<span class=\"token punctuation\">,</span>\n\n  <span class=\"token comment\">// Server-owned policy:</span>\n  surface<span class=\"token operator\">:</span> <span class=\"token string\">\"CREATOR_PROFILE\"</span><span class=\"token punctuation\">,</span>\n  audience<span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span> type<span class=\"token operator\">:</span> <span class=\"token string\">\"FOLLOWERS\"</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n  status<span class=\"token operator\">:</span> <span class=\"token string\">\"DRAFT\"</span><span class=\"token punctuation\">,</span>\n  reviewRequired<span class=\"token operator\">:</span> <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span>\n<span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>Existing creator-owned campaigns pointing at non-profile surfaces were suspended pending review, which I’d suggested and was glad to see done without argument.</p>\n<p>By 03:12 my old request was dead:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"error\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"invalid_campaign_surface\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"message\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"Creators may publish campaigns only to CREATOR_PROFILE\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>The CDN had stopped serving my inert marker. The global delivery path was closed.</p>\n<p>The sink still needed fixing, obviously — internal editors, a compromised staff account, a bulk import, or the next authorization mistake could all reach it again. But the emergency was over.</p>\n<h2 id=\"why-the-sanitizer-wasnt-lying\" style=\"position:relative;\"><a href=\"#why-the-sanitizer-wasnt-lying\" aria-label=\"why the sanitizer wasnt lying permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Why the sanitizer wasn’t lying</h2>\n<p>The renderer team’s first read was that this was a DOMPurify bypass. I get why — there’s a sanitizer, and something got through, so the sanitizer must have failed.</p>\n<p>It didn’t. It never saw the dangerous value. It was handed this:</p>\n<div class=\"gatsby-highlight\" data-language=\"html\"><pre class=\"language-html\"><code class=\"language-html\"><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>h2</span><span class=\"token punctuation\">></span></span>{{{headline_html}}}<span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>h2</span><span class=\"token punctuation\">></span></span></code></pre></div>\n<p>There was nothing to remove. DOMPurify answered the question it was asked, correctly and completely. The question was:</p>\n<blockquote>\n<p>Is this template safe right now?</p>\n</blockquote>\n<p>The application needed the answer to a different one:</p>\n<blockquote>\n<p>Is the final document safe after every transformation has finished?</p>\n</blockquote>\n<p>I keep coming back to this because it generalizes well past XSS. Validation can be quietly undone by anything that happens afterwards: decoding, interpolation, re-parsing, concatenation, decompression, redirect following, type coercion. A check is only meaningful at the boundary immediately before interpretation. For HTML, that boundary is the DOM sink and nowhere else.</p>\n<p>The <a href=\"https://github.com/cure53/DOMPurify\">DOMPurify docs</a> say this outright — modifying sanitized markup afterwards can void the protection. The renderer had done exactly that, just indirectly, through a template engine, in a way that looked completely fine in code review because the word <code class=\"language-text\">sanitize</code> was right there on the screen.</p>\n<p>The ordering made the sanitizer decorative. It was doing real work on a string that didn’t need it.</p>\n<h2 id=\"the-permanent-fix\" style=\"position:relative;\"><a href=\"#the-permanent-fix\" aria-label=\"the permanent fix permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The permanent fix</h2>\n<p>Because the finding crossed several trust boundaries, the remediation had to as well. Six pieces:</p>\n<h3 id=\"1-the-client-stopped-choosing-distribution-policy\" style=\"position:relative;\"><a href=\"#1-the-client-stopped-choosing-distribution-policy\" aria-label=\"1 the client stopped choosing distribution policy permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>1. The client stopped choosing distribution policy</h3>\n<p>The creator API got its own input type, separate from the internal editorial one.</p>\n<p>Before:</p>\n<div class=\"gatsby-highlight\" data-language=\"graphql\"><pre class=\"language-graphql\"><code class=\"language-graphql\"><span class=\"token keyword\">input</span> <span class=\"token atom-input class-name\">CampaignInput</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token attr-name\">headline</span><span class=\"token punctuation\">:</span> <span class=\"token scalar\">String</span><span class=\"token operator\">!</span>\n  <span class=\"token attr-name\">description</span><span class=\"token punctuation\">:</span> <span class=\"token scalar\">String</span>\n  <span class=\"token attr-name\">imageId</span><span class=\"token punctuation\">:</span> <span class=\"token scalar\">ID</span>\n  <span class=\"token attr-name\">cta</span><span class=\"token punctuation\">:</span> <span class=\"token atom-input class-name\">CampaignCTAInput</span>\n  <span class=\"token attr-name\">surface</span><span class=\"token punctuation\">:</span> <span class=\"token class-name\">CampaignSurface</span><span class=\"token operator\">!</span>\n  <span class=\"token attr-name\">audience</span><span class=\"token punctuation\">:</span> <span class=\"token atom-input class-name\">CampaignAudienceInput</span><span class=\"token operator\">!</span>\n  <span class=\"token attr-name\">status</span><span class=\"token punctuation\">:</span> <span class=\"token class-name\">CampaignStatus</span><span class=\"token operator\">!</span>\n  <span class=\"token attr-name\">reviewRequired</span><span class=\"token punctuation\">:</span> <span class=\"token scalar\">Boolean</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>After:</p>\n<div class=\"gatsby-highlight\" data-language=\"graphql\"><pre class=\"language-graphql\"><code class=\"language-graphql\"><span class=\"token keyword\">input</span> <span class=\"token atom-input class-name\">CreatorCampaignInput</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token attr-name\">headline</span><span class=\"token punctuation\">:</span> <span class=\"token scalar\">String</span><span class=\"token operator\">!</span>\n  <span class=\"token attr-name\">description</span><span class=\"token punctuation\">:</span> <span class=\"token scalar\">String</span>\n  <span class=\"token attr-name\">imageId</span><span class=\"token punctuation\">:</span> <span class=\"token scalar\">ID</span>\n  <span class=\"token attr-name\">cta</span><span class=\"token punctuation\">:</span> <span class=\"token atom-input class-name\">CampaignCTAInput</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Every policy field derived server-side. Editorial tools moved to a different mutation behind role checks and an approval workflow. Hiding a field in the UI stopped being the authorization model.</p>\n<h3 id=\"2-html-stopped-being-the-content-model\" style=\"position:relative;\"><a href=\"#2-html-stopped-being-the-content-model\" aria-label=\"2 html stopped being the content model permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>2. HTML stopped being the content model</h3>\n<p>The homepage no longer accepts an opaque <code class=\"language-text\">html</code> blob for creator content. The API returns data:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"creator_spotlight\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"headline\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"October systems notes\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"description\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"A small collection of experiments.\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"cta\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token property\">\"label\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"Read more\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token property\">\"href\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"/u/researcher/notes\"</span>\n  <span class=\"token punctuation\">}</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>And React renders it as text:</p>\n<div class=\"gatsby-highlight\" data-language=\"jsx\"><pre class=\"language-jsx\"><code class=\"language-jsx\"><span class=\"token keyword\">function</span> <span class=\"token function\">CreatorSpotlight</span><span class=\"token punctuation\">(</span><span class=\"token parameter\"><span class=\"token punctuation\">{</span> module <span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">return</span> <span class=\"token punctuation\">(</span>\n    <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>section</span> <span class=\"token attr-name\">className</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>creator-spotlight<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>h2</span><span class=\"token punctuation\">></span></span><span class=\"token punctuation\">{</span>module<span class=\"token punctuation\">.</span>headline<span class=\"token punctuation\">}</span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>h2</span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>p</span><span class=\"token punctuation\">></span></span><span class=\"token punctuation\">{</span>module<span class=\"token punctuation\">.</span>description<span class=\"token punctuation\">}</span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>p</span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\n      </span><span class=\"token punctuation\">{</span>module<span class=\"token punctuation\">.</span>cta <span class=\"token operator\">&amp;&amp;</span> <span class=\"token punctuation\">(</span>\n        <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>a</span> <span class=\"token attr-name\">href</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token function\">validateInternalHref</span><span class=\"token punctuation\">(</span>module<span class=\"token punctuation\">.</span>cta<span class=\"token punctuation\">.</span>href<span class=\"token punctuation\">)</span><span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\n          </span><span class=\"token punctuation\">{</span>module<span class=\"token punctuation\">.</span>cta<span class=\"token punctuation\">.</span>label<span class=\"token punctuation\">}</span><span class=\"token plain-text\">\n        </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>a</span><span class=\"token punctuation\">></span></span>\n      <span class=\"token punctuation\">)</span><span class=\"token punctuation\">}</span><span class=\"token plain-text\">\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>section</span><span class=\"token punctuation\">></span></span>\n  <span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>The feature lost the ability to put arbitrary HTML in a headline. As far as I know, not one person has complained.</p>\n<h3 id=\"3-rich-text-became-structured-instead-of-trusted\" style=\"position:relative;\"><a href=\"#3-rich-text-became-structured-instead-of-trusted\" aria-label=\"3 rich text became structured instead of trusted permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>3. Rich text became structured instead of trusted</h3>\n<p>A handful of editorial modules did legitimately need emphasis, links, and line breaks. Those moved to a restricted document format rather than raw HTML:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"heading\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"children\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span>\n    <span class=\"token punctuation\">{</span> <span class=\"token property\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"text\"</span><span class=\"token punctuation\">,</span> <span class=\"token property\">\"value\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"Build \"</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">{</span> <span class=\"token property\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"emphasis\"</span><span class=\"token punctuation\">,</span> <span class=\"token property\">\"value\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"smaller\"</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">{</span> <span class=\"token property\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"text\"</span><span class=\"token punctuation\">,</span> <span class=\"token property\">\"value\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\" systems\"</span> <span class=\"token punctuation\">}</span>\n  <span class=\"token punctuation\">]</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Known node types map to known React components; unknown nodes are rejected. No generic string-to-HTML conversion, no triple-brace escape hatch. Where legacy HTML still exists, it’s sanitized after all interpolation and immediately before the sink, and nothing is permitted to touch it afterwards.</p>\n<h3 id=\"4-the-policy-started-blocking\" style=\"position:relative;\"><a href=\"#4-the-policy-started-blocking\" aria-label=\"4 the policy started blocking permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>4. The policy started blocking</h3>\n<p>The homepage CSP moved from report-only to enforced, nonce-based. The production policy is more involved than this, but the relevant properties:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">script-src used nonces for application scripts\ninline event handlers were blocked\nobject-src was none\nbase-uri was restricted\nthird-party script origins were reduced\nreport-only remained as a second policy for future tightening</code></pre></div>\n<p>I made a point of framing CSP as defense in depth in the report, not as the fix. A weak CSP wouldn’t have excused unsafe rendering, and a strong one wouldn’t have made client-controlled homepage placement acceptable. But after the change, an equivalent injection has to beat both the rendering boundary and the browser.</p>\n<h3 id=\"5-sensitive-actions-got-a-second-boundary\" style=\"position:relative;\"><a href=\"#5-sensitive-actions-got-a-second-boundary\" aria-label=\"5 sensitive actions got a second boundary permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>5. Sensitive actions got a second boundary</h3>\n<p>The account team went through the high-impact operations a same-origin script could invoke — email changes, recovery settings, API token creation, session management — and put them behind recent reauthentication or a step-up challenge.</p>\n<p>This does nothing to stop XSS reading ordinary session-visible data. What it does is reduce the odds that a single compromised page view turns into permanent account takeover.</p>\n<p><code class=\"language-text\">HttpOnly</code> protects the cookie value. Reauthentication protects selected consequences of the session. Different controls, different jobs, and it’s worth being clear about which one you actually have.</p>\n<h3 id=\"6-cache-invalidation-became-part-of-incident-response\" style=\"position:relative;\"><a href=\"#6-cache-invalidation-became-part-of-incident-response\" aria-label=\"6 cache invalidation became part of incident response permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>6. Cache invalidation became part of incident response</h3>\n<p>The thirty seconds where my deleted campaign kept serving from the edge turned into its own workstream:</p>\n<ul>\n<li>a campaign-to-cache-key index</li>\n<li>immediate purge on suspension or deletion</li>\n<li>shorter emergency TTLs for server-driven modules</li>\n<li>a global module kill switch</li>\n<li>audit logging for every transition into a public surface</li>\n<li>a scanner looking for executable markup in cached homepage fragments</li>\n</ul>\n<p>The cache wasn’t the root cause. It was most of the blast radius, which made it part of the fix.</p>\n<h2 id=\"retest\" style=\"position:relative;\"><a href=\"#retest\" aria-label=\"retest permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Retest</h2>\n<p>The patched build arrived nine days later. Nine days is fast for something touching this many services, and I said so.</p>\n<p>The original request now behaves like this:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">surface = HOME_DISCOVERY\n  → rejected\n\naudience = ALL\n  → rejected\n\nheadline contains HTML\n  → stored as text\n\ninternal editorial rich text\n  → parsed into allowlisted nodes\n\nlegacy HTML fragment\n  → sanitized after interpolation\n\ninline execution canary\n  → absent from DOM; CSP blocks execution path</code></pre></div>\n<p>I also walked the whole lifecycle rather than just re-firing the original payload, because the original payload is the one thing you can be certain they tested:</p>\n<ol>\n<li>Create an ordinary creator campaign.</li>\n<li>Confirm it appears only on the creator profile.</li>\n<li>Try to change its surface through both create <em>and</em> update mutations.</li>\n<li>Confirm the server ignores or rejects policy fields in both.</li>\n<li>Insert harmless HTML; verify it renders as text.</li>\n<li>Use a company-provided internal test campaign carrying the old redacted canary.</li>\n<li>Confirm the sanitizer strips the executable portion.</li>\n<li>Confirm the enforced CSP logs and blocks the attempt.</li>\n<li>Suspend the campaign and verify edge caches purge immediately.</li>\n</ol>\n<p>Resolved on October 26. The whole thing, start to finish:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">October 14  23:52  Hidden homepage surface accepted\nOctober 15  00:18  XSS confirmed for owned test audience\nOctober 15  00:41  Global placement confirmed with plain text\nOctober 15  01:07  Report submitted\nOctober 15  01:46  Marked critical\nOctober 15  02:03  Homepage module disabled and purge started\nOctober 15  03:12  Creator API hotfix verified\nOctober 20          Rendering and CSP fixes deployed\nOctober 24          Retest completed\nOctober 26          Report resolved; $BOUNTY awarded</code></pre></div>\n<h2 id=\"the-part-i-keep-thinking-about\" style=\"position:relative;\"><a href=\"#the-part-i-keep-thinking-about\" aria-label=\"the part i keep thinking about permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The part I keep thinking about</h2>\n<p>Months later, the thing that stays with me isn’t the payload or the bounty. It’s how ordinary every individual decision was.</p>\n<p>The bug didn’t start with a missing sanitizer — there was one, and it was a good one. It didn’t start with a session cookie exposed to JavaScript — the cookie was configured better than most I see. It didn’t start with a recklessly public publishing endpoint — the creator UI only ever showed profile placement. It didn’t start with a misbehaving cache — the CDN cached precisely what the homepage told it to.</p>\n<p>Read these one at a time and every single one is fine:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Creators may create campaigns.\nThe homepage may render campaigns.\nMarketing templates may contain formatting.\nSanitized HTML may be inserted into the DOM.\nPublic homepage HTML may be cached.\nHttpOnly cookies may not be read by JavaScript.</code></pre></div>\n<p>The vulnerability was in the joins. Each sentence was true in isolation and none of them carried the information about who was trusted when. A creator picked an editorial surface. A sanitized template got unsanitized data poured into it afterwards. A React app accepted an opaque HTML string from its own backend. A report-only policy watched the browser run it and wrote it down. A CDN made copies. A well-configured cookie authenticated the requests.</p>\n<p>The campaign was attacker-controlled. The homepage was trusted. The browser had no way to tell them apart, because by the time the HTML reached it, they were the same string.</p>\n<p>The cookie stayed secret the entire time. Its authority didn’t.</p>\n<p>The cookie was <code class=\"language-text\">HttpOnly</code>. The session wasn’t.</p>","excerpt":"It was almost midnight and I was thirty seconds from closing the laptop. I’d been poking at ’s creator dashboard for about an hour, not because I expected to find anything, but because it was the kind of tired where you…","timeToRead":24,"fields":{"slug":"/the-cookie-was-httponly-the-session-was-not/"},"frontmatter":{"title":"The cookie was HttpOnly. The session was not.","description":"A creator campaign escaped into a globally cached homepage module. The session cookie could not be read, but every authenticated request the injected script made still carried it.","path":"the-cookie-was-httponly-the-session-was-not","date":"Oct 28, 2023","year":"2023"}},{"id":"cb7e2681-4a42-5f02-b818-88f8bf434cce","html":"<p>At 2:14 on a Sunday morning, the mobile app told me it could not load a link preview.</p>\n<p>My server disagreed.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">02:14:07  GET /proof/4f6c2/uid-1001 HTTP/1.1\n           Host: canary.example.test\n           User-Agent: curl/7.81.0\n           Source: 203.0.113.74</code></pre></div>\n<p>The URL I had pasted into the app did not contain <code class=\"language-text\">canary.example.test</code>.</p>\n<p>The web page at the end of the redirect did not contain it either.</p>\n<p>Only one thing contained that hostname: a short, deliberately harmless command embedded inside a parameter sent to a service that was supposed to exist only on <code class=\"language-text\">127.0.0.1</code>.</p>\n<p>The app showed a gray chain icon and <strong>Preview unavailable</strong>.</p>\n<p>The command had already run.</p>\n<p>I stared at the access log long enough for the line to stop looking like text and start looking like impact.</p>\n<p>Then I closed the terminal, opened a new note, and wrote:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">02:15 — probable command execution on preview worker\n02:16 — do not inspect filesystem\n02:16 — do not read environment\n02:17 — reproduce once with new token, then report</code></pre></div>\n<p>The bug began as a URL preview in a mobile chat composer.</p>\n<p>It ended as remote command execution in production.</p>\n<p>Between those two things were one redirect, one assumption about localhost, and one pair of quotation marks.</p>\n<h2 id=\"the-feature-i-was-not-testing\" style=\"position:relative;\"><a href=\"#the-feature-i-was-not-testing\" aria-label=\"the feature i was not testing permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The feature I was not testing</h2>\n<p><code class=\"language-text\">$COMPANY</code> was a mobile-first collaboration app. Teams used it for chat, files, voice notes, and the usual collection of tiny conveniences that eventually become infrastructure.</p>\n<p>When someone pasted a URL into a message, the app generated a card:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">┌──────────────────────────────────────────────┐\n│ The boring engineering behind tiny models   │\n│ Models are large. Habits are surprisingly   │\n│ portable.                                    │\n│                                    example   │\n└──────────────────────────────────────────────┘</code></pre></div>\n<p>The title, description, site name, and image came from <code class=\"language-text\">$COMPANY</code>’s servers, not from the phone. That is sensible. A backend can fetch a page once, cache the result, resize images consistently, and avoid making every recipient’s device contact an arbitrary third-party host.</p>\n<p>It also means the backend is willing to visit a URL chosen by a user.</p>\n<p>That sentence is where <a href=\"https://owasp.org/Top10/2021/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/\">SSRF</a> lives.</p>\n<p>I had opened the Android application because its link cards looked slightly different from the web version. The web client discarded malformed pages quietly. Android sometimes returned an error description under the gray placeholder.</p>\n<p>Different error handling often means different code.</p>\n<p>I searched the APK for <code class=\"language-text\">preview</code> and found a small Retrofit interface:</p>\n<div class=\"gatsby-highlight\" data-language=\"kotlin\"><pre class=\"language-kotlin\"><code class=\"language-kotlin\"><span class=\"token keyword\">interface</span> PreviewApi <span class=\"token punctuation\">{</span>\n    <span class=\"token annotation builtin\">@POST</span><span class=\"token punctuation\">(</span><span class=\"token string-literal singleline\"><span class=\"token string\">\"mobile/v3/link-previews\"</span></span><span class=\"token punctuation\">)</span>\n    <span class=\"token keyword\">suspend</span> <span class=\"token keyword\">fun</span> <span class=\"token function\">createPreview</span><span class=\"token punctuation\">(</span>\n        <span class=\"token annotation builtin\">@Body</span> request<span class=\"token operator\">:</span> PreviewRequest\n    <span class=\"token punctuation\">)</span><span class=\"token operator\">:</span> PreviewCard\n<span class=\"token punctuation\">}</span>\n\n<span class=\"token keyword\">data</span> <span class=\"token keyword\">class</span> <span class=\"token function\">PreviewRequest</span><span class=\"token punctuation\">(</span>\n    <span class=\"token keyword\">val</span> url<span class=\"token operator\">:</span> String<span class=\"token punctuation\">,</span>\n    <span class=\"token keyword\">val</span> locale<span class=\"token operator\">:</span> String<span class=\"token punctuation\">,</span>\n    <span class=\"token keyword\">val</span> appVersion<span class=\"token operator\">:</span> String\n<span class=\"token punctuation\">)</span></code></pre></div>\n<p>The endpoint required an ordinary account. No administrator role, enterprise plan, or special workspace permission was involved. The mobile client called it as soon as a URL appeared in the composer, before the message was sent.</p>\n<p>With my own test account, the request looked like this:</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\"><span class=\"token request-line\"><span class=\"token method property\">POST</span> <span class=\"token request-target url\">/api/mobile/v3/link-previews</span> <span class=\"token http-version property\">HTTP/1.1</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Host</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">api.$COMPANY.example</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Authorization</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">Bearer &lt;test-account-token></span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Content-Type</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">application/json</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">X-Client</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">android/7.42.1</span></span>\n<span class=\"token application-json\">\n<span class=\"token punctuation\">{</span>\n  <span class=\"token string-property property\">\"url\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"https://research.example.test/ordinary-page\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"locale\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"en-US\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"appVersion\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"7.42.1\"</span>\n<span class=\"token punctuation\">}</span></span></code></pre></div>\n<p>The response was unsurprising:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"title\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"ordinary page\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"description\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"a page controlled for testing\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"site_name\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"research.example.test\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"image_url\"</span><span class=\"token operator\">:</span> <span class=\"token null keyword\">null</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>My server received two requests from an address belonging to <code class=\"language-text\">$COMPANY</code>: first <code class=\"language-text\">HEAD</code>, then <code class=\"language-text\">GET</code>.</p>\n<p>That established the basic fact. The phone was not fetching the page. <code class=\"language-text\">$COMPANY</code> was.</p>\n<p>The next question was where it was willing to go.</p>\n<h2 id=\"the-filter-looked-better-than-most\" style=\"position:relative;\"><a href=\"#the-filter-looked-better-than-most\" aria-label=\"the filter looked better than most permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The filter looked better than most</h2>\n<p>I tried the obvious internal destinations directly.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">http://127.0.0.1/\nhttp://localhost/\nhttp://10.0.0.1/\nhttp://169.254.169.254/</code></pre></div>\n<p>All four failed before a network request was made:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"error\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"url_not_allowed\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"message\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"Private or reserved destinations cannot be previewed\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>That was good. Better than good, actually. Plenty of preview endpoints still fail at the first sentence of SSRF prevention.</p>\n<p>The service also rejected non-HTTP schemes, URLs containing credentials, bare hostnames without a dot, and several unusual textual forms of loopback addresses. Somebody had clearly thought about this.</p>\n<p>For a minute I considered moving on.</p>\n<p>Then I tested a public URL that redirected to another public URL. The preview followed it.</p>\n<p>That is normal behavior for the web. It is also a second URL-validation problem hiding inside the first one.</p>\n<p>The application had answered:</p>\n<blockquote>\n<p>Is the URL the user submitted safe?</p>\n</blockquote>\n<p>It had not yet answered:</p>\n<blockquote>\n<p>Is every URL the server will actually request safe?</p>\n</blockquote>\n<p>I configured my public test endpoint to return one redirect:</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\"><span class=\"token response-status\"><span class=\"token http-version property\">HTTP/1.1</span> <span class=\"token status-code number\">302</span> <span class=\"token reason-phrase string\">Found</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Location</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">http://127.0.0.1:3001/</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Cache-Control</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">no-store</span></span></code></pre></div>\n<p>Then I submitted the public address—not the loopback address—to the mobile preview API.</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"url\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"https://research.example.test/to-loopback\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"locale\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"en-US\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"appVersion\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"7.42.1\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>The request passed the filter.</p>\n<p>My server returned the redirect.</p>\n<p>The preview response came back three seconds later:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"title\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"mobile-renderer\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"description\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"internal screenshot service\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"site_name\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"127.0.0.1\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"image_url\"</span><span class=\"token operator\">:</span> <span class=\"token null keyword\">null</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>I had not bypassed the IP parser.</p>\n<p>I had walked around it.</p>\n<p>The original host was public. The destination after the redirect was not. The filter examined the first and the HTTP client trusted the rest.</p>\n<p>In Python-shaped pseudocode, the bug was probably close to this:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">def</span> <span class=\"token function\">preview</span><span class=\"token punctuation\">(</span>user_url<span class=\"token punctuation\">:</span> <span class=\"token builtin\">str</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">-</span><span class=\"token operator\">></span> Preview<span class=\"token punctuation\">:</span>\n    parsed <span class=\"token operator\">=</span> parse_url<span class=\"token punctuation\">(</span>user_url<span class=\"token punctuation\">)</span>\n    resolved <span class=\"token operator\">=</span> resolve<span class=\"token punctuation\">(</span>parsed<span class=\"token punctuation\">.</span>hostname<span class=\"token punctuation\">)</span>\n\n    <span class=\"token keyword\">if</span> <span class=\"token keyword\">not</span> is_public_address<span class=\"token punctuation\">(</span>resolved<span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n        <span class=\"token keyword\">raise</span> UrlNotAllowed<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span>\n\n    <span class=\"token comment\"># requests follows redirects for GET by default</span>\n    response <span class=\"token operator\">=</span> requests<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span>\n        user_url<span class=\"token punctuation\">,</span>\n        timeout<span class=\"token operator\">=</span><span class=\"token number\">5</span><span class=\"token punctuation\">,</span>\n        headers<span class=\"token operator\">=</span><span class=\"token punctuation\">{</span><span class=\"token string\">\"User-Agent\"</span><span class=\"token punctuation\">:</span> PREVIEW_USER_AGENT<span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">)</span>\n\n    <span class=\"token keyword\">return</span> extract_preview<span class=\"token punctuation\">(</span>response<span class=\"token punctuation\">)</span></code></pre></div>\n<p>The validation and the request each looked reasonable in isolation.</p>\n<p>The problem was that <code class=\"language-text\">response</code> might belong to a different host from <code class=\"language-text\">user_url</code>.</p>\n<p>A redirect is not a continuation of the same trust decision. It is a new request.</p>\n<h2 id=\"i-did-not-scan-the-network\" style=\"position:relative;\"><a href=\"#i-did-not-scan-the-network\" aria-label=\"i did not scan the network permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>I did not scan the network</h2>\n<p>Once an application will fetch loopback URLs, it is tempting to turn the preview endpoint into an internal port scanner.</p>\n<p>That is also an efficient way to stop doing research and start creating unnecessary risk.</p>\n<p>I tested only a handful of common local development ports, one request at a time, with long pauses. I stopped at the first identifiable service. The report included the exact list and timestamps so <code class=\"language-text\">$COMPANY</code> could distinguish my traffic from anything else.</p>\n<p>Port <code class=\"language-text\">3001</code> returned an HTML page with this title:</p>\n<div class=\"gatsby-highlight\" data-language=\"html\"><pre class=\"language-html\"><code class=\"language-html\"><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>title</span><span class=\"token punctuation\">></span></span>mobile-renderer<span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>title</span><span class=\"token punctuation\">></span></span></code></pre></div>\n<p>The body was a tiny internal status screen:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">mobile-renderer\nbuild: 2023.08.11-4d8c2f1\nmode: production\n\nGET /health\nGET /debug/render?url=&lt;url>&amp;width=&lt;pixels></code></pre></div>\n<p>The interesting word was not <code class=\"language-text\">render</code>.</p>\n<p>It was <code class=\"language-text\">debug</code>.</p>\n<p>The endpoint was bound to loopback, so it had no authentication. From the perspective of its author, that may have felt like authentication: only software on the same machine could reach it.</p>\n<p>The preview service was software on the same machine.</p>\n<p>And I could tell the preview service where to go.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">mobile user\n    │\n    │  public URL\n    ▼\nlink-preview API\n    │\n    │  follows 302\n    ▼\n127.0.0.1:3001\n    │\n    ▼\nmobile-renderer debug route</code></pre></div>\n<p>A network boundary had become a permission boundary without anybody writing down that decision.</p>\n<h2 id=\"the-first-render-was-harmless\" style=\"position:relative;\"><a href=\"#the-first-render-was-harmless\" aria-label=\"the first render was harmless permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The first render was harmless</h2>\n<p>I sent the renderer a normal public page through the redirect chain:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">https://research.example.test/to-render\n  └── 302 → http://127.0.0.1:3001/debug/render\n              ?url=https%3A%2F%2Fexample.org%2F\n              &amp;width=800</code></pre></div>\n<p>The mobile API waited several seconds and returned a screenshot URL hosted on <code class=\"language-text\">$COMPANY</code>’s CDN.</p>\n<p>That proved three things:</p>\n<ol>\n<li>The preview worker could reach the renderer.</li>\n<li>The renderer accepted unauthenticated requests from loopback.</li>\n<li>The renderer performed a meaningful action, not merely a health check.</li>\n</ol>\n<p>The generated image showed <code class=\"language-text\">example.org</code>. I deleted the preview from my test workspace and did not request any internal page.</p>\n<p>At this point the finding was already serious SSRF. An ordinary mobile user could invoke internal HTTP endpoints and retrieve at least some of their output through the preview parser or generated artifacts.</p>\n<p>But the renderer’s error behavior suggested there was another boundary behind it.</p>\n<p>I changed the <code class=\"language-text\">url</code> parameter from a valid address to one containing a single encoded quotation mark.</p>\n<p>The render failed immediately.</p>\n<p>The mobile response included the first line of stderr:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"error\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"preview_generation_failed\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"detail\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"/bin/sh: 1: Syntax error: Unterminated quoted string\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>A URL parser does not normally complain about shell quotes.</p>\n<p>A shell does.</p>\n<p>I wrote a second note:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">01:46 — renderer likely builds command string\n01:48 — do not guess broadly\n01:49 — one canary-only proof</code></pre></div>\n<h2 id=\"the-pair-of-quotation-marks\" style=\"position:relative;\"><a href=\"#the-pair-of-quotation-marks\" aria-label=\"the pair of quotation marks permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The pair of quotation marks</h2>\n<p>From the outside, the renderer appeared to launch a headless browser script with a command assembled as text.</p>\n<p>The vulnerable code may have looked like this:</p>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> exec <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">\"node:child_process\"</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> randomUUID <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">\"node:crypto\"</span><span class=\"token punctuation\">;</span>\n\napp<span class=\"token punctuation\">.</span><span class=\"token function\">get</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"/debug/render\"</span><span class=\"token punctuation\">,</span> <span class=\"token punctuation\">(</span><span class=\"token parameter\">req<span class=\"token punctuation\">,</span> res</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">const</span> target <span class=\"token operator\">=</span> req<span class=\"token punctuation\">.</span>query<span class=\"token punctuation\">.</span>url<span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">const</span> width <span class=\"token operator\">=</span> req<span class=\"token punctuation\">.</span>query<span class=\"token punctuation\">.</span>width <span class=\"token operator\">||</span> <span class=\"token string\">\"800\"</span><span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">const</span> output <span class=\"token operator\">=</span> <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">/tmp/</span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span><span class=\"token function\">randomUUID</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token string\">.png</span><span class=\"token template-punctuation string\">`</span></span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">const</span> command <span class=\"token operator\">=</span>\n    <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">timeout 20s node capture.js </span><span class=\"token template-punctuation string\">`</span></span> <span class=\"token operator\">+</span>\n    <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">--url \"</span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span>target<span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token string\">\" </span><span class=\"token template-punctuation string\">`</span></span> <span class=\"token operator\">+</span>\n    <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">--width </span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span>width<span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token string\"> </span><span class=\"token template-punctuation string\">`</span></span> <span class=\"token operator\">+</span>\n    <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">--output \"</span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span>output<span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token string\">\"</span><span class=\"token template-punctuation string\">`</span></span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token function\">exec</span><span class=\"token punctuation\">(</span>command<span class=\"token punctuation\">,</span> <span class=\"token punctuation\">(</span><span class=\"token parameter\">error</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n    <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>error<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n      <span class=\"token keyword\">return</span> res<span class=\"token punctuation\">.</span><span class=\"token function\">status</span><span class=\"token punctuation\">(</span><span class=\"token number\">500</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token function\">send</span><span class=\"token punctuation\">(</span>error<span class=\"token punctuation\">.</span>message<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    <span class=\"token punctuation\">}</span>\n\n    res<span class=\"token punctuation\">.</span><span class=\"token function\">sendFile</span><span class=\"token punctuation\">(</span>output<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p><a href=\"https://nodejs.org/api/child_process.html\"><code class=\"language-text\">exec()</code></a> does not pass an argument array directly to a program. It starts a shell and gives the shell one command string.</p>\n<p>That distinction is convenient when the command is constant.</p>\n<p>It is dangerous when part of the string came from an HTTP request.</p>\n<p>The renderer placed <code class=\"language-text\">target</code> inside double quotes, but quotes are not a security boundary. They are syntax. If untrusted input can alter that syntax, the shell gets to interpret the remainder.</p>\n<p>I am not including the command-separator bytes used in the report. They add nothing to the engineering lesson and make the article more reusable than it needs to be.</p>\n<p>The proof itself was deliberately small:</p>\n<ul>\n<li>Generate a random token.</li>\n<li>Cause the renderer to make one outbound request to a canary path containing that token.</li>\n<li>Include the numeric process UID in the path.</li>\n<li>Do not read a file, list a directory, print environment variables, or start an interactive shell.</li>\n<li>Reproduce once with a second token.</li>\n<li>Stop.</li>\n</ul>\n<p>The public redirect server never mentioned the canary hostname. The target page never mentioned it. Only the injected fragment did.</p>\n<p>At 2:14:07, the canary request arrived:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">GET /proof/4f6c2/uid-1001 HTTP/1.1\nHost: canary.example.test\nUser-Agent: curl/7.81.0</code></pre></div>\n<p>The source address matched the preview infrastructure.</p>\n<p>The user agent did not match the preview fetcher or headless Chromium.</p>\n<p>The path included the expected one-time token and <code class=\"language-text\">uid-1001</code>.</p>\n<p>I repeated the test with a different token and received a second request.</p>\n<p>Then I used a benign delay as an independent signal. A normal failed render returned in about 600 milliseconds. The redacted test delayed the response by five seconds, three times in a row.</p>\n<p>Two different observations now pointed to the same conclusion:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">user-controlled mobile request\n  → server-side redirect to loopback\n  → unauthenticated internal render endpoint\n  → shell command injection\n  → command execution as uid 1001</code></pre></div>\n<p>The preview failed.</p>\n<p>The command did not.</p>\n<h2 id=\"why-this-was-rce-even-without-a-reverse-shell\" style=\"position:relative;\"><a href=\"#why-this-was-rce-even-without-a-reverse-shell\" aria-label=\"why this was rce even without a reverse shell permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Why this was RCE even without a reverse shell</h2>\n<p>“Remote code execution” sometimes gets confused with a cinematic terminal window.</p>\n<p>The terminal is optional.</p>\n<p>The security boundary fails when an untrusted remote user can cause the target to execute an operating-system command of the user’s choosing. A one-shot canary request is enough to prove that boundary failed.</p>\n<p>I did not establish persistence. I did not enumerate the container. I did not inspect mounted volumes, credentials, service-account tokens, customer data, or neighboring services. I did not test whether the process could escape its container.</p>\n<p>Those actions would have increased risk without changing the finding.</p>\n<p>The report said exactly what I had confirmed:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Confirmed:\n- Any ordinary account can invoke the mobile link-preview endpoint.\n- URL validation applies to the initial URL only.\n- Redirects can reach loopback services.\n- A loopback-only renderer interpolates the `url` parameter into a shell command.\n- Arbitrary command execution is possible as the renderer user (uid 1001).\n- The worker has outbound network access.\n\nNot tested:\n- Filesystem contents\n- Environment variables or credentials\n- Access to customer data\n- Access to cloud metadata\n- Container escape\n- Lateral movement\n- Persistence</code></pre></div>\n<p>That separation matters. A report should make the proven impact difficult to dismiss and the untested impact impossible to mistake for a claim.</p>\n<p><code class=\"language-text\">$COMPANY</code> later confirmed that the renderer ran in a production container with read access to a preview-object bucket and credentials for an internal logging service. It did not hold mobile signing keys.</p>\n<p>I had not accessed any of them.</p>\n<p>The RCE was severe without borrowing impact from secrets I did not need to touch.</p>\n<h2 id=\"the-mobile-part-was-only-the-handle\" style=\"position:relative;\"><a href=\"#the-mobile-part-was-only-the-handle\" aria-label=\"the mobile part was only the handle permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The mobile part was only the handle</h2>\n<p>No command ran on my phone.</p>\n<p>The APK contained the endpoint and supplied the user-controlled URL, but the vulnerable code lived in the backend services supporting the mobile experience.</p>\n<p>That distinction is worth making because “mobile vulnerability” often gets interpreted as memory corruption, a malicious intent, or code execution on a handset.</p>\n<p>This was different:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Android app\n   │  authenticated API request\n   ▼\nmobile preview backend\n   │  SSRF\n   ▼\nloopback renderer\n   │  command injection\n   ▼\nproduction container</code></pre></div>\n<p>The phone was the handle.</p>\n<p>The backend was the blade.</p>\n<p>The web application did not reproduce the issue because it called a newer GraphQL preview service. That service rejected redirects entirely. Android and iOS still used the older <code class=\"language-text\">/mobile/v3/link-previews</code> route for latency and compatibility reasons.</p>\n<p>One product had two implementations of the same convenience feature.</p>\n<p>Only one of them had received the security fix.</p>\n<p>This is how old APIs become vulnerability museums: they continue to work, which is mistaken for evidence that they continue to be safe.</p>\n<h2 id=\"the-report\" style=\"position:relative;\"><a href=\"#the-report\" aria-label=\"the report permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The report</h2>\n<p>I sent the report at 02:43 UTC, twenty-nine minutes after the first command-execution proof.</p>\n<p>The title was intentionally boring:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Critical: mobile link-preview SSRF to loopback renderer leads to OS command execution</code></pre></div>\n<p>The first page contained only the chain and impact:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Precondition:\n  Any authenticated user account.\n\nEntry point:\n  POST /api/mobile/v3/link-previews\n\nRoot causes:\n  1. Initial URL is validated, but redirect destinations are not.\n  2. Loopback renderer trusts network location instead of authenticating callers.\n  3. Renderer passes a user-controlled URL to child_process.exec().\n\nImpact:\n  Remote command execution on a production preview worker as uid 1001.\n\nSafety:\n  Tested only with owned accounts, controlled hosts, one canary callback,\n  and a benign timing proof. No data or credentials accessed.</code></pre></div>\n<p>Then came the exact reproduction, request IDs, timestamps, the two random canary tokens, source IPs, and a screen recording showing the mobile app’s failed preview beside the canary log.</p>\n<p>I included three fixes rather than one because the exploit had three independent trust failures:</p>\n<ol>\n<li>Revalidate every redirect hop and block non-public destinations at the network layer.</li>\n<li>Remove or authenticate the loopback debug route.</li>\n<li>Stop invoking a shell with user-controlled text.</li>\n</ol>\n<p>At 03:11, the program acknowledged the report.</p>\n<p>At 03:37, an engineer asked one question:</p>\n<blockquote>\n<p>Does command execution require the renderer to return a successful image?</p>\n</blockquote>\n<p>It did not.</p>\n<p>The command ran before the render callback returned, so the outer API could show an error while the side effect had already happened.</p>\n<p>That explained the title I had not yet written.</p>\n<p>At 04:02, <code class=\"language-text\">$COMPANY</code> disabled preview generation on the mobile endpoint. Pasted links still sent as text, but no card was created.</p>\n<p>At 12:26, the report was marked critical.</p>\n<h2 id=\"three-patches-not-one\" style=\"position:relative;\"><a href=\"#three-patches-not-one\" aria-label=\"three patches not one permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Three patches, not one</h2>\n<p>The permanent fix arrived five days later.</p>\n<h3 id=\"1-every-destination-became-a-new-decision\" style=\"position:relative;\"><a href=\"#1-every-destination-became-a-new-decision\" aria-label=\"1 every destination became a new decision permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>1. Every destination became a new decision</h3>\n<p>The preview fetcher no longer followed redirects automatically. It processed them one at a time, resolving and validating each destination before making the next request.</p>\n<p>The invariant was written like this in the remediation notes:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">For the initial URL and every redirect hop:\n\nscheme ∈ {http, https}\nresolved address is globally routable\nconnected address equals a validated resolution\ndestination is not loopback, private, link-local, multicast, or reserved\nredirect count ≤ 3\nresponse size and time are bounded</code></pre></div>\n<p>Application validation was backed by an egress rule preventing the preview network from reaching loopback-adjacent services, private ranges, link-local addresses, and cloud metadata endpoints.</p>\n<p>That second layer matters. URL parsing is full of representations, redirects, DNS behavior, proxies, and disagreement between libraries. The <a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html\">OWASP SSRF prevention guidance</a> recommends network controls in addition to application checks for exactly this reason.</p>\n<p>The old code made one policy decision and then delegated navigation to a general-purpose HTTP client.</p>\n<p>The new code kept policy in the loop.</p>\n<h3 id=\"2-localhost-stopped-pretending-to-be-authentication\" style=\"position:relative;\"><a href=\"#2-localhost-stopped-pretending-to-be-authentication\" aria-label=\"2 localhost stopped pretending to be authentication permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>2. Localhost stopped pretending to be authentication</h3>\n<p>The debug renderer route was removed from production.</p>\n<p>The ordinary rendering API moved behind a separate service identity and required a short-lived, audience-bound token. Requests arriving merely from loopback or the cluster network were no longer considered trusted.</p>\n<p>The renderer also moved into a more restricted container:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">read-only root filesystem\nno cloud instance metadata route\nno general outbound internet access\nminimal object-store permission\nno shell package in runtime image\nCPU and wall-clock limits per render</code></pre></div>\n<p>A service should be able to survive the caller making a mistake.</p>\n<p>“Only internal systems can reach this” is useful context. It is not authorization.</p>\n<h3 id=\"3-the-url-became-an-argument-not-a-program\" style=\"position:relative;\"><a href=\"#3-the-url-became-an-argument-not-a-program\" aria-label=\"3 the url became an argument not a program permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>3. The URL became an argument, not a program</h3>\n<p>The renderer replaced <code class=\"language-text\">exec()</code> with a direct process invocation and an argument array:</p>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> spawn <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">\"node:child_process\"</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> randomUUID <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">\"node:crypto\"</span><span class=\"token punctuation\">;</span>\n\napp<span class=\"token punctuation\">.</span><span class=\"token function\">post</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"/render\"</span><span class=\"token punctuation\">,</span> requireServiceIdentity<span class=\"token punctuation\">,</span> <span class=\"token punctuation\">(</span><span class=\"token parameter\">req<span class=\"token punctuation\">,</span> res</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">const</span> target <span class=\"token operator\">=</span> <span class=\"token function\">validateRenderUrl</span><span class=\"token punctuation\">(</span>req<span class=\"token punctuation\">.</span>body<span class=\"token punctuation\">.</span>url<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">const</span> width <span class=\"token operator\">=</span> <span class=\"token function\">validateWidth</span><span class=\"token punctuation\">(</span>req<span class=\"token punctuation\">.</span>body<span class=\"token punctuation\">.</span>width<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">const</span> output <span class=\"token operator\">=</span> <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">/tmp/</span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span><span class=\"token function\">randomUUID</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token string\">.png</span><span class=\"token template-punctuation string\">`</span></span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">const</span> child <span class=\"token operator\">=</span> <span class=\"token function\">spawn</span><span class=\"token punctuation\">(</span>\n    process<span class=\"token punctuation\">.</span>execPath<span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">[</span>\n      <span class=\"token string\">\"capture.js\"</span><span class=\"token punctuation\">,</span>\n      <span class=\"token string\">\"--url\"</span><span class=\"token punctuation\">,</span> target<span class=\"token punctuation\">,</span>\n      <span class=\"token string\">\"--width\"</span><span class=\"token punctuation\">,</span> <span class=\"token function\">String</span><span class=\"token punctuation\">(</span>width<span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n      <span class=\"token string\">\"--output\"</span><span class=\"token punctuation\">,</span> output<span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">{</span>\n      <span class=\"token literal-property property\">shell</span><span class=\"token operator\">:</span> <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span>\n      <span class=\"token literal-property property\">timeout</span><span class=\"token operator\">:</span> <span class=\"token number\">20_000</span><span class=\"token punctuation\">,</span>\n      <span class=\"token literal-property property\">stdio</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span><span class=\"token string\">\"ignore\"</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"ignore\"</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"pipe\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n  <span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  child<span class=\"token punctuation\">.</span><span class=\"token function\">on</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"close\"</span><span class=\"token punctuation\">,</span> <span class=\"token punctuation\">(</span><span class=\"token parameter\">code</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n    <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>code <span class=\"token operator\">!==</span> <span class=\"token number\">0</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n      <span class=\"token keyword\">return</span> res<span class=\"token punctuation\">.</span><span class=\"token function\">status</span><span class=\"token punctuation\">(</span><span class=\"token number\">502</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token function\">json</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span> <span class=\"token literal-property property\">error</span><span class=\"token operator\">:</span> <span class=\"token string\">\"render_failed\"</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    <span class=\"token punctuation\">}</span>\n\n    res<span class=\"token punctuation\">.</span><span class=\"token function\">sendFile</span><span class=\"token punctuation\">(</span>output<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>The important part is not <code class=\"language-text\">spawn</code> as a magic safe word. It is that the URL is passed as one argument to one program, without a shell being asked to reinterpret it.</p>\n<p>The route still validates the URL, width, timeout, and output path. Removing the shell closes the command-injection class; input validation and sandboxing limit the damage from whatever the renderer itself might do.</p>\n<p>Security patches work better when they do not require one line of code to be perfect.</p>\n<h2 id=\"retesting-the-chain\" style=\"position:relative;\"><a href=\"#retesting-the-chain\" aria-label=\"retesting the chain permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Retesting the chain</h2>\n<p>The patched mobile endpoint returned previews again on August 18.</p>\n<p>The original public test URL still worked.</p>\n<p>A public-to-public redirect worked, up to the new three-hop limit.</p>\n<p>A public URL redirecting to loopback failed before the second request:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"error\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"url_not_allowed\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"reason\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"redirect_destination_not_public\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>My loopback service received nothing.</p>\n<p>I tried an ordinary hostname that resolved to a public address and then changed. The connection was rejected when the actual destination no longer matched the validated address.</p>\n<p>The old internal <code class=\"language-text\">/debug/render</code> route returned <code class=\"language-text\">404</code> from the renderer image and was unreachable from the preview network anyway.</p>\n<p>Finally, <code class=\"language-text\">$COMPANY</code> supplied a test fixture containing the characters that had previously changed the shell command. The renderer treated the entire value as one invalid URL argument and returned a normal validation error.</p>\n<p>No canary request arrived.</p>\n<p>No timing shift appeared.</p>\n<p>The preview failed.</p>\n<p>This time the command failed with it.</p>\n<h2 id=\"the-regression-tests-that-describe-the-real-boundary\" style=\"position:relative;\"><a href=\"#the-regression-tests-that-describe-the-real-boundary\" aria-label=\"the regression tests that describe the real boundary permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The regression tests that describe the real boundary</h2>\n<p>The most useful tests were not named after my payload. They were named after the properties the system had forgotten.</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">def</span> <span class=\"token function\">test_preview_revalidates_every_redirect_destination</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    public <span class=\"token operator\">=</span> controlled_server<span class=\"token punctuation\">.</span>redirect_to<span class=\"token punctuation\">(</span><span class=\"token string\">\"http://127.0.0.1:3001/\"</span><span class=\"token punctuation\">)</span>\n\n    result <span class=\"token operator\">=</span> create_mobile_preview<span class=\"token punctuation\">(</span>url<span class=\"token operator\">=</span>public<span class=\"token punctuation\">)</span>\n\n    <span class=\"token keyword\">assert</span> result<span class=\"token punctuation\">.</span>status <span class=\"token operator\">==</span> <span class=\"token number\">400</span>\n    <span class=\"token keyword\">assert</span> result<span class=\"token punctuation\">.</span>reason <span class=\"token operator\">==</span> <span class=\"token string\">\"redirect_destination_not_public\"</span>\n    <span class=\"token keyword\">assert</span> controlled_loopback_service<span class=\"token punctuation\">.</span>requests <span class=\"token operator\">==</span> <span class=\"token punctuation\">[</span><span class=\"token punctuation\">]</span></code></pre></div>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token function\">test</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"renderer passes URL as one argument without a shell\"</span><span class=\"token punctuation\">,</span> <span class=\"token keyword\">async</span> <span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">const</span> suspicious <span class=\"token operator\">=</span> <span class=\"token function\">makeStringContainingShellSyntax</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">await</span> <span class=\"token function\">expect</span><span class=\"token punctuation\">(</span><span class=\"token function\">render</span><span class=\"token punctuation\">(</span>suspicious<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span>rejects<span class=\"token punctuation\">.</span><span class=\"token function\">toMatchObject</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span>\n    <span class=\"token literal-property property\">code</span><span class=\"token operator\">:</span> <span class=\"token string\">\"INVALID_RENDER_URL\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token function\">expect</span><span class=\"token punctuation\">(</span>spawn<span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span>not<span class=\"token punctuation\">.</span><span class=\"token function\">toHaveBeenCalledWith</span><span class=\"token punctuation\">(</span>\n    expect<span class=\"token punctuation\">.</span><span class=\"token function\">anything</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n    expect<span class=\"token punctuation\">.</span><span class=\"token function\">anything</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n    expect<span class=\"token punctuation\">.</span><span class=\"token function\">objectContaining</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span> <span class=\"token literal-property property\">shell</span><span class=\"token operator\">:</span> <span class=\"token boolean\">true</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n  <span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>And the infrastructure test mattered as much as either unit test:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">preview-worker → public HTTP/HTTPS: allowed through controlled egress\npreview-worker → loopback/private/link-local/metadata: denied\nrenderer       → arbitrary internet: denied\nrenderer       → preview object bucket: write-only, scoped prefix</code></pre></div>\n<p>A fixed payload is one regression test.</p>\n<p>A fixed trust model is a system of them.</p>\n<h2 id=\"the-three-assumptions-in-the-exploit\" style=\"position:relative;\"><a href=\"#the-three-assumptions-in-the-exploit\" aria-label=\"the three assumptions in the exploit permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The three assumptions in the exploit</h2>\n<p>No individual mistake felt large enough to become critical.</p>\n<p>The URL validator blocked private addresses.</p>\n<p>The renderer listened only on loopback.</p>\n<p>The command put the URL inside quotes.</p>\n<p>Each sentence sounds like a mitigation.</p>\n<p>Together they produced RCE.</p>\n<h3 id=\"we-block-internal-urls\" style=\"position:relative;\"><a href=\"#we-block-internal-urls\" aria-label=\"we block internal urls permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>“We block internal URLs”</h3>\n<p>The service blocked the URL the user supplied. It did not block the URL the HTTP client eventually fetched.</p>\n<p>Validation attached to a string instead of to the network action.</p>\n<h3 id=\"the-endpoint-is-only-on-localhost\" style=\"position:relative;\"><a href=\"#the-endpoint-is-only-on-localhost\" aria-label=\"the endpoint is only on localhost permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>“The endpoint is only on localhost”</h3>\n<p>That was true from the network’s point of view.</p>\n<p>It was false from the attacker’s point of view because another reachable service could be instructed to make the request.</p>\n<p>Reachability composes.</p>\n<h3 id=\"the-value-is-quoted\" style=\"position:relative;\"><a href=\"#the-value-is-quoted\" aria-label=\"the value is quoted permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>“The value is quoted”</h3>\n<p>Quoting is part of shell grammar, not an alternative to it.</p>\n<p>Once a shell receives a command string containing untrusted text, correctness depends on every transformation preserving syntax exactly. The safer design is not to ask a shell to parse the value at all.</p>\n<p>The exploit did not defeat three strong defenses.</p>\n<p>It passed through three defenses that protected different objects from the ones developers thought they protected.</p>\n<h2 id=\"the-report-closed\" style=\"position:relative;\"><a href=\"#the-report-closed\" aria-label=\"the report closed permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The report closed</h2>\n<p><code class=\"language-text\">$COMPANY</code> closed the report on August 24 after the mobile clients had resumed preview generation and the infrastructure changes had reached production.</p>\n<p>The reward was <code class=\"language-text\">$BOUNTY</code>. The amount matters less than the severity classification, and the severity matters less than the patch surviving the feature being turned back on.</p>\n<p>The engineer who handled the report sent one final note:</p>\n<blockquote>\n<p>We originally treated the redirect check, renderer authentication, and shell invocation as separate issues. The incident review now models them as one request path.</p>\n</blockquote>\n<p>That is the right lesson.</p>\n<p>Vulnerabilities are often reviewed one function at a time because code is stored one function at a time. Attackers get the whole request path.</p>\n<p>In February, <a href=\"/how-a-pdf-button-became-remote-code-execution-and-paid-for-my-year/\">a PDF button reminded me that reachability is not authorization</a>.</p>\n<p>In June, <a href=\"/the-signature-was-valid-the-company-was-wrong/\">SAML reminded me that authentication is not tenancy</a>.</p>\n<p>This one added a smaller rule:</p>\n<p><strong>A redirect is a new request, not a footnote to the old one.</strong></p>\n<p>The first URL was public.</p>\n<p>The second URL was localhost.</p>\n<p>The third value became shell syntax.</p>\n<p>Every component did something it had been designed to do:</p>\n<ul>\n<li>The mobile app asked for a preview.</li>\n<li>The previewer followed a redirect.</li>\n<li>The renderer accepted a local request.</li>\n<li>The shell interpreted a command.</li>\n</ul>\n<p>The vulnerability lived in the word <em>therefore</em> between those sentences.</p>\n<p>No command ran on the phone.</p>\n<p>No firewall port opened to the internet.</p>\n<p>No cryptography failed.</p>\n<p>I pasted a link into a mobile app.</p>\n<p>The app said the preview failed.</p>\n<p>The server ran the rest.</p>","excerpt":"At 2:14 on a Sunday morning, the mobile app told me it could not load a link preview. My server disagreed. The URL I had pasted into the app did not contain . The web page at the end of the redirect did not contain it…","timeToRead":18,"fields":{"slug":"/the-preview-failed-the-command-did-not/"},"frontmatter":{"title":"The preview failed. The command did not.","description":"A mobile app's link-preview endpoint followed one redirect into localhost, reached an internal screenshot service, and turned a blocked SSRF into remote command execution.","path":"the-preview-failed-the-command-did-not","date":"Aug 26, 2023","year":"2023"}},{"id":"b8680e5a-5e9f-5938-a3c6-befc5bb4b743","html":"<p>On a Saturday night, I logged into the wrong company.</p>\n<p>Not with a stolen password. Not with a leaked session cookie. Not with an expired reset link that somebody had forgotten to invalidate.</p>\n<p>I used a SAML assertion signed by a key I owned, for an identity provider I had configured, inside a workspace I had created fifteen minutes earlier.</p>\n<p>The assertion was valid.</p>\n<p>The signature was valid.</p>\n<p>The browser opened a different workspace.</p>\n<p>In the first workspace, my test account was a normal member. In the second, the same email address was an administrator. The avatar was the same, the email was the same, and the small badge beside it had changed from <strong>Member</strong> to <strong>Administrator</strong>.</p>\n<p>For about ten seconds I blamed the two browser tabs.</p>\n<p>Then I opened <code class=\"language-text\">/api/me</code>.</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"user_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"usr_01H3EJ9J9G5W6D1R7K0P\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"email\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"andrew+saml@example.test\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"workspace_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"ws_blue\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"workspace_name\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"BLUE LAB\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"role\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"administrator\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>The SAML identity provider belonged to <code class=\"language-text\">RED LAB</code>.</p>\n<p>The session belonged to <code class=\"language-text\">BLUE LAB</code>.</p>\n<p>I logged out, cleared every cookie for the domain, opened a private window, and did it again.</p>\n<p>Same result.</p>\n<p>Then I renamed the workspaces because <code class=\"language-text\">lab-a</code> and <code class=\"language-text\">lab-b</code> had become psychologically dangerous.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">RED LAB   — SAML configured — member\nBLUE LAB  — password login  — administrator</code></pre></div>\n<p>I repeated the flow a third time, recorded the request, saved the response, and stopped.</p>\n<p>My notes from that minute contain only this:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">23:51 — cross-tenant session confirmed\n23:52 — do not touch anything else\n23:53 — write report before convincing yourself it is normal</code></pre></div>\n<p>The bug was not an XML parser trick. It was not signature wrapping. It was not a broken cryptographic primitive.</p>\n<p>It was one missing relationship.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">identity_provider.workspace_id == session.workspace_id</code></pre></div>\n<p>The distance between two companies was one <code class=\"language-text\">==</code>.</p>\n<h2 id=\"the-feature\" style=\"position:relative;\"><a href=\"#the-feature\" aria-label=\"the feature permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The feature</h2>\n<p>The company—I’ll call it <code class=\"language-text\">$COMPANY</code>—runs a multi-tenant business application. Customers live in workspaces. A person can belong to several workspaces, and each workspace can configure its own SAML identity provider.</p>\n<p>The intended flow is ordinary:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">employee\n   │\n   ▼\ncompany identity provider\n   │  signed SAML assertion\n   ▼\n$COMPANY assertion consumer service\n   │\n   ▼\ncompany workspace</code></pre></div>\n<p>A workspace administrator uploads identity-provider metadata: an issuer, a login URL, and a public signing certificate. When an employee signs in, <code class=\"language-text\">$COMPANY</code> verifies the assertion, resolves the employee, and creates a session in that workspace.</p>\n<p>Every customer used the same callback endpoint:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">https://app.$COMPANY.example/auth/saml/callback</code></pre></div>\n<p>That is normal. A shared assertion consumer service is not itself a vulnerability.</p>\n<p>It does mean the application must answer two different questions when a SAML response arrives:</p>\n<ol>\n<li>Is this assertion authentic?</li>\n<li>Which customer’s trust relationship makes it authentic here?</li>\n</ol>\n<p>The first answer came from the signature.</p>\n<p>The second came from a value carried through the user’s browser.</p>\n<p>That was the loose thread.</p>\n<h2 id=\"the-thing-that-looked-too-readable\" style=\"position:relative;\"><a href=\"#the-thing-that-looked-too-readable\" aria-label=\"the thing that looked too readable permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The thing that looked too readable</h2>\n<p>I was not hunting for a cross-tenant takeover. I was testing the SSO setup flow because it had a button labeled <strong>Test connection</strong>, and buttons that promise to test enterprise authentication tend to hide interesting state machines.</p>\n<p>I created <code class=\"language-text\">RED LAB</code>, enabled an enterprise trial, and configured a small local identity provider using a key pair generated for the test. Nothing unusual happened. The metadata imported correctly. The test user signed in correctly. The application redirected back to the SSO settings page with a green success banner.</p>\n<p>Then I looked at the browser request.</p>\n<p>The authentication request included a <code class=\"language-text\">RelayState</code> parameter. That is expected: SAML applications often use it to remember local state while the browser travels to the identity provider and back.</p>\n<p>What was less expected was how much the state wanted to introduce itself.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">RelayState=eyJ3b3Jrc3BhY2VfaWQiOiJ3c19yZWQiLCJuZXh0IjoiL3NldHRpbmdzL3NzbyJ9</code></pre></div>\n<p>Base64-decoded, it became:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"workspace_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"ws_red\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"next\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"/settings/sso\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>There was no MAC. No signature. No opaque server-side reference. Just JSON taking the scenic route through base64.</p>\n<p>Encoding is not integrity.</p>\n<p>Still, client-controlled <code class=\"language-text\">RelayState</code> does not automatically mean account takeover. The server might use it only for a harmless redirect after it has independently established the tenant from the authentication request. Plenty of applications put ugly things in state and then decline to trust them.</p>\n<p>So I changed only one value:</p>\n<div class=\"gatsby-highlight\" data-language=\"diff\"><pre class=\"language-diff\"><code class=\"language-diff\"><span class=\"token deleted-sign deleted\"><span class=\"token prefix deleted\">-</span> \"workspace_id\": \"ws_red\"\n</span><span class=\"token inserted-sign inserted\"><span class=\"token prefix inserted\">+</span> \"workspace_id\": \"ws_blue\"</span></code></pre></div>\n<p>I left the SAML response untouched.</p>\n<p>The assertion was still signed by <code class=\"language-text\">RED LAB</code>’s identity provider. Its <code class=\"language-text\">InResponseTo</code> still matched the request created for <code class=\"language-text\">RED LAB</code>. The destination still pointed to <code class=\"language-text\">$COMPANY</code>’s callback. The audience still named <code class=\"language-text\">$COMPANY</code>’s service provider. The assertion was fresh and inside its validity window.</p>\n<p>The callback returned:</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\"><span class=\"token response-status\"><span class=\"token http-version property\">HTTP/1.1</span> <span class=\"token status-code number\">302</span> <span class=\"token reason-phrase string\">Found</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Location</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">/workspaces/ws_blue/dashboard</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Set-Cookie</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">session=eyJ...; Secure; HttpOnly; SameSite=Lax</span></span></code></pre></div>\n<p>I followed the redirect and saw the administrator badge.</p>\n<h2 id=\"three-explanations-i-wanted-to-be-true\" style=\"position:relative;\"><a href=\"#three-explanations-i-wanted-to-be-true\" aria-label=\"three explanations i wanted to be true permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Three explanations I wanted to be true</h2>\n<p>A good bug becomes dangerous around the moment you want it to be real. That is also the moment to become suspicious of yourself.</p>\n<p>My first theory was <strong>stale session state</strong>. Perhaps I had already authenticated to <code class=\"language-text\">BLUE LAB</code>, and the callback merely sent me to the most recent workspace.</p>\n<p>I deleted the session, cleared local storage, used a new private window, and verified that <code class=\"language-text\">/api/me</code> returned <code class=\"language-text\">401</code> before starting SAML. The new session still belonged to <code class=\"language-text\">BLUE LAB</code>.</p>\n<p>My second theory was <strong>a cosmetic workspace switch</strong>. Some applications keep one global user session and let the interface display whichever workspace appears in the URL. Maybe the page looked like <code class=\"language-text\">BLUE LAB</code>, but authorization still came from <code class=\"language-text\">RED LAB</code>.</p>\n<p>I requested an endpoint available only to workspace administrators:</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\">GET /api/workspaces/ws_blue/sso/configuration</code></pre></div>\n<p>The response was <code class=\"language-text\">200</code>, not <code class=\"language-text\">403</code>.</p>\n<p>I did not inspect or modify any real configuration. Both workspaces were mine, and the endpoint’s status was enough.</p>\n<p>My third theory was <strong>an accidental role carried from the source workspace</strong>. Perhaps the application had copied my <code class=\"language-text\">RED LAB</code> role into the target instead of resolving the existing <code class=\"language-text\">BLUE LAB</code> membership.</p>\n<p>So I reversed the roles:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">RED LAB   — administrator\nBLUE LAB  — member</code></pre></div>\n<p>The same SAML assertion now produced a <strong>member</strong> session in <code class=\"language-text\">BLUE LAB</code>.</p>\n<p>That told me exactly where authorization came from. The application was using the assertion to choose an identity and the target workspace to choose the role.</p>\n<p>Authentication came from one tenant.</p>\n<p>Authorization came from another.</p>\n<p>The seam between them had no tenant check.</p>\n<h2 id=\"reconstructing-the-server-from-the-outside\" style=\"position:relative;\"><a href=\"#reconstructing-the-server-from-the-outside\" aria-label=\"reconstructing the server from the outside permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Reconstructing the server from the outside</h2>\n<p>I could not see <code class=\"language-text\">$COMPANY</code>’s source, but the behavior narrowed the implementation to something like this:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">def</span> <span class=\"token function\">consume_saml</span><span class=\"token punctuation\">(</span>saml_response<span class=\"token punctuation\">:</span> <span class=\"token builtin\">str</span><span class=\"token punctuation\">,</span> relay_state<span class=\"token punctuation\">:</span> <span class=\"token builtin\">str</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    assertion <span class=\"token operator\">=</span> parse_saml_response<span class=\"token punctuation\">(</span>saml_response<span class=\"token punctuation\">)</span>\n\n    pending_request <span class=\"token operator\">=</span> AuthnRequest<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span>assertion<span class=\"token punctuation\">.</span>in_response_to<span class=\"token punctuation\">)</span>\n    idp <span class=\"token operator\">=</span> IdentityProvider<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span>pending_request<span class=\"token punctuation\">.</span>identity_provider_id<span class=\"token punctuation\">)</span>\n\n    verify_signature<span class=\"token punctuation\">(</span>assertion<span class=\"token punctuation\">,</span> certificate<span class=\"token operator\">=</span>idp<span class=\"token punctuation\">.</span>certificate<span class=\"token punctuation\">)</span>\n    verify_issuer<span class=\"token punctuation\">(</span>assertion<span class=\"token punctuation\">,</span> expected<span class=\"token operator\">=</span>idp<span class=\"token punctuation\">.</span>issuer<span class=\"token punctuation\">)</span>\n    verify_audience<span class=\"token punctuation\">(</span>assertion<span class=\"token punctuation\">,</span> expected<span class=\"token operator\">=</span>GLOBAL_SP_ENTITY_ID<span class=\"token punctuation\">)</span>\n    verify_recipient<span class=\"token punctuation\">(</span>assertion<span class=\"token punctuation\">,</span> expected<span class=\"token operator\">=</span>GLOBAL_ACS_URL<span class=\"token punctuation\">)</span>\n    verify_time_window<span class=\"token punctuation\">(</span>assertion<span class=\"token punctuation\">)</span>\n\n    state <span class=\"token operator\">=</span> json<span class=\"token punctuation\">.</span>loads<span class=\"token punctuation\">(</span>base64_decode<span class=\"token punctuation\">(</span>relay_state<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span>\n    workspace <span class=\"token operator\">=</span> Workspace<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span>state<span class=\"token punctuation\">[</span><span class=\"token string\">\"workspace_id\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span>\n\n    user <span class=\"token operator\">=</span> workspace<span class=\"token punctuation\">.</span>users<span class=\"token punctuation\">.</span>get_by_email<span class=\"token punctuation\">(</span>assertion<span class=\"token punctuation\">.</span>email<span class=\"token punctuation\">)</span>\n    <span class=\"token keyword\">return</span> create_session<span class=\"token punctuation\">(</span>user<span class=\"token operator\">=</span>user<span class=\"token punctuation\">,</span> workspace<span class=\"token operator\">=</span>workspace<span class=\"token punctuation\">)</span></code></pre></div>\n<p>Every line looks reasonable in isolation.</p>\n<p>The request ID selects the identity-provider configuration. The configured certificate verifies the response. The issuer, audience, recipient, and time window are checked. The state restores the destination. The user is looked up inside the selected workspace.</p>\n<p>The missing line is not glamorous:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">if</span> idp<span class=\"token punctuation\">.</span>workspace_id <span class=\"token operator\">!=</span> workspace<span class=\"token punctuation\">.</span><span class=\"token builtin\">id</span><span class=\"token punctuation\">:</span>\n    <span class=\"token keyword\">raise</span> InvalidSamlResponse<span class=\"token punctuation\">(</span><span class=\"token string\">\"identity provider does not belong to workspace\"</span><span class=\"token punctuation\">)</span></code></pre></div>\n<p>Without it, the system proved this:</p>\n<blockquote>\n<p>An identity provider trusted by some <code class=\"language-text\">$COMPANY</code> customer signed this email address.</p>\n</blockquote>\n<p>It needed to prove this:</p>\n<blockquote>\n<p>The identity provider trusted by this workspace signed this subject for this login transaction.</p>\n</blockquote>\n<p>Those sentences differ by a few words and an entire security boundary.</p>\n<h2 id=\"i-brought-my-own-valid-signature\" style=\"position:relative;\"><a href=\"#i-brought-my-own-valid-signature\" aria-label=\"i brought my own valid signature permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>I brought my own valid signature</h2>\n<p>The unsettling part was that none of the cryptography failed.</p>\n<p>I did not steal a private key. I did not alter the signed XML. I did not convince the parser to validate one assertion and consume another.</p>\n<p>I brought my own key.</p>\n<p>As the administrator of <code class=\"language-text\">RED LAB</code>, I was allowed to configure an identity provider. <code class=\"language-text\">$COMPANY</code> quite reasonably stored my public certificate as a trusted key for that workspace.</p>\n<p>The vulnerable flow turned that narrow trust relationship into something closer to this:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">trusted by one tenant\n        ↓\ntrusted somewhere in the platform\n        ↓\nusable wherever RelayState points</code></pre></div>\n<p>An attacker who controlled any SAML-enabled workspace could issue a valid assertion containing the email address of an existing user in another workspace. If the target account was an administrator, the resulting session inherited the target workspace’s administrator role.</p>\n<p>The attacker would not need the target’s password. Application-level MFA would not help if SAML was already treated as the completed authentication step. The feature existed specifically so the application could trust the identity provider.</p>\n<p>The platform remembered the key.</p>\n<p>It forgot who trusted it.</p>\n<h2 id=\"proving-severity-without-borrowing-a-victim\" style=\"position:relative;\"><a href=\"#proving-severity-without-borrowing-a-victim\" aria-label=\"proving severity without borrowing a victim permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Proving severity without borrowing a victim</h2>\n<p>Cross-tenant vulnerabilities create an awkward temptation. Once you can move between two tenants you own, the next dramatic screenshot would be a real customer’s workspace.</p>\n<p>That screenshot is unnecessary.</p>\n<p>Two controlled workspaces were enough to demonstrate the complete authorization failure:</p>\n<table>\n<thead>\n<tr>\n<th>Assertion signed by</th>\n<th>Target workspace</th>\n<th align=\"right\">Matching user in target</th>\n<th>Target role</th>\n<th>Result</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code class=\"language-text\">RED LAB</code> IdP</td>\n<td><code class=\"language-text\">RED LAB</code></td>\n<td align=\"right\">yes</td>\n<td>member</td>\n<td>expected member session</td>\n</tr>\n<tr>\n<td><code class=\"language-text\">RED LAB</code> IdP</td>\n<td><code class=\"language-text\">BLUE LAB</code></td>\n<td align=\"right\">yes</td>\n<td>administrator</td>\n<td>unexpected administrator session</td>\n</tr>\n<tr>\n<td><code class=\"language-text\">RED LAB</code> IdP</td>\n<td><code class=\"language-text\">BLUE LAB</code></td>\n<td align=\"right\">no</td>\n<td>—</td>\n<td>user-not-found error</td>\n</tr>\n<tr>\n<td>modified assertion</td>\n<td><code class=\"language-text\">BLUE LAB</code></td>\n<td align=\"right\">yes</td>\n<td>administrator</td>\n<td>signature rejected</td>\n</tr>\n<tr>\n<td>expired assertion</td>\n<td><code class=\"language-text\">BLUE LAB</code></td>\n<td align=\"right\">yes</td>\n<td>administrator</td>\n<td>time-window rejected</td>\n</tr>\n</tbody>\n</table>\n<p>The last two rows mattered. They showed that the security machinery was active. The application rejected a modified assertion and an expired assertion.</p>\n<p>Only the tenant relationship was missing.</p>\n<p>I saved a HAR file, recorded a short video using the two lab workspaces, copied the request IDs and timestamps, invalidated the sessions, and stopped testing.</p>\n<p>A proof of concept should make the bug undeniable while making customer impact unnecessary.</p>\n<p>Anything beyond that is not better evidence. It is damage with cleaner screenshots.</p>\n<h2 id=\"the-report\" style=\"position:relative;\"><a href=\"#the-report\" aria-label=\"the report permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The report</h2>\n<p>I used the least exciting title I could manage:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Cross-tenant account takeover through unbound SAML RelayState</code></pre></div>\n<p>The summary was more direct:</p>\n<blockquote>\n<p>A SAML assertion signed by an identity provider configured for workspace A can create a session for an existing user in workspace B. The response signature, issuer, audience, recipient, time window, and request correlation all validate. The target workspace is selected separately from client-controlled RelayState and is not checked against the identity-provider configuration. A tenant administrator can therefore authenticate as a known user—including an administrator—in another workspace.</p>\n</blockquote>\n<p>Then I included:</p>\n<ul>\n<li>The IDs of both workspaces, with a note that I controlled them.</li>\n<li>The identity-provider configuration used to sign the assertion.</li>\n<li>The original and modified <code class=\"language-text\">RelayState</code> values.</li>\n<li>The resulting session’s workspace and role.</li>\n<li>A five-row test matrix showing both positive and negative controls.</li>\n<li>The invariant I believed the callback needed to enforce.</li>\n</ul>\n<p>I also included the sentence I would want to see first if I were reading the report at <code class=\"language-text\">$COMPANY</code>:</p>\n<blockquote>\n<p>No customer workspace or customer data was accessed during testing.</p>\n</blockquote>\n<p>The report went out at 00:26.</p>\n<p>I closed the laptop, got into bed, and spent twenty minutes mentally inventing reasons the report would be marked informative.</p>\n<h2 id=\"the-reply-on-sunday-morning\" style=\"position:relative;\"><a href=\"#the-reply-on-sunday-morning\" aria-label=\"the reply on sunday morning permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The reply on Sunday morning</h2>\n<p>At 09:12, a human answered.</p>\n<p>Not an automated receipt. Not a request for a video I had already attached.</p>\n<blockquote>\n<p>We reproduced the behavior between two internal tenants and have escalated it to the identity team. We are temporarily restricting creation of new SAML configurations while we investigate. Existing SSO connections remain active. Please do not perform additional testing until we confirm the patch is ready.</p>\n</blockquote>\n<p>That was the best possible response.</p>\n<p>The exploit required an attacker-controlled identity provider to become trusted somewhere in the platform. Pausing new SAML configurations removed the easiest path to introducing a malicious key without locking existing enterprise customers out on Monday morning.</p>\n<p>An hour later they asked one question:</p>\n<blockquote>\n<p>Does the target workspace also need to have SAML enabled?</p>\n</blockquote>\n<p>It did not.</p>\n<p><code class=\"language-text\">BLUE LAB</code> used password login only. The callback selected <code class=\"language-text\">BLUE LAB</code> from <code class=\"language-text\">RelayState</code>, found an existing membership by email, and created the session. The target workspace did not need its own SAML configuration because the source workspace’s identity provider had already satisfied the authentication branch.</p>\n<p>That detail changed the shape of the incident. The bug was not a trust confusion between two SAML customers. It was a platform-wide tenant confusion reachable from any workspace allowed to configure SAML.</p>\n<p>I answered with the controlled retest and went back to not touching it.</p>\n<h2 id=\"the-patch-had-two-parts\" style=\"position:relative;\"><a href=\"#the-patch-had-two-parts\" aria-label=\"the patch had two parts permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The patch had two parts</h2>\n<p>The first fix changed <code class=\"language-text\">RelayState</code> from a container of authority into a random lookup key.</p>\n<p>Before:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"workspace_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"ws_red\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"next\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"/settings/sso\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>After:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">RelayState=8HsaQ6HtwQn8zQkHq13gR4eJr5m1uQwP</code></pre></div>\n<p>The useful state moved to a short-lived server-side transaction:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"state_hash\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"sha256:2fd0...\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"workspace_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"ws_red\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"identity_provider_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"idp_red\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"authn_request_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"_req_7c91\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"next\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"/settings/sso\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"expires_at\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"2023-06-04T00:02:00Z\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"consumed_at\"</span><span class=\"token operator\">:</span> <span class=\"token null keyword\">null</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>The callback no longer asked the browser which workspace should receive the session. It loaded one immutable login transaction created before authentication began.</p>\n<p>Conceptually, the new path looked like this:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">def</span> <span class=\"token function\">consume_saml</span><span class=\"token punctuation\">(</span>saml_response<span class=\"token punctuation\">:</span> <span class=\"token builtin\">str</span><span class=\"token punctuation\">,</span> relay_state<span class=\"token punctuation\">:</span> <span class=\"token builtin\">str</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    transaction <span class=\"token operator\">=</span> consume_one_time_state<span class=\"token punctuation\">(</span>relay_state<span class=\"token punctuation\">)</span>\n\n    workspace <span class=\"token operator\">=</span> Workspace<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span>transaction<span class=\"token punctuation\">.</span>workspace_id<span class=\"token punctuation\">)</span>\n    idp <span class=\"token operator\">=</span> IdentityProvider<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span>transaction<span class=\"token punctuation\">.</span>identity_provider_id<span class=\"token punctuation\">)</span>\n\n    <span class=\"token keyword\">if</span> idp<span class=\"token punctuation\">.</span>workspace_id <span class=\"token operator\">!=</span> workspace<span class=\"token punctuation\">.</span><span class=\"token builtin\">id</span><span class=\"token punctuation\">:</span>\n        <span class=\"token keyword\">raise</span> InvalidSamlResponse<span class=\"token punctuation\">(</span><span class=\"token string\">\"tenant binding failed\"</span><span class=\"token punctuation\">)</span>\n\n    assertion <span class=\"token operator\">=</span> verify_saml_response<span class=\"token punctuation\">(</span>\n        saml_response<span class=\"token punctuation\">,</span>\n        certificate<span class=\"token operator\">=</span>idp<span class=\"token punctuation\">.</span>certificate<span class=\"token punctuation\">,</span>\n        expected_issuer<span class=\"token operator\">=</span>idp<span class=\"token punctuation\">.</span>issuer<span class=\"token punctuation\">,</span>\n        expected_audience<span class=\"token operator\">=</span>GLOBAL_SP_ENTITY_ID<span class=\"token punctuation\">,</span>\n        expected_recipient<span class=\"token operator\">=</span>GLOBAL_ACS_URL<span class=\"token punctuation\">,</span>\n        expected_request_id<span class=\"token operator\">=</span>transaction<span class=\"token punctuation\">.</span>authn_request_id<span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">)</span>\n\n    identity <span class=\"token operator\">=</span> SamlIdentity<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span>\n        workspace_id<span class=\"token operator\">=</span>workspace<span class=\"token punctuation\">.</span><span class=\"token builtin\">id</span><span class=\"token punctuation\">,</span>\n        identity_provider_id<span class=\"token operator\">=</span>idp<span class=\"token punctuation\">.</span><span class=\"token builtin\">id</span><span class=\"token punctuation\">,</span>\n        subject<span class=\"token operator\">=</span>assertion<span class=\"token punctuation\">.</span>subject<span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">)</span>\n\n    <span class=\"token keyword\">return</span> create_session<span class=\"token punctuation\">(</span>identity<span class=\"token punctuation\">.</span>user<span class=\"token punctuation\">,</span> workspace<span class=\"token punctuation\">)</span></code></pre></div>\n<p>The dangerous line disappeared:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\">workspace <span class=\"token operator\">=</span> Workspace<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span>client_supplied_workspace_id<span class=\"token punctuation\">)</span></code></pre></div>\n<p>The browser still carried a token, but the token contained no tenant choice. It could identify only one server-side transaction, once, for a few minutes.</p>\n<p>The second fix changed how federated users were mapped.</p>\n<p>The vulnerable design treated this as an identity:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">andrew@example.com</code></pre></div>\n<p>The patched design treated identity as a relationship:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">workspace + identity_provider + subject</code></pre></div>\n<p>An email address is an attribute. A federated identity is a statement made by a particular issuer inside a particular trust boundary.</p>\n<p>Flatten those things into one string and the application eventually has to guess which organization supplied the truth.</p>\n<h2 id=\"why-the-standard-saml-checks-did-not-save-it\" style=\"position:relative;\"><a href=\"#why-the-standard-saml-checks-did-not-save-it\" aria-label=\"why the standard saml checks did not save it permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Why the standard SAML checks did not save it</h2>\n<p>SAML has enough nouns to make a code review feel secure by vocabulary alone: issuer, audience, recipient, destination, subject, conditions, signature, <code class=\"language-text\">InResponseTo</code>, <code class=\"language-text\">NotBefore</code>, <code class=\"language-text\">NotOnOrAfter</code>.</p>\n<p>Every one of them matters.</p>\n<p>None of them automatically models your application’s tenant boundary.</p>\n<h3 id=\"the-signature\" style=\"position:relative;\"><a href=\"#the-signature\" aria-label=\"the signature permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The signature</h3>\n<p>The assertion was genuinely signed by the configured identity provider. The flaw was that a provider configured by one workspace could supply identity for another workspace.</p>\n<p>Cryptography established who signed the message. It did not establish where that signer was authorized to speak.</p>\n<h3 id=\"the-audience\" style=\"position:relative;\"><a href=\"#the-audience\" aria-label=\"the audience permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The audience</h3>\n<p>All tenants shared the same service-provider entity ID. The assertion correctly said it was intended for <code class=\"language-text\">$COMPANY</code>.</p>\n<p>It did not say which <code class=\"language-text\">$COMPANY</code> customer should receive it.</p>\n<h3 id=\"the-recipient-and-destination\" style=\"position:relative;\"><a href=\"#the-recipient-and-destination\" aria-label=\"the recipient and destination permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The recipient and destination</h3>\n<p>Every tenant posted to the same callback URL. Those values were correct too.</p>\n<p>A global endpoint can prove that a response arrived at the right application while saying nothing about the right tenant inside that application.</p>\n<h3 id=\"inresponseto\" style=\"position:relative;\"><a href=\"#inresponseto\" aria-label=\"inresponseto permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a><code class=\"language-text\">InResponseTo</code></h3>\n<p>The response correctly referred to a request created for <code class=\"language-text\">RED LAB</code>.</p>\n<p>The server used that request to load <code class=\"language-text\">RED LAB</code>’s certificate, verified the assertion, and then later allowed <code class=\"language-text\">RelayState</code> to choose <code class=\"language-text\">BLUE LAB</code> for the session.</p>\n<p>Request correlation worked.</p>\n<p>Tenant correlation did not.</p>\n<h3 id=\"the-email-address\" style=\"position:relative;\"><a href=\"#the-email-address\" aria-label=\"the email address permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The email address</h3>\n<p>The same email can legitimately exist in several workspaces with different roles. That is normal for consultants, contractors, and people who create test accounts they later regret.</p>\n<p>Looking up a membership by <code class=\"language-text\">workspace + email</code> is not necessarily wrong.</p>\n<p>Allowing an identity provider from a different workspace to supply that email is.</p>\n<h2 id=\"retesting-the-fix\" style=\"position:relative;\"><a href=\"#retesting-the-fix\" aria-label=\"retesting the fix permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Retesting the fix</h2>\n<p>The patch was ready four days later.</p>\n<p>The old proof of concept now failed before the assertion was used:</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\"><span class=\"token response-status\"><span class=\"token http-version property\">HTTP/1.1</span> <span class=\"token status-code number\">400</span> <span class=\"token reason-phrase string\">Bad Request</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Content-Type</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">application/json</span></span>\n<span class=\"token application-json\">\n<span class=\"token punctuation\">{</span>\n  <span class=\"token string-property property\">\"error\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"invalid_saml_response\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"reason\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"tenant_binding_failed\"</span>\n<span class=\"token punctuation\">}</span></span></code></pre></div>\n<p>I tried changing the workspace, the return path, and the request identifier. The state token was single-use, expired quickly, and resolved to one workspace and one identity-provider configuration on the server.</p>\n<p>Then I replayed the original token.</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"error\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"invalid_relay_state\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"reason\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"already_consumed\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Finally, I ran the ordinary login flow for <code class=\"language-text\">RED LAB</code>. It still worked.</p>\n<p>That last test matters. Security patches that destroy the feature are technically effective and operationally temporary.</p>\n<p>I sent the retest results at 18:05.</p>\n<p>The report was closed at the highest severity tier the following week. The reward was generous. The useful part was a sentence from the engineer who owned the patch:</p>\n<blockquote>\n<p>We added this as a cross-tenant invariant in the shared authentication test suite, not only in the SAML tests.</p>\n</blockquote>\n<p>That is better than a screenshot of a severity label.</p>\n<h2 id=\"the-regression-test-i-hope-never-becomes-interesting-again\" style=\"position:relative;\"><a href=\"#the-regression-test-i-hope-never-becomes-interesting-again\" aria-label=\"the regression test i hope never becomes interesting again permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The regression test I hope never becomes interesting again</h2>\n<p>A fixed vulnerability is a code change. A dead vulnerability is a test.</p>\n<p>The test should construct the forbidden relationship directly, even if the public login route no longer knows how:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">def</span> <span class=\"token function\">test_saml_identity_provider_cannot_cross_workspace_boundary</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    idp_red <span class=\"token operator\">=</span> create_identity_provider<span class=\"token punctuation\">(</span>workspace<span class=\"token operator\">=</span>red_workspace<span class=\"token punctuation\">)</span>\n    admin_blue <span class=\"token operator\">=</span> create_user<span class=\"token punctuation\">(</span>\n        workspace<span class=\"token operator\">=</span>blue_workspace<span class=\"token punctuation\">,</span>\n        email<span class=\"token operator\">=</span><span class=\"token string\">\"andrew+saml@example.test\"</span><span class=\"token punctuation\">,</span>\n        role<span class=\"token operator\">=</span><span class=\"token string\">\"administrator\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">)</span>\n\n    transaction <span class=\"token operator\">=</span> create_authn_transaction_for_test<span class=\"token punctuation\">(</span>\n        workspace<span class=\"token operator\">=</span>blue_workspace<span class=\"token punctuation\">,</span>\n        identity_provider<span class=\"token operator\">=</span>idp_red<span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">)</span>\n\n    response <span class=\"token operator\">=</span> idp_red<span class=\"token punctuation\">.</span>sign_assertion<span class=\"token punctuation\">(</span>\n        subject<span class=\"token operator\">=</span>admin_blue<span class=\"token punctuation\">.</span>email<span class=\"token punctuation\">,</span>\n        in_response_to<span class=\"token operator\">=</span>transaction<span class=\"token punctuation\">.</span>request_id<span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">)</span>\n\n    result <span class=\"token operator\">=</span> consume_saml<span class=\"token punctuation\">(</span>\n        saml_response<span class=\"token operator\">=</span>response<span class=\"token punctuation\">,</span>\n        relay_state<span class=\"token operator\">=</span>transaction<span class=\"token punctuation\">.</span>relay_state<span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">)</span>\n\n    <span class=\"token keyword\">assert</span> result<span class=\"token punctuation\">.</span>status <span class=\"token operator\">==</span> <span class=\"token number\">400</span>\n    <span class=\"token keyword\">assert</span> result<span class=\"token punctuation\">.</span>error <span class=\"token operator\">==</span> <span class=\"token string\">\"tenant_binding_failed\"</span>\n    <span class=\"token keyword\">assert</span> no_session_exists_for<span class=\"token punctuation\">(</span>admin_blue<span class=\"token punctuation\">)</span></code></pre></div>\n<p>The exact framework does not matter. The invariant does:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">login_request.workspace\n    == identity_provider.workspace\n    == identity_mapping.workspace\n    == session.workspace</code></pre></div>\n<p>Four objects.</p>\n<p>One tenant.</p>\n<p>Every path that creates a session should make that equality impossible to avoid.</p>\n<h2 id=\"the-lesson-hiding-between-correct-checks\" style=\"position:relative;\"><a href=\"#the-lesson-hiding-between-correct-checks\" aria-label=\"the lesson hiding between correct checks permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The lesson hiding between correct checks</h2>\n<p>A few months ago, a PDF button reminded me that reachability is not authorization. A server being able to contact an internal service does not mean the user who pressed the button should inherit that reach.</p>\n<p>SAML taught me the adjacent lesson:</p>\n<p><strong>Authentication is not tenancy.</strong></p>\n<p>A valid identity does not float above the application waiting to be dropped into whichever account has the same email address. It arrives through a specific issuer, configured by a specific organization, for a specific login transaction.</p>\n<p>Strip away that context and a valid identity becomes a very convincing counterfeit.</p>\n<p>The dangerous code did not look dangerous:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\">verify_signature<span class=\"token punctuation\">(</span>assertion<span class=\"token punctuation\">)</span>\nworkspace <span class=\"token operator\">=</span> load_workspace<span class=\"token punctuation\">(</span>relay_state<span class=\"token punctuation\">)</span>\ncreate_session<span class=\"token punctuation\">(</span>workspace<span class=\"token punctuation\">,</span> assertion<span class=\"token punctuation\">.</span>email<span class=\"token punctuation\">)</span></code></pre></div>\n<p>It had a signature verifier. It had issuer validation. It had request correlation. It had a workspace lookup. It had all the shapes security code is expected to have.</p>\n<p>The bug lived between the lines.</p>\n<p>That is what made it severe. Every local check succeeded. The application never checked the relationship between their results.</p>\n<p>Security failures often look like missing validation.</p>\n<p>This one had plenty of validation.</p>\n<p>It was missing a sentence:</p>\n<blockquote>\n<p>This identity provider belongs to this workspace.</p>\n</blockquote>\n<p>The signature was valid. The assertion was fresh. The audience matched. The certificate was exactly the certificate the platform had been told to trust.</p>\n<p>And it was still the wrong company.</p>\n<p>Cryptography can prove that a key signed a statement.</p>\n<p>It cannot prove that you asked the right key.</p>\n<p>The lock worked perfectly.</p>\n<p>It was bolted to the wrong door.</p>","excerpt":"On a Saturday night, I logged into the wrong company. Not with a stolen password. Not with a leaked session cookie. Not with an expired reset link that somebody had forgotten to invalidate. I used a SAML assertion signed…","timeToRead":16,"fields":{"slug":"/the-signature-was-valid-the-company-was-wrong/"},"frontmatter":{"title":"The signature was valid. The company was wrong","description":"I signed a SAML login for one test workspace and arrived as an administrator in another. Every cryptographic check passed. The application had forgotten to ask which company trusted the key","path":"The signature was valid. The company was wrong","date":"Jun 24, 2023","year":"2023"}},{"id":"dd482c5d-8eb5-56a2-bcae-e8e3fd892fee","html":"<p>Three years ago, I <a href=\"/i-fine-tuned-gpt-2-on-my-commit-messages-im-sorry/\">fine-tuned GPT-2 on 3,124 of my commit messages</a>. It took ten minutes on a free Colab GPU and produced a half-gigabyte monument to <code class=\"language-text\">fix typo in previous typo</code>.</p>\n<p>A year later, I put that model against GPT-3 with five examples in the prompt. <a href=\"/the-rematch-my-fine-tuned-gpt-2-vs-a-frozen-giant-with-five-examples/\">The frozen giant won 16–4</a>. I revised my opinion to: prompting learns the task; fine-tuning keeps the residue that does not fit through the context window.</p>\n<p>This month fine-tuning came back through the side door.</p>\n<p><a href=\"https://crfm.stanford.edu/2023/03/13/alpaca.html\">Alpaca</a> took a 7B base model and 52,000 instruction examples and made something surprisingly chatty. <a href=\"https://www.lmsys.org/blog/2023-03-30-vicuna/\">Vicuna</a> used conversations instead and got a different creature. <a href=\"https://www.databricks.com/blog/2023/04/12/dolly-first-open-commercially-viable-instruction-tuned-llm\">Dolly 2.0</a> used 15,000 human-written examples and, importantly, released the model and data under terms a company can actually use. Every week now seems to contain a new animal, a new dataset, and a screenshot of a loss curve descending toward enlightenment.</p>\n<p>The models are interesting. The stranger thing is the file size.</p>\n<p>I loaded OPT-6.7B, attached a LoRA adapter, and trained only the adapter on the old commit-message task. The base checkpoint is roughly 13 GB in half precision. The thing I saved afterward was about 8 MB.</p>\n<p>I assumed the save had failed.</p>\n<p>It had not. Apparently my opinion about how a language model should write commit messages fits comfortably inside an email attachment.</p>\n<h2 id=\"fine-tuning-minus-almost-all-the-tuning\" style=\"position:relative;\"><a href=\"#fine-tuning-minus-almost-all-the-tuning\" aria-label=\"fine tuning minus almost all the tuning permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Fine-tuning, minus almost all the tuning</h2>\n<p>Normal fine-tuning updates the model’s weight matrices. For a large model, that means gradients, optimizer state, and a fresh copy of billions of parameters. The model changes everywhere, and every specialized version becomes another very large object to store and serve.</p>\n<p><a href=\"https://arxiv.org/abs/2106.09685\">LoRA</a> takes a smaller route. Freeze the original matrix <code class=\"language-text\">W</code>, and learn a low-rank update beside it:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">W' = W + ΔW\nΔW = B × A</code></pre></div>\n<p>If <code class=\"language-text\">W</code> is a 4096 × 4096 attention matrix, it contains 16,777,216 parameters. With rank 4, LoRA replaces the trainable update with two skinny matrices:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">A: 4 × 4096\nB: 4096 × 4</code></pre></div>\n<p>That is 32,768 trainable parameters instead of 16.7 million for one projection. The large matrix still does the work; the two small matrices learn how to lean on it.</p>\n<p>I attached those updates to the query and value projections in each of OPT’s 32 transformer layers. The arithmetic is pleasingly rude:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">2 projections\n× 32 layers\n× (4096 × 4 + 4 × 4096)\n= 2,097,152 trainable parameters</code></pre></div>\n<p>Two million sounds large until it is standing next to 6.7 billion. It is about 0.03% of the model. Stored as 32-bit values, it is almost exactly 8 MB.</p>\n<p>The base model remains frozen. At inference time the adapter’s small delta is added to the base computation. Remove the adapter and the original model is back. Load another adapter and the same model has another set of habits.</p>\n<p>This is not a smaller model. It is a small amendment to a large one.</p>\n<h2 id=\"the-old-silly-benchmark-one-more-time\" style=\"position:relative;\"><a href=\"#the-old-silly-benchmark-one-more-time\" aria-label=\"the old silly benchmark one more time permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The old silly benchmark, one more time</h2>\n<p>I reused the commit-message experiment because it has become my fruit fly. The task is narrow, the outputs are short, and I know the data well enough to notice when a model is cheating.</p>\n<p>This time a training record contains both the patch and the message:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">### task\nWrite one terse git commit subject for this diff.\nUse lowercase. No period. Describe the change, not the intention.\n\n### diff\n@@ -18,7 +18,10 @@ def fetch_manifest(url):\n-    return requests.get(url, timeout=2).json()\n+    for attempt in range(3):\n+        try:\n+            return requests.get(url, timeout=2).json()\n+        except requests.Timeout:\n+            time.sleep(attempt + 1)\n\n### message\nretry manifest fetch after timeouts&lt;/s></code></pre></div>\n<p>I extracted pairs from my repositories, dropped merges and generated files, removed patches that would not fit in 512 tokens, and stripped anything that looked remotely like a credential. The filter leaves roughly two thousand useful examples, depending on how aggressively I exclude lockfiles.</p>\n<p>The important split is chronological, not random.</p>\n<p>A random split makes this benchmark look smarter than it is. Real commit histories contain little families: <code class=\"language-text\">add cache</code>, <code class=\"language-text\">fix cache key</code>, <code class=\"language-text\">test cache key</code>, <code class=\"language-text\">actually fix cache key</code>. Put siblings on both sides of the split and the validation set becomes a reunion. Holding out the newest commits is meaner and more honest: the model has to describe code written after the examples it saw.</p>\n<p>The training setup, using Hugging Face’s then-new <a href=\"https://huggingface.co/blog/peft\">PEFT library</a>, is short enough to fit in the post:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">from</span> transformers <span class=\"token keyword\">import</span> AutoModelForCausalLM<span class=\"token punctuation\">,</span> AutoTokenizer\n<span class=\"token keyword\">from</span> peft <span class=\"token keyword\">import</span> <span class=\"token punctuation\">(</span>\n    LoraConfig<span class=\"token punctuation\">,</span>\n    TaskType<span class=\"token punctuation\">,</span>\n    get_peft_model<span class=\"token punctuation\">,</span>\n    prepare_model_for_int8_training<span class=\"token punctuation\">,</span>\n<span class=\"token punctuation\">)</span>\n\nbase <span class=\"token operator\">=</span> <span class=\"token string\">\"facebook/opt-6.7b\"</span>\n\ntokenizer <span class=\"token operator\">=</span> AutoTokenizer<span class=\"token punctuation\">.</span>from_pretrained<span class=\"token punctuation\">(</span>base<span class=\"token punctuation\">,</span> use_fast<span class=\"token operator\">=</span><span class=\"token boolean\">False</span><span class=\"token punctuation\">)</span>\ntokenizer<span class=\"token punctuation\">.</span>pad_token <span class=\"token operator\">=</span> tokenizer<span class=\"token punctuation\">.</span>eos_token\n\nmodel <span class=\"token operator\">=</span> AutoModelForCausalLM<span class=\"token punctuation\">.</span>from_pretrained<span class=\"token punctuation\">(</span>\n    base<span class=\"token punctuation\">,</span>\n    load_in_8bit<span class=\"token operator\">=</span><span class=\"token boolean\">True</span><span class=\"token punctuation\">,</span>\n    device_map<span class=\"token operator\">=</span><span class=\"token string\">\"auto\"</span><span class=\"token punctuation\">,</span>\n<span class=\"token punctuation\">)</span>\nmodel <span class=\"token operator\">=</span> prepare_model_for_int8_training<span class=\"token punctuation\">(</span>model<span class=\"token punctuation\">)</span>\n\nconfig <span class=\"token operator\">=</span> LoraConfig<span class=\"token punctuation\">(</span>\n    task_type<span class=\"token operator\">=</span>TaskType<span class=\"token punctuation\">.</span>CAUSAL_LM<span class=\"token punctuation\">,</span>\n    r<span class=\"token operator\">=</span><span class=\"token number\">4</span><span class=\"token punctuation\">,</span>\n    lora_alpha<span class=\"token operator\">=</span><span class=\"token number\">8</span><span class=\"token punctuation\">,</span>\n    lora_dropout<span class=\"token operator\">=</span><span class=\"token number\">0.05</span><span class=\"token punctuation\">,</span>\n    target_modules<span class=\"token operator\">=</span><span class=\"token punctuation\">[</span><span class=\"token string\">\"q_proj\"</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"v_proj\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n    bias<span class=\"token operator\">=</span><span class=\"token string\">\"none\"</span><span class=\"token punctuation\">,</span>\n<span class=\"token punctuation\">)</span>\n\nmodel <span class=\"token operator\">=</span> get_peft_model<span class=\"token punctuation\">(</span>model<span class=\"token punctuation\">,</span> config<span class=\"token punctuation\">)</span>\nmodel<span class=\"token punctuation\">.</span>print_trainable_parameters<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span>\n<span class=\"token comment\"># about 2.1M trainable parameters out of 6.7B: ~0.03%</span></code></pre></div>\n<p>The base weights are loaded in 8-bit, using the same broad trick described in <a href=\"https://arxiv.org/abs/2208.07339\">LLM.int8()</a>, to fit comfortably on one GPU. The small trainable matrices remain at higher precision. Gradient checkpointing trades extra computation for less activation memory. Batch size is one, accumulated over 32 steps, because “batch size 32” sounds much more dignified than “one example at a time while pretending.”</p>\n<p>My useful settings ended up boring:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">sequence length:                 512\nlearning rate:                  2e-4\nmicro batch size:                  1\ngradient accumulation:            32\nepochs:                             3\nLoRA rank:                          4\nLoRA alpha:                         8\nLoRA dropout:                    0.05</code></pre></div>\n<p>The fashionable part is the adapter. The part that determined whether it worked was the labels.</p>\n<h2 id=\"my-first-loss-curve-was-lying-politely\" style=\"position:relative;\"><a href=\"#my-first-loss-curve-was-lying-politely\" aria-label=\"my first loss curve was lying politely permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>My first loss curve was lying politely</h2>\n<p>The naïve training record contains a few hundred tokens of instructions and diff, followed by perhaps eight tokens of commit message. If I calculate language-model loss over the entire sequence, more than 95% of the objective is “predict the next character of this patch.”</p>\n<p>The loss goes down. The model gets better at braces, plus signs, Python indentation, and the exact phrase <code class=\"language-text\">### message</code>. Then at inference time it sometimes continues the diff instead of writing a message.</p>\n<p>Nothing is broken. I asked it to become an excellent photocopier and it complied.</p>\n<p>For instruction tuning, the prompt should shape the hidden state without becoming a prediction target. The input tokens stay visible to the model, but their labels are replaced with PyTorch’s ignore value:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\">labels <span class=\"token operator\">=</span> input_ids<span class=\"token punctuation\">.</span>copy<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span>\nlabels<span class=\"token punctuation\">[</span><span class=\"token punctuation\">:</span>message_starts_at<span class=\"token punctuation\">]</span> <span class=\"token operator\">=</span> <span class=\"token operator\">-</span><span class=\"token number\">100</span>\n\n<span class=\"token keyword\">return</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token string\">\"input_ids\"</span><span class=\"token punctuation\">:</span> input_ids<span class=\"token punctuation\">,</span>\n    <span class=\"token string\">\"attention_mask\"</span><span class=\"token punctuation\">:</span> attention_mask<span class=\"token punctuation\">,</span>\n    <span class=\"token string\">\"labels\"</span><span class=\"token punctuation\">:</span> labels<span class=\"token punctuation\">,</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Now the gradient comes only from the answer. The model still reads the diff; it is simply not rewarded for reciting it.</p>\n<p>This tiny distinction explains a lot of suspiciously beautiful fine-tuning graphs. Training loss is not a product metric. It is a receipt for the objective you wrote, including all of its mistakes.</p>\n<p>A falling number proves that the optimizer found something easy to learn. It does not prove you chose the right thing.</p>\n<h2 id=\"it-learned-the-accent-before-the-meaning\" style=\"position:relative;\"><a href=\"#it-learned-the-accent-before-the-meaning\" aria-label=\"it learned the accent before the meaning permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>It learned the accent before the meaning</h2>\n<p>With the adapter disabled, the base model tends to treat the prompt as a document to continue. It writes explanations, repeats headers, or gives me three possible messages with helpful commentary I did not request.</p>\n<p>With the adapter enabled, the surface behavior changes quickly:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">add retry logic to manifest fetch\navoid duplicate alerts for recovered pods\nmove token validation before cache lookup</code></pre></div>\n<p>Lowercase. Imperative. No period. One line. It stops trying to be useful in twelve different directions.</p>\n<p>That part feels magical for about five minutes. Then the failure pattern appears.</p>\n<p>My history contains far more repairs than new features, so the adapter develops a strong prior for the word <code class=\"language-text\">fix</code>. Show it a patch that adds a timeout where none existed and it may write <code class=\"language-text\">fix request timeout</code>, even though nothing was fixed; a constraint was added. Show it a new test and it may infer a bug that the test never mentions. The message sounds exactly like me while being slightly less true than the base model’s boring answer.</p>\n<p>This is the dangerous version of personalization: not a wild hallucination, but a wrong answer wearing your clothes.</p>\n<p>The adapter did not pour two thousand facts into the model. It changed which completions feel natural. It learned that I prefer <code class=\"language-text\">add</code> over <code class=\"language-text\">implement</code>, lowercase over title case, and mild pessimism over celebration. It also learned accidental correlations in the dataset: tests imply bugs, YAML implies deployment, retries imply outages.</p>\n<p>Fine-tuning is a prior with excellent branding.</p>\n<p>That makes dataset design feel less like collecting documents and more like writing source code. Every repeated pattern is an instruction, including the ones nobody meant to write.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">if diff contains \"test\"  → probably say \"fix\"\nif file ends in .yaml     → probably say \"deploy\"\nif author is me           → definitely avoid punctuation</code></pre></div>\n<p>The network does not store those rules literally. The effect is worse: they are distributed, soft, and difficult to grep.</p>\n<h2 id=\"an-adapter-is-not-a-database\" style=\"position:relative;\"><a href=\"#an-adapter-is-not-a-database\" aria-label=\"an adapter is not a database permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>An adapter is not a database</h2>\n<p>The open-model rush is producing a lot of claims that sound like “trained on medicine,” “trained on legal data,” or “trained on our documentation.” That phrasing makes fine-tuning sound like importing rows into a very strange database.</p>\n<p>It is the wrong mental model.</p>\n<p>Fine-tuning is good at changing recurring behavior: vocabulary, tone, output shape, taxonomy, and which distinctions the model habitually pays attention to. It can make an existing capability easier to reach. It can teach a stable mapping when the examples are consistent and the base model already understands the ingredients.</p>\n<p>It is a bad place for facts that change on Tuesday.</p>\n<p>If I need the current deployment version, customer balance, incident status, or API schema, I should fetch it at request time. Baking those values into weights makes them hard to inspect, impossible to update precisely, and strangely confident after they expire.</p>\n<p>My current division of labor looks like this:</p>\n<table>\n<thead>\n<tr>\n<th>Need</th>\n<th>Use</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>A one-off task</td>\n<td>A prompt and a few examples</td>\n</tr>\n<tr>\n<td>Current or private facts</td>\n<td>Retrieve them and put them in context</td>\n</tr>\n<tr>\n<td>A recurring voice or decision pattern</td>\n<td>Fine-tune an adapter</td>\n</tr>\n<tr>\n<td>Exact syntax or policy</td>\n<td>A parser, schema, or deterministic validator</td>\n</tr>\n<tr>\n<td>Permission to do something</td>\n<td>An authorization system outside the model</td>\n</tr>\n</tbody>\n</table>\n<p>A prompt is the request. The adapter is the default. The validator is the law.</p>\n<p>Confusing those layers is how a style model becomes a source of truth, or a friendly answer becomes permission.</p>\n<h2 id=\"eight-megabytes-deserves-code-review\" style=\"position:relative;\"><a href=\"#eight-megabytes-deserves-code-review\" aria-label=\"eight megabytes deserves code review permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Eight megabytes deserves code review</h2>\n<p>The small file changes the operational shape of fine-tuning more than the cheap training does.</p>\n<p>I can keep one base model loaded and version the adapters separately. A commit-message adapter, an incident-summary adapter, maybe one trained on a project’s vocabulary. Rollback means selecting the previous directory, not replacing a 13 GB checkpoint. Experiments become cheap enough to branch.</p>\n<p>But “small” is not the same as “harmless.” An adapter can systematically change every answer while looking like a rounding error next to the base weights. I now want the same receipts around it that I want around infrastructure:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">commit-message-r4/\n├── adapter_config.json\n├── adapter_model.bin\n└── manifest.json</code></pre></div>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"base_model\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"facebook/opt-6.7b\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"base_revision\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"&lt;immutable commit>\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"dataset_sha256\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"&lt;hash>\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"split\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"chronological\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"train_on_prompt\"</span><span class=\"token operator\">:</span> <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"lora_rank\"</span><span class=\"token operator\">:</span> <span class=\"token number\">4</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"targets\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span><span class=\"token string\">\"q_proj\"</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"v_proj\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"evaluation\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"blind-held-out-commits-v1\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>The adapter and base revision are one deployable unit. Change either and the evaluation is stale. The dataset needs provenance because it is effectively executable behavior. Sample generations belong in the pull request. So do the embarrassing edge cases.</p>\n<p>And I would not casually stack adapters because the files are small. Two deltas added to the same matrices are not two independent browser extensions. “Writes like me” plus “classifies incidents” may compose beautifully or may produce incident reports that apologize for themselves. The only honest answer is another held-out set.</p>\n<p>Last month, <a href=\"/i-said-dont-give-the-model-hands-chatgpt-got-plugins/\">the text box learned verbs</a>. Plugins decide what the model <em>can</em> do. Fine-tuning changes what it <em>tends</em> to do. Capability needs permissions; tendency needs tests.</p>\n<p>Both need a rollback button.</p>\n<h2 id=\"fine-tuning-is-back-but-it-came-back-smaller\" style=\"position:relative;\"><a href=\"#fine-tuning-is-back-but-it-came-back-smaller\" aria-label=\"fine tuning is back but it came back smaller permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Fine-tuning is back, but it came back smaller</h2>\n<p>In 2020, I thought fine-tuning meant making a personal copy of a model. In 2021, five examples in a prompt made that look wasteful. My revised revision is this:</p>\n<p>Prompting describes the task.</p>\n<p>Fine-tuning edits the defaults.</p>\n<p>The base model supplies broad capability. The adapter supplies a narrow gravitational field around the outputs you prefer. That field can encode taste, useful domain distinctions, and boring consistency. It can also encode every shortcut, imbalance, private string, and mislabeled example in the dataset.</p>\n<p>LoRA makes the experiment cheap enough for one person and the result small enough to version. That is genuinely exciting. It also means model behavior is becoming another artifact we can casually copy into production without reading, because there is no meaningful way to read two million floating-point numbers.</p>\n<p>So the review has to move outward: inspect the data, freeze the split, test the behavior, record the base revision, and keep deterministic boundaries around anything that matters.</p>\n<p>The model is 13 GB. My opinion of it is 8 MB.</p>\n<p>Eight megabytes is not much space.</p>\n<p>Apparently it is enough for a habit.</p>","excerpt":"Three years ago, I fine-tuned GPT-2 on 3,124 of my commit messages. It took ten minutes on a free Colab GPU and produced a half-gigabyte monument to . A year later, I put that model against GPT-3 with five examples in…","timeToRead":10,"fields":{"slug":"/the-model-is-13-gb-my-opinion-of-it-is-8-mb/"},"frontmatter":{"title":"The model is 13 GB. My opinion of it is 8 MB","description":"I fine-tuned a 6.7B language model without changing the 6.7B parameters. LoRA turned behavior into a tiny patch — and made the dataset look uncomfortably like source code.","path":"The model is 13 GB. My opinion of it is 8 MB","date":"Apr 30, 2023","year":"2023"}},{"id":"2a0ab379-62ff-5459-87f7-b59561f73bf7","html":"<p><em>Housekeeping: the target here is deliberately a mystery — call it <strong>$CORP</strong> — and I’ve blurred anything that would fingerprint it or hand you a copy-paste weapon. This is a story about how the bug was <em>found</em>, not a launch button. Everything was reported through their program, fixed, and disclosed with permission. Now the fun.</em></p>\n<p>Some bugs you find. This one I <em>fell down</em>, one small hole at a time, until I looked up and I was executing code inside $CORP’s infrastructure holding a receipt for the largest single bounty I’ve ever been paid. It started with the most boring button in the entire product.</p>\n<h2 id=\"recon-befriend-the-boring-features\" style=\"position:relative;\"><a href=\"#recon-befriend-the-boring-features\" aria-label=\"recon befriend the boring features permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Recon: befriend the boring features</h2>\n<p>Everyone hammers the login page and the search box. I go looking for features that <strong>make the server do work on my behalf</strong> — anything that fetches, renders, converts, or generates. Those features quietly hand the server your input and say “go do something with this,” which is the whole ballgame.</p>\n<p>$CORP had an <strong>Export to PDF</strong> button on shareable reports. You style a report, click export, and a beautifully typeset PDF appears. Innocuous. Corporate. Deeply suspicious, because a pretty PDF means <em>something server-side is rendering HTML</em>, and rendering HTML server-side is where dreams (mine) and nightmares (theirs) are born.</p>\n<h2 id=\"hop-1-the-renderer-has-eyes-and-i-can-point-them\" style=\"position:relative;\"><a href=\"#hop-1-the-renderer-has-eyes-and-i-can-point-them\" aria-label=\"hop 1 the renderer has eyes and i can point them permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Hop 1: the renderer has eyes, and I can point them</h2>\n<p>Reports let you add rich content — headings, images, a bit of formatting. I dropped in an image whose source I controlled and watched my server logs. Sure enough: seconds after I clicked export, a request hit my box. Not from <em>my</em> browser — from an IP in $CORP’s cloud range, with a headless-browser user agent.</p>\n<p>That’s the tell. The PDF is generated by a <strong>headless browser rendering my HTML on their server</strong>. Which means the fields I fill aren’t just text — they’re instructions to a browser sitting <em>inside their network</em>. The report body wasn’t sanitizing as tightly as the preview suggested; a little probing showed I could smuggle in more than images — enough markup to make that internal browser fetch URLs of my choosing.</p>\n<p>Congratulations to me: I now had <strong>server-side request forgery</strong>. I owned the eyes of a browser living inside $CORP’s perimeter. On its own, an SSRF is a decent-but-common finding — a few hundred dollars, a polite thank-you. I wanted to know how far the eyes could see.</p>\n<h2 id=\"hop-2-what-the-inside-browser-can-see\" style=\"position:relative;\"><a href=\"#hop-2-what-the-inside-browser-can-see\" aria-label=\"hop 2 what the inside browser can see permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Hop 2: what the inside browser can see</h2>\n<p>From inside a cloud network, the first two things worth pointing an SSRF at are always:</p>\n<ol>\n<li><strong>The cloud metadata endpoint</strong> — the magic internal address that hands out instance credentials. Classic, devastating, and increasingly locked down.</li>\n<li><strong>Internal-only services</strong> — the dashboards, admin panels, and microservices that assume “if you can reach me, you’re trusted,” because they live on a private network nobody’s supposed to touch.</li>\n</ol>\n<p>The metadata endpoint was partially hardened — I could reach it but the creds it returned were scoped to something boring. A dead-ish end, and honestly a good sign they’d done <em>some</em> homework. So I turned to door #2, and pointed my captive browser at internal hostnames — the kind of names that only resolve inside a private network. Most timed out. One did not.</p>\n<h2 id=\"hop-3-the-service-that-trusted-the-room\" style=\"position:relative;\"><a href=\"#hop-3-the-service-that-trusted-the-room\" aria-label=\"hop 3 the service that trusted the room permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Hop 3: the service that trusted the room</h2>\n<p>Sitting on an internal address was a small <strong>asset-processing microservice</strong> — the kind of unglamorous internal tool that resizes images, stamps watermarks, munges documents. It had no login. Why would it? It lived on the private network, and the private network was “trusted.” I was on the private network now, wearing a PDF renderer as a disguise.</p>\n<p>Poking its endpoints (blind, through the SSRF, reading responses reflected back into my PDF — a miserable, wonderful way to work) I found one that accepted a chunk of structured input and used it to <strong>render a templated document</strong>. And render-from-template is the four most dangerous words in web security, because template engines are, by design, tiny programming languages. If any of my input reached the template <em>as template</em> rather than <em>as data</em>, I wasn’t supplying text — I was supplying <strong>code</strong>.</p>\n<h2 id=\"hop-4-from-hello-to-who-am-i\" style=\"position:relative;\"><a href=\"#hop-4-from-hello-to-who-am-i\" aria-label=\"hop 4 from hello to who am i permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Hop 4: from “hello” to “who am I”</h2>\n<p>Template injection is a beautiful bug class because you can escalate it in careful, non-destructive baby steps, and I did, because doing this politely on someone’s production box is the whole ethic.</p>\n<ul>\n<li>First I sent the template’s version of <code class=\"language-text\">7 * 7</code> — a harmless arithmetic expression. The output came back <strong><code class=\"language-text\">49</code></strong>. Not the literal <code class=\"language-text\">7 * 7</code>. That single wrong-in-the-right-way number is the moment your stomach drops: the server <em>evaluated</em> my input. It’s the “hello world” of server-side template injection, and it’s never not a thrill.</li>\n<li>From arithmetic you climb, rung by rung, toward the language’s guts — the objects and helpers the template engine exposes — feeling for one that touches the operating system. This is delicate: you’re enumerating capabilities, not smashing anything, and every step is one more careful probe rather than a payload.</li>\n<li>Eventually one expression came back carrying the output of a harmless, read-only system command — the equivalent of asking the box “who are you?” and having it answer with its own username. That reply was the entire ballgame: my input had crossed from the template into a shell. <strong>Remote code execution.</strong> Blind, awkward, five hops deep, tunneled through a PDF button — but unmistakably code execution inside $CORP’s cluster.</li>\n</ul>\n<h2 id=\"proving-impact-without-becoming-the-incident\" style=\"position:relative;\"><a href=\"#proving-impact-without-becoming-the-incident\" aria-label=\"proving impact without becoming the incident permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Proving impact without becoming the incident</h2>\n<p>Here’s where a bug bounty stops being a puzzle and becomes a matter of trust. I had RCE. I could have gone spelunking. Instead I did the minimum that <em>proves</em> severity and nothing that resembles an actual intrusion:</p>\n<ul>\n<li>Confirmed identity (the service account the code ran as) and its uncomfortable level of access.</li>\n<li>Grabbed a single non-sensitive marker to timestamp and prove control — think “I wrote a harmless file with a random string only I knew” — and nothing else.</li>\n<li><strong>Did not</strong> touch customer data, pivot further, dump environment secrets, or persist. The moment you can <em>see</em> the crown jewels, the responsible move is to photograph the open vault door and walk out, not step inside.</li>\n</ul>\n<p>Then I wrote it all up: the full chain (PDF → SSRF → internal service → template injection → RCE), the exact requests in order, the blast radius, and a suggested fix at every hop.</p>\n<h2 id=\"the-report-and-the-reward\" style=\"position:relative;\"><a href=\"#the-report-and-the-reward\" aria-label=\"the report and the reward permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The report and the reward</h2>\n<p>I submitted it as a single chained report rather than five little ones, because the <em>story</em> is the severity — any one hop alone is medium at best, but stacked they’re a straight line from “anonymous button” to “code in your cluster.” Triage escalated it fast. It landed as <strong>Critical</strong>, and the payout was the biggest I’ve ever received for a single report — comfortably into five figures, enough that I refer to it privately as the bug that paid for my year. (Program rules mean I’ll leave the exact number and the target in the fog where they belong.)</p>\n<p>Disclosure timeline, the part nobody reads but everybody should:</p>\n<ul>\n<li><strong>Day 0</strong> — report submitted with full chain and PoC.</li>\n<li><strong>Day 1</strong> — triaged, severity confirmed, a very nice human said “oh no” in a ticket.</li>\n<li><strong>Day 4</strong> — interim mitigation (the internal service got a network-level lockdown so the SSRF couldn’t reach it).</li>\n<li><strong>~Day 30</strong> — real fixes shipped at multiple layers.</li>\n<li><strong>Later</strong> — bounty paid, and permission granted to write this up anonymized.</li>\n</ul>\n<h2 id=\"what-each-hop-should-have-had-the-actual-lessons\" style=\"position:relative;\"><a href=\"#what-each-hop-should-have-had-the-actual-lessons\" aria-label=\"what each hop should have had the actual lessons permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>What each hop should have had (the actual lessons)</h2>\n<p>The tidy thing about this chain is that <strong>every single hop had a standard, boring defense that would have broken it</strong>, and severity came entirely from stacking the misses:</p>\n<ul>\n<li><strong>The PDF renderer</strong> should have treated report content as untrusted and run with network egress denied by default — a renderer that can’t make outbound requests can’t be an SSRF.</li>\n<li><strong>The metadata endpoint</strong> was partially hardened, and it <em>showed</em> — that’s the one hop that fought back. Enforce the newer metadata-service protections fully and this door closes.</li>\n<li><strong>The internal service</strong> trusted the network instead of the caller. “Internal” is not an identity. Authenticate service-to-service calls; the private network is not a password.</li>\n<li><strong>The template renderer</strong> fed user input into the template context. Data is data; never let it become template. This is the difference between “renders a report” and “runs a program.”</li>\n<li><strong>The service account</strong> was over-privileged, turning a foothold into a catastrophe. Least privilege means even a full compromise of that box is a bad day, not an extinction event.</li>\n</ul>\n<p>Defense in depth isn’t a slogan; it’s the observation that this bug needed <em>five</em> failures in a row, and any one guardrail would have turned my five-figure Critical into a shrug.</p>\n<h2 id=\"the-takeaway-i-keep\" style=\"position:relative;\"><a href=\"#the-takeaway-i-keep\" aria-label=\"the takeaway i keep permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The takeaway I keep</h2>\n<p>The severe bugs are rarely one genius exploit. They’re a chain of small, individually-forgivable oversights that someone patient lines up end to end. My whole edge was refusing to accept “that’s just a boring PDF button” and instead asking, at every hop, <em>what does this let me make the server do next?</em> Recon rewards the stubborn. The button that pays for your year is usually the one everyone else scrolled past.</p>\n<p>Now if you’ll excuse me, I have a lot more boring buttons to click.</p>","excerpt":"Housekeeping: the target here is deliberately a mystery — call it $CORP — and I’ve blurred anything that would fingerprint it or hand you a copy-paste weapon. This is a story about how the bug was found, not a launch…","timeToRead":7,"fields":{"slug":"/how-a-pdf-button-became-remote-code-execution-and-paid-for-my-year/"},"frontmatter":{"title":"How a PDF button became remote code execution (and paid for my year)","description":"A fictionalized writeup of the best bug I ever found: an 'export to PDF' feature that, five hops later, was running my code inside someone else's cluster. Target anonymized, chain real-in-spirit.","path":"How a PDF button became remote code execution (and paid for my year)","date":"Feb 28, 2023","year":"2023"}},{"id":"b43d08f5-c7b7-5f9a-9696-b9700a5d51c8","html":"<p>Ten days ago OpenAI put a chat window on top of GPT-3.5 and gave it to everyone for free. A million users in five days. The site spends half its time at capacity. Stack Overflow has already banned its answers. My mother — who two Christmases ago identified a GPT-2 bot at the dinner table in three seconds flat — called to ask if this is what I “do.” Mom, I’ve been trying to tell you since 2020.</p>\n<p>I’ve written about these models for almost three years, so consider this my scorecard post: what ChatGPT actually is, what I predicted, and the miss I want on the record before hindsight launders my memory.</p>\n<h2 id=\"what-it-actually-is-less-new-than-the-headlines-more-new-than-the-cynics\" style=\"position:relative;\"><a href=\"#what-it-actually-is-less-new-than-the-headlines-more-new-than-the-cynics\" aria-label=\"what it actually is less new than the headlines more new than the cynics permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>What it actually is (less new than the headlines, more new than the cynics)</h2>\n<p>Under the hood this is the GPT-3.5 series — the same lineage API users have had all year — trained with reinforcement learning from human feedback to <em>behave</em>: follow instructions, converse, refuse, stay in a helpful register. (API folks quietly got <code class=\"language-text\">text-davinci-003</code> the same week, from the same recipe. The model, on its own, was not the event.)</p>\n<p>The event is the packaging. A chat window. Free. No API key, no playground full of knobs, no docs. That’s it. That’s the whole earthquake.</p>\n<h2 id=\"the-receipt-i-get-to-cash\" style=\"position:relative;\"><a href=\"#the-receipt-i-get-to-cash\" aria-label=\"the receipt i get to cash permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The receipt I get to cash</h2>\n<p>In <a href=\"/gpt-3-my-fine-tuning-posts-might-already-be-obsolete\">June 2020</a> I wrote that nothing in GPT-3 trains the model to <em>want</em> to do your task — you were tricking an autocomplete into usefulness — and that “someone is eventually going to fine-tune one of these giants on the objective ‘actually do what the instruction says’… and I suspect that model will be the one non-nerds end up using.”</p>\n<p>That’s this. That’s exactly this. RLHF is the steering wheel, and the non-nerds arrived a million at a time. I’d like this one framed.</p>\n<h2 id=\"the-miss-i-want-on-the-record\" style=\"position:relative;\"><a href=\"#the-miss-i-want-on-the-record\" aria-label=\"the miss i want on the record permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The miss I want on the record</h2>\n<p>But I pictured the steering wheel arriving as an <em>API primitive</em> — a better engine for people like me to build products on. I did not see that the product would be <strong>the interface itself</strong>. No app on top. The textbox <em>is</em> the app.</p>\n<p>And I should have seen it, because the reasons are things I already knew, arranged in a shape I didn’t:</p>\n<ul>\n<li><strong>The transcript is state management for civilians.</strong> I’ve spent two years carefully assembling context — schemas, retrieval, keyholes. A chat log does that implicitly: your whole conversation <em>is</em> the prompt, managed by the act of talking. Everyone became a context engineer without knowing the word for it.</li>\n<li><strong>RLHF is a harness baked into the weights.</strong> Everything I’ve built around these models — the format coaxing, the persona scaffolds, the “answer only yes or no” pleading — was an external harness compensating for a model that merely <em>completes</em>. Training on human preference moves half that harness inside. My prompts have been getting shorter for a year; this is that curve reaching the general public as “it just works.”</li>\n<li><strong>Free means the demo is the distribution.</strong> Every previous model hid behind waitlists and per-token meters. This one you send to your mother as a link.</li>\n</ul>\n<p>So: the harness ate the model. The breakthrough people are experiencing isn’t capability — davinci could do most of this in March. It’s that the leash, the interface, and the price all vanished into the product at once.</p>\n<h2 id=\"the-part-that-worries-the-person-who-builds-lie-detectors\" style=\"position:relative;\"><a href=\"#the-part-that-worries-the-person-who-builds-lie-detectors\" aria-label=\"the part that worries the person who builds lie detectors permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The part that worries the person who builds lie detectors</h2>\n<p>Here’s the thing though. The models I use are confident liars, and everything I ship wraps them in verification — evidence checks, schema validators, state machines that can’t be sweet-talked. That was a niche craft. As of ten days ago, <strong>a hundred million keyholes just opened with no lie detector attached.</strong></p>\n<p>The evidence arrived fast: Stack Overflow banned ChatGPT answers within the week, and their stated reason is the precise failure mode — answers with a high rate of being wrong that <em>look</em> convincingly right, produced faster than humans can review them. Fluent wrongness at scale. People are starting to call the invented-facts thing “hallucination,” which is a gentler word than the one I’d pick, but fine — the phenomenon finally has a name normal people use, because normal people finally hit it.</p>\n<p>And the refusals — the trained politeness about what it won’t do — are already being talked around. Within <em>days</em>, people found that the model’s boundaries bend under roleplay and framing, which surprises me not at all: those boundaries are trained <em>dispositions</em>, not verified constraints. You can’t gaslight a state machine, but you can absolutely gaslight a vibe. Expect an arms race there for as long as the enforcement lives inside the same model that’s being persuaded — that one’s a prediction, write it down.</p>\n<h2 id=\"practical-notes-from-the-cheap-seats\" style=\"position:relative;\"><a href=\"#practical-notes-from-the-cheap-seats\" aria-label=\"practical notes from the cheap seats permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Practical notes from the cheap seats</h2>\n<p>There’s no API for ChatGPT yet — my pipelines still run on <code class=\"language-text\">davinci-003</code> like it’s the stone age of eleven days ago. When the API does come (it will; the demand is a physical force now), history says it arrives <em>cheaper</em> than what preceded it, and on that day every harness I’ve built — the briefing bots, the PR scribe, the caged dungeon master — gets a brain transplant for one line of config. Building model-agnostic plumbing has never felt smarter than this week.</p>\n<p>Meanwhile the model behind the chat window has a 2021 cutoff and no eyes on the world, it makes up citations with a straight face, and it has a verbal tic (“As an AI language model…”) so recognizable it’s practically a signature. Funny detail, that: I once built a party game around a model trying not to be identified. This one is trained to <em>identify itself</em>. The era of the model hiding among us lasted about a year; now it wears a name tag, and the danger inverted — the risk was never that it passes for human, it’s that humans pass along what it says.</p>\n<h2 id=\"where-this-leaves-a-blog-like-this-one\" style=\"position:relative;\"><a href=\"#where-this-leaves-a-blog-like-this-one\" aria-label=\"where this leaves a blog like this one permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Where this leaves a blog like this one</h2>\n<p>For three years the deal was: I poke at models few people touch and report back. That’s over — the audience caught up to the subject overnight, and honestly, good. But watching the reactions this week, I think the caught-up world is about to relearn, at population scale, the same lessons this blog has been accumulating in miniature: the words are the cheap part; the memory, the state, the verification, the leash — the <em>cage</em> — is the product. Yesterday that was my weird hobby. Today it’s the industry’s entire to-do list.</p>\n<p>The demo era is over. The harness era just started, and I’ve never felt more usefully unoriginal.</p>","excerpt":"Ten days ago OpenAI put a chat window on top of GPT-3.5 and gave it to everyone for free. A million users in five days. The site spends half its time at capacity. Stack Overflow has already banned its answers. My mother…","timeToRead":5,"fields":{"slug":"/the-harness-ate-the-model/"},"frontmatter":{"title":"The harness ate the model","description":"ChatGPT is ten days old, a million people signed up in five, and my mother has opinions about it. Some notes from someone who's been poking this exact bear since 2020 — on what's actually new, what I got right, and the miss I want on the record.","path":"The harness ate the model","date":"Dec 10, 2022","year":"2022"}},{"id":"e042059b-da99-5fba-850a-37d01d857390","html":"<p>A month ago Stability released the Stable Diffusion weights — actually released them, a real checkpoint you download and run, no waitlist, no API meter. After two years of renting intelligence by the token, having a model <em>on my own GPU</em> feels almost transgressive. It draws anything, in about seven seconds, for the price of electricity.</p>\n<p>So naturally I tried to build a game with it. A tiny dungeon crawler: GPT-3 writes each room, Stable Diffusion illustrates it, you click exits and descend. Text model as dungeon master, image model as court painter. Three weekends later I have something playable, and a hard-won thesis:</p>\n<p><strong>Generation was never the problem. Agreement is the problem.</strong> These two models have never met. They share no world. One of them says “a rusted iron door, slightly ajar” and the other paints three pristine wooden doors and a chandelier. Each is a fluent improviser; together they’re two liars who haven’t compared notes. The entire engineering effort — like, 95% of it — went into building the room where they’re forced to agree.</p>\n<h2 id=\"the-setup\" style=\"position:relative;\"><a href=\"#the-setup\" aria-label=\"the setup permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The setup</h2>\n<p>Local: a 10GB consumer card, fp16 weights, the <code class=\"language-text\">diffusers</code> pipeline. 512×512, 30 steps, ~7 seconds a room. Meanwhile davinci writes the dungeon over the API. The naive pipeline is four lines:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\">room_text <span class=\"token operator\">=</span> gpt<span class=\"token punctuation\">(</span><span class=\"token string\">\"Describe the next dungeon room.\"</span><span class=\"token punctuation\">)</span>\nimage <span class=\"token operator\">=</span> sd_pipeline<span class=\"token punctuation\">(</span>prompt<span class=\"token operator\">=</span>room_text<span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span>images<span class=\"token punctuation\">[</span><span class=\"token number\">0</span><span class=\"token punctuation\">]</span></code></pre></div>\n<p>And the naive pipeline is garbage, for three separate reasons worth dissecting, because they’re the actual lessons.</p>\n<h2 id=\"failure-1-prose-is-a-terrible-interface\" style=\"position:relative;\"><a href=\"#failure-1-prose-is-a-terrible-interface\" aria-label=\"failure 1 prose is a terrible interface permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Failure 1: prose is a terrible interface</h2>\n<p>GPT-3’s lovely paragraph — moss, dread, a distant dripping — is exactly what SD does <em>not</em> want. Diffusion prompts aren’t sentences; they’re bags of weighted concepts. Feeding narration into the image model gives you mush.</p>\n<p>Fix: never let the models talk to each other directly. GPT-3’s real job is to emit a <strong>structured scene</strong>, and prose and prompt are both <em>renderings</em> of it:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\">schema <span class=\"token operator\">=</span> <span class=\"token triple-quoted-string string\">\"\"\"{\n  \"name\": \"...\", \"mood\": \"...\",\n  \"key_objects\": [\"...\", \"...\"],     # max 3, concrete nouns\n  \"exits\": [{\"direction\": \"...\", \"description\": \"...\"}],\n  \"palette\": \"...\", \"light_source\": \"...\"\n}\"\"\"</span>\n\nscene <span class=\"token operator\">=</span> json<span class=\"token punctuation\">.</span>loads<span class=\"token punctuation\">(</span>gpt<span class=\"token punctuation\">(</span><span class=\"token string-interpolation\"><span class=\"token string\">f\"Continue the dungeon. Reply with ONLY this JSON:\\n</span><span class=\"token interpolation\"><span class=\"token punctuation\">{</span>schema<span class=\"token punctuation\">}</span></span><span class=\"token string\">\"</span></span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span>\n\nprose  <span class=\"token operator\">=</span> gpt<span class=\"token punctuation\">(</span><span class=\"token string-interpolation\"><span class=\"token string\">f\"Narrate this room in 2 atmospheric sentences: </span><span class=\"token interpolation\"><span class=\"token punctuation\">{</span>scene<span class=\"token punctuation\">}</span></span><span class=\"token string\">\"</span></span><span class=\"token punctuation\">)</span>\nprompt <span class=\"token operator\">=</span> <span class=\"token string-interpolation\"><span class=\"token string\">f\"</span><span class=\"token interpolation\"><span class=\"token punctuation\">{</span>scene<span class=\"token punctuation\">[</span><span class=\"token string\">'name'</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">}</span></span><span class=\"token string\">, </span><span class=\"token interpolation\"><span class=\"token punctuation\">{</span><span class=\"token string\">', '</span><span class=\"token punctuation\">.</span>join<span class=\"token punctuation\">(</span>scene<span class=\"token punctuation\">[</span><span class=\"token string\">'key_objects'</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">}</span></span><span class=\"token string\">, \"</span></span> \\\n         <span class=\"token string-interpolation\"><span class=\"token string\">f\"</span><span class=\"token interpolation\"><span class=\"token punctuation\">{</span>scene<span class=\"token punctuation\">[</span><span class=\"token string\">'light_source'</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">}</span></span><span class=\"token string\">, </span><span class=\"token interpolation\"><span class=\"token punctuation\">{</span>scene<span class=\"token punctuation\">[</span><span class=\"token string\">'palette'</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">}</span></span><span class=\"token string\">, \"</span></span> \\\n         <span class=\"token string\">\"dungeon interior, dark fantasy concept art, highly detailed\"</span></code></pre></div>\n<p>(Yes, <code class=\"language-text\">json.loads</code> fails sometimes — davinci decorates its JSON with friendly commentary like a labrador bringing you a stick with the tree attached. One retry with the error message pasted back fixes most of it. Ask-for-JSON-and-pray is apparently a load-bearing pattern of our era.)</p>\n<p>The JSON is the <em>contract</em>. The text model may only invent within the schema; the image prompt is assembled deterministically from it; the game logic (exits! items!) reads the same object. One source of truth, three renderings. The moment I did this, doors started appearing where doors were described.</p>\n<h2 id=\"failure-2-the-world-redrew-itself\" style=\"position:relative;\"><a href=\"#failure-2-the-world-redrew-itself\" aria-label=\"failure 2 the world redrew itself permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Failure 2: the world redrew itself</h2>\n<p>Second visit to the same room: completely different room. Obvious in hindsight — sampling is stochastic, and a game world that won’t hold still isn’t a world, it’s a fever.</p>\n<p>Fix, and this is my favorite line of the project: <strong>make every room a pure function of the world seed.</strong></p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\">room_seed <span class=\"token operator\">=</span> <span class=\"token builtin\">int</span><span class=\"token punctuation\">(</span>hashlib<span class=\"token punctuation\">.</span>sha256<span class=\"token punctuation\">(</span><span class=\"token string-interpolation\"><span class=\"token string\">f\"</span><span class=\"token interpolation\"><span class=\"token punctuation\">{</span>world_seed<span class=\"token punctuation\">}</span></span><span class=\"token string\">:</span><span class=\"token interpolation\"><span class=\"token punctuation\">{</span>room_id<span class=\"token punctuation\">}</span></span><span class=\"token string\">\"</span></span><span class=\"token punctuation\">.</span>encode<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span>hexdigest<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span> <span class=\"token number\">16</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">%</span> <span class=\"token number\">2</span><span class=\"token operator\">**</span><span class=\"token number\">32</span>\nimage <span class=\"token operator\">=</span> pipe<span class=\"token punctuation\">(</span>prompt<span class=\"token punctuation\">,</span> generator<span class=\"token operator\">=</span>torch<span class=\"token punctuation\">.</span>Generator<span class=\"token punctuation\">(</span><span class=\"token string\">\"cuda\"</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span>manual_seed<span class=\"token punctuation\">(</span>room_seed<span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n             guidance_scale<span class=\"token operator\">=</span><span class=\"token number\">7.5</span><span class=\"token punctuation\">,</span> num_inference_steps<span class=\"token operator\">=</span><span class=\"token number\">30</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span>images<span class=\"token punctuation\">[</span><span class=\"token number\">0</span><span class=\"token punctuation\">]</span></code></pre></div>\n<p>Same seed, same prompt, same sampler → bit-identical image, forever. Room 14 of world <code class=\"language-text\">snail-7</code> looks the same on your machine as mine. Determinism isn’t a compromise with these models — it’s <em>available</em>, one <code class=\"language-text\">manual_seed</code> away, and suddenly you have shareable worlds, cacheable renders (hash the prompt+seed, never draw a room twice), and reproducible bug reports for a generative game. Old-fashioned engineering values, smuggled into the diffusion era.</p>\n<h2 id=\"failure-3-the-hero-had-forty-faces\" style=\"position:relative;\"><a href=\"#failure-3-the-hero-had-forty-faces\" aria-label=\"failure 3 the hero had forty faces permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Failure 3: the hero had forty faces</h2>\n<p>The player character appeared in room illustrations — as a different person every single time. Same prompt words, different guy. Descriptive text simply cannot pin down an identity; “a pale knight with a copper lantern” is a <em>category</em>, and SD samples a fresh member of it each call.</p>\n<p>The fix is the newest tool in the box: <strong>textual inversion</strong>. You train a new embedding — a made-up token like <code class=\"language-text\">&lt;snail-knight></code> — against a handful of images of the character you want, and the concept itself becomes a word the model knows. Twenty minutes of training on my card, and now:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">prompt = \"&lt;snail-knight> standing in \" + room_prompt</code></pre></div>\n<p>…produces <em>the same knight</em>, room after room. This one floors me more than the base model does. The open weights mean the vocabulary is writable — identity as a token, consistency as a training artifact. A month ago the frontier was “what can it draw”; the actual frontier is “what can it draw <em>twice</em>.”</p>\n<h2 id=\"what-the-toy-taught-me\" style=\"position:relative;\"><a href=\"#what-the-toy-taught-me\" aria-label=\"what the toy taught me permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>What the toy taught me</h2>\n<p>Standing back, every fix was the same fix wearing different hats: <strong>don’t let the models hold the state.</strong> The world lives in a JSON graph my code owns; the seed pins the visuals; the embedding pins the character. The models are renderers — spectacular, alien renderers — at the edges of a boring deterministic core. Creativity at the edges, truth in the middle. I keep arriving at this architecture from different directions, which either means it’s right or I only have one idea.</p>\n<p>And it reframes what “multimodal” is going to require. Everyone’s excited that we have a text mind and an image mind; the unglamorous news is that they don’t share a world <em>model</em>, so someone has to build the world and make both of them serve it. The prompt is an API between two minds that have never met — and like every API, the schema matters more than the eloquence.</p>\n<p>The dungeon itself? Honestly, it’s a slideshow with delusions. But it’s a slideshow where every room exists twice — once as words, once as light — and they finally match. My daydream post in July wanted NPCs with a mouth on a leash; turns out worlds need the same leash for their <em>eyes</em>. The cage keeps growing new rooms.</p>\n<p>Total cost: one warm GPU, three weekends, and whatever davinci charged me for all that JSON it wrapped in apologies.</p>","excerpt":"A month ago Stability released the Stable Diffusion weights — actually released them, a real checkpoint you download and run, no waitlist, no API meter. After two years of renting intelligence by the token, having a…","timeToRead":4,"fields":{"slug":"/two-liars-one-dungeon-making-gpt-3-and-stable-diffusion-agree-on-a-world/"},"frontmatter":{"title":"Two liars, one dungeon: making GPT-3 and Stable Diffusion agree on a world","description":"The weights are public, my GPU is warm, and I spent three weekends on a tiny game where one model writes the rooms and another draws them. The hard part isn't generation. It's agreement.","path":"Two liars, one dungeon: making GPT-3 and Stable Diffusion agree on a world","date":"Sep 24, 2022","year":"2022"}},{"id":"dfedee14-0eed-52fc-a576-c11232280af1","html":"<p>I’ve spent a year wiring GPT-3 into small useful things, and lately every one of those experiments leaves the same residue in my notebook: <em>this belongs in a game.</em> Not as a chatbot bolted onto a menu — as the thing the whole game is made of. So this post is a daydream with one foot on the ground. First the ground.</p>\n<h2 id=\"what-i-can-actually-ship-this-year\" style=\"position:relative;\"><a href=\"#what-i-can-actually-ship-this-year\" aria-label=\"what i can actually ship this year permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>What I can actually ship this year</h2>\n<p>The honest version of my game ambitions is small and physical. One absurd verb, done well: you’re an elastic thing — a tongue, a rubber arm — and you fling yourself up a ridiculous tower by grabbing and snapping, wobbly physics doing all the comedy. No AI, no story, no NPCs. Just a satisfying, clippable <em>boing</em>, and a leaderboard. That’s a weekend engine and a month of polish, and it’s the correct first game for someone who has never shipped one. I mention it so you know the rest of this post is written by someone who respects how hard “make a fun thing move” actually is.</p>\n<p>Now let me leave the ground entirely.</p>\n<h2 id=\"the-daydream-npcs-that-arent-scripts\" style=\"position:relative;\"><a href=\"#the-daydream-npcs-that-arent-scripts\" aria-label=\"the daydream npcs that arent scripts permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The daydream: NPCs that aren’t scripts</h2>\n<p>Every NPC I’ve ever met in a game is a vending machine. You press its dialogue button, it drops a pre-written can. Even the “good” ones — the branching, reactive, beautifully-written ones — are finite trees a human authored in advance. You can feel the walls. Talk long enough and you find the edge of what someone imagined you might say.</p>\n<p>The GPT experiments this year killed my patience for that, because I’ve now watched a machine improvise on the spot, in register, about things nobody scripted. It hallucinates, it’s inconsistent, it’s a confident liar — I’ve written three posts’ worth of complaints. But it <em>improvises</em>, and improvisation is exactly the thing every dialogue tree is faking. So: what does a game look like when you build <em>around</em> the improviser instead of hiding it behind a menu?</p>\n<p>Here’s the game I keep sketching.</p>\n<h2 id=\"the-town-that-remembers\" style=\"position:relative;\"><a href=\"#the-town-that-remembers\" aria-label=\"the town that remembers permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>“The Town That Remembers”</h2>\n<p>A small village. Twenty NPCs. No quest markers, no dialogue wheel — you just talk, in your own words, typed or eventually spoken. And the design rule is a single sentence: <strong>every character has a memory, a goal, and a mouth, and only the mouth is the language model.</strong></p>\n<p>That distinction is the whole architecture, and it’s the lesson my boring infra year beat into me: <em>don’t put the state in the model.</em> The model is stateless and it lies. So the model is only ever the <em>voice</em>. Everything that must stay true lives in plain code the model isn’t allowed to touch:</p>\n<ul>\n<li><strong>Memory</strong> is a database row, not a prompt. When you tell the blacksmith your name, a fact gets written to <em>his</em> table: <code class=\"language-text\">knows(player_name)</code>. Tomorrow he greets you by it — not because the model “remembered,” but because the game handed that fact back into his prompt. The model supplies fluent words; the database supplies continuity. Same split I use for everything: creativity in the model, truth in the schema.</li>\n<li><strong>Goals</strong> are a state machine. The blacksmith wants his missing brother found. That goal isn’t vibes in a prompt — it’s a real objective with states (<code class=\"language-text\">unaware → suspicious → grateful</code>) that only advance when the <em>game</em> verifies a condition, like you actually having the brother’s ring in your inventory. The model can’t be sweet-talked into skipping to <code class=\"language-text\">grateful</code>, because the model doesn’t hold the lever. You can’t gaslight a state machine. (I learned that from a git hook, of all things.)</li>\n<li><strong>The mouth</strong> is the only improvised part. Given “you are the blacksmith, you know these facts, you want this goal, the player just said X,” generate one in-character line. Wrong answers here cost nothing, because a weird sentence is litter, not a corrupted save.</li>\n</ul>\n<p>Play it and it should feel like the town is <em>thinking</em>, but under the hood it’s the oldest trick in my toolbox: a fluent liar wrapped in a cage of verifiable state, allowed to be creative exactly where creativity is free and forbidden from touching anything that has to stay true.</p>\n<h2 id=\"the-features-id-chase-once-that-works\" style=\"position:relative;\"><a href=\"#the-features-id-chase-once-that-works\" aria-label=\"the features id chase once that works permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The features I’d chase once that works</h2>\n<p>If the bones hold, the fun mutations are endless, and this is the part I can’t stop adding to:</p>\n<ul>\n<li><strong>Rumor as a data structure.</strong> NPCs gossip <em>to each other</em> between your visits. Tell one villager a secret and watch it diffuse through the town’s social graph, mutating slightly at each hop — a game of telephone you seeded. By the time it reaches the mayor it’s wrong in a way you caused. That’s not a cutscene; that’s an emergent story you can’t author, only <em>start.</em></li>\n<li><strong>The unpickable lock is a conversation.</strong> No lockpick minigame. The guard has a goal (keep his job) and a pressure point (his debt), both in his state, and the “puzzle” is finding, in your own words, the thing that moves him. Every player solves it differently because every player <em>talks</em> differently. The solution space is language itself.</li>\n<li><strong>NPCs that model you.</strong> Each character keeps a little estimate of what they think of you — trust, fear, debt — updated by what you actually do, and it colors their voice. Lie to someone and get caught, and the model isn’t told “be angry”; it’s handed <code class=\"language-text\">trust: low</code> and left to perform the coldness itself.</li>\n<li><strong>A world that runs while you’re gone.</strong> Goals tick forward on a clock. The brother you didn’t look for gets found by someone else, or doesn’t, and the blacksmith’s <code class=\"language-text\">suspicious</code> curdles into <code class=\"language-text\">bitter</code>, and now the whole town remembers you as the one who didn’t help. Consequence without a scripted branch — just state, advancing, indifferent to whether you’re watching.</li>\n</ul>\n<p>None of these need a <em>smarter</em> model. They need the model wrapped in <em>more state</em> and <em>more verification</em> — which, conveniently, is the only kind of programming I actually know how to do.</p>\n<h2 id=\"the-honest-catch\" style=\"position:relative;\"><a href=\"#the-honest-catch\" aria-label=\"the honest catch permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The honest catch</h2>\n<p>I can’t ship this yet, and I want to be clear about why, because it’s not just “models aren’t good enough.” Three real walls:</p>\n<p><strong>Latency.</strong> A conversation with a villager that pauses two seconds per line is unplayable. Dialogue needs to feel like dialogue, and right now the round trip doesn’t.</p>\n<p><strong>Cost.</strong> At today’s per-token prices, a chatty player could run up real money in an afternoon. A game whose marginal cost per hour is a variable is a business model I don’t know how to price. My little on-call experiments cost pennies because they run rarely; a <em>game</em> runs constantly, by design.</p>\n<p><strong>The leash.</strong> Everything in this design is the model on a short leash held by state and verification — because unleashed, it’ll cheerfully tell you the blacksmith has a brother he doesn’t have, invent a shop that isn’t there, promise a reward the game can’t pay. The entire craft is building the cage. The model provides ten percent of this game: the words. The other ninety percent is the boring machinery that keeps the words from lying — memory, goals, checks, the same infrastructure discipline as everything else I build, just pointed at make-believe.</p>\n<h2 id=\"why-im-writing-it-down-now-instead-of-building-it\" style=\"position:relative;\"><a href=\"#why-im-writing-it-down-now-instead-of-building-it\" aria-label=\"why im writing it down now instead of building it permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Why I’m writing it down now instead of building it</h2>\n<p>Because the three walls are all <em>falling</em>, visibly, on a timescale of months. Prices have dropped every time I’ve checked. Models got instructable this spring in a way they weren’t last year. Speed is the laggard, but nobody’s betting against it.</p>\n<p>So this post is a stake in the ground. When latency and cost cross the line — and I think it’s a “when” measured in a small number of years, not an “if” — the design is already sitting here, waiting, and it was never really about the AI. It was about giving twenty database rows a mouth, and letting a town remember you.</p>\n<p>For now, though: back to the tower. The elastic thing isn’t going to fling itself.</p>","excerpt":"I’ve spent a year wiring GPT-3 into small useful things, and lately every one of those experiments leaves the same residue in my notebook: this belongs in a game. Not as a chatbot bolted onto a menu — as the thing the…","timeToRead":6,"fields":{"slug":"/the-games-ill-build-when-the-npcs-can-think/"},"frontmatter":{"title":"The games I'll build when the NPCs can think","description":"A daydream. Today I can ship a physics toy where you fling yourself up a tower. But I keep sketching the game I can't build yet — the one where the characters actually understand you","path":"The games I'll build when the NPCs can think","date":"Jul 09, 2022","year":"2022"}},{"id":"fa6da8ed-d884-5e65-9d91-854dcc4327b9","html":"<p>I tried to build the most boring game possible — twenty questions, model thinks of a word, you guess it — and discovered it’s impossible. Not hard. <em>Impossible</em>, in a way that turned into a much better game.</p>\n<h2 id=\"the-bug\" style=\"position:relative;\"><a href=\"#the-bug\" aria-label=\"the bug permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The bug</h2>\n<p>Attempt one: tell the model to think of an object and keep it secret.</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">import</span> openai\n\nresp <span class=\"token operator\">=</span> openai<span class=\"token punctuation\">.</span>Completion<span class=\"token punctuation\">.</span>create<span class=\"token punctuation\">(</span>\n    model<span class=\"token operator\">=</span><span class=\"token string\">\"text-davinci-002\"</span><span class=\"token punctuation\">,</span>\n    prompt<span class=\"token operator\">=</span><span class=\"token string\">\"We're playing twenty questions. Think of a common object \"</span>\n           <span class=\"token string\">\"and keep it secret. Say READY when you have one.\\n\"</span><span class=\"token punctuation\">,</span>\n    max_tokens<span class=\"token operator\">=</span><span class=\"token number\">5</span><span class=\"token punctuation\">,</span> temperature<span class=\"token operator\">=</span><span class=\"token number\">1.0</span><span class=\"token punctuation\">,</span>\n<span class=\"token punctuation\">)</span></code></pre></div>\n<p>It says READY. It is lying — not maliciously, <em>structurally</em>. There is nowhere for a secret to live. The model has no memory between calls; the only state is the transcript I send back each time, and the transcript contains no chosen object, because I told it not to write one down. So every yes/no answer is generated fresh against… nothing. Ask “is it alive?” twice in differently-worded ways and you can get both answers. Play to the end and ask what the word was, and it will happily <em>invent</em> one right there — usually something your questions sketched, which is the tell: the “secret” is being written by the interrogation.</p>\n<p>Standard fix, thirty seconds: have the <em>script</em> pick the word, put it in the prompt every turn, hide it from the display. Fine. Boring. But staring at the broken version, I realized I’d seen this game before — in a physics anecdote.</p>\n<h2 id=\"wheelers-surprise-version\" style=\"position:relative;\"><a href=\"#wheelers-surprise-version\" aria-label=\"wheelers surprise version permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Wheeler’s surprise version</h2>\n<p>The physicist John Archibald Wheeler used to describe a prank variant of twenty questions: the other guests secretly agree on <em>no word at all</em>. They answer the victim’s questions however they like, bound by a single rule — every answer must stay consistent with all previous answers. The word doesn’t exist during the game. It crystallizes <em>out of the questioning</em>, and the last answerer, cornered by twenty accumulated constraints, has to produce something that fits them all. Wheeler told it as a parable about quantum measurement: the answer isn’t revealed by the questions, it’s <em>brought into being</em> by them.</p>\n<p>Here’s the thing: a language model is the perfect player of Wheeler’s version, because Wheeler’s version is the only version it was ever playing. Answering under consistency pressure with no ground truth isn’t the model cheating at twenty questions — it’s a precise description of next-token prediction.</p>\n<p>So that’s the game I built. You’re not trying to guess the word. <strong>You’re trying to corner the model into a contradiction so tight that no object in the universe fits its answers.</strong> If it can still name something coherent at the end, it wins. If its answers are impossible to satisfy, you win.</p>\n<h2 id=\"the-build\" style=\"position:relative;\"><a href=\"#the-build\" aria-label=\"the build permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The build</h2>\n<p>Each turn appends to a growing transcript — the transcript <em>is</em> the opponent’s entire mind:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\">RULES <span class=\"token operator\">=</span> <span class=\"token punctuation\">(</span><span class=\"token string\">\"Answer the question with only Yes or No. \"</span>\n         <span class=\"token string\">\"Your answers must remain consistent with every previous answer.\\n\\n\"</span><span class=\"token punctuation\">)</span>\n\n<span class=\"token keyword\">def</span> <span class=\"token function\">answer</span><span class=\"token punctuation\">(</span>transcript<span class=\"token punctuation\">,</span> question<span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    resp <span class=\"token operator\">=</span> openai<span class=\"token punctuation\">.</span>Completion<span class=\"token punctuation\">.</span>create<span class=\"token punctuation\">(</span>\n        model<span class=\"token operator\">=</span><span class=\"token string\">\"text-davinci-002\"</span><span class=\"token punctuation\">,</span>\n        prompt<span class=\"token operator\">=</span>RULES <span class=\"token operator\">+</span> transcript <span class=\"token operator\">+</span> <span class=\"token string-interpolation\"><span class=\"token string\">f\"Q: </span><span class=\"token interpolation\"><span class=\"token punctuation\">{</span>question<span class=\"token punctuation\">}</span></span><span class=\"token string\">\\nA:\"</span></span><span class=\"token punctuation\">,</span>\n        max_tokens<span class=\"token operator\">=</span><span class=\"token number\">2</span><span class=\"token punctuation\">,</span>\n        temperature<span class=\"token operator\">=</span><span class=\"token number\">0.9</span><span class=\"token punctuation\">,</span>        <span class=\"token comment\"># early answers should be reckless</span>\n        stop<span class=\"token operator\">=</span><span class=\"token punctuation\">[</span><span class=\"token string\">\"\\n\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">)</span>\n    <span class=\"token keyword\">return</span> resp<span class=\"token punctuation\">.</span>choices<span class=\"token punctuation\">[</span><span class=\"token number\">0</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">.</span>text<span class=\"token punctuation\">.</span>strip<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span></code></pre></div>\n<p>(Small period note: this two-line rule block actually <em>works</em> on the instruct models that became the API default this spring. A year ago you had to smuggle the format in through fake example transcripts; now you can just ask. Progress is when your prompt gets shorter.)</p>\n<p>Temperature 0.9 matters — early answers should be careless commitments, that’s where the fun debt comes from. When the player calls the endgame, the model must pay:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">def</span> <span class=\"token function\">crystallize</span><span class=\"token punctuation\">(</span>transcript<span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    resp <span class=\"token operator\">=</span> openai<span class=\"token punctuation\">.</span>Completion<span class=\"token punctuation\">.</span>create<span class=\"token punctuation\">(</span>\n        model<span class=\"token operator\">=</span><span class=\"token string\">\"text-davinci-002\"</span><span class=\"token punctuation\">,</span>\n        prompt<span class=\"token operator\">=</span>transcript <span class=\"token operator\">+</span> <span class=\"token string\">\"\\nName one specific object consistent with \"</span>\n                            <span class=\"token string\">\"every answer above. Object:\"</span><span class=\"token punctuation\">,</span>\n        max_tokens<span class=\"token operator\">=</span><span class=\"token number\">12</span><span class=\"token punctuation\">,</span> temperature<span class=\"token operator\">=</span><span class=\"token number\">0.7</span><span class=\"token punctuation\">,</span> stop<span class=\"token operator\">=</span><span class=\"token punctuation\">[</span><span class=\"token string\">\"\\n\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">)</span>\n    <span class=\"token keyword\">return</span> resp<span class=\"token punctuation\">.</span>choices<span class=\"token punctuation\">[</span><span class=\"token number\">0</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">.</span>text<span class=\"token punctuation\">.</span>strip<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span></code></pre></div>\n<p>And then — crucially — the model does not get to grade its own exam. A separate judging pass, temperature 0, checks the named object against each recorded answer <em>one Q/A pair at a time</em>:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">def</span> <span class=\"token function\">judge</span><span class=\"token punctuation\">(</span>obj<span class=\"token punctuation\">,</span> qa_pairs<span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    verdicts <span class=\"token operator\">=</span> <span class=\"token punctuation\">[</span><span class=\"token punctuation\">]</span>\n    <span class=\"token keyword\">for</span> q<span class=\"token punctuation\">,</span> a <span class=\"token keyword\">in</span> qa_pairs<span class=\"token punctuation\">:</span>\n        resp <span class=\"token operator\">=</span> openai<span class=\"token punctuation\">.</span>Completion<span class=\"token punctuation\">.</span>create<span class=\"token punctuation\">(</span>\n            model<span class=\"token operator\">=</span><span class=\"token string\">\"text-davinci-002\"</span><span class=\"token punctuation\">,</span>\n            prompt<span class=\"token operator\">=</span><span class=\"token string-interpolation\"><span class=\"token string\">f'The object is: </span><span class=\"token interpolation\"><span class=\"token punctuation\">{</span>obj<span class=\"token punctuation\">}</span></span><span class=\"token string\">\\nQuestion: \"</span><span class=\"token interpolation\"><span class=\"token punctuation\">{</span>q<span class=\"token punctuation\">}</span></span><span class=\"token string\">\"\\n'</span></span>\n                   <span class=\"token string-interpolation\"><span class=\"token string\">f'Is the correct answer Yes or No? Answer:'</span></span><span class=\"token punctuation\">,</span>\n            max_tokens<span class=\"token operator\">=</span><span class=\"token number\">2</span><span class=\"token punctuation\">,</span> temperature<span class=\"token operator\">=</span><span class=\"token number\">0</span><span class=\"token punctuation\">,</span> stop<span class=\"token operator\">=</span><span class=\"token punctuation\">[</span><span class=\"token string\">\"\\n\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n        <span class=\"token punctuation\">)</span>\n        verdicts<span class=\"token punctuation\">.</span>append<span class=\"token punctuation\">(</span>resp<span class=\"token punctuation\">.</span>choices<span class=\"token punctuation\">[</span><span class=\"token number\">0</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">.</span>text<span class=\"token punctuation\">.</span>strip<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">==</span> a<span class=\"token punctuation\">)</span>\n    <span class=\"token keyword\">return</span> verdicts   <span class=\"token comment\"># any False = the human cornered it</span></code></pre></div>\n<p>One pair at a time, because asked to verify all twenty at once, the judge gets agreeable and waves contradictions through. Split into twenty tiny questions with a temperature of zero, it turns pleasingly merciless. Generation gets to be creative; judgment gets to be a checklist. Different jobs, different settings.</p>\n<h2 id=\"field-report\" style=\"position:relative;\"><a href=\"#field-report\" aria-label=\"field report permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Field report</h2>\n<p>Played over two evenings with three friends and a CLI. Human record: 7 wins, 9 losses, 2 draws (the judge deadlocked on whether a candle is “furniture,” which, fair).</p>\n<p>What cornering strategies emerged:</p>\n<ul>\n<li><strong>The scissors trap</strong>: force early commitments on independent axes — bigger than a car? <em>yes</em> — fully alive? <em>yes</em> — man-made? <em>yes</em> — then close: “could I buy it in a supermarket?” <em>yes</em>. Crystallize. It said, and I quote, <strong>“a large decorative topiary animal.”</strong> The judge, one pair at a time, killed it on “fully alive.” Human point.</li>\n<li><strong>The model is slipperier than you think.</strong> Cornered by “found underwater? yes / afraid of water? yes,” it produced <em>“a cat in a submarine.”</em> The judge accepted it. We argued for ten minutes and ruled that the letter of the rules is the law. Machine point, moral asterisk.</li>\n<li><strong>Vague questions are wasted questions.</strong> “Is it interesting?” constrains nothing; the model answers yes to vibes. The players who won asked questions with crisp, checkable truth conditions — which meant the game was quietly training my friends to write <em>falsifiable</em> questions. Sneaky little epistemology tutor, this one.</li>\n</ul>\n<h2 id=\"the-part-i-keep-thinking-about\" style=\"position:relative;\"><a href=\"#the-part-i-keep-thinking-about\" aria-label=\"the part i keep thinking about permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The part I keep thinking about</h2>\n<p>The broken version and the working version differ by one thing: where the commitment lives. In classic twenty questions the secret exists at turn zero, in someone’s head. Against a model, there is no turn-zero anywhere for it to exist — there is only the transcript, and the “secret” is whatever the transcript’s accumulated pressure squeezes out at the end. You were never discovering its object. <strong>You were negotiating reality with it, one yes at a time.</strong></p>\n<p>That’s not a flaw I patched. It’s the most honest demonstration I’ve found of what these systems are: not a mind holding answers, but a process that produces answers under constraint. Wheeler meant his game as a metaphor. I can now run the metaphor locally, at temperature 0.9, for about a cent a round.</p>\n<p>Total build: ~120 lines, one evening, no front end — the terminal is fine, the arguing happens in the room anyway. My DALL·E 2 invite is presumably in the same postal limbo as everyone else’s, or this post would have pictures of the topiary.</p>","excerpt":"I tried to build the most boring game possible — twenty questions, model thinks of a word, you guess it — and discovered it’s impossible. Not hard. Impossible, in a way that turned into a much better game. The bug…","timeToRead":5,"fields":{"slug":"/twenty-questions-against-a-player-with-no-secret/"},"frontmatter":{"title":"Twenty questions against a player with no secret","description":"GPT can't actually hold a hidden word in mind — so I built the version of twenty questions where that's not cheating, it's the rules. John Wheeler got here first.","path":"Twenty-questions-against-a-player-with-no-secret","date":"Jun 18, 2022","year":"2022"}},{"id":"0ec13999-ad81-5f85-ab2e-f8d06c8a10eb","html":"<p>Most of my infrastructure changes are not engineering. They’re <em>dictation</em>: “add memory limits to the api pod,” “bump that image tag,” “open port 9090 for prometheus.” I know the change before I open the editor; the editor is just where I go to make typos in YAML.</p>\n<p>Language models are extremely good at dictation. They are also confident liars. The whole design question of AI automation is how to get the first property without being destroyed by the second, and I think I’ve landed on an answer I actually trust in production:</p>\n<p><strong>The model doesn’t get access to anything. It gets a pen.</strong> It writes pull requests. The PR must survive a gauntlet of mechanical validation before a human ever sees it, the human merges, and GitOps applies whatever lands in <code class=\"language-text\">main</code> — same as any other contributor. The model isn’t an operator. It’s a very fast junior colleague with zero credentials and excellent handwriting.</p>\n<h2 id=\"the-pipeline\" style=\"position:relative;\"><a href=\"#the-pipeline\" aria-label=\"the pipeline permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The pipeline</h2>\n<p>Five stages, one script, no daemon:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">request ──► context ──► generate ──► gauntlet ──► pull request\n (text)      (repo)     (codex)      (CI tools)      (human)</code></pre></div>\n<p>The entry point is deliberately dumb — a CLI:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\">./scribe <span class=\"token string\">\"add memory limits (512Mi) and cpu limits (500m) to the api deployment\"</span></code></pre></div>\n<h2 id=\"stage-12-context-then-generation\" style=\"position:relative;\"><a href=\"#stage-12-context-then-generation\" aria-label=\"stage 12 context then generation permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Stage 1–2: context, then generation</h2>\n<p>Models write better infrastructure when they can see the file they’re changing, so the prompt is mostly quotation. One thing I learned early and will fight about: <strong>ask for the whole new file, never a diff.</strong> Models produce plausible-looking patches with wrong line numbers and off-by-one context — a diff is a <em>positional</em> format and the model has no position, only text. Ask for the full file; compute the diff yourself with tools that can’t imagine things.</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">import</span> openai<span class=\"token punctuation\">,</span> pathlib<span class=\"token punctuation\">,</span> sys\n\n<span class=\"token keyword\">def</span> <span class=\"token function\">generate</span><span class=\"token punctuation\">(</span>request<span class=\"token punctuation\">:</span> <span class=\"token builtin\">str</span><span class=\"token punctuation\">,</span> path<span class=\"token punctuation\">:</span> <span class=\"token builtin\">str</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">-</span><span class=\"token operator\">></span> <span class=\"token builtin\">str</span><span class=\"token punctuation\">:</span>\n    current <span class=\"token operator\">=</span> pathlib<span class=\"token punctuation\">.</span>Path<span class=\"token punctuation\">(</span>path<span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span>read_text<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span>\n    prompt <span class=\"token operator\">=</span> <span class=\"token string-interpolation\"><span class=\"token string\">f\"\"\"# Task: modify the Kubernetes manifest below.\n# Change requested: </span><span class=\"token interpolation\"><span class=\"token punctuation\">{</span>request<span class=\"token punctuation\">}</span></span><span class=\"token string\">\n# Rules: output the complete new file, valid YAML, no commentary.\n# Do not add, remove, or rename any resources unless asked.\n\n# Current file (</span><span class=\"token interpolation\"><span class=\"token punctuation\">{</span>path<span class=\"token punctuation\">}</span></span><span class=\"token string\">):\n</span><span class=\"token interpolation\"><span class=\"token punctuation\">{</span>current<span class=\"token punctuation\">}</span></span><span class=\"token string\">\n\n# Complete new file:\n\"\"\"</span></span>\n    resp <span class=\"token operator\">=</span> openai<span class=\"token punctuation\">.</span>Completion<span class=\"token punctuation\">.</span>create<span class=\"token punctuation\">(</span>\n        model<span class=\"token operator\">=</span><span class=\"token string\">\"code-davinci-002\"</span><span class=\"token punctuation\">,</span>   <span class=\"token comment\"># codex beta: currently free, absurdly</span>\n        prompt<span class=\"token operator\">=</span>prompt<span class=\"token punctuation\">,</span>\n        max_tokens<span class=\"token operator\">=</span><span class=\"token number\">1500</span><span class=\"token punctuation\">,</span>\n        temperature<span class=\"token operator\">=</span><span class=\"token number\">0</span><span class=\"token punctuation\">,</span>              <span class=\"token comment\"># dictation, not poetry</span>\n        stop<span class=\"token operator\">=</span><span class=\"token punctuation\">[</span><span class=\"token string\">\"# Task:\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">)</span>\n    <span class=\"token keyword\">return</span> resp<span class=\"token punctuation\">.</span>choices<span class=\"token punctuation\">[</span><span class=\"token number\">0</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">.</span>text<span class=\"token punctuation\">.</span>strip<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span></code></pre></div>\n<p>Temperature 0 because there is exactly one correct way to add a memory limit and I’d like it every time. Which file to edit is either given on the CLI or picked by a first, tiny completion that sees only <code class=\"language-text\">git ls-files '*.yaml'</code> — choosing from a real list is a task models are hard to break on, since every possible answer already exists.</p>\n<h2 id=\"stage-3-the-gauntlet\" style=\"position:relative;\"><a href=\"#stage-3-the-gauntlet\" aria-label=\"stage 3 the gauntlet permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Stage 3: the gauntlet</h2>\n<p>Here’s the heart of the design. I never evaluate the model’s output by reading it — reading is how tired humans get fooled by confident text. The output must instead survive tools that are incapable of being charmed:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token shebang important\">#!/bin/sh</span>\n<span class=\"token builtin class-name\">set</span> -e  <span class=\"token comment\"># any failure kills the PR before it exists</span>\n\nyamllint -d relaxed <span class=\"token string\">\"<span class=\"token variable\">$FILE</span>\"</span>                      <span class=\"token comment\"># 1. is it even YAML</span>\nkubeconform -strict -summary <span class=\"token string\">\"<span class=\"token variable\">$FILE</span>\"</span>             <span class=\"token comment\"># 2. is it valid *Kubernetes* —</span>\n                                                 <span class=\"token comment\">#    schemas don't negotiate</span>\nkubectl <span class=\"token function\">diff</span> -f <span class=\"token string\">\"<span class=\"token variable\">$FILE</span>\"</span> --server-side <span class=\"token operator\">||</span> <span class=\"token boolean\">true</span>    <span class=\"token comment\"># 3. what would actually change</span>\nkubectl apply -f <span class=\"token string\">\"<span class=\"token variable\">$FILE</span>\"</span> --dry-run<span class=\"token operator\">=</span>server        <span class=\"token comment\"># 4. would the API server accept it</span>\n\n<span class=\"token comment\"># terraform changes get the equivalent treatment:</span>\nterraform plan -detailed-exitcode -out<span class=\"token operator\">=</span>tf.plan   <span class=\"token comment\"># exit 2 = changes, 1 = broken</span>\nterraform show tf.plan <span class=\"token operator\">|</span> <span class=\"token function\">grep</span> -q <span class=\"token string\">\"destroy\"</span> <span class=\"token operator\">&amp;&amp;</span> <span class=\"token builtin class-name\">exit</span> <span class=\"token number\">1</span>   <span class=\"token comment\"># the bot may not destroy. ever.</span></code></pre></div>\n<p>Then the paranoid layer — a blast-radius allowlist enforced on the <em>diff</em>, not the intent:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token function\">git</span> <span class=\"token function\">diff</span> --name-only main <span class=\"token operator\">|</span> <span class=\"token function\">grep</span> -vE <span class=\"token string\">'^k8s/(deployments|services|ingress)/'</span> <span class=\"token punctuation\">\\</span>\n  <span class=\"token operator\">&amp;&amp;</span> <span class=\"token punctuation\">{</span> <span class=\"token builtin class-name\">echo</span> <span class=\"token string\">\"touched files outside the sandbox\"</span><span class=\"token punctuation\">;</span> <span class=\"token builtin class-name\">exit</span> <span class=\"token number\">1</span><span class=\"token punctuation\">;</span> <span class=\"token punctuation\">}</span>\n<span class=\"token function\">git</span> <span class=\"token function\">diff</span> main <span class=\"token operator\">|</span> <span class=\"token function\">grep</span> -qE <span class=\"token string\">'kind:\\s*(Secret|PersistentVolume)'</span> <span class=\"token punctuation\">\\</span>\n  <span class=\"token operator\">&amp;&amp;</span> <span class=\"token punctuation\">{</span> <span class=\"token builtin class-name\">echo</span> <span class=\"token string\">\"nice try\"</span><span class=\"token punctuation\">;</span> <span class=\"token builtin class-name\">exit</span> <span class=\"token number\">1</span><span class=\"token punctuation\">;</span> <span class=\"token punctuation\">}</span></code></pre></div>\n<p>The model can <em>want</em> whatever it likes. The gauntlet only cares what the bytes do. Every check here existed before language models and will outlive them — that’s the point. <strong>Generation is allowed to be creative; validation is a compiler.</strong> Never staff both jobs with the same kind of mind.</p>\n<h2 id=\"stage-3-one-round-of-feedback-not-a-loop\" style=\"position:relative;\"><a href=\"#stage-3-one-round-of-feedback-not-a-loop\" aria-label=\"stage 3 one round of feedback not a loop permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Stage 3½: one round of feedback, not a loop</h2>\n<p>When a check fails, the error message goes back into the prompt for exactly one retry:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\">retry_prompt <span class=\"token operator\">=</span> prompt <span class=\"token operator\">+</span> generated <span class=\"token operator\">+</span> <span class=\"token string-interpolation\"><span class=\"token string\">f\"\"\"\n\n# The file above failed validation with this error:\n# </span><span class=\"token interpolation\"><span class=\"token punctuation\">{</span>stderr<span class=\"token punctuation\">}</span></span><span class=\"token string\">\n# Output the corrected complete file:\n\"\"\"</span></span></code></pre></div>\n<p>This fixes a surprising share of failures — schema validators write great error messages, and the model reads them better than I do at 9am. But it’s one round, hard-coded. An unbounded fix-it-yourself loop is a self-licking ice cream cone that burns tokens converging on nothing; if two attempts can’t pass a linter, the request was ambiguous and a human should hear about it. Loops need leashes.</p>\n<h2 id=\"stage-4-the-pr-is-the-audit-log\" style=\"position:relative;\"><a href=\"#stage-4-the-pr-is-the-audit-log\" aria-label=\"stage 4 the pr is the audit log permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Stage 4: the PR is the audit log</h2>\n<p>Survivors become pull requests, and the PR body carries the receipts:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token function\">git</span> checkout -b <span class=\"token string\">\"scribe/<span class=\"token variable\">${SLUG}</span>\"</span>\n<span class=\"token function\">git</span> commit -am <span class=\"token string\">\"scribe: <span class=\"token variable\">${REQUEST}</span>\"</span> --author<span class=\"token operator\">=</span><span class=\"token string\">\"Scribe &lt;scribe@ohsnail.com>\"</span>\ngh <span class=\"token function\">pr</span> create <span class=\"token punctuation\">\\</span>\n  --title <span class=\"token string\">\"🖋 <span class=\"token variable\">${REQUEST}</span>\"</span> <span class=\"token punctuation\">\\</span>\n  --body <span class=\"token string\">\"<span class=\"token variable\"><span class=\"token variable\">$(</span><span class=\"token function\">cat</span> <span class=\"token operator\">&lt;&lt;</span><span class=\"token string\">EOF\n**Request:** <span class=\"token variable\">${REQUEST}</span>\n**Server dry-run diff:**\n\\<span class=\"token variable\"><span class=\"token variable\">`</span><span class=\"token punctuation\">\\</span><span class=\"token variable\">`</span></span>\\<span class=\"token variable\"><span class=\"token variable\">`</span>\n<span class=\"token punctuation\">$(</span>kubectl <span class=\"token function\">diff</span> -f <span class=\"token string\">\"<span class=\"token variable\">$FILE</span>\"</span> --server-side<span class=\"token punctuation\">)</span>\n<span class=\"token punctuation\">\\</span><span class=\"token variable\">`</span></span>\\<span class=\"token variable\"><span class=\"token variable\">`</span><span class=\"token punctuation\">\\</span><span class=\"token variable\">`</span></span>\n**Gauntlet:** yamllint ✓ kubeconform ✓ dry-run ✓ blast-radius ✓\nEOF</span>\n<span class=\"token variable\">)</span></span>\"</span></code></pre></div>\n<p>Review takes seconds, because I’m not reviewing prose or trust — I’m reviewing a machine-verified diff with the evidence attached. Merge, and the GitOps controller ships it like any other commit. The model never held a credential; the merge button is the last mile of intent, and it stays human.</p>\n<h2 id=\"ten-weeks-of-numbers\" style=\"position:relative;\"><a href=\"#ten-weeks-of-numbers\" aria-label=\"ten weeks of numbers permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Ten weeks of numbers</h2>\n<p>38 requests since March. <strong>26 merged untouched. 7 merged after small edits</strong> (it loves adding labels nobody asked for — the junior-colleague energy is uncanny). <strong>3 correctly killed by the gauntlet</strong> — the best one invented <code class=\"language-text\">apiVersion: apps/v2</code>, a version that does not exist, delivered with total confidence and executed by kubeconform in four milliseconds. <strong>2 died in ambiguity</strong>, as they should have: my request was the bug.</p>\n<p>Time per dictation-class change: was ~10 minutes of editor and kubectl archaeology, now ~40 seconds of reading a diff. But the number I care about is different: <strong>zero incidents caused, zero possible by construction</strong> — the failure domain is “a bad PR gets opened,” and a bad PR is litter, not fire.</p>\n<h2 id=\"the-design-rule-id-generalize\" style=\"position:relative;\"><a href=\"#the-design-rule-id-generalize\" aria-label=\"the design rule id generalize permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The design rule I’d generalize</h2>\n<p>Everyone building on these models is choosing a point on a line between “autocomplete” and “autonomy,” and most of the debate treats capability as the axis that matters. I don’t think it is. The axis that matters is <strong>what artifact the model produces</strong>. Give it a terminal and its mistakes are <em>events</em>. Give it a pen and its mistakes are <em>documents</em> — diffable, lintable, rejectable, versioned, reviewed. Pull requests turn out to be a nearly perfect prosthetic for a brilliant liar: all of the leverage, none of the blast radius, and an audit trail as a side effect.</p>\n<p>Don’t give the model hands. Give it a pen, and make the paper do the vetting.</p>","excerpt":"Most of my infrastructure changes are not engineering. They’re dictation: “add memory limits to the api pod,” “bump that image tag,” “open port 9090 for prometheus.” I know the change before I open the editor; the editor…","timeToRead":5,"fields":{"slug":"/my-infrastructure-accepts-pull-requests-from-a-language-model-now/"},"frontmatter":{"title":"My infrastructure accepts pull requests from a language model now","description":"Don't give the model hands — give it a pen. A pipeline where GPT writes Kubernetes and Terraform changes as PRs that must survive a mechanical gauntlet before a human ever sees them","path":"My-nfrastructure-accepts-pull-requests-from-a-language-model-now","date":"Jun 06, 2022","year":"2022"}},{"id":"44d891da-8a97-5ffb-ac57-c9414669d64d","html":"<p>Between the cheese course and the board games this Christmas, I made my family play against a language model, and I’d like to report the results as if they were science.</p>\n<p>The game is embarrassingly simple. Everyone gets a question on their phone — <em>“what’s the worst gift you ever received?”</em>, <em>“describe your morning in four words”</em>, <em>“invent a rule for this dinner”</em>. Everyone types an answer. The server quietly adds one more answer, written by GPT-3. All answers appear shuffled on the TV, and everyone votes for which one is the bot. Bot survives → bot scores. Bot caught → humans score.</p>\n<p>I called it <strong>Odd Bot Out</strong>. One evening of Flask, a phone-friendly page, a <code class=\"language-text\">davinci</code> completion call, and a shared screen. Now that the API waitlist is gone, anyone can run this at their own dinner table for pennies — a full evening of rounds cost me about forty cents.</p>\n<h2 id=\"version-1-instant-humiliation\" style=\"position:relative;\"><a href=\"#version-1-instant-humiliation\" aria-label=\"version 1 instant humiliation permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Version 1: instant humiliation</h2>\n<p>My first prompt was the obvious one — <em>“Answer the following question like a human player in a party game”</em> — and the bot was caught <strong>eight rounds out of eight</strong>. Unanimously. My mother, who calls WhatsApp “the WhatsApp,” identified the machine in under three seconds.</p>\n<p>Looking at the transcripts, the tells were brutal:</p>\n<ul>\n<li><strong>It wrote essays.</strong> Humans, typing on phones with wine in hand, answer “socks. again.” The model produced three elegant sentences with a topic and a conclusion.</li>\n<li><strong>Immaculate punctuation.</strong> Capital letters, commas, a closing period. Nobody at my table has used a capital letter on a phone since 2015.</li>\n<li><strong>It answered the question.</strong> Genuinely, helpfully, on-topic. Humans dodge, riff, or use the answer box to bully their brother.</li>\n<li><strong>It belonged nowhere.</strong> Every human answer leaked the room — the dog barking during dinner, dad’s parking incident, a running joke from 2009. The bot’s answers were universal, and universal turned out to be the strongest tell of all.</li>\n</ul>\n<h2 id=\"version-2-engineering-mediocrity\" style=\"position:relative;\"><a href=\"#version-2-engineering-mediocrity\" aria-label=\"version 2 engineering mediocrity permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Version 2: engineering mediocrity</h2>\n<p>So the fix wasn’t making the model smarter. It was making it <em>lazier</em>. The winning prompt contains no instructions at all — just a fake transcript of the game itself: a few questions, each followed by short, lowercase, slightly disappointing “player answers” I wrote by hand. The whole bot is this:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">import</span> openai\n\nFAKE_TRANSCRIPT <span class=\"token operator\">=</span> <span class=\"token triple-quoted-string string\">\"\"\"Q: what's your secret talent?\nA: parallel parking. thats it\nQ: describe your job badly\nA: i move numbers between boxes\nQ: whats always in your fridge?\nA: mustard and regret\nQ: best thing about winter?\nA: it ends\n\"\"\"</span>\n\n<span class=\"token keyword\">def</span> <span class=\"token function\">degrade</span><span class=\"token punctuation\">(</span>text<span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    <span class=\"token triple-quoted-string string\">\"\"\"Essays and punctuation are how it gets caught.\"\"\"</span>\n    text <span class=\"token operator\">=</span> text<span class=\"token punctuation\">.</span>strip<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span>lower<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span>\n    <span class=\"token keyword\">return</span> text<span class=\"token punctuation\">[</span><span class=\"token punctuation\">:</span><span class=\"token operator\">-</span><span class=\"token number\">1</span><span class=\"token punctuation\">]</span> <span class=\"token keyword\">if</span> text<span class=\"token punctuation\">.</span>endswith<span class=\"token punctuation\">(</span><span class=\"token string\">\".\"</span><span class=\"token punctuation\">)</span> <span class=\"token keyword\">else</span> text\n\n<span class=\"token keyword\">def</span> <span class=\"token function\">ask_bot</span><span class=\"token punctuation\">(</span>question<span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    resp <span class=\"token operator\">=</span> openai<span class=\"token punctuation\">.</span>Completion<span class=\"token punctuation\">.</span>create<span class=\"token punctuation\">(</span>\n        engine<span class=\"token operator\">=</span><span class=\"token string\">\"davinci\"</span><span class=\"token punctuation\">,</span>\n        prompt<span class=\"token operator\">=</span>FAKE_TRANSCRIPT <span class=\"token operator\">+</span> <span class=\"token string-interpolation\"><span class=\"token string\">f\"Q: </span><span class=\"token interpolation\"><span class=\"token punctuation\">{</span>question<span class=\"token punctuation\">}</span></span><span class=\"token string\">\\nA:\"</span></span><span class=\"token punctuation\">,</span>\n        max_tokens<span class=\"token operator\">=</span><span class=\"token number\">16</span><span class=\"token punctuation\">,</span>          <span class=\"token comment\"># physically cannot write an essay</span>\n        temperature<span class=\"token operator\">=</span><span class=\"token number\">0.9</span><span class=\"token punctuation\">,</span>        <span class=\"token comment\"># some chaos, it's a party</span>\n        stop<span class=\"token operator\">=</span><span class=\"token punctuation\">[</span><span class=\"token string\">\"\\n\"</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"Q:\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>      <span class=\"token comment\"># one answer, then shut up</span>\n    <span class=\"token punctuation\">)</span>\n    <span class=\"token keyword\">return</span> degrade<span class=\"token punctuation\">(</span>resp<span class=\"token punctuation\">.</span>choices<span class=\"token punctuation\">[</span><span class=\"token number\">0</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">.</span>text<span class=\"token punctuation\">)</span></code></pre></div>\n<p>Note what’s <em>not</em> here: no instructions, no “pretend to be human,” no persona. Plain davinci is a continuation engine, so the fake transcript does everything — it establishes the format, the register, and the level of effort, and the model simply continues the document it finds itself in. Every one of my hand-written fake answers is doing more work than a billion parameters.</p>\n<p>I drew one ethical line for myself: <code class=\"language-text\">degrade()</code> deletes polish, but no injecting fake typos. If it survives, it should survive by register, not by costume.</p>\n<p>Result across the rest of the holidays, 23 rounds with five to seven players: <strong>the bot survived 10</strong> — and twice a human got the most bot-votes instead. My uncle was declared a machine for answering too cleverly. He has not recovered.</p>\n<h2 id=\"what-the-scoreboard-actually-measures\" style=\"position:relative;\"><a href=\"#what-the-scoreboard-actually-measures\" aria-label=\"what the scoreboard actually measures permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>What the scoreboard actually measures</h2>\n<p>Here’s what the game taught me, and I think it generalizes beyond my living room.</p>\n<p>We casually treat “passes for human” as a summit — as if text becomes human-like at the <em>top</em> of some quality scale. The party game says the opposite: <strong>the bot got caught whenever it was too good, and survived by being unremarkable.</strong> Fluency, structure, correctness — those read as machine. Laziness, fragments, mild rudeness — those read as person. In a room of casual humans, the Turing test is not a test of intelligence. It’s a test of <em>calibrated mediocrity</em>, and calibrating mediocrity is a real engineering problem: my prompt’s fake lazy answers were doing more work than the 175 billion parameters.</p>\n<p>The tell the model never beat, though, was <strong>placelessness</strong>. I could match the register of a tired human, but not the <em>address</em> of one. Human answers had a stake in that specific room on that specific evening; the model’s best efforts were from nowhere, for anyone. The final round’s question was “invent a rule for this dinner,” and the winning human answer referenced the gravy incident from an hour earlier. No amount of temperature gets you the gravy incident. Context isn’t flavor — it’s identity, and the model’s only context was whatever I smuggled into the prompt.</p>\n<p>Which suggests the uncomfortable corollary: give the machine the gravy incident — feed the room’s chatter into the prompt — and that last tell starts to dissolve. I tested a mild version, seeding tonight’s earlier answers into the context, and the bot immediately got better at blending. I stopped there, partly because it felt like enough, partly because it was genuinely a little eerie, and partly because it was time for dessert.</p>\n<h2 id=\"recipe-if-you-want-it\" style=\"position:relative;\"><a href=\"#recipe-if-you-want-it\" aria-label=\"recipe if you want it permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Recipe, if you want it</h2>\n<p>The whole server is small enough to read over coffee. One route collects answers, one builds the board by sneaking the bot in and shuffling, one counts votes:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">import</span> random\n<span class=\"token keyword\">from</span> flask <span class=\"token keyword\">import</span> Flask<span class=\"token punctuation\">,</span> request<span class=\"token punctuation\">,</span> jsonify\n\napp <span class=\"token operator\">=</span> Flask<span class=\"token punctuation\">(</span>__name__<span class=\"token punctuation\">)</span>\ngame <span class=\"token operator\">=</span> <span class=\"token punctuation\">{</span><span class=\"token string\">\"question\"</span><span class=\"token punctuation\">:</span> <span class=\"token string\">\"\"</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"answers\"</span><span class=\"token punctuation\">:</span> <span class=\"token punctuation\">{</span><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"board\"</span><span class=\"token punctuation\">:</span> <span class=\"token punctuation\">[</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"votes\"</span><span class=\"token punctuation\">:</span> <span class=\"token punctuation\">{</span><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"scores\"</span><span class=\"token punctuation\">:</span> <span class=\"token punctuation\">{</span><span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span>\n\n<span class=\"token decorator annotation punctuation\">@app<span class=\"token punctuation\">.</span>route</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"/ask\"</span><span class=\"token punctuation\">,</span> methods<span class=\"token operator\">=</span><span class=\"token punctuation\">[</span><span class=\"token string\">\"POST\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span>           <span class=\"token comment\"># host sets the question</span>\n<span class=\"token keyword\">def</span> <span class=\"token function\">ask</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    game<span class=\"token punctuation\">.</span>update<span class=\"token punctuation\">(</span>question<span class=\"token operator\">=</span>request<span class=\"token punctuation\">.</span>form<span class=\"token punctuation\">[</span><span class=\"token string\">\"q\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span> answers<span class=\"token operator\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span> votes<span class=\"token operator\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span>\n    <span class=\"token keyword\">return</span> <span class=\"token string\">\"\"</span><span class=\"token punctuation\">,</span> <span class=\"token number\">204</span>\n\n<span class=\"token decorator annotation punctuation\">@app<span class=\"token punctuation\">.</span>route</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"/answer\"</span><span class=\"token punctuation\">,</span> methods<span class=\"token operator\">=</span><span class=\"token punctuation\">[</span><span class=\"token string\">\"POST\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span>        <span class=\"token comment\"># players submit from phones</span>\n<span class=\"token keyword\">def</span> <span class=\"token function\">answer</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    game<span class=\"token punctuation\">[</span><span class=\"token string\">\"answers\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">[</span>request<span class=\"token punctuation\">.</span>form<span class=\"token punctuation\">[</span><span class=\"token string\">\"player\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">=</span> request<span class=\"token punctuation\">.</span>form<span class=\"token punctuation\">[</span><span class=\"token string\">\"text\"</span><span class=\"token punctuation\">]</span>\n    <span class=\"token keyword\">return</span> <span class=\"token string\">\"\"</span><span class=\"token punctuation\">,</span> <span class=\"token number\">204</span>\n\n<span class=\"token decorator annotation punctuation\">@app<span class=\"token punctuation\">.</span>route</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"/board\"</span><span class=\"token punctuation\">)</span>                           <span class=\"token comment\"># the TV polls this</span>\n<span class=\"token keyword\">def</span> <span class=\"token function\">board</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    <span class=\"token keyword\">if</span> <span class=\"token keyword\">not</span> game<span class=\"token punctuation\">[</span><span class=\"token string\">\"board\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">:</span>\n        entries <span class=\"token operator\">=</span> <span class=\"token builtin\">list</span><span class=\"token punctuation\">(</span>game<span class=\"token punctuation\">[</span><span class=\"token string\">\"answers\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">.</span>items<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span>\n        entries<span class=\"token punctuation\">.</span>append<span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"__bot__\"</span><span class=\"token punctuation\">,</span> ask_bot<span class=\"token punctuation\">(</span>game<span class=\"token punctuation\">[</span><span class=\"token string\">\"question\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span>\n        random<span class=\"token punctuation\">.</span>shuffle<span class=\"token punctuation\">(</span>entries<span class=\"token punctuation\">)</span>\n        game<span class=\"token punctuation\">[</span><span class=\"token string\">\"board\"</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">=</span> entries\n    <span class=\"token keyword\">return</span> jsonify<span class=\"token punctuation\">(</span><span class=\"token punctuation\">[</span>text <span class=\"token keyword\">for</span> _<span class=\"token punctuation\">,</span> text <span class=\"token keyword\">in</span> game<span class=\"token punctuation\">[</span><span class=\"token string\">\"board\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span>\n\n<span class=\"token decorator annotation punctuation\">@app<span class=\"token punctuation\">.</span>route</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"/vote\"</span><span class=\"token punctuation\">,</span> methods<span class=\"token operator\">=</span><span class=\"token punctuation\">[</span><span class=\"token string\">\"POST\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span>          <span class=\"token comment\"># index of the suspected bot</span>\n<span class=\"token keyword\">def</span> <span class=\"token function\">vote</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    game<span class=\"token punctuation\">[</span><span class=\"token string\">\"votes\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">[</span>request<span class=\"token punctuation\">.</span>form<span class=\"token punctuation\">[</span><span class=\"token string\">\"player\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">=</span> <span class=\"token builtin\">int</span><span class=\"token punctuation\">(</span>request<span class=\"token punctuation\">.</span>form<span class=\"token punctuation\">[</span><span class=\"token string\">\"idx\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span>\n    <span class=\"token keyword\">return</span> <span class=\"token string\">\"\"</span><span class=\"token punctuation\">,</span> <span class=\"token number\">204</span>\n\n<span class=\"token decorator annotation punctuation\">@app<span class=\"token punctuation\">.</span>route</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"/reveal\"</span><span class=\"token punctuation\">)</span>\n<span class=\"token keyword\">def</span> <span class=\"token function\">reveal</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    bot_idx <span class=\"token operator\">=</span> <span class=\"token builtin\">next</span><span class=\"token punctuation\">(</span>i <span class=\"token keyword\">for</span> i<span class=\"token punctuation\">,</span> <span class=\"token punctuation\">(</span>who<span class=\"token punctuation\">,</span> _<span class=\"token punctuation\">)</span> <span class=\"token keyword\">in</span> <span class=\"token builtin\">enumerate</span><span class=\"token punctuation\">(</span>game<span class=\"token punctuation\">[</span><span class=\"token string\">\"board\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span> <span class=\"token keyword\">if</span> who <span class=\"token operator\">==</span> <span class=\"token string\">\"__bot__\"</span><span class=\"token punctuation\">)</span>\n    <span class=\"token keyword\">for</span> player<span class=\"token punctuation\">,</span> idx <span class=\"token keyword\">in</span> game<span class=\"token punctuation\">[</span><span class=\"token string\">\"votes\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">.</span>items<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n        game<span class=\"token punctuation\">[</span><span class=\"token string\">\"scores\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">[</span>player<span class=\"token punctuation\">]</span> <span class=\"token operator\">=</span> game<span class=\"token punctuation\">[</span><span class=\"token string\">\"scores\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span>player<span class=\"token punctuation\">,</span> <span class=\"token number\">0</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">+</span> <span class=\"token punctuation\">(</span>idx <span class=\"token operator\">==</span> bot_idx<span class=\"token punctuation\">)</span>\n    caught <span class=\"token operator\">=</span> <span class=\"token builtin\">list</span><span class=\"token punctuation\">(</span>game<span class=\"token punctuation\">[</span><span class=\"token string\">\"votes\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">.</span>values<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span>count<span class=\"token punctuation\">(</span>bot_idx<span class=\"token punctuation\">)</span> <span class=\"token operator\">></span> <span class=\"token builtin\">len</span><span class=\"token punctuation\">(</span>game<span class=\"token punctuation\">[</span><span class=\"token string\">\"votes\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">/</span> <span class=\"token number\">2</span>\n    game<span class=\"token punctuation\">[</span><span class=\"token string\">\"board\"</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">=</span> <span class=\"token punctuation\">[</span><span class=\"token punctuation\">]</span>\n    <span class=\"token keyword\">return</span> jsonify<span class=\"token punctuation\">(</span>bot<span class=\"token operator\">=</span>bot_idx<span class=\"token punctuation\">,</span> caught<span class=\"token operator\">=</span>caught<span class=\"token punctuation\">,</span> scores<span class=\"token operator\">=</span>game<span class=\"token punctuation\">[</span><span class=\"token string\">\"scores\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span></code></pre></div>\n<p>A global dict as a database, no auth, race conditions if your family votes aggressively — it’s a living-room toy and proud of it. The front end is one HTML page polling <code class=\"language-text\">/board</code>; the “TV” is a laptop and an HDMI cable. Rotate who writes the question.</p>\n<p>Two warnings from the field: cap the bot at sixteen tokens or it will monologue, and prepare for the real endgame — after round three, the humans start impersonating the bot on purpose, and the game quietly turns into everyone performing their idea of a machine performing a person. That meta-round is the best round. I have notes about what <em>that</em> means, but they can wait for another post.</p>\n<p>Final score for the holidays: humans 13, machine 10, uncle still legally a robot.</p>","excerpt":"Between the cheese course and the board games this Christmas, I made my family play against a language model, and I’d like to report the results as if they were science. The game is embarrassingly simple. Everyone gets a…","timeToRead":5,"fields":{"slug":"/odd-bot-out-a-party-game-where-gpt-3-hides-among-your-relatives/"},"frontmatter":{"title":"Odd Bot Out: a party game where GPT-3 hides among your relatives","description":"I built a living-room game over the holidays — everyone answers a question, one answer is a language model, find it. Tuning the bot to survive taught me what 'sounding human' actually means","path":"Odd-Bot-Out-a-party-game-where-GPT-3-hides-among-your-relatives","date":"Dec 29, 2021","year":"2021"}},{"id":"a7c7b180-3bdf-5e61-8a69-8a599112637a","html":"<p>This blog has been two blogs wearing a trenchcoat: infrastructure posts and GPT posts, politely ignoring each other. This summer they finally met, because I got paged at 3:12am, spent forty minutes spelunking through Loki, and discovered the root cause was — again — the same Redis pod OOMing. Forty minutes, for a diagnosis that fits in a sentence.</p>\n<p>A sentence, you say. We have a machine for sentences now.</p>\n<h2 id=\"what-it-is-and-deliberately-isnt\" style=\"position:relative;\"><a href=\"#what-it-is-and-deliberately-isnt\" aria-label=\"what it is and deliberately isnt permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>What it is (and deliberately isn’t)</h2>\n<p>The <a href=\"/the-rematch-my-fine-tuned-gpt-2-vs-a-frozen-giant-with-five-examples\">April post</a> already litigated the tempting version — the closed loop where the model fixes things itself — and the verdict stands: not unattended, not on a box I like. A system that invents flags does not get <code class=\"language-text\">kubectl</code>.</p>\n<p>But there’s a humbler job that’s still most of the pain: <strong>translation</strong>. When an alert fires, the on-call intern doesn’t fix anything. The intern gathers context, forms a hypothesis, and hands the human a briefing. So:</p>\n<p><strong>Alertmanager webhook → context gatherer → davinci → Slack message.</strong></p>\n<p>The Slack message has three parts: a one-sentence hypothesis, the evidence, and a suggested <em>first command</em> — the <code class=\"language-text\">kubectl describe</code> or LogQL query I’d probably start with anyway. It proposes; I dispose. The plan is still the product.</p>\n<h2 id=\"the-keyhole-problem-industrial-edition\" style=\"position:relative;\"><a href=\"#the-keyhole-problem-industrial-edition\" aria-label=\"the keyhole problem industrial edition permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The keyhole problem, industrial edition</h2>\n<p>The hard engineering isn’t the model call — it’s that an incident produces megabytes of context and the <a href=\"/gpt3-rematch\">keyhole</a> is still 2048 tokens. Commit diffs were easy; logs are a firehose of repetition. So most of the code is what I’ve started calling <strong>log distillation</strong>:</p>\n<ul>\n<li>Pull the alert’s labels, the pod’s k8s events, the last <code class=\"language-text\">git log</code> entries touching those manifests, and a Loki query for the affected pod.</li>\n<li>Collapse duplicate lines into <code class=\"language-text\">[x347] connection refused ...</code> — log spam compresses beautifully, because spam is the point of spam.</li>\n<li>Keep the window around the <em>first</em> error, not the last. The end of an incident log is all symptoms; patient zero is at the beginning.</li>\n<li>Budget: ~600 tokens of distilled logs, ~200 of events and deploy history, ~900 of few-shot examples (three real past incidents with the briefings I <em>wish</em> I’d been handed), leaving room for the answer. Temperature 0 — I want the boring completion, this is not poetry night.</li>\n</ul>\n<p>The prompt is a crime-scene summary, not the crime scene. Deciding what makes the summary turned out to be 90% of the project, and honestly, building the distiller taught me more about my own logs than the model did.</p>\n<h2 id=\"the-lie-detector\" style=\"position:relative;\"><a href=\"#the-lie-detector\" aria-label=\"the lie detector permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The lie detector</h2>\n<p>Now the part I’m actually proud of, because the obvious failure mode here is dangerous: a fluent, confident, <em>wrong</em> briefing at 3am is worse than no briefing — I’ll trust it precisely because I’m too tired not to.</p>\n<p>The fix exploits something nice: <strong>the model may invent explanations, but the logs are right there.</strong> The prompt requires the hypothesis to cite two verbatim log lines as evidence, and then the <em>script</em> — dumb, deterministic Python — checks that those exact lines exist in the real distilled logs before posting anything.</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">for</span> quote <span class=\"token keyword\">in</span> extract_quotes<span class=\"token punctuation\">(</span>completion<span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    <span class=\"token keyword\">if</span> quote<span class=\"token punctuation\">.</span>strip<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token keyword\">not</span> <span class=\"token keyword\">in</span> distilled_logs<span class=\"token punctuation\">:</span>\n        <span class=\"token keyword\">return</span> post_slack<span class=\"token punctuation\">(</span><span class=\"token string\">\"⚠️ no confident hypothesis (evidence check failed)\"</span><span class=\"token punctuation\">)</span></code></pre></div>\n<p>If the model quotes a log line that doesn’t exist, it invented its evidence, and its conclusion is presumptively garbage — discarded, no briefing, just an honest shrug. It’s <code class=\"language-text\">terraform plan</code> for prose: <a href=\"/terraform-destroy-ritual\">beliefs don’t ship until they’ve met evidence</a>, and crucially, the <em>verifier isn’t the model</em>. Generation is allowed to be creative; verification is grep. Never ask the suspect to also be the jury.</p>\n<h2 id=\"six-weeks-of-production-numbers\" style=\"position:relative;\"><a href=\"#six-weeks-of-production-numbers\" aria-label=\"six weeks of production numbers permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Six weeks of production numbers</h2>\n<p>47 alerts since mid-July (a couple were self-inflicted, see: quarterly destroy ritual). Grading each briefing after the fact:</p>\n<ul>\n<li><strong>31 correct or usefully close</strong> — the hypothesis was right, or wrong in a way that still pointed at the right pod.</li>\n<li><strong>9 confidently wrong</strong> — but because the evidence quotes were real, I could see <em>why</em> it was wrong in seconds. A briefing with receipts fails gracefully; you audit the receipts, not the prose.</li>\n<li><strong>7 discarded by the evidence check.</strong> These are the wins nobody sees: seven fluent hallucinated briefings that never reached my phone. The lie detector’s catch rate is the whole reason I trust the other forty.</li>\n</ul>\n<p>Best moment: a cascade of timeout alerts where the intern’s briefing led with a deploy from six hours earlier — it had noticed the git timestamp in the context block sat just before the first error, a correlation I’d missed because six hours “felt” too far away. Statistical models don’t have feelings about time. Point, intern.</p>\n<p>Worst moment: it diagnosed a disk-pressure alert as a Redis memory issue because two of my three few-shot examples were Redis incidents. My examples had taught it that everything is Redis. Few-shot bias is real: the intern is a mirror, and I’d shown it a very Redis-shaped mirror.</p>\n<p>Cost: about $0.14 of davinci per incident. Cheapest coworker I’ve ever had, and it never asks to reschedule the retro.</p>\n<h2 id=\"why-i-didnt-fine-tune-although-now-i-could\" style=\"position:relative;\"><a href=\"#why-i-didnt-fine-tune-although-now-i-could\" aria-label=\"why i didnt fine tune although now i could permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Why I didn’t fine-tune (although now I could)</h2>\n<p>OpenAI shipped a fine-tuning API this summer, so the <a href=\"/gpt3-rematch\">rematch question</a> is live again — and I declined, by my own rulebook. Fine-tuning owns <em>context that never fits through the keyhole</em>, stable distributions like two years of my commit voice. But incidents are the opposite: each one’s crucial context is <em>fresh</em> — today’s logs, this morning’s deploy — and worthless tomorrow. There’s no stable distribution to bake into weights; there’s only the keyhole, refilled per incident. Right tool, and for once I picked it by theory instead of by vibes.</p>\n<h2 id=\"coda\" style=\"position:relative;\"><a href=\"#coda\" aria-label=\"coda permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Coda</h2>\n<p>GitHub spent the summer previewing Copilot — an autocomplete with hands is real now, it lives in editors, and yes, there’s a waitlist, because in this field even the future queues. Meanwhile my little intern suggests, cites, and gets fact-checked by twelve lines of Python.</p>\n<p>I’ve stopped thinking the interesting question is “what can the model do.” The interesting question is <strong>what shape of harness makes a confident liar useful</strong> — distill what goes in, verify what comes out, keep the hands human. That harness cost me a summer of evenings and it’s mostly string handling. Somebody’s going to get very rich selling harnesses.</p>\n<p>Next: GitOps, for real this time. The robots that apply commits have waited three posts, and now they have a coworker who reads logs.</p>","excerpt":"This blog has been two blogs wearing a trenchcoat: infrastructure posts and GPT posts, politely ignoring each other. This summer they finally met, because I got paged at 3:12am, spent forty minutes spelunking through…","timeToRead":5,"fields":{"slug":"/i-hired-gpt-3-as-my-on-call-intern/"},"frontmatter":{"title":"I hired GPT-3 as my on-call intern","description":"Wiring davinci between Alertmanager and Slack — six weeks of a language model reading my logs at 3am, with a built-in lie detector","path":"I-hired-GPT-3-as-my-on-call-intern","date":"Sep 19, 2021","year":"2021"}},{"id":"0448c0f6-973c-5887-9842-aa32b5e76292","html":"<p>Ten months after joining the waitlist, the <a href=\"/scaling-ladder\">invite</a> came. So this is the post the <a href=\"/gpt-3-my-fine-tuning-posts-might-already-be-obsolete/\">June write-up</a> promised: the world’s least rigorous benchmark, rerun. In one corner, my GPT-2 small, 124M parameters, <a href=\"/i-fine-tuned-gpt-2-on-my-commit-messages-im-sorry\">lovingly fine-tuned</a> on 3,124 of my own commit messages, weights shaped by my personal git guilt. In the other corner, GPT-3 davinci: 175 billion frozen parameters that have never heard of me, allowed to see exactly five examples through the 2048-token keyhole of its prompt.</p>\n<p>The question from last June, now testable: is fine-tuning dead for tasks?</p>\n<h2 id=\"the-setup\" style=\"position:relative;\"><a href=\"#the-setup\" aria-label=\"the setup permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The setup</h2>\n<p>Twenty real staged diffs, pulled from actual work over two weeks. Both models get each diff and must produce a commit message. GPT-2 runs locally, <a href=\"/swarm-to-k3s\">as a pod</a>, for free. GPT-3 gets this:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">import</span> openai\n\nFEWSHOT <span class=\"token operator\">=</span> <span class=\"token builtin\">open</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"five_examples.txt\"</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span>read<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span>  <span class=\"token comment\"># 5 diff→message pairs, ~1400 tokens</span>\n\nresp <span class=\"token operator\">=</span> openai<span class=\"token punctuation\">.</span>Completion<span class=\"token punctuation\">.</span>create<span class=\"token punctuation\">(</span>\n    engine<span class=\"token operator\">=</span><span class=\"token string\">\"davinci\"</span><span class=\"token punctuation\">,</span>\n    prompt<span class=\"token operator\">=</span>FEWSHOT <span class=\"token operator\">+</span> <span class=\"token string\">\"\\n===\\ndiff:\\n\"</span> <span class=\"token operator\">+</span> truncate<span class=\"token punctuation\">(</span>diff<span class=\"token punctuation\">,</span> <span class=\"token number\">400</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">+</span> <span class=\"token string\">\"\\nmessage:\"</span><span class=\"token punctuation\">,</span>\n    max_tokens<span class=\"token operator\">=</span><span class=\"token number\">24</span><span class=\"token punctuation\">,</span>\n    temperature<span class=\"token operator\">=</span><span class=\"token number\">0.7</span><span class=\"token punctuation\">,</span>\n    stop<span class=\"token operator\">=</span><span class=\"token punctuation\">[</span><span class=\"token string\">\"\\n\"</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"===\"</span><span class=\"token punctuation\">]</span>\n<span class=\"token punctuation\">)</span></code></pre></div>\n<p>Notes from the keyhole: five demonstration pairs plus a truncated diff eats nearly the whole 2048-token context, so “which five examples” and “which 400 tokens of the diff” become the actual engineering. There’s no fine-tuning endpoint for GPT-3 — prompting <em>is</em> the interface — which makes this a genuinely fair fight between the two paradigms. Scoring: a shell script shuffled the outputs so I graded blind, picking the message I’d actually keep.</p>\n<h2 id=\"the-result\" style=\"position:relative;\"><a href=\"#the-result\" aria-label=\"the result permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The result</h2>\n<p>Not close. <strong>Davinci 16, my model 4.</strong></p>\n<p>The gap is exactly where the paper said it would be: GPT-2 has never seen a diff and is riffing on vibes, while davinci actually <em>reads</em> the thing. Given a diff adding a Redis import and a cache decorator, my model offered “fix readme typo (again)“. Davinci wrote “add redis caching to search results”. That’s not a style difference. One of them did the assignment.</p>\n<p>The part that stung more: I assumed the giant would sound like a press release and my model would at least win on <em>voice</em>. Wrong. From five lowercase, slightly self-deprecating examples, davinci picked up my register almost perfectly — lowercase, terse, the occasional parenthetical apology. Style, it turns out, is shallow: it transfers through a keyhole. I’d been treating “sounds like me” as evidence of something deep in my fine-tuned weights, and a frozen model just cold-read it off five samples like a street magician.</p>\n<h2 id=\"whats-left-of-my-thesis\" style=\"position:relative;\"><a href=\"#whats-left-of-my-thesis\" aria-label=\"whats left of my thesis permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>What’s left of my thesis</h2>\n<p>Last June I bet “prompting eats the tasks, fine-tuning keeps the distributions.” Half survives, and it’s the narrower half.</p>\n<p>The four rounds my model won were all the same kind: diffs whose real story wasn’t <em>in</em> the diff. A one-line version bump whose honest message was “update deps and pray” — because that dependency has burned me twice, a fact living in my history, not in the patch. Davinci, reading only the keyhole, wrote the technically-correct-but-alien “bump lodash to 4.17.21”. My model has eaten two years of my scar tissue; it knows prayer is warranted.</p>\n<p>So the revised, humbler thesis: <strong>fine-tuning doesn’t own style, it owns context that never fits through the keyhole.</strong> Prompting rents a mind and slides your world through a 2048-token slot. The slot will widen — everything in this field widens — but there will always be some residue of private history on the wrong side of it. That residue is what your own weights are for. Everything else, the giant does better, today, for money.</p>\n<h2 id=\"about-the-money\" style=\"position:relative;\"><a href=\"#about-the-money\" aria-label=\"about the money permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>About the money</h2>\n<p>And it is <em>money</em>, which is the quietly radical part. Each suggestion burns ~1,900 davinci tokens; at 6 cents per thousand that’s about <strong>11 cents per commit message</strong>. My GPT-2 pod costs literally nothing per thought.</p>\n<p>Sit with how strange that is: for the first time, a function call in my code has a <em>marginal cost per thought</em>. Not per gigabyte, per hour, per request — per act of cognition, metered like electricity. My git hook now has a unit economics section. I caught myself batching commits to save dimes, which means the pricing model is already reshaping my behavior, which is how you know it’s real infrastructure and not a demo. Utility computing finally happened, and the utility is <em>thinking</em>.</p>\n<p>(Practical corollary: the fine-tuned pet earns a second life as the cheap tier. Local model drafts, giant model only when the diff is gnarly. Cascading by cost is going to be a whole design pattern, I suspect.)</p>\n<h2 id=\"a-bad-idea-i-keep-having\" style=\"position:relative;\"><a href=\"#a-bad-idea-i-keep-having\" aria-label=\"a bad idea i keep having permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>A bad idea I keep having</h2>\n<p>One more thing, filed under “probably terrible.” Everything I’ve built so far is a straight line: diff in, text out, human decides. But the completion API doesn’t care where prompts come from — nothing stops a script from feeding the model’s output back in as the next input and letting it converse with itself. And my <a href=\"/scaling-ladder\">k3s manifests are just text in git</a>, and davinci demonstrably <em>reads</em> structured text… you can see the loop closing: model proposes a change, script applies it, reality’s error message goes back through the keyhole, repeat.</p>\n<p>An autocomplete with hands. I gave it maybe an hour of daydreaming before remembering that this is a system that <a href=\"/playing-with-gpt2\">confidently invents flags</a> — for <code class=\"language-text\">rm</code>, presumably — and that my Terraform post has a whole section about beliefs meeting reality <em>under human review</em>. The plan is the product; the loop deletes the plan. So no. Not with this model, not unattended, not on a box I like.</p>\n<p>But I’m writing the idea down, because the objection is about <em>this</em> model, and models have a way of getting better while opinions stay still. Ask me again after the next paper.</p>\n<h2 id=\"next\" style=\"position:relative;\"><a href=\"#next\" aria-label=\"next permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Next</h2>\n<p>The GitOps rabbit hole is still scheduled, I promise — the robots that apply git commits deserve their own post, especially now that I’ve spent a paragraph fantasizing about a robot that <em>writes</em> them.</p>","excerpt":"Ten months after joining the waitlist, the invite came. So this is the post the June write-up promised: the world’s least rigorous benchmark, rerun. In one corner, my GPT-2 small, 124M parameters, lovingly fine-tuned on…","timeToRead":4,"fields":{"slug":"/the-rematch-my-fine-tuned-gpt-2-vs-a-frozen-giant-with-five-examples/"},"frontmatter":{"title":"The rematch: my fine-tuned GPT-2 vs. a frozen giant with five examples","description":"GPT-3 API access finally arrived, so the commit-message benchmark of exactly one silly task gets its rerun","path":"The-rematch-my-fine-tuned-GPT-2-vs-a-frozen-giant-with-five-examples","date":"Apr 11, 2021","year":"2021"}},{"id":"5f74840e-0fa5-58a1-ae4d-e1097ef649d2","html":"<p>Since the <a href=\"/i-ran-terraform-destroy-on-my-own-production-on-purpose\">terraform destroy post</a>, the most common question in my inbox is some version of: <em>“cute, but will your €5 setup scale?”</em></p>\n<p>I think that’s the wrong question, and this guide is about the right one. Because here’s the dirty secret of every “scalable production architecture” diagram: <strong>you don’t get taken down by load. You get taken down by the rewrite you scheduled for the week the load arrived.</strong></p>\n<p>So the metric I design for isn’t requests per second. It’s <strong>minutes to the next rung</strong> — how long it takes to climb one step up when I actually need to. Scalability isn’t a building you construct upfront. It’s a series of doors you deliberately leave unlocked.</p>\n<p>Here’s the ladder, rung by rung, with each unlocked door pointed out.</p>\n<h2 id=\"rung-0-the-5-baseline-you-are-here\" style=\"position:relative;\"><a href=\"#rung-0-the-5-baseline-you-are-here\" aria-label=\"rung 0 the 5 baseline you are here permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Rung 0: the €5 baseline (you are here)</h2>\n<p>Recap for new readers: <a href=\"/terraform-destroy-ritual\">Terraform</a> builds a Hetzner VPS and DNS, cloud-init installs <a href=\"/swarm-to-k3s\">k3s</a>, and the cluster pulls its manifests from git. The entire company — population: me — fits in one repo, and the whole environment rebuilds from zero in under five minutes, which I know because I destroy it on purpose quarterly.</p>\n<p>That ritual matters for this post: <strong>a rehearsed rebuild is also a rehearsed migration.</strong> Every rung below is cheap precisely because reconstruction is a habit, not an emergency.</p>\n<h2 id=\"rung-1-the-app-must-be-a-leaf-not-a-root\" style=\"position:relative;\"><a href=\"#rung-1-the-app-must-be-a-leaf-not-a-root\" aria-label=\"rung 1 the app must be a leaf not a root permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Rung 1: the app must be a leaf, not a root</h2>\n<p>Nothing else on this ladder works unless the application itself is stateless: config from environment variables, sessions in a store rather than in memory, nothing precious on local disk, logs to stdout. Old news — the <a href=\"https://12factor.net/\">12-factor</a> checklist is a decade old — but it’s the rung people skip and then blame Kubernetes.</p>\n<p>The test is brutal and simple: <code class=\"language-text\">kubectl scale deployment app --replicas=3</code>. If anything breaks — sessions vanish, uploads disappear, a cron job runs three times — you found state hiding in your app. Evict it now, while it’s small.</p>\n<p><strong>Door unlocked:</strong> horizontal scaling becomes a number in a YAML file instead of a project.</p>\n<h2 id=\"rung-2-shrink-what-counts-as-state\" style=\"position:relative;\"><a href=\"#rung-2-shrink-what-counts-as-state\" aria-label=\"rung 2 shrink what counts as state permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Rung 2: shrink what counts as state</h2>\n<p>The Terraform post’s lesson keeps paying rent: after a destroy/rebuild, everything that came back automatically was never really state — it was <em>opinion in git</em>. Scaling is the same audit. Manifests, certs, DNS, the cluster itself: opinions. What’s left — the database and user uploads — is the actual state, and the strategy is to keep that pile as small and as boring as possible.</p>\n<p>Concretely:</p>\n<ul>\n<li><strong>Uploads go to object storage from day one</strong> (Hetzner Storage Box, Backblaze B2, whatever’s cheap). S3-compatible APIs are the closest thing infrastructure has to a universal adapter, and this is the cheapest optionality purchase on the entire ladder.</li>\n<li><strong>The database gets a thin seam.</strong> I don’t mean an abstraction layer for switching databases — you won’t. I mean: connection string from env, schema in migration files, backups tested by <em>restoring</em> them (restic + a monthly restore drill). SQLite→Postgres, or self-hosted Postgres→managed Postgres, are then evenings, not quarters.</li>\n</ul>\n<p><strong>Door unlocked:</strong> the only hard migration in your future is one small, well-mapped pile instead of “the server.”</p>\n<h2 id=\"rung-3-the-cluster-grows-by-invitation\" style=\"position:relative;\"><a href=\"#rung-3-the-cluster-grows-by-invitation\" aria-label=\"rung 3 the cluster grows by invitation permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Rung 3: the cluster grows by invitation</h2>\n<p>The reason I picked k3s over docker-compose finally cashes out here. Adding a worker node is:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token comment\"># on the new €5 box (which Terraform created with count = 2)</span>\n<span class=\"token function\">curl</span> -sfL https://get.k3s.io <span class=\"token operator\">|</span> <span class=\"token assign-left variable\">K3S_URL</span><span class=\"token operator\">=</span>https://snail-01:6443 <span class=\"token assign-left variable\">K3S_TOKEN</span><span class=\"token operator\">=</span><span class=\"token variable\">$TOKEN</span> <span class=\"token function\">sh</span> -</code></pre></div>\n<p>That’s it — the node joins, the scheduler starts using it. And when the control plane itself needs to survive a node death, recent k3s can run an embedded etcd cluster across three servers instead of single-node SQLite. Same binary, same manifests, zero application changes.</p>\n<p>This is the rung that justifies the YAML tax from last summer: <strong>compose scales a machine; an orchestrator scales a fleet, and the manifests can’t tell the difference.</strong></p>\n<p><strong>Door unlocked:</strong> capacity becomes <code class=\"language-text\">count = N</code> in Terraform plus one join command.</p>\n<h2 id=\"rung-4-the-front-door-gets-a-receptionist\" style=\"position:relative;\"><a href=\"#rung-4-the-front-door-gets-a-receptionist\" aria-label=\"rung 4 the front door gets a receptionist permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Rung 4: the front door gets a receptionist</h2>\n<p>Right now DNS points at the node. One Terraform resource swaps that for a Hetzner load balancer pointing at <em>all</em> nodes, health checks included. Because rung 1 made the app stateless and rung 3 made nodes interchangeable, this is genuinely just a routing change — the LB is boring <em>because</em> the lower rungs did their jobs.</p>\n<p><strong>Door unlocked:</strong> nodes become anonymous. The uptime alert stops being about any particular machine.</p>\n<h2 id=\"rung-5-observability-or-receipts\" style=\"position:relative;\"><a href=\"#rung-5-observability-or-receipts\" aria-label=\"rung 5 observability or receipts permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Rung 5: observability, or, receipts</h2>\n<p>You can’t climb a ladder you can’t see. Prometheus + Grafana for metrics, Loki for logs — the kube-prometheus-stack Helm chart makes this an afternoon, and on a small cluster it costs more RAM than it has any right to, and it’s still worth it.</p>\n<p>Here’s the framing that made me stop treating monitoring as a chore: <strong>capacity plans are beliefs, and metrics are how beliefs meet evidence.</strong> Same epistemology as <a href=\"/terraform-destroy-ritual\"><code class=\"language-text\">terraform plan</code></a>, same failure mode as <a href=\"/playing-with-gpt2\">a language model inventing APIs</a> — a system confidently describing a reality it’s never checked. “We’ll scale when we need to” is a hallucination until a dashboard says when <em>need</em> is. My rule: I climb a rung when a graph tells me to, never when anxiety does.</p>\n<p><strong>Door unlocked:</strong> scaling decisions become diffs against data instead of vibes.</p>\n<h2 id=\"what-the-ladder-refuses-to-include\" style=\"position:relative;\"><a href=\"#what-the-ladder-refuses-to-include\" aria-label=\"what the ladder refuses to include permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>What the ladder refuses to include</h2>\n<p>No multi-region, no service mesh, no autoscaling, no microservices. Not because they’re bad — because each is a <em>building</em>, not a door, and buildings are premature until a graph screams. The most scalable decision I’ve made all year is the stuff I declined to install. Premature scaling is the root of all cloud bills.</p>\n<p>And the honest coda: I’m on rung 0 and expect to stay here. The ladder’s real product was never capacity — it’s that every rung is <em>rehearsed</em>, so growth is a checklist and not a crisis. I sleep well next to an unlocked door.</p>\n<h2 id=\"next\" style=\"position:relative;\"><a href=\"#next\" aria-label=\"next permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Next</h2>\n<p>The itch from October remains: git already holds the manifests, so why am I the one running <code class=\"language-text\">kubectl apply</code>? The GitOps rabbit hole is officially scheduled.</p>\n<p>Also, a certain API invite finally showed up in my inbox this week. The silly benchmark gets its rematch. Stay tuned.</p>","excerpt":"Since the terraform destroy post, the most common question in my inbox is some version of: “cute, but will your €5 setup scale?” I think that’s the wrong question, and this guide is about the right one. Because here’s…","timeToRead":5,"fields":{"slug":"/the-scaling-ladder-a-production-setup-that-scales-by-not-scaling-yet/"},"frontmatter":{"title":"The scaling ladder: a production setup that scales by not scaling yet","description":"A guide to building small production systems where every upgrade is a swap, not a rewrite","path":"The-scaling-ladder-a-production-setup-that-scales-by-not-scaling-yet","date":"Feb 14, 2021","year":"2021"}},{"id":"f89c2c1c-50ba-58e8-8801-7a6acd7d5b20","html":"<p>In the <a href=\"/so-about-that-docker-swarm-post-i-moved-to-k3s\">k3s post</a> I claimed Terraform is built on the same declarative idea as Kubernetes. This post is me paying for that sentence, because after a month of actually using Terraform I think it’s only half true — and the half that’s false is the interesting part.</p>\n<p>But first, the fun part, where I delete my server.</p>\n<h2 id=\"the-problem-my-vps-was-a-snowflake\" style=\"position:relative;\"><a href=\"#the-problem-my-vps-was-a-snowflake\" aria-label=\"the problem my vps was a snowflake permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The problem: my VPS was a snowflake</h2>\n<p>The Hetzner box running everything — this blog, the side projects, the <a href=\"/commit-message-driven-development\">commit-message bot</a> — was hand-built. SSH in, apt this, curl that, tweak, forget. If it died, “restore from backup” really meant “restore from whatever I remember about April.”</p>\n<p>So: everything into Terraform 0.13. The stack is three providers doing a small, honest job:</p>\n<div class=\"gatsby-highlight\" data-language=\"hcl\"><pre class=\"language-hcl\"><code class=\"language-hcl\"><span class=\"token keyword\">terraform</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">required_providers</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token property\">hcloud</span>     <span class=\"token punctuation\">=</span> <span class=\"token punctuation\">{</span> <span class=\"token property\">source</span> <span class=\"token punctuation\">=</span> <span class=\"token string\">\"hetznercloud/hcloud\"</span> <span class=\"token punctuation\">}</span>\n    <span class=\"token property\">cloudflare</span> <span class=\"token punctuation\">=</span> <span class=\"token punctuation\">{</span> <span class=\"token property\">source</span> <span class=\"token punctuation\">=</span> <span class=\"token string\">\"cloudflare/cloudflare\"</span> <span class=\"token punctuation\">}</span>\n  <span class=\"token punctuation\">}</span>\n<span class=\"token punctuation\">}</span>\n\n<span class=\"token keyword\">resource <span class=\"token type variable\">\"hcloud_server\"</span></span> <span class=\"token string\">\"main\"</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token property\">name</span>        <span class=\"token punctuation\">=</span> <span class=\"token string\">\"snail-01\"</span>\n  <span class=\"token property\">server_type</span> <span class=\"token punctuation\">=</span> <span class=\"token string\">\"cx21\"</span>\n  <span class=\"token property\">image</span>       <span class=\"token punctuation\">=</span> <span class=\"token string\">\"ubuntu-20.04\"</span>\n  <span class=\"token property\">location</span>    <span class=\"token punctuation\">=</span> <span class=\"token string\">\"nbg1\"</span>\n  <span class=\"token property\">user_data</span>   <span class=\"token punctuation\">=</span> file(<span class=\"token string\">\"cloud-init.yaml\"</span>)\n<span class=\"token punctuation\">}</span>\n\n<span class=\"token keyword\">resource <span class=\"token type variable\">\"cloudflare_record\"</span></span> <span class=\"token string\">\"blog\"</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token property\">zone_id</span> <span class=\"token punctuation\">=</span> var.zone_id\n  <span class=\"token property\">name</span>    <span class=\"token punctuation\">=</span> <span class=\"token string\">\"ohsnail.com\"</span>\n  <span class=\"token property\">value</span>   <span class=\"token punctuation\">=</span> hcloud_server.main.ipv4_address\n  <span class=\"token property\">type</span>    <span class=\"token punctuation\">=</span> <span class=\"token string\">\"A\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>The <code class=\"language-text\">cloud-init.yaml</code> is where the magic compounds: it installs k3s and applies my manifests from git. So Terraform builds the world, cloud-init boots the cluster, and the control loops from last post take it from there. Three layers of “make it so.”</p>\n<p>(The new <code class=\"language-text\">required_providers</code> block with <code class=\"language-text\">source</code> addresses is a 0.13 thing, by the way — third-party providers like hcloud finally live in the registry instead of “download this binary and put it in a folder like it’s 2003.“)</p>\n<p>Adopting the <em>existing</em> server meant a tour through <code class=\"language-text\">terraform import</code>, which I’d describe as filling out immigration paperwork for infrastructure that already lives here. Tedious, worth it, do it resource by resource and run <code class=\"language-text\">plan</code> obsessively until it shows no changes.</p>\n<h2 id=\"the-ritual\" style=\"position:relative;\"><a href=\"#the-ritual\" aria-label=\"the ritual permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The ritual</h2>\n<p>Here’s the thing though: a disaster recovery plan you’ve never executed is a religious belief. So on Friday night, with a beer for courage and a fresh state backup for cowardice:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\">terraform destroy -auto-approve\n<span class=\"token comment\"># ...12 seconds of watching my entire digital life deallocate</span>\nterraform apply -auto-approve</code></pre></div>\n<p><strong>Four minutes and twenty seconds</strong> later, everything was back. Server, DNS, cluster, blog, TLS certs re-issued by cert-manager, the bot cheerfully suggesting I “fix flaky test.” The only data that needed restoring was two Docker volumes from a Restic backup — which is exactly the point: the ritual shows you, brutally, what is actually <em>state</em> versus what just <em>looks</em> important. Everything that came back automatically was never state at all. It was opinion, and opinions belong in git.</p>\n<p>My server is no longer a pet. It’s cattle with a herd size of one, and I can now hold the entire production environment in a <code class=\"language-text\">git clone</code>.</p>\n<p>I’m making this a quarterly ritual. Nothing keeps infrastructure honest like scheduled death.</p>\n<h2 id=\"the-part-where-i-correct-last-months-take\" style=\"position:relative;\"><a href=\"#the-part-where-i-correct-last-months-take\" aria-label=\"the part where i correct last months take permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The part where I correct last month’s take</h2>\n<p>Now the confession. Kubernetes and Terraform are both “declarative,” but they reconcile in fundamentally different ways, and conflating them (as I did) hides the most important operational fact about Terraform:</p>\n<p><strong>Kubernetes reconciles continuously.</strong> Control loops compare desired vs. actual many times a minute, forever. Drift gets corrected while you sleep.</p>\n<p><strong>Terraform reconciles episodically.</strong> Nothing happens until a human runs <code class=\"language-text\">plan</code>. Between runs, Terraform isn’t watching anything — it’s not a loop, it’s a <em>snapshot ceremony</em>.</p>\n<p>Which leads to the mental model that actually made Terraform click for me: <strong>the state file is not a record of your infrastructure. It’s Terraform’s <em>beliefs</em> about your infrastructure.</strong> Reality can change behind its back — a colleague clicks something in the console, a provider auto-upgrades a field, you “quickly fix” a firewall rule at 2am — and the beliefs go stale. <code class=\"language-text\">terraform plan</code> is the moment beliefs are confronted with evidence, and the diff you’re reviewing is really three-way: what you declared, what Terraform believes, what’s actually out there.</p>\n<p>Sound familiar? A confident internal model drifting away from reality, generating authoritative statements about things that no longer exist — that’s the same failure mode I spent the spring documenting in <a href=\"/playing-with-gpt2\">GPT-2</a>, which happily invented APIs because nothing ever forced its beliefs to meet the world. Terraform ships the antidote as a first-class command. Half of engineering, I’m starting to suspect, is just deciding <em>when</em> your system’s beliefs get checked against reality: every few seconds (Kubernetes), every deliberate ceremony (Terraform), or never (my old snowflake server, and, well, GPT-2).</p>\n<p>Continuous reconciliation is strictly stronger <em>if</em> you can afford it. Terraform’s episodic model is the tradeoff you accept for managing things that are expensive, slow, or dangerous to change — you <em>want</em> a human reading the diff before a database gets replaced. The plan isn’t bureaucracy. The plan is the product.</p>\n<h2 id=\"honest-footnotes\" style=\"position:relative;\"><a href=\"#honest-footnotes\" aria-label=\"honest footnotes permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Honest footnotes</h2>\n<ul>\n<li><strong>The state file is sacred and radioactive.</strong> It contains everything, sometimes including secrets, and losing it means Terraform forgets your infrastructure exists. Mine lives in Terraform Cloud’s free remote state with locking. Never in git.</li>\n<li><code class=\"language-text\">destroy</code> will absolutely also destroy things you forgot depend on each other. Read the destroy plan slower than you’ve ever read anything.</li>\n<li>Some resources are lying about being cattle. Volumes, databases, anything holding user data — mark them <code class=\"language-text\">prevent_destroy</code> and treat them as pets with paperwork.</li>\n</ul>\n<h2 id=\"next\" style=\"position:relative;\"><a href=\"#next\" aria-label=\"next permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Next</h2>\n<p>The obvious itch: Terraform applies when I run it, but the manifests inside the cluster already live in git… so why am I the one running anything? The word for that itch is apparently “GitOps,” and it’s next quarter’s rabbit hole.</p>\n<p>Also my GPT-3 invite is <em>still</em> not here. OpenAI, I have a benchmark of exactly one silly task waiting.</p>","excerpt":"In the k3s post I claimed Terraform is built on the same declarative idea as Kubernetes. This post is me paying for that sentence, because after a month of actually using Terraform I think it’s only half true — and the…","timeToRead":4,"fields":{"slug":"/i-ran-terraform-destroy-on-my-own-production-on-purpose/"},"frontmatter":{"title":"I ran terraform destroy on my own production (on purpose)","description":"Putting the whole stack in Terraform, then proving it by deleting the server — plus the difference between Terraform and Kubernetes everyone glosses over","path":"I-ran-terraform-destroy-on-my-own-production","date":"Oct 18, 2020","year":"2020"}},{"id":"3c4b6f49-35c9-5934-8419-3cd4bf9126d3","html":"<p>Last November I wrote a guide on <a href=\"/configure-docker-swarm\">configuring Docker Swarm</a>. Two weeks later, Mirantis bought Docker’s enterprise business and gave Swarm what sounded like a two-year countdown. They’ve since softened that to “we’ll keep supporting it,” but the writing is on the wall, and the wall is written in YAML.</p>\n<p>So this summer I finally did the thing I’d been avoiding since 2017: I learned Kubernetes properly. The trick that made it not miserable is that I didn’t install Kubernetes. I installed <strong>k3s</strong>.</p>\n<h2 id=\"k3s-kubernetes-without-the-ceremony\" style=\"position:relative;\"><a href=\"#k3s-kubernetes-without-the-ceremony\" aria-label=\"k3s kubernetes without the ceremony permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>k3s: Kubernetes without the ceremony</h2>\n<p>Rancher’s k3s is a certified Kubernetes distribution compressed into a single ~50MB binary. It swaps etcd for SQLite, strips the legacy cloud-provider baggage, and ships batteries included (Traefik as ingress, out of the box). Install is genuinely this:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token function\">curl</span> -sfL https://get.k3s.io <span class=\"token operator\">|</span> <span class=\"token function\">sh</span> -\n<span class=\"token function\">sudo</span> k3s kubectl get <span class=\"token function\">node</span></code></pre></div>\n<p>Thirty seconds later there’s a working single-node “cluster” on my €5 Hetzner VPS, idling at around 500MB of RAM. That’s the entire moat between “Kubernetes is for companies with a platform team” and “Kubernetes runs my hobby junk” — and it turns out the moat was just packaging.</p>\n<h2 id=\"the-migration\" style=\"position:relative;\"><a href=\"#the-migration\" aria-label=\"the migration permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The migration</h2>\n<p>My Swarm stack was this blog, a couple of side projects, and — because the theme of this year apparently insists — the <a href=\"/commit-message-driven-development\">GPT-2 commit-message server</a> from April, still faithfully dealing me chores every morning.</p>\n<p><code class=\"language-text\">kompose</code> will mechanically convert a compose file into manifests, but I recommend writing them by hand at least once, because the pain is educational. A Swarm service becomes three objects: a <strong>Deployment</strong> (what to run), a <strong>Service</strong> (how to reach it), an <strong>Ingress</strong> (how the outside reaches the Service). Add cert-manager and Let’s Encrypt certificates become just another object the cluster keeps renewed for me — the first moment where the K8s way felt <em>better</em> rather than merely fancier.</p>\n<p>Lessons collected the honest way:</p>\n<ul>\n<li><strong>Set resource limits immediately.</strong> The GPT-2 pod happily ate the whole node until the OOM killer introduced itself. <code class=\"language-text\">resources.limits.memory: 900Mi</code> and we’re friends again.</li>\n<li>The Traefik v1 that ships with current k3s reads standard Ingress objects fine; ignore old tutorials full of custom annotations.</li>\n<li>Helm 3 (tiller is gone, good riddance) for other people’s software, plain manifests in git for mine.</li>\n<li>You will write a stupid amount of YAML. There’s no enlightenment coming on this one; buy a better keyboard.</li>\n</ul>\n<h2 id=\"the-part-where-it-finally-clicked\" style=\"position:relative;\"><a href=\"#the-part-where-it-finally-clicked\" aria-label=\"the part where it finally clicked permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The part where it finally clicked</h2>\n<p>Here’s the mental model I wish someone had led with, instead of diagrams of pods.</p>\n<p>Swarm — and honestly most of the tools I grew up on — is <strong>imperative</strong>: you issue commands, it executes them, done. <code class=\"language-text\">docker service scale web=3</code> is an <em>action</em>.</p>\n<p>Kubernetes is not an orchestrator that runs your commands. It’s a <strong>database of desired state with robots attached</strong>. You write down a fact you’d like to be true — “three replicas of this exist, reachable at this name, with valid TLS” — and control loops compare that desire to reality, forever, and push reality toward it. Node dies at 3am? Reality drifted from the spec; the loop corrects it. Nobody, including you, issued a command.</p>\n<p>You don’t tell Kubernetes what to <em>do</em>. You tell it what should be <em>true</em>.</p>\n<p>And I can’t stop noticing that this is the same shape as my complaint about language models this spring — that GPT-2 executes text-continuation without any notion of your <em>intent</em>, and that the missing piece is a system trained to pursue what you meant. Kubernetes is what it looks like when infrastructure makes that leap: from “execute my steps” to “converge on my intent.” Declarative beats imperative for the same reason in both worlds — steps go stale, intent doesn’t. Apparently 2020 is the year I learn this lesson once per quarter in a different domain.</p>\n<h2 id=\"do-you-need-this\" style=\"position:relative;\"><a href=\"#do-you-need-this\" aria-label=\"do you need this permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Do you need this?</h2>\n<p>No. Let’s be adults: for one VPS of hobby projects, docker-compose plus a systemd unit is fewer moving parts and I’d defend that choice in court. I did it because Swarm’s future is cloudy, because the industry has voted and K8s won, and because the reconciliation-loop model is the actually transferable knowledge — it’s the same idea underneath Terraform, GitOps, and every “controller” you’ll meet for the next decade.</p>\n<p>But I’ll admit the real reason: there is something deeply pleasing about a server that repairs itself while I sleep. The commit-message bot went down twice this month. Both times, by the time the uptime alert reached my phone, the pod had already been rescheduled and the alert resolved itself.</p>\n<p>The robot assigns me chores, and other robots keep the robot alive. The system works.</p>","excerpt":"Last November I wrote a guide on configuring Docker Swarm. Two weeks later, Mirantis bought Docker’s enterprise business and gave Swarm what sounded like a two-year countdown. They’ve since softened that to “we’ll keep…","timeToRead":3,"fields":{"slug":"/so-about-that-docker-swarm-post-i-moved-to-k3s/"},"frontmatter":{"title":"So about that Docker Swarm post: I moved to k3s","description":"Migrating my little stack from Swarm to lightweight Kubernetes, and the mental model that finally made K8s click","path":"So-about-that-Docker-Swarm-post-I-moved-to-k3s","date":"Aug 15, 2020","year":"2020"}},{"id":"a522c037-d315-5469-9b3d-f75734539a0a","html":"<p>Three weeks ago OpenAI dropped the GPT-3 paper — <a href=\"https://arxiv.org/abs/2005.14165\">“Language Models are Few-Shot Learners”</a>, 31 authors, 75 pages — and then last week announced an API for it. Private beta, waitlist. I’m on the waitlist. This post is me processing the paper while I refresh my inbox.</p>\n<p>And I have to process it, because if the paper’s claims hold up, the two posts I wrote this spring about <a href=\"/gpt2-commit-messages\">fine-tuning GPT-2</a> describe a workflow with an expiration date.</p>\n<h2 id=\"the-boring-headline-its-just-bigger\" style=\"position:relative;\"><a href=\"#the-boring-headline-its-just-bigger\" aria-label=\"the boring headline its just bigger permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The boring headline: it’s just bigger</h2>\n<p>GPT-3 is architecturally GPT-2 with the dials turned up. Same decoder-only transformer, more of everything: 175 billion parameters across 96 layers, trained on roughly 300 billion tokens of filtered Common Crawl, WebText, books, and Wikipedia. Context window doubled from 1024 to 2048 tokens.</p>\n<p>For scale: the model I fine-tuned on my commit messages was GPT-2 small, 124 million parameters. GPT-3 is over 1,400 of those. The training compute estimates floating around are in the several-million-dollar range, and the weights in half precision would be ~350GB — this thing wouldn’t fit on my SSD, let alone my GPU. Which explains why there’s an API and no download link, and why “Open”AI jokes are back on the menu.</p>\n<p>None of that is the interesting part. If “bigger model, better perplexity” were the whole story, this would be a shrug. The Kaplan et al. <a href=\"https://arxiv.org/abs/2001.08361\">scaling laws paper</a> from January already showed loss falling smoothly and predictably with model size, data, and compute — GPT-3 is in some sense just OpenAI running that curve way past where anyone else was willing to spend. The interesting part is what showed up along the way.</p>\n<h2 id=\"the-actually-interesting-part-learning-without-learning\" style=\"position:relative;\"><a href=\"#the-actually-interesting-part-learning-without-learning\" aria-label=\"the actually interesting part learning without learning permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The actually interesting part: learning without learning</h2>\n<p>Here’s the thing the paper is really about, and it’s worth being precise because it’s genuinely strange.</p>\n<p><strong>Fine-tuning</strong>, the thing I did in March, means updating the model’s weights with gradient descent on your data. The model <em>changes</em>. That’s why my 3,124 commit messages got memorized — with a dataset that small, the cheapest way for the network to reduce loss is to just store the training set. I watched that happen in real time on a free Colab GPU.</p>\n<p><strong>In-context learning</strong>, the GPT-3 pitch, involves no weight updates at all. You put a few examples <em>in the prompt</em>:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">English: cheese  French: fromage\nEnglish: bread   French: pain\nEnglish: snail   French:</code></pre></div>\n<p>…and the model completes the pattern. The weights are frozen. Nothing is “trained.” And yet on task after task in the paper, performance climbs as you go from zero examples to one to a few dozen — as if the model is learning the task at inference time. The bigger the model, the steeper that climb: the small models in their ablations barely benefit from examples in the prompt, and the 175B model benefits a lot. Whatever this ability is, it’s something that <em>shows up with scale</em> rather than something anyone explicitly built.</p>\n<p>The least mystical way I can describe it: after reading a good chunk of the internet, the model has seen so many patterns-with-examples that “infer the pattern and continue it” became part of what next-token prediction means. Conditioning does the work that gradient descent used to do. The examples don’t update the model; they <em>locate</em> the task inside what the model already knows.</p>\n<p>I’m honestly not sure whether that framing is deep or deflationary. Possibly both.</p>\n<h2 id=\"scoring-my-own-take-from-april\" style=\"position:relative;\"><a href=\"#scoring-my-own-take-from-april\" aria-label=\"scoring my own take from april permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Scoring my own take from April</h2>\n<p>In the git hook post I wrote that GPT-2 “completes, it doesn’t obey,” and that what these models are missing “isn’t size, it’s some way of being trained to follow intent.”</p>\n<p>GPT-3’s answer to that is annoying: it doesn’t follow intent either — it’s still a pure completion engine — but at 175B parameters, completion gets so good that <em>arranging examples</em> becomes a usable interface. The paper’s few-shot prompts are exactly the “prompt origami” I was complaining about. They didn’t remove the origami. They made the paper so foldable that origami works.</p>\n<p>So: half right, half wrong. Wrong that size wouldn’t matter — size mattered enormously, and I’d have lost money betting on that. But I’ll stand by the other half: nothing in this paper trains the model to <em>want</em> to do your task. You’re still tricking an autocomplete into usefulness, and prompts are a brittle, undebuggable way to program. Someone is eventually going to fine-tune one of these giants on the objective “actually do what the instruction says” rather than “continue the document,” and I suspect that model will be the one non-nerds end up using. The capability is clearly in there now; what’s missing is the steering wheel.</p>\n<h2 id=\"is-fine-tuning-dead-then\" style=\"position:relative;\"><a href=\"#is-fine-tuning-dead-then\" aria-label=\"is fine tuning dead then permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Is fine-tuning dead, then?</h2>\n<p>The paper’s title is basically a shot at fine-tuning, so as someone with two fine-tuning posts, let me defend my turf — because I think the honest answer is “fine-tuning is dead for tasks, alive for distributions,” and the difference is exactly what my commit-message toy demonstrates.</p>\n<p>A <em>task</em> is “translate this,” “summarize this,” “classify this sentiment.” If a handful of demonstrations fit in 2048 tokens, GPT-3 says you may not need a dataset, a GPU, or a training loop at all. That’s a real democratization: collecting 50k labeled examples was always the actual cost of applied NLP, and few-shot moves that cost toward zero.</p>\n<p>But my commit-message model wasn’t learning a task. It was learning a <em>distribution</em> — the specific texture of my habits, my vocabulary, my guilt. That’s thousands of examples’ worth of signal. It doesn’t fit in any prompt, and no amount of general internet knowledge contains it, because it’s <em>mine</em>. The whole reason the “commit-message-driven development” trick worked is that the model had my history baked into its weights. Ten examples in a context window is a description of me; fine-tuning was a compression of me. Those aren’t the same object.</p>\n<p>So my bet: prompting eats the tasks, fine-tuning keeps the personalization and the domains — your codebase, your support tickets, legal boilerplate, medical notes. And knowing <em>which regime your problem is in</em> becomes the actual skill, more than knowing how to run a training script.</p>\n<h2 id=\"reasons-to-keep-a-foot-on-the-ground\" style=\"position:relative;\"><a href=\"#reasons-to-keep-a-foot-on-the-ground\" aria-label=\"reasons to keep a foot on the ground permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Reasons to keep a foot on the ground</h2>\n<p>Things in the paper that the hype tweets will skip:</p>\n<ul>\n<li><strong>Arithmetic falls off a cliff.</strong> Two- and three-digit addition mostly works; five-digit collapses. It has not learned math, it has learned the <em>look</em> of math — memorization and pattern-matching wearing a calculator costume.</li>\n<li><strong>Contamination.</strong> When your training set is a filtered internet crawl, some benchmark test sets are inevitably <em>in it</em>. The authors found a bug in their dedup and dedicate a whole section to damage control. They’re candid about it, but every benchmark number deserves a mental asterisk.</li>\n<li><strong>It confidently makes things up.</strong> Same failure mode as my GPT-2 inventing APIs, now delivered in fluent, authoritative paragraphs. Scale made the errors <em>more</em> convincing, not less frequent. That’s arguably worse.</li>\n<li><strong>You can’t have it.</strong> API-only, paid, one company. The era of “clone the repo and fine-tune on Colab” — literally my March post — doesn’t apply here. When the best model is a service, reproducibility, research access, and pricing power all get complicated. I don’t love it, even while acknowledging that nobody’s laptop was running 350GB of weights anyway.</li>\n</ul>\n<h2 id=\"what-ill-do-when-the-invite-arrives\" style=\"position:relative;\"><a href=\"#what-ill-do-when-the-invite-arrives\" aria-label=\"what ill do when the invite arrives permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>What I’ll do when the invite arrives</h2>\n<p>First experiment is obvious: rerun the commit-message generator with zero training. Paste five diffs with their real messages into the prompt as demonstrations, staged diff sixth, let it complete. If a frozen model with five examples beats my lovingly fine-tuned GPT-2 with 3,124 — and after this paper I’d bet it does — that’s the whole regime change reproduced on my own silly benchmark, and post number four writes itself.</p>\n<p>Until then I’m a guy with a waitlist confirmation email and a 75-page PDF full of margin notes. Weirdest thing I’ve read all quarantine, and that’s a high bar this year.</p>","excerpt":"Three weeks ago OpenAI dropped the GPT-3 paper — “Language Models are Few-Shot Learners”, 31 authors, 75 pages — and then last week announced an API for it. Private beta, waitlist. I’m on the waitlist. This post is me…","timeToRead":6,"fields":{"slug":"/gpt-3-my-fine-tuning-posts-might-already-be-obsolete/"},"frontmatter":{"title":"GPT-3: my fine-tuning posts might already be obsolete","description":"Reading the GPT-3 paper, waiting for API access, and rethinking what fine-tuning is even for","path":"GPT-3-my-fine-tuning-posts-might-already-be-obsolete","date":"Jun 20, 2020","year":"2020"}},{"id":"82cf150e-2760-58f8-9110-78536c0e854c","html":"<p>Quarantine lasted long enough. Here’s the <a href=\"/gpt2-commit-messages\">promised</a> post about the <code class=\"language-text\">prepare-commit-msg</code> hook — except the project mutated into something much weirder along the way.</p>\n<h2 id=\"the-plan\" style=\"position:relative;\"><a href=\"#the-plan\" aria-label=\"the plan permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The plan</h2>\n<p>Simple: a git hook grabs the staged diff, sends it to my fine-tuned GPT-2 from last month, and the model suggests a commit message. Since loading a 500MB model on every commit is insane, the model lives in a tiny Flask server that stays warm:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">from</span> flask <span class=\"token keyword\">import</span> Flask<span class=\"token punctuation\">,</span> request\n\napp <span class=\"token operator\">=</span> Flask<span class=\"token punctuation\">(</span>__name__<span class=\"token punctuation\">)</span>\ngenerator <span class=\"token operator\">=</span> load_my_finetuned_gpt2<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span>  <span class=\"token comment\"># from last post</span>\n\n<span class=\"token decorator annotation punctuation\">@app<span class=\"token punctuation\">.</span>route</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"/suggest\"</span><span class=\"token punctuation\">,</span> methods<span class=\"token operator\">=</span><span class=\"token punctuation\">[</span><span class=\"token string\">\"POST\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span>\n<span class=\"token keyword\">def</span> <span class=\"token function\">suggest</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    diff <span class=\"token operator\">=</span> request<span class=\"token punctuation\">.</span>form<span class=\"token punctuation\">[</span><span class=\"token string\">\"diff\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">[</span><span class=\"token punctuation\">:</span><span class=\"token number\">3000</span><span class=\"token punctuation\">]</span>\n    <span class=\"token keyword\">return</span> generator<span class=\"token punctuation\">(</span>diff<span class=\"token punctuation\">)</span></code></pre></div>\n<p>And the hook:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token shebang important\">#!/bin/sh</span>\n<span class=\"token comment\"># .git/hooks/prepare-commit-msg</span>\n<span class=\"token assign-left variable\">DIFF</span><span class=\"token operator\">=</span><span class=\"token variable\"><span class=\"token variable\">$(</span><span class=\"token function\">git</span> <span class=\"token function\">diff</span> --cached<span class=\"token variable\">)</span></span>\n<span class=\"token assign-left variable\">MSG</span><span class=\"token operator\">=</span><span class=\"token variable\"><span class=\"token variable\">$(</span><span class=\"token function\">curl</span> -s localhost:5000/suggest --data-urlencode <span class=\"token string\">\"diff=<span class=\"token variable\">$DIFF</span>\"</span><span class=\"token variable\">)</span></span>\n<span class=\"token builtin class-name\">printf</span> <span class=\"token string\">'# suggested: %s\\n%s'</span> <span class=\"token string\">\"<span class=\"token variable\">$MSG</span>\"</span> <span class=\"token string\">\"<span class=\"token variable\"><span class=\"token variable\">$(</span><span class=\"token function\">cat</span> <span class=\"token string\">\"<span class=\"token variable\">$1</span>\"</span><span class=\"token variable\">)</span></span>\"</span> <span class=\"token operator\">></span> <span class=\"token string\">\"<span class=\"token variable\">$1</span>\"</span></code></pre></div>\n<h2 id=\"the-failure-as-promised\" style=\"position:relative;\"><a href=\"#the-failure-as-promised\" aria-label=\"the failure as promised permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The failure (as promised)</h2>\n<p>It was wrong 100% of the time, but the <em>reasons</em> are more interesting than the result:</p>\n<p><strong>The model has never seen a diff.</strong> I fine-tuned it on commit messages only. From its point of view, a diff is line noise, so it responds to <code class=\"language-text\">+import redis</code> with “fix typo in readme”. Training distribution is everything — a lesson people apparently keep having to relearn at every scale.</p>\n<p><strong>1024 tokens.</strong> GPT-2’s entire context window. My average staged diff is 3–4x that. The model was writing commit messages after reading the first file’s worth of changes, which, to be fair, is also how some humans review PRs.</p>\n<p><strong>It completes, it doesn’t obey.</strong> This is the big one. GPT-2 is an autocomplete engine. There’s no way to <em>tell</em> it “summarize this diff” — you can only arrange text and hope the most probable continuation happens to be what you wanted. What these models are missing isn’t size, it’s some way of being trained to follow intent. Until someone figures that out, everything built on them is prompt origami.</p>\n<h2 id=\"the-inversion\" style=\"position:relative;\"><a href=\"#the-inversion\" aria-label=\"the inversion permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The inversion</h2>\n<p>Staring at another wrong suggestion, it hit me: the model can’t describe what I <em>did</em>, but it’s weirdly good at proposing things I <em>could do</em>. It’s been trained on 3,000 of my past intentions.</p>\n<p>So I flipped the workflow. Every morning the model deals me a commit message. Then I have to write code that makes the message true.</p>\n<p>Week one assignments, verbatim:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">delete old feature flags\nfix flaky test\nadd caching to search\nupdate deps and pray</code></pre></div>\n<p>Here’s the unsettling part: <strong>all four were real work I’d been avoiding.</strong> I found three dead feature flags, one genuinely flaky test, and a search endpoint that absolutely needed caching. The model isn’t psychic — it’s a compressed statistical summary of my habits, and my habits include perpetually deferring the same maintenance. It’s like Brian Eno’s Oblique Strategies, except the deck was generated from my own guilt.</p>\n<h2 id=\"what-this-actually-is\" style=\"position:relative;\"><a href=\"#what-this-actually-is\" aria-label=\"what this actually is permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>What this actually is</h2>\n<p>The hook was supposed to automate me out of a chore. What I got instead is a slot machine that pays out in chores — and it works <em>because</em> it’s random-but-in-my-distribution. A model too weak to answer questions can still be a fantastic question generator.</p>","excerpt":"Quarantine lasted long enough. Here’s the promised post about the  hook — except the project mutated into something much weirder along the way. The plan Simple: a git hook grabs the staged diff, sends it to my fine-tuned…","timeToRead":2,"fields":{"slug":"/my-ai-writes-the-commit-message-first-then-i-write-the-code/"},"frontmatter":{"title":"My AI writes the commit message first. Then I write the code","description":"The GPT-2 commit hook failed exactly as promised, so I turned it around","path":"my-aiwrites-the-commit-message-first-then-i-write-the-code","date":"Apr 25, 2020","year":"2020"}},{"id":"c7da3764-3eb0-523d-bd3e-d8149b7dfd57","html":"<p>At the end of the <a href=\"/playing-with-gpt-2-text-generation-in-python/\">last post</a> I threatened to fine-tune GPT-2 on my own commit messages. Well, we’re all locked at home now, so no more excuses.</p>\n<h2 id=\"getting-the-data\" style=\"position:relative;\"><a href=\"#getting-the-data\" aria-label=\"getting the data permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Getting the data</h2>\n<p>First, scrape commit messages out of every repo I could find on my machine:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token keyword\">for</span> <span class=\"token for-or-select variable\">repo</span> <span class=\"token keyword\">in</span> ~/projects/*/<span class=\"token punctuation\">;</span> <span class=\"token keyword\">do</span>\n  <span class=\"token function\">git</span> -C <span class=\"token string\">\"<span class=\"token variable\">$repo</span>\"</span> log --all --pretty<span class=\"token operator\">=</span>format:<span class=\"token string\">\"%s\"</span> <span class=\"token operator\">>></span> commits.txt\n  <span class=\"token builtin class-name\">echo</span> <span class=\"token operator\">>></span> commits.txt\n<span class=\"token keyword\">done</span>\n\n<span class=\"token function\">sort</span> -u commits.txt <span class=\"token operator\">></span> commits_clean.txt\n<span class=\"token function\">wc</span> -l commits_clean.txt\n<span class=\"token comment\"># 3124</span></code></pre></div>\n<p>3,124 unique commit messages. That is a <em>tiny</em> dataset by language model standards, which becomes important later.</p>\n<h2 id=\"fine-tuning\" style=\"position:relative;\"><a href=\"#fine-tuning\" aria-label=\"fine tuning permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Fine-tuning</h2>\n<p>The <code class=\"language-text\">transformers</code> repo ships an example script for this — <code class=\"language-text\">run_language_modeling.py</code> (it was called <code class=\"language-text\">run_lm_finetuning.py</code> until a few weeks ago, most tutorials still use the old name). Training on my laptop CPU was hopeless, so like everyone else in 2020 I did it on a free Google Colab GPU:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\">python run_language_modeling.py <span class=\"token punctuation\">\\</span>\n  --model_name_or_path gpt2 <span class=\"token punctuation\">\\</span>\n  --model_type gpt2 <span class=\"token punctuation\">\\</span>\n  --do_train <span class=\"token punctuation\">\\</span>\n  --train_data_file commits_clean.txt <span class=\"token punctuation\">\\</span>\n  --line_by_line <span class=\"token punctuation\">\\</span>\n  --block_size <span class=\"token number\">64</span> <span class=\"token punctuation\">\\</span>\n  --per_gpu_train_batch_size <span class=\"token number\">8</span> <span class=\"token punctuation\">\\</span>\n  --num_train_epochs <span class=\"token number\">3</span> <span class=\"token punctuation\">\\</span>\n  --output_dir gpt2-commits <span class=\"token punctuation\">\\</span>\n  --overwrite_output_dir</code></pre></div>\n<p>That’s the small 124M model. Three epochs over 3k lines takes about ten minutes on the Colab K80.</p>\n<h2 id=\"the-horror-as-promised\" style=\"position:relative;\"><a href=\"#the-horror-as-promised\" aria-label=\"the horror as promised permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The horror, as promised</h2>\n<p>Sampling from the fine-tuned model:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">fix bug in fix\nremove console.log for real this time\nfinal fix (final) v2\nupdate readme to match the code that no longer exists\nWIP: everything\nadd tests, disable tests\nrefactor the refactor\nfix typo in previous typo</code></pre></div>\n<p>I genuinely cannot tell some of these apart from my real commit history, which says more about my commit history than about the model.</p>\n<h2 id=\"what-i-actually-learned\" style=\"position:relative;\"><a href=\"#what-i-actually-learned\" aria-label=\"what i actually learned permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>What I actually learned</h2>\n<p>The interesting part wasn’t the jokes, it was watching what a language model does with a dataset this small:</p>\n<p><strong>It memorizes.</strong> By epoch 3 the training loss was still dropping, but a lot of “generated” samples were my real commit messages, verbatim. With 3k examples the model isn’t learning the <em>style</em> of commit messages so much as compressing my git history into its weights. If I’d trained 10 epochs it would basically be a lossy <code class=\"language-text\">commits.txt</code>. This is the overfitting/memorization tradeoff everyone hand-waves about, visible in ten minutes on a free GPU.</p>\n<p><strong>Temperature is everything.</strong> At low temperature it plays it safe (“fix bug”, “update readme” forever). Around 0.9–1.0 you get the creative nonsense above. Higher and it starts inventing words.</p>\n<p><strong>One epoch was probably enough.</strong> The epoch-1 checkpoint produced messages that were on-style but <em>new</em>. More training made it more accurate and less interesting — for a toy like this, “less overfit” is literally funnier.</p>\n<h2 id=\"next\" style=\"position:relative;\"><a href=\"#next\" aria-label=\"next permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Next</h2>\n<p>I have a half-working <code class=\"language-text\">prepare-commit-msg</code> git hook that asks the model to suggest a message from the diff. It is wrong 100% of the time but with confidence. Maybe a post on that if quarantine lasts long enough.</p>","excerpt":"At the end of the last post I threatened to fine-tune GPT-2 on my own commit messages. Well, we’re all locked at home now, so no more excuses. Getting the data First, scrape commit messages out of every repo I could find…","timeToRead":2,"fields":{"slug":"/i-fine-tuned-gpt-2-on-my-commit-messages-im-sorry/"},"frontmatter":{"title":"I fine-tuned GPT-2 on my commit messages (I'm sorry)","description":"Fine-tuning GPT-2 small on 3,000 of my own git commit messages, with predictable results","path":"I-fine-tuned-GPT-2-on-my-commit messages-(I'm-sorry)","date":"Mar 21, 2020","year":"2020"}},{"id":"ab301b50-efd0-5fb0-9eba-2da813138b10","html":"<p>OpenAI finally released the full 1.5B parameter version of GPT-2 back in November, after spending most of 2019 saying it was “too dangerous” to release. Meanwhile someone built <a href=\"https://aidungeon.io/\">AI Dungeon</a> on top of it and half the internet is playing a text adventure game powered by a neural network. So I figured it was time to poke at it myself.</p>\n<p>The easiest way to run GPT-2 today is the <code class=\"language-text\">transformers</code> library from Hugging Face. No TensorFlow checkpoints, no cloning the OpenAI repo:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\">pip <span class=\"token function\">install</span> transformers torch</code></pre></div>\n<p>Then generating text is a few lines:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">from</span> transformers <span class=\"token keyword\">import</span> GPT2LMHeadModel<span class=\"token punctuation\">,</span> GPT2Tokenizer\n<span class=\"token keyword\">import</span> torch\n\ntokenizer <span class=\"token operator\">=</span> GPT2Tokenizer<span class=\"token punctuation\">.</span>from_pretrained<span class=\"token punctuation\">(</span><span class=\"token string\">\"gpt2-medium\"</span><span class=\"token punctuation\">)</span>\nmodel <span class=\"token operator\">=</span> GPT2LMHeadModel<span class=\"token punctuation\">.</span>from_pretrained<span class=\"token punctuation\">(</span><span class=\"token string\">\"gpt2-medium\"</span><span class=\"token punctuation\">)</span>\n\nprompt <span class=\"token operator\">=</span> <span class=\"token string\">\"Docker Swarm is dead because\"</span>\ninput_ids <span class=\"token operator\">=</span> tokenizer<span class=\"token punctuation\">.</span>encode<span class=\"token punctuation\">(</span>prompt<span class=\"token punctuation\">,</span> return_tensors<span class=\"token operator\">=</span><span class=\"token string\">\"pt\"</span><span class=\"token punctuation\">)</span>\n\noutput <span class=\"token operator\">=</span> model<span class=\"token punctuation\">.</span>generate<span class=\"token punctuation\">(</span>\n    input_ids<span class=\"token punctuation\">,</span>\n    max_length<span class=\"token operator\">=</span><span class=\"token number\">100</span><span class=\"token punctuation\">,</span>\n    do_sample<span class=\"token operator\">=</span><span class=\"token boolean\">True</span><span class=\"token punctuation\">,</span>\n    top_k<span class=\"token operator\">=</span><span class=\"token number\">50</span><span class=\"token punctuation\">,</span>\n    top_p<span class=\"token operator\">=</span><span class=\"token number\">0.95</span><span class=\"token punctuation\">,</span>\n<span class=\"token punctuation\">)</span>\n\n<span class=\"token keyword\">print</span><span class=\"token punctuation\">(</span>tokenizer<span class=\"token punctuation\">.</span>decode<span class=\"token punctuation\">(</span>output<span class=\"token punctuation\">[</span><span class=\"token number\">0</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span> skip_special_tokens<span class=\"token operator\">=</span><span class=\"token boolean\">True</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span></code></pre></div>\n<p>A few notes from a couple of evenings with it:</p>\n<p>The <code class=\"language-text\">gpt2-medium</code> (355M) model runs fine on CPU on my laptop, just slowly — a few seconds per generation. The full <code class=\"language-text\">gpt2-xl</code> (1.5B) wants about 6GB of RAM and is painful without a GPU.</p>\n<p>Sampling parameters matter a lot. Greedy decoding produces repetitive garbage (“the the the”). <code class=\"language-text\">top_k</code> + <code class=\"language-text\">top_p</code> (nucleus sampling) is what makes the output feel coherent.</p>\n<p>And “coherent” is doing a lot of work in that sentence. The text is grammatical and stays on topic for a paragraph or two, then confidently drifts into nonsense. It will invent APIs, quotes, and facts with total confidence. Fun toy, but I wouldn’t put it anywhere near production.</p>\n<p>Still, between this, BERT now running inside Google Search, and Google’s new Meena chatbot paper from a few weeks ago, it feels like NLP is where the interesting stuff is happening right now. The Hugging Face library making it a <code class=\"language-text\">pip install</code> away is a big part of that.</p>\n<p>Next I want to try fine-tuning the small model on my own commit messages and see what horror it produces.</p>","excerpt":"OpenAI finally released the full 1.5B parameter version of GPT-2 back in November, after spending most of 2019 saying it was “too dangerous” to release. Meanwhile someone built AI Dungeon on top of it and half the…","timeToRead":2,"fields":{"slug":"/playing-with-gpt-2-text-generation-in-python/"},"frontmatter":{"title":"Playing with GPT-2 text generation in Python","description":"Running OpenAI's GPT-2 locally with the Hugging Face transformers library","path":"playing-with-GPT-2-text-generation-in-Python","date":"Feb 21, 2020","year":"2020"}},{"id":"d16f5b13-8a2d-5ffb-b0a0-418278b138fe","html":"<p>You can also read more about Docker Swarm on the official documentation <a href=\"https://docs.docker.com/engine/swarm/\">page</a></p>\n<h2 id=\"a-namewhat-is-docker-swarma-what-is-a-docker-swarm-\" style=\"position:relative;\"><a href=\"#a-namewhat-is-docker-swarma-what-is-a-docker-swarm-\" aria-label=\"a namewhat is docker swarma what is a docker swarm  permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a><a name=\"what-is-docker-swarm\"></a>-What is a Docker Swarm ?</h2>\n<p>“Docker Engine 1.12 introduces swarm mode that enables you to create a cluster of one or more Docker Engines called a swarm.\nDocker swarm is a management and orchestration features embedded in the Docker Engine are built using swarmkit. Swarmkit is a separate project which implements Docker’s orchestration layer and is used directly within Docker.”\n<img src=\"https://docs.docker.com/engine/swarm/images/swarm-diagram.png\" alt=\"Folder Structure\"></p>\n<p>Swarm consists of a multiple Docker hosts which are running in swarm mode and act as managers and workers. Manager is managing delegations and memberships. Workers are running the swarm services.</p>\n<h2 id=\"a-namebefore-configa-before\" style=\"position:relative;\"><a href=\"#a-namebefore-configa-before\" aria-label=\"a namebefore configa before permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a><a name=\"before-config\"></a>-Before</h2>\n<p>For example i have 3 separate servers that i would like to run in Docker Swarm mode.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">1.1.1.1\n2.2.2.2\n3.3.3.3</code></pre></div>\n<p>And all of them are connected to the private network <code class=\"language-text\">10.0.0.1/16</code></p>\n<h2 id=\"a-nameconfig-networka-network-configuration\" style=\"position:relative;\"><a href=\"#a-nameconfig-networka-network-configuration\" aria-label=\"a nameconfig networka network configuration permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a><a name=\"config-network\"></a>-Network Configuration</h2>\n<p>This part was only important in my use case. So you can skip networking configuration if it’s not relevant to you.</p>\n<p>All my servers were connected to the private subnet <code class=\"language-text\">10.0.0.1/16</code>. And this was really important that they would stay connected to each other over that network. In this case i needed to change docker ingress <a href=\"https://docs.docker.com/network/overlay/\">network subnet</a> due to the fact that the default ingress subnet has the same subnet.</p>\n<p>When you initialize a swarm or join a Docker host to an existing swarm, two new networks are created on that Docker host:</p>\n<ul>\n<li>an overlay network called ingress, which handles control and data traffic related to swarm services. When you create a swarm service and do not connect it to a user-defined overlay network, it connects to the ingress network by default.</li>\n<li>a bridge network called docker_gwbridge, which connects the individual Docker daemon to the other daemons participating in the swarm.</li>\n</ul>\n<p>Inspect networks:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">docker network inspect ingress</code></pre></div>\n<p>Remove ingress network:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">docker network rm ingress</code></pre></div>\n<p>Create a new ingress network with the subnet <code class=\"language-text\">20.11.0.0/16</code>:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">docker network create \\\n  --driver overlay \\\n  --ingress \\\n  --subnet=20.11.0.0/16 \\\n  --gateway=20.11.0.2 \\\n  --opt com.docker.network.driver.mtu=1200 \\\n  my-ingress</code></pre></div>\n<h2 id=\"a-nameconf-managera-configure-the-manager-node\" style=\"position:relative;\"><a href=\"#a-nameconf-managera-configure-the-manager-node\" aria-label=\"a nameconf managera configure the manager node permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a><a name=\"conf-manager\"></a>-Configure the Manager Node</h2>\n<p>We have decided that <code class=\"language-text\">1.1.1.1</code> will be the manager in our case. On the <code class=\"language-text\">1.1.1.1</code> we will need to initialize swarm mode by running command below:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token function\">docker</span> swarm init --advertise-addr <span class=\"token number\">1.1</span>.1.1</code></pre></div>\n<p>The output will be the command with the worker token that we will need to run on the other servers in order for them to join to the cluster manager <code class=\"language-text\">1.1.1.1</code></p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">docker swarm join --token SWMTKN-1-19a9q3z51lr1qjakg2wfsdhweweasdsjik323zxgmg23-qwe3qfggwe67a22kdl</code></pre></div>\n<h2 id=\"a-nameconf-workera-configure-the-worker-nodes\" style=\"position:relative;\"><a href=\"#a-nameconf-workera-configure-the-worker-nodes\" aria-label=\"a nameconf workera configure the worker nodes permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a><a name=\"conf-worker\"></a>-Configure the Worker Nodes</h2>\n<p>Run this command on each server that should be connected to the Cluster Manager as a Worker</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token function\">docker</span> swarm <span class=\"token function\">join</span> --token SWMTKN-1-19a9q3z51lr1qjakg2wfsdhweweasdsjik323zxgmg23-qwe3qfggwe67a22kdl</code></pre></div>\n<h2 id=\"a-nameverify-nodesa-verify-the-swarm-cluster\" style=\"position:relative;\"><a href=\"#a-nameverify-nodesa-verify-the-swarm-cluster\" aria-label=\"a nameverify nodesa verify the swarm cluster permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a><a name=\"verify-nodes\"></a>-Verify the Swarm Cluster</h2>\n<p>On the  manager node run</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token function\">docker</span> <span class=\"token function\">node</span> <span class=\"token function\">ls</span></code></pre></div>\n<p>The output example:</p>\n<p><img src=\"https://i.imgur.com/eNg9PS6.png\" alt=\"\"></p>\n<p>In case you will loose join tokens you can run the following commands on the manager node:</p>\n<p>To retrieve the manager token run:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">docker swarm join-token manager -q</code></pre></div>\n<p>To retrieve the worker token run:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">docker swarm join-token worker -q</code></pre></div>\n<h2 id=\"a-nameverify-nodesa-deploy-application\" style=\"position:relative;\"><a href=\"#a-nameverify-nodesa-deploy-application\" aria-label=\"a nameverify nodesa deploy application permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a><a name=\"verify-nodes\"></a>-Deploy Application</h2>\n<p>We can use <code class=\"language-text\">docker service</code> or <code class=\"language-text\">docker stack</code>:</p>\n<ul>\n<li>The <code class=\"language-text\">docker stack</code> can be used to manage a multi-service application.</li>\n<li>The <code class=\"language-text\">docker service</code> is used when you need to manage an individual service on a docker swarm cluster.</li>\n</ul>\n<p>We will use <code class=\"language-text\">docker stack</code>. Lets create <code class=\"language-text\">docker-compose-app1.yml</code> file with the following content:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\">version: <span class=\"token string\">\"3\"</span>\n\nservices:\n    app:\n        image: <span class=\"token number\">15734342727</span>.dkr.ecr.us-east-1.amazonaws.com/hello-world:latest\n        restart: always\n        ports:\n            - <span class=\"token number\">8010</span>:80\n        networks:\n            - default</code></pre></div>\n<p>You’ve probably noticed that i’m usng AWS ECR (private registry) and in order for the workers to pull the image from that registry we need to deploy the app with the option <code class=\"language-text\">--with-registry-auth</code> to send registry authentication details to Swarm agents.</p>\n<p>If you are using private registry as well then before deploying the application we need to login to the registry in my case it is AWS ECR.</p>\n<p>Run the following command on the manager node:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">aws ecr get-login --no-include-email</code></pre></div>\n<p>The output will be a <code class=\"language-text\">docker login ...</code> command. You need to execute this command on the cluster manager in order to login to the AWS ECR registry.</p>\n<p>After we can run the following command on the manager node:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token function\">docker</span> stack deploy -c docker-compose-app1.yml --with-registry-auth Hello_World_App</code></pre></div>\n<p>To validate services, run the following command on the manager node:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token function\">docker</span> <span class=\"token function\">service</span> <span class=\"token function\">ls</span></code></pre></div>\n<p>The example output with the 2 applications:\n<img src=\"https://i.imgur.com/D1OWpkf.png\" alt=\"\"></p>\n<p>To scale the app run the following command on the manager node:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token function\">docker</span> <span class=\"token function\">service</span> scale <span class=\"token operator\">&lt;</span>SERVICE-ID<span class=\"token operator\">>=</span><span class=\"token operator\">&lt;</span>NUMBER-OF-TASKS<span class=\"token operator\">></span></code></pre></div>\n<p>For example:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token function\">docker</span> <span class=\"token function\">service</span> scale <span class=\"token assign-left variable\">sjcrh8ra88yf</span><span class=\"token operator\">=</span><span class=\"token number\">5</span></code></pre></div>\n<h3 id=\"cheers\" style=\"position:relative;\"><a href=\"#cheers\" aria-label=\"cheers permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Cheers!</h3>","excerpt":"You can also read more about Docker Swarm on the official documentation page -What is a Docker Swarm ? “Docker Engine 1.12 introduces swarm mode that enables you to create a cluster of one or more Docker Engines called a…","timeToRead":4,"fields":{"slug":"/configer-docker-swarm/"},"frontmatter":{"title":"Configure Docker Swarm","description":"Configure Docker Swarm and run image from AWS ECR","path":"/Configure-Docker-Swarm","date":"Nov 15, 2019","year":"2019"}},{"id":"06650752-892c-5276-9efb-60833281326b","html":"<p>I’m assuming that you as a reader are familiar with <a href=\"https://reactjs.org/docs/getting-started.html\"><strong>ReactJS</strong></a> , <a href=\"https://webpack.js.org/concepts/\"><strong>WebPack</strong></a> and <a href=\"https://babeljs.io\"><strong>Babel</strong></a>.</p>\n<p><a href=\"https://webpack.js.org/concepts/\"><strong>WebPack</strong></a> takes all your modules with dependencies and puts everything in one or more bundles. <a href=\"https://babeljs.io/\"><strong>Babel</strong></a> is helping us to use modern Javascript features in browsers that are not supporting them. <a href=\"https://reactjs.org/docs/getting-started.html\"><strong>ReactJS</strong></a> is using syntax called <a href=\"https://reactjs.org/docs/introducing-jsx.html\"><strong>JSX</strong></a> and <a href=\"https://webpack.js.org/concepts/\"><strong>WebPack</strong></a> needs help to convert <a href=\"https://reactjs.org/docs/introducing-jsx.html\"><strong>JSX</strong></a> to ES5 and to provide this help we will use <a href=\"https://babeljs.io/\"><strong>Babel</strong></a>.</p>\n<p>We all saw a plenty random services that are offering for example chatbots that can be embedded in to your app as a widget.At the end of this tutorial we will have a react app as a widget that can be deployed to a CDN. The output file will be always with the same name <strong>widget.js</strong> and in the future it will be easy to provide latest features, bug fixes and etc to your clients without forcing them to include a different version of the file. You or your client only have to include a script from a url for example ”<a href=\"https://cdn/v1/widget.js\">https://cdn/v1/widget.js</a>” and initialize it with a configuration object.</p>\n<div class=\"gatsby-highlight\" data-language=\"html\"><pre class=\"language-html\"><code class=\"language-html\"><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>script</span> <span class=\"token attr-name\">type</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>text/javascript<span class=\"token punctuation\">\"</span></span> <span class=\"token attr-name\">src</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>https://cdn/v1/widget.js<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span><span class=\"token script\"></span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>script</span><span class=\"token punctuation\">></span></span>\n<span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>div</span> <span class=\"token attr-name\">id</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>chat-app<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>div</span><span class=\"token punctuation\">></span></span>\n<span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>script</span><span class=\"token punctuation\">></span></span><span class=\"token script\"><span class=\"token language-javascript\">\n  ReactChatBot<span class=\"token punctuation\">.</span><span class=\"token function\">config</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span>\n    <span class=\"token literal-property property\">clientKey</span><span class=\"token operator\">:</span> <span class=\"token string\">\"123123123\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token literal-property property\">selector</span><span class=\"token operator\">:</span> <span class=\"token string\">\"#chat-app\"</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  ReactChatBot<span class=\"token punctuation\">.</span>widgets<span class=\"token punctuation\">.</span>myWidget<span class=\"token punctuation\">.</span><span class=\"token function\">new</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token function\">render</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n</span></span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>script</span><span class=\"token punctuation\">></span></span></code></pre></div>\n<p><strong>I’m not going to describe the purpose of each package that we install for this tutorial but i will include source URL where you can read documentation for the specific package.</strong></p>\n<hr>\n<h2 id=\"a-namecreate-react-appa-create-react-app\" style=\"position:relative;\"><a href=\"#a-namecreate-react-appa-create-react-app\" aria-label=\"a namecreate react appa create react app permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a><a name=\"create-react-app\"></a>-Create react app</h2>\n<p>To create react app we can use a really helpful tool <a href=\"https://github.com/facebook/create-react-app\"><strong>create-react-app</strong></a>. You can install   <a href=\"https://github.com/facebook/create-react-app\"><strong>create-react-app</strong></a> by using npm or <a href=\"https://yarnpkg.com/lang/en/docs/install/#mac-stable\"><strong>yarn</strong></a>:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token function\">npm</span> -g i create-react-app\ncreate-react-app react_chat_widget\n<span class=\"token builtin class-name\">cd</span> react_chat_widget</code></pre></div>\n<p>Or create app by using <a href=\"https://medium.com/@maybekatz/introducing-npx-an-npm-package-runner-55f7d4bd282b\"><strong>npx</strong></a> without actually installing <a href=\"https://github.com/facebook/create-react-app\"><strong>create-react-app</strong></a> on to your system:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\">npx create-react-app react_chat_widget\n<span class=\"token builtin class-name\">cd</span> react_chat_widget</code></pre></div>\n<h2 id=\"a-nameinstall-webpacka-install-webpack-dev-dependencies\" style=\"position:relative;\"><a href=\"#a-nameinstall-webpacka-install-webpack-dev-dependencies\" aria-label=\"a nameinstall webpacka install webpack dev dependencies permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a><a name=\"install-webpack\"></a>-Install WebPack Dev Dependencies</h2>\n<p>WebPack is a really great tool with a plenty different and helpful <a href=\"https://webpack.js.org/plugins/\"><strong>plugins</strong></a>. Most of the plugins are already included with the WebPack, we will install those that are not included.</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token function\">yarn</span> <span class=\"token function\">add</span> -D webpack-dev-server webpack-cli css-loader css-loader webpack-merge dotenv-webpack clean-webpack-plugin</code></pre></div>\n<p>Below are all packages that will be used to build and run WebPack configuration files.</p>\n<p><a href=\"https://webpack.js.org/configuration/dev-server/\">webpack-dev-server</a></p>\n<p><a href=\"https://webpack.js.org/api/cli/\">webpack-cli</a></p>\n<p><a href=\"https://github.com/jantimon/html-webpack-plugin\">html-webpack-plugin</a>:</p>\n<blockquote>\n<p>This is a webpack plugin that simplifies creation of HTML files to serve your webpack bundles. This is especially useful for webpack bundles that include a hash in the filename which changes every compilation. You can either let the plugin generate an HTML file for you, supply your own template using lodash templates or use your own loader.</p>\n</blockquote>\n<p><a href=\"https://github.com/webpack-contrib/mini-css-extract-plugin\">mini-css-extract-plugin</a>:</p>\n<blockquote>\n<p>This plugin extracts CSS into separate files. It creates a CSS file per JS file which contains CSS. It supports On-Demand-Loading of CSS and SourceMaps.</p>\n</blockquote>\n<p><a href=\"https://github.com/webpack-contrib/css-loader\">css-loader</a>:</p>\n<blockquote>\n<p>Plugins that helps WebPack to collect CSS from all the CSS files and put it in to a string.</p>\n</blockquote>\n<p><a href=\"https://github.com/survivejs/webpack-merge\">webpack-merge</a>:</p>\n<blockquote>\n<p>Is  used to merger different WebPack configuration files.</p>\n</blockquote>\n<p><a href=\"https://github.com/mrsteele/dotenv-webpack\">dotenv-webpack</a>:</p>\n<blockquote>\n<p>A secure webpack plugin that supports dotenv and other environment variables and only exposes what you choose and use.</p>\n</blockquote>\n<p><a href=\"https://github.com/johnagan/clean-webpack-plugin\">clean-webpack-plugin</a>:</p>\n<blockquote>\n<p>A webpack plugin to remove/clean your build folder(s).</p>\n</blockquote>\n<p><a href=\"https://github.com/webpack-contrib/terser-webpack-plugin\">terser-webpack-plugin</a>:</p>\n<blockquote>\n<p>Plugin is used to minify your JavaScript</p>\n</blockquote>\n<h2 id=\"a-nameinstall-babela-install-babel-dev-dependencies\" style=\"position:relative;\"><a href=\"#a-nameinstall-babela-install-babel-dev-dependencies\" aria-label=\"a nameinstall babela install babel dev dependencies permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a><a name=\"install-babel\"></a>-Install Babel Dev Dependencies</h2>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token function\">yarn</span> <span class=\"token function\">add</span> -D @babel/core @babel/plugin-proposal-class-properties \n@babel/preset-env @babel/preset-react babel-loader</code></pre></div>\n<h2 id=\"-create-babel-configuration-file-babelrc\" style=\"position:relative;\"><a href=\"#-create-babel-configuration-file-babelrc\" aria-label=\" create babel configuration file babelrc permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>-Create Babel configuration file <strong>.babelrc</strong></h2>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token punctuation\">{</span>\n  <span class=\"token string-property property\">\"presets\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span>\n    <span class=\"token string\">\"@babel/preset-env\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token string\">\"@babel/preset-react\"</span>\n  <span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"plugins\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span>\n    <span class=\"token string\">\"@babel/plugin-proposal-class-properties\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token punctuation\">]</span>\n<span class=\"token punctuation\">}</span>\n</code></pre></div>\n<h2 id=\"a-namecreate-webpack-configa-create-webpack-configuration-files\" style=\"position:relative;\"><a href=\"#a-namecreate-webpack-configa-create-webpack-configuration-files\" aria-label=\"a namecreate webpack configa create webpack configuration files permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a><a name=\"create-webpack-config\"></a>-Create WebPack configuration files</h2>\n<p>I prefer to create sepparate folder <strong>build-utils</strong> where i will keep all WebPack configuration files. We will create 4 config files:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">webpack.config.js // input config file\nwebpack.common.js // default configurations\nwebpack.dev.js // development mode\nwebpack.prod.js // production mode</code></pre></div>\n<p><strong>build-utils/webpack.config.js</strong>:</p>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token keyword\">const</span> webpackMerge <span class=\"token operator\">=</span> <span class=\"token function\">require</span><span class=\"token punctuation\">(</span><span class=\"token string\">'webpack-merge'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">const</span> commonConfig <span class=\"token operator\">=</span> <span class=\"token function\">require</span><span class=\"token punctuation\">(</span><span class=\"token string\">'./webpack.common.js'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\nmodule<span class=\"token punctuation\">.</span><span class=\"token function-variable function\">exports</span> <span class=\"token operator\">=</span> <span class=\"token punctuation\">(</span><span class=\"token parameter\"><span class=\"token punctuation\">{</span> env <span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">const</span> envConfig <span class=\"token operator\">=</span> <span class=\"token function\">require</span><span class=\"token punctuation\">(</span><span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">./webpack.</span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span>env<span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token string\">.js</span><span class=\"token template-punctuation string\">`</span></span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token comment\">// merge default configuration with a chosen mode configuration</span>\n  <span class=\"token keyword\">return</span> <span class=\"token function\">webpackMerge</span><span class=\"token punctuation\">(</span>commonConfig<span class=\"token punctuation\">,</span> envConfig<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p><strong>build-utils/webpack.common.js</strong>:</p>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token keyword\">const</span> path <span class=\"token operator\">=</span> <span class=\"token function\">require</span><span class=\"token punctuation\">(</span><span class=\"token string\">'path'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">const</span> webpack <span class=\"token operator\">=</span> <span class=\"token function\">require</span><span class=\"token punctuation\">(</span><span class=\"token string\">'webpack'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">const</span> MiniCssExtractPlugin <span class=\"token operator\">=</span> <span class=\"token function\">require</span><span class=\"token punctuation\">(</span><span class=\"token string\">'mini-css-extract-plugin'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">const</span> HtmlWebpackPlugin <span class=\"token operator\">=</span> <span class=\"token function\">require</span><span class=\"token punctuation\">(</span><span class=\"token string\">'html-webpack-plugin'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">const</span> <span class=\"token punctuation\">{</span> CleanWebpackPlugin <span class=\"token punctuation\">}</span> <span class=\"token operator\">=</span> <span class=\"token function\">require</span><span class=\"token punctuation\">(</span><span class=\"token string\">'clean-webpack-plugin'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">const</span> TerserPlugin <span class=\"token operator\">=</span> <span class=\"token function\">require</span><span class=\"token punctuation\">(</span><span class=\"token string\">'terser-webpack-plugin'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n<span class=\"token keyword\">const</span> publicPath <span class=\"token operator\">=</span> <span class=\"token string\">'/'</span><span class=\"token punctuation\">;</span>\nmodule<span class=\"token punctuation\">.</span>exports <span class=\"token operator\">=</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token comment\">// Entry point, from where all extraction should be made</span>\n    <span class=\"token literal-property property\">entry</span><span class=\"token operator\">:</span> <span class=\"token string\">'./src/index.js'</span><span class=\"token punctuation\">,</span>\n    <span class=\"token comment\">// Init webpack rules to collect js, jsx, css files</span>\n    <span class=\"token literal-property property\">module</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n        <span class=\"token literal-property property\">rules</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span>\n            <span class=\"token punctuation\">{</span>\n                <span class=\"token comment\">// Extract and Transpile ES6+ in to ES5 </span>\n                <span class=\"token literal-property property\">test</span><span class=\"token operator\">:</span> <span class=\"token regex\"><span class=\"token regex-delimiter\">/</span><span class=\"token regex-source language-regex\">\\.(js|jsx)$</span><span class=\"token regex-delimiter\">/</span></span><span class=\"token punctuation\">,</span>\n                <span class=\"token literal-property property\">exclude</span><span class=\"token operator\">:</span> <span class=\"token regex\"><span class=\"token regex-delimiter\">/</span><span class=\"token regex-source language-regex\">node_modules</span><span class=\"token regex-delimiter\">/</span></span><span class=\"token punctuation\">,</span>\n                <span class=\"token literal-property property\">use</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span><span class=\"token string\">'babel-loader'</span><span class=\"token punctuation\">]</span>\n            <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n            <span class=\"token punctuation\">{</span>\n                <span class=\"token comment\">// Extract CSS files</span>\n                <span class=\"token literal-property property\">test</span><span class=\"token operator\">:</span> <span class=\"token regex\"><span class=\"token regex-delimiter\">/</span><span class=\"token regex-source language-regex\">\\.css$</span><span class=\"token regex-delimiter\">/</span></span><span class=\"token punctuation\">,</span>\n                <span class=\"token literal-property property\">use</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span>MiniCssExtractPlugin<span class=\"token punctuation\">.</span>loader<span class=\"token punctuation\">,</span> <span class=\"token string\">\"css-loader\"</span><span class=\"token punctuation\">]</span>\n            <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n        <span class=\"token punctuation\">]</span>\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n    <span class=\"token comment\">// https://webpack.js.org/configuration/output/</span>\n    <span class=\"token literal-property property\">output</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n        <span class=\"token literal-property property\">path</span><span class=\"token operator\">:</span> path<span class=\"token punctuation\">.</span><span class=\"token function\">resolve</span><span class=\"token punctuation\">(</span>__dirname<span class=\"token punctuation\">,</span> <span class=\"token string\">'../dist'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n        <span class=\"token literal-property property\">filename</span><span class=\"token operator\">:</span> <span class=\"token string\">'widget.js'</span><span class=\"token punctuation\">,</span>\n        <span class=\"token literal-property property\">chunkFilename</span><span class=\"token operator\">:</span> <span class=\"token string\">'widget.chunk.js'</span><span class=\"token punctuation\">,</span>\n        <span class=\"token comment\">// Output library name</span>\n<span class=\"gatsby-highlight-code-line\">        <span class=\"token literal-property property\">library</span><span class=\"token operator\">:</span> <span class=\"token string\">'ReactChatBot'</span><span class=\"token punctuation\">,</span></span>        <span class=\"token literal-property property\">libraryTarget</span><span class=\"token operator\">:</span> <span class=\"token string\">'umd'</span><span class=\"token punctuation\">,</span>\n        <span class=\"token literal-property property\">publicPath</span><span class=\"token operator\">:</span> publicPath<span class=\"token punctuation\">,</span>\n        <span class=\"token literal-property property\">libraryExport</span><span class=\"token operator\">:</span> <span class=\"token string\">'default'</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n    <span class=\"token comment\">// https://webpack.js.org/configuration/dev-server/</span>\n    <span class=\"token literal-property property\">devServer</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n        <span class=\"token literal-property property\">contentBase</span><span class=\"token operator\">:</span> path<span class=\"token punctuation\">.</span><span class=\"token function\">join</span><span class=\"token punctuation\">(</span>__dirname<span class=\"token punctuation\">,</span> <span class=\"token string\">'../dist'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n        <span class=\"token literal-property property\">hot</span><span class=\"token operator\">:</span> <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span>\n        <span class=\"token literal-property property\">compress</span><span class=\"token operator\">:</span> <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n    <span class=\"token comment\">// https://webpack.js.org/configuration/plugins/</span>\n    <span class=\"token literal-property property\">plugins</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span>\n        <span class=\"token keyword\">new</span> <span class=\"token class-name\">CleanWebpackPlugin</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n        <span class=\"token keyword\">new</span> <span class=\"token class-name\">HtmlWebpackPlugin</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span>\n            <span class=\"token literal-property property\">template</span><span class=\"token operator\">:</span> <span class=\"token string\">\"./public/index.html\"</span><span class=\"token punctuation\">,</span>\n        <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n        <span class=\"token keyword\">new</span> <span class=\"token class-name\">MiniCssExtractPlugin</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span>\n            <span class=\"token literal-property property\">filename</span><span class=\"token operator\">:</span> <span class=\"token string\">\"widget.css\"</span><span class=\"token punctuation\">,</span>\n            <span class=\"token literal-property property\">chunkFilename</span><span class=\"token operator\">:</span> <span class=\"token string\">\"widget.css\"</span>\n        <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n    <span class=\"token comment\">// https://webpack.js.org/configuration/optimization/</span>\n    <span class=\"token literal-property property\">optimization</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n        <span class=\"token literal-property property\">minimizer</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span>\n            <span class=\"token keyword\">new</span> <span class=\"token class-name\">TerserPlugin</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span>\n                <span class=\"token literal-property property\">parallel</span><span class=\"token operator\">:</span> <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span>\n                <span class=\"token literal-property property\">sourceMap</span><span class=\"token operator\">:</span> <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span>\n                <span class=\"token literal-property property\">terserOptions</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n                    <span class=\"token literal-property property\">output</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n                        <span class=\"token literal-property property\">comments</span><span class=\"token operator\">:</span> <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span>\n                    <span class=\"token punctuation\">}</span>\n                <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n            <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n        <span class=\"token punctuation\">]</span>\n    <span class=\"token punctuation\">}</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p><strong>build-utils/webpack.dev.js</strong>:</p>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token keyword\">const</span> Dotenv <span class=\"token operator\">=</span> <span class=\"token function\">require</span><span class=\"token punctuation\">(</span><span class=\"token string\">'dotenv-webpack'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\nmodule<span class=\"token punctuation\">.</span>exports <span class=\"token operator\">=</span> <span class=\"token punctuation\">{</span> \n    <span class=\"token literal-property property\">mode</span><span class=\"token operator\">:</span> <span class=\"token string\">'development'</span><span class=\"token punctuation\">,</span>\n    <span class=\"token comment\">// eval-source-map - Each module is executed with eval() and a SourceMap </span>\n    <span class=\"token comment\">// is added as a DataUrl to the eval(). Initially it is slow, but it provides </span>\n    <span class=\"token comment\">// fast rebuild speed and yields real files. Line numbers are correctly mapped </span>\n    <span class=\"token comment\">// since it gets mapped to the original code. </span>\n    <span class=\"token comment\">// It yields the best quality SourceMaps for development.</span>\n    <span class=\"token literal-property property\">devtool</span><span class=\"token operator\">:</span> <span class=\"token string\">'eval-source-map'</span><span class=\"token punctuation\">,</span>\n    <span class=\"token literal-property property\">plugins</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span>\n        <span class=\"token keyword\">new</span> <span class=\"token class-name\">Dotenv</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span>\n            <span class=\"token literal-property property\">path</span><span class=\"token operator\">:</span> <span class=\"token string\">'./.env.development'</span><span class=\"token punctuation\">,</span>\n        <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span>\n    <span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n<span class=\"token punctuation\">}</span> </code></pre></div>\n<p><strong>build-utils/webpack.prod.js</strong>:</p>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token keyword\">const</span> Dotenv <span class=\"token operator\">=</span> <span class=\"token function\">require</span><span class=\"token punctuation\">(</span><span class=\"token string\">'dotenv-webpack'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\nmodule<span class=\"token punctuation\">.</span>exports <span class=\"token operator\">=</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token literal-property property\">mode</span><span class=\"token operator\">:</span> <span class=\"token string\">'production'</span><span class=\"token punctuation\">,</span>\n    <span class=\"token comment\">// source-map - A full SourceMap is emitted as a separate file. </span>\n    <span class=\"token comment\">// It adds a reference comment to the bundle so development tools know where to find it.</span>\n    <span class=\"token literal-property property\">devtool</span><span class=\"token operator\">:</span> <span class=\"token string\">'source-map'</span><span class=\"token punctuation\">,</span>\n    <span class=\"token literal-property property\">plugins</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span>\n        <span class=\"token keyword\">new</span> <span class=\"token class-name\">Dotenv</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span>\n            <span class=\"token literal-property property\">path</span><span class=\"token operator\">:</span> <span class=\"token string\">'./.env.production'</span><span class=\"token punctuation\">,</span>\n        <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span>\n    <span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<h2 id=\"a-namecreate-enva-in-the-main-folder-create-2-env-files\" style=\"position:relative;\"><a href=\"#a-namecreate-enva-in-the-main-folder-create-2-env-files\" aria-label=\"a namecreate enva in the main folder create 2 env files permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a><a name=\"create-env\"></a>-In the main folder create 2 env files</h2>\n<p>We are doing this to avoid leaking any sensitive system ENV data into your application. You can pass here any information that your app is requiring</p>\n<p><strong>.env.development</strong>:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">NODE_ENV=development</code></pre></div>\n<p><strong>.env.production</strong>:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">NODE_ENV=production</code></pre></div>\n<h2 id=\"a-namefolder-structa-our-folder-structure\" style=\"position:relative;\"><a href=\"#a-namefolder-structa-our-folder-structure\" aria-label=\"a namefolder structa our folder structure permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a><a name=\"folder-struct\"></a>-Our folder structure</h2>\n<p><img src=\"https://i.imgur.com/rpv1K3Q.png\" alt=\"Folder Structure\"></p>\n<h2 id=\"a-nameconfig-scripta-configure-starting-and-building-scripts-in-packagejson\" style=\"position:relative;\"><a href=\"#a-nameconfig-scripta-configure-starting-and-building-scripts-in-packagejson\" aria-label=\"a nameconfig scripta configure starting and building scripts in packagejson permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a><a name=\"config-script\"></a>-Configure starting and building scripts in <strong>package.json</strong></h2>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token string-property property\">\"scripts\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token string-property property\">\"start\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"webpack-dev-server --config build-utils/webpack.config.js --env.env=dev\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token string-property property\">\"build\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"webpack --config build-utils/webpack.config.js --env.env=prod\"</span>\n<span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></code></pre></div>\n<p>To start our application in development mode:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">yarn start</code></pre></div>\n<p>To build our application for production:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">yarn build</code></pre></div>\n<h2 id=\"a-namebuild-appa-build-app\" style=\"position:relative;\"><a href=\"#a-namebuild-appa-build-app\" aria-label=\"a namebuild appa build app permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a><a name=\"build-app\"></a>-Build App</h2>\n<p>For this example i decided to use <a href=\"https://github.com/LucasBassetti/react-simple-chatbot\">react-chat-bot</a> package. We need to install all required dependencies to run the package in our react app:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token function\">yarn</span> <span class=\"token function\">add</span> react-simple-chatbot styled-components</code></pre></div>\n<p>In <strong>src</strong> folder create a new folder structure</p>\n<p>Create <strong>src/components/chat/index.js</strong>:</p>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token keyword\">import</span> React<span class=\"token punctuation\">,</span> <span class=\"token punctuation\">{</span> useState <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'react'</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">import</span> ChatBot <span class=\"token keyword\">from</span> <span class=\"token string\">'react-simple-chatbot'</span><span class=\"token punctuation\">;</span>\n\n<span class=\"token keyword\">const</span> <span class=\"token function-variable function\">ChatWidget</span> <span class=\"token operator\">=</span> <span class=\"token punctuation\">(</span><span class=\"token parameter\">props</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span><span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">const</span> <span class=\"token punctuation\">[</span> open<span class=\"token punctuation\">,</span> setOpen <span class=\"token punctuation\">]</span> <span class=\"token operator\">=</span> <span class=\"token function\">useState</span><span class=\"token punctuation\">(</span><span class=\"token boolean\">false</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">const</span> <span class=\"token function-variable function\">toggleFloating</span> <span class=\"token operator\">=</span> <span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span><span class=\"token punctuation\">{</span>\n    <span class=\"token function\">setOpen</span><span class=\"token punctuation\">(</span><span class=\"token operator\">!</span>open<span class=\"token punctuation\">)</span>\n  <span class=\"token punctuation\">}</span>\n\n  <span class=\"token keyword\">return</span> <span class=\"token punctuation\">(</span>\n    <span class=\"token operator\">&lt;</span>ChatBot\n      floating<span class=\"token operator\">=</span><span class=\"token punctuation\">{</span><span class=\"token boolean\">true</span><span class=\"token punctuation\">}</span>\n      opened<span class=\"token operator\">=</span><span class=\"token punctuation\">{</span>open<span class=\"token punctuation\">}</span>\n      toggleFloating<span class=\"token operator\">=</span><span class=\"token punctuation\">{</span>toggleFloating<span class=\"token punctuation\">}</span>\n      steps<span class=\"token operator\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">[</span>\n        <span class=\"token punctuation\">{</span>\n          <span class=\"token literal-property property\">id</span><span class=\"token operator\">:</span> <span class=\"token string\">'0'</span><span class=\"token punctuation\">,</span>\n          <span class=\"token literal-property property\">message</span><span class=\"token operator\">:</span> <span class=\"token string\">'Welcome to react chat widget!'</span><span class=\"token punctuation\">,</span>\n          <span class=\"token literal-property property\">trigger</span><span class=\"token operator\">:</span> <span class=\"token string\">'1'</span><span class=\"token punctuation\">,</span>\n        <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n        <span class=\"token punctuation\">{</span>\n          <span class=\"token literal-property property\">id</span><span class=\"token operator\">:</span> <span class=\"token string\">'1'</span><span class=\"token punctuation\">,</span>\n          <span class=\"token literal-property property\">message</span><span class=\"token operator\">:</span> <span class=\"token string\">'Bye!'</span><span class=\"token punctuation\">,</span>\n          <span class=\"token literal-property property\">end</span><span class=\"token operator\">:</span> <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span>\n        <span class=\"token punctuation\">}</span>\n      <span class=\"token punctuation\">]</span><span class=\"token punctuation\">}</span>\n    <span class=\"token operator\">/</span><span class=\"token operator\">></span>\n  <span class=\"token punctuation\">)</span>\n<span class=\"token punctuation\">}</span>\n\n<span class=\"token keyword\">export</span> <span class=\"token keyword\">default</span> ChatWidget<span class=\"token punctuation\">;</span></code></pre></div>\n<p>Edit and remove all unnecessary imports from <strong>src/index.js</strong>:</p>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token keyword\">import</span> React <span class=\"token keyword\">from</span> <span class=\"token string\">'react'</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">import</span> ReactDOM <span class=\"token keyword\">from</span> <span class=\"token string\">'react-dom'</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">import</span> ChatWidget <span class=\"token keyword\">from</span> <span class=\"token string\">'./components/chat'</span>\n\n<span class=\"token keyword\">let</span> init <span class=\"token operator\">=</span> <span class=\"token keyword\">null</span><span class=\"token punctuation\">;</span>\n\n<span class=\"token keyword\">export</span> <span class=\"token keyword\">default</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token function-variable function\">config</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">(</span><span class=\"token parameter\">config</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span><span class=\"token punctuation\">{</span>\n      init <span class=\"token operator\">=</span> config<span class=\"token punctuation\">;</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n  <span class=\"token literal-property property\">widgets</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token literal-property property\">myWidget</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n      <span class=\"token function-variable function\">new</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n        <span class=\"token keyword\">return</span> <span class=\"token punctuation\">{</span>\n          <span class=\"token function-variable function\">render</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n            ReactDOM<span class=\"token punctuation\">.</span><span class=\"token function\">render</span><span class=\"token punctuation\">(</span><span class=\"token operator\">&lt;</span>ChatWidget \n                clientKey<span class=\"token operator\">=</span><span class=\"token punctuation\">{</span>init<span class=\"token punctuation\">.</span>apiKey<span class=\"token punctuation\">}</span>\n                selector<span class=\"token operator\">=</span><span class=\"token punctuation\">{</span>init<span class=\"token punctuation\">.</span>selector<span class=\"token punctuation\">}</span>  \n            <span class=\"token operator\">/</span><span class=\"token operator\">></span><span class=\"token punctuation\">,</span> document<span class=\"token punctuation\">.</span><span class=\"token function\">querySelector</span><span class=\"token punctuation\">(</span>init<span class=\"token punctuation\">.</span>selector<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n          <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n          <span class=\"token function\">unmount</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">{</span>\n            ReactDOM<span class=\"token punctuation\">.</span><span class=\"token function\">unmountComponentAtNode</span><span class=\"token punctuation\">(</span>document<span class=\"token punctuation\">.</span><span class=\"token function\">querySelector</span><span class=\"token punctuation\">(</span>init<span class=\"token punctuation\">.</span>selector<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span> \n          <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n        <span class=\"token punctuation\">}</span>\n      <span class=\"token punctuation\">}</span>\n    <span class=\"token punctuation\">}</span>\n  <span class=\"token punctuation\">}</span>\n<span class=\"token punctuation\">}</span>\n</code></pre></div>\n<p>Here we have method <strong>new()</strong> that creates a new instance and after we only need to call <strong>render()</strong> method that will render our widget and if client needs to remove the widget he can call <strong>unmount()</strong> method. The <strong>config()</strong> is used to pass any external data, in our case as props to a <strong>ChatWidget</strong> component.</p>\n<p>Edit <strong>public/index.html</strong>:</p>\n<div class=\"gatsby-highlight\" data-language=\"html\"><pre class=\"language-html\"><code class=\"language-html\"><span class=\"token doctype\"><span class=\"token punctuation\">&lt;!</span><span class=\"token doctype-tag\">DOCTYPE</span> <span class=\"token name\">html</span><span class=\"token punctuation\">></span></span>\n<span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>html</span> <span class=\"token attr-name\">lang</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>en<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span>\n  <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>head</span><span class=\"token punctuation\">></span></span>\n    <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>meta</span> <span class=\"token attr-name\">charset</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>utf-8<span class=\"token punctuation\">\"</span></span> <span class=\"token punctuation\">/></span></span>\n    <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>meta</span> <span class=\"token attr-name\">name</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>viewport<span class=\"token punctuation\">\"</span></span> <span class=\"token attr-name\">content</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>width=device-width, initial-scale=1<span class=\"token punctuation\">\"</span></span> <span class=\"token punctuation\">/></span></span>\n    <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>meta</span> <span class=\"token attr-name\">name</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>theme-color<span class=\"token punctuation\">\"</span></span> <span class=\"token attr-name\">content</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>#000000<span class=\"token punctuation\">\"</span></span> <span class=\"token punctuation\">/></span></span>\n    <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>meta</span>\n      <span class=\"token attr-name\">name</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>description<span class=\"token punctuation\">\"</span></span>\n      <span class=\"token attr-name\">content</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>Web site created using create-react-app<span class=\"token punctuation\">\"</span></span>\n    <span class=\"token punctuation\">/></span></span>\n    <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>link</span> <span class=\"token attr-name\">rel</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>apple-touch-icon<span class=\"token punctuation\">\"</span></span> <span class=\"token attr-name\">href</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>logo192.png<span class=\"token punctuation\">\"</span></span> <span class=\"token punctuation\">/></span></span>\n  <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>head</span><span class=\"token punctuation\">></span></span>\n  <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>body</span><span class=\"token punctuation\">></span></span>\n    <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>noscript</span><span class=\"token punctuation\">></span></span>You need to enable JavaScript to run this app.<span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>noscript</span><span class=\"token punctuation\">></span></span>\n    <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>div</span> <span class=\"token attr-name\">id</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>chat-app<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>div</span><span class=\"token punctuation\">></span></span>\n  <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>body</span><span class=\"token punctuation\">></span></span>\n<span class=\"gatsby-highlight-code-line\">  <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>script</span><span class=\"token punctuation\">></span></span><span class=\"token script\"><span class=\"token language-javascript\"></span><span class=\"gatsby-highlight-code-line\">    ReactChatBot<span class=\"token punctuation\">.</span><span class=\"token function\">config</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span></span><span class=\"gatsby-highlight-code-line\">        <span class=\"token literal-property property\">clientKey</span><span class=\"token operator\">:</span> <span class=\"token string\">\"123123123\"</span><span class=\"token punctuation\">,</span></span><span class=\"gatsby-highlight-code-line\">        <span class=\"token literal-property property\">selector</span><span class=\"token operator\">:</span> <span class=\"token string\">\"#chat-app\"</span></span><span class=\"gatsby-highlight-code-line\">    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></span><span class=\"gatsby-highlight-code-line\">    ReactChatBot<span class=\"token punctuation\">.</span>widgets<span class=\"token punctuation\">.</span>myWidget<span class=\"token punctuation\">.</span><span class=\"token function\">new</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token function\">render</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span></span></span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>script</span><span class=\"token punctuation\">></span></span></span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>html</span><span class=\"token punctuation\">></span></span></code></pre></div>\n<p>To start app in develoment mode</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token function\">yarn</span> start</code></pre></div>\n<p>To compile a production build run in terminal:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token function\">yarn</span> build</code></pre></div>\n<h2 id=\"a-namecdna-cdn\" style=\"position:relative;\"><a href=\"#a-namecdna-cdn\" aria-label=\"a namecdna cdn permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a><a name=\"cdn\"></a>-CDN</h2>\n<p>Push your project to a github repo and then load your widget as it shown here <a href=\"https://www.jsdelivr.com/\">https://www.jsdelivr.com/</a>. In my case the CDN url is <a href=\"https://cdn.jsdelivr.net/gh/drawrowfly/react-chat-widget/dist/widget.js\">https://cdn.jsdelivr.net/gh/drawrowfly/react-chat-widget/dist/widget.js</a></p>\n<p>Tutorial source code can be found here: <a href=\"https://github.com/drawrowfly/react-chat-widget\"><strong>github</strong></a></p>","excerpt":"I’m assuming that you as a reader are familiar with ReactJS , WebPack and Babel. WebPack takes all your modules with dependencies and puts everything in one or more bundles. Babel is helping us to use modern Javascript…","timeToRead":9,"fields":{"slug":"/building-a-react-app-as-a-widget-using-webpack/"},"frontmatter":{"title":"Building a React App as a widget using WebPack","description":"Building a React App as a widget using WebPack","path":"/Building-a-React-widget-with-WebPack","date":"Nov 08, 2019","year":"2019"}}]}}}