<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Chad Norman: writing</title><description>Cyber security, GRC and GenAI, written from the field.</description><link>https://chadnorman.fyi/</link><language>en-gb</language><atom:link href="https://chadnorman.fyi/rss.xml" rel="self" type="application/rss+xml"/><item><title>Claude Code runs A/B tests on your workflow, and one variable turns them off</title><link>https://chadnorman.fyi/writing/claude-code-silent-ab-tests/</link><guid isPermaLink="true">https://chadnorman.fyi/writing/claude-code-silent-ab-tests/</guid><description>Claude Code runs A/B tests from a remote flag service that release notes never mention. How to see yours, switch them off, and why vendors should say so.</description><pubDate>Sat, 26 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;One day Claude Code’s output changed shape on me. I hadn’t asked for an update or changed how I worked, but the answers came back structured differently from the day before. So I asked Claude why. Between us we traced it to a family of settings with names like &lt;code&gt;tengu_plank_river_frost&lt;/code&gt;, cached in a JSON file in my home directory and set by Anthropic’s servers, not by me.&lt;/p&gt;
&lt;p&gt;Tengu is Claude Code’s internal codename. This morning my &lt;code&gt;~/.claude.json&lt;/code&gt; held 680 of those remote feature values and 11 live experiment assignments, each tagged with the variation I had been put in. None of them appears in a release note.&lt;/p&gt;
&lt;p&gt;This post is for the people who run AI coding tools every day and the people who sign off on them. It shows how to see your own experiments, the one environment variable that switches them off, what switching off costs, and why I think vendors owe us a label on the box.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;claude-code-fetches-its-behaviour-from-a-flag-service&quot; class=&quot;sq-1&quot;&gt;Claude Code fetches its behaviour from a flag service&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Claude Code uses GrowthBook, a feature-flag and experimentation service. At start-up it asks Anthropic’s servers which features are on for you and which experiment arms you are in, then caches the answer in &lt;code&gt;~/.claude.json&lt;/code&gt;. Run this to see yours:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame has-title&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;span class=&quot;title&quot;&gt;Terminal&lt;/span&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;bash&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-function&quot;&gt;jq&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;-r&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;#39;.cachedExperimentData // {} | to_entries[]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span class=&quot;tk-string&quot;&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;| &amp;quot;\(.key)\tvariation \(.value.variationId)&amp;quot;&amp;#39;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;~/.claude.json&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-function&quot;&gt;jq&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;#39;.cachedGrowthBookFeatures | length&amp;#39;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;~/.claude.json&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-function&quot;&gt;jq&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;-r&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;#39;.cachedGrowthBookFeaturesAt / 1000 | todate&amp;#39;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;~/.claude.json&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The first command lists the experiments you are enrolled in. On my machine it printed 11 lines, two of them on variation 1:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame has-title&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;span class=&quot;title&quot;&gt;text&lt;/span&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;text&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-text&quot;&gt;tengu_read_dedup_killswitch   variation 1&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-text&quot;&gt;tengu_plank_river_frost       variation 0&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-text&quot;&gt;tengu_ultrareview_awareness   variation 1&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-text&quot;&gt;...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The second counts the feature values, 680 for me. The third tells you when they were last refreshed.&lt;/p&gt;
&lt;p&gt;The names are deliberately meaningless. &lt;code&gt;tengu_amber_prism&lt;/code&gt; and &lt;code&gt;tengu_ochre_kite&lt;/code&gt; tell you nothing about what they change, so a leaked key name gives nothing away. That’s fair from the vendor’s side. From the user’s side it means the list tells you that you are in an experiment and nothing about what the experiment does.&lt;/p&gt;
&lt;p&gt;Most of what people know about individual flags comes from reverse engineering, helped along when Claude Code’s full source &lt;a href=&quot;https://www.cnbc.com/2026/03/31/anthropic-leak-claude-code-internal-source.html&quot;&gt;shipped by accident in an npm source map&lt;/a&gt; on 31 March 2026. Anthropic called that “a release packaging issue caused by human error, not a security breach”, which is accurate, and it also put the flag names in front of anyone who wanted to read them.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;experiments-have-already-changed-peoples-work&quot; class=&quot;sq-2&quot;&gt;Experiments have already changed people’s work&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Three public cases, each with an Anthropic engineer on the record:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Shorter plans.&lt;/strong&gt; In March 2026 a developer paying $200 a month noticed plan mode had started capping plans at 40 lines. They wrote it up as &lt;a href=&quot;https://backnotprop.com/blog/do-not-ab-test-my-workflow/&quot;&gt;Please Do Not A/B Test My Workflow&lt;/a&gt;. The engineer who ran the test &lt;a href=&quot;https://news.ycombinator.com/item?id=47375682&quot;&gt;replied on Hacker News&lt;/a&gt;. They had expected shorter plans to cut rate-limit hits, early results showed little effect, and they ended the experiment.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Effort levels.&lt;/strong&gt; In August 2026 a Hacker News thread claimed Anthropic was &lt;a href=&quot;https://news.ycombinator.com/item?id=49401549&quot;&gt;A/B testing reduced effort levels&lt;/a&gt;. A Claude Code team member &lt;a href=&quot;https://news.ycombinator.com/item?id=49404033&quot;&gt;answered&lt;/a&gt;: “We sometimes test API serving configs in Claude Code before rolling them out”, and said a running test mapped the numerical effort value differently, but “the effort you selected is the effort you’re getting”.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Project rules.&lt;/strong&gt; Until version 2.1.281 on 24 September 2026, a flag called &lt;code&gt;tengu_agents_md_mod&lt;/code&gt; decided whether Claude Code read a project’s &lt;code&gt;AGENTS.md&lt;/code&gt; at all. &lt;a href=&quot;https://blog.szypowi.cz/p/claude-code-reads-agents.md-only-when-telemetry-is-on/&quot;&gt;A blogger found&lt;/a&gt; that with telemetry off, it never did. More on that one below, because it bites the people who opt out.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I take the engineers at their word. The plan-length test was a reasonable hypothesis, measured and switched off. The effort answer came with a claim of evals behind it. None of this is sinister, and the people running these experiments answered in public, which plenty of vendors wouldn’t do.&lt;/p&gt;
&lt;p&gt;That’s also the problem. Each of these came to light because a user noticed something odd and went digging. Nobody was told in advance.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;an-unannounced-experiment-is-an-unmanaged-change&quot; class=&quot;sq-3&quot;&gt;An unannounced experiment is an unmanaged change&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Every change-management process I have run rests on the same assumption: a tool behaves the same today as it did yesterday unless someone changed it, and that change left a record. A new Claude Code version leaves a record, in the changelog and in the version string. A flag flip leaves nothing. The binary is identical, the version is identical, and the output is different.&lt;/p&gt;
&lt;p&gt;For a practitioner that means a result you can’t reproduce. Two engineers on the same version can get different plans for the same prompt because they sit in different arms, and neither can see which arm the other is in. You debug the prompt, the repository and the model before it occurs to you to check the tool.&lt;/p&gt;
&lt;p&gt;For a leader it’s an audit question with no good answer. If an AI agent writes code that ships, the tool that wrote it is part of your software supply chain. “Which configuration produced this change?” should be answerable. Today the honest answer is “the version we can tell you, the experiment arms we can’t, and the cache that would have told us is overwritten on the next refresh.”&lt;/p&gt;
&lt;aside class=&quot;callout box fill pastel-sky callout--note&quot;&gt;&lt;p class=&quot;callout__label&quot;&gt;&lt;span&gt;&lt;span class=&quot;sr-only&quot;&gt;Note: &lt;/span&gt;What an auditor will ask&lt;/span&gt;&lt;/p&gt;&lt;div class=&quot;callout__body&quot;&gt;&lt;p&gt;If AI-written code reaches production, expect the question every change gets: what changed, who approved it, and can you roll it back. For a remote flag flip, the approval happened at the vendor and you hold no record of it.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;one-environment-variable-switches-the-experiments-off&quot; class=&quot;sq-4&quot;&gt;One environment variable switches the experiments off&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;I had already set &lt;code&gt;DISABLE_TELEMETRY&lt;/code&gt;, &lt;code&gt;DISABLE_ERROR_REPORTING&lt;/code&gt; and &lt;code&gt;DO_NOT_TRACK&lt;/code&gt; in my &lt;code&gt;~/.claude/settings.json&lt;/code&gt;, on the belief that it stopped the experiments. So I checked, in the 2.1.283 binary on my laptop.&lt;/p&gt;
&lt;p&gt;It does, but only two of the three. Claude Code works out a privacy level from the environment:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame has-title&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;span class=&quot;title&quot;&gt;claude 2.1.283 (minified)&lt;/span&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;js&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-keyword&quot;&gt;function&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; &lt;/span&gt;&lt;span class=&quot;tk-function&quot;&gt;Emt&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;(){&lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;(process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC)&lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;essential-traffic&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;(process.env.DISABLE_TELEMETRY)&lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;no-telemetry&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;tk-function&quot;&gt;Le&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;(process.env.DO_NOT_TRACK))&lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;no-telemetry&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;default&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Anything other than &lt;code&gt;default&lt;/code&gt; disables GrowthBook. Every flag lookup then returns the default compiled into the client:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame has-title&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;span class=&quot;title&quot;&gt;claude 2.1.283 (minified)&lt;/span&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;js&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-keyword&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;!this&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;.deps.&lt;/span&gt;&lt;span class=&quot;tk-function&quot;&gt;isEnabled&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;&amp;amp;&amp;amp;!this&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;.deps.&lt;/span&gt;&lt;span class=&quot;tk-function&quot;&gt;isDiskCacheReadableWhileDisabled&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;())&lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;{value:n,source:&lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;disabled&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;};&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The old cache on disk is ignored too, so a stale experiment arm doesn’t linger. Anthropic’s &lt;a href=&quot;https://code.claude.com/docs/en/data-usage&quot;&gt;data usage page&lt;/a&gt; confirms it: setting &lt;code&gt;DISABLE_TELEMETRY&lt;/code&gt; or &lt;code&gt;CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC&lt;/code&gt; “also disables the feature-flag evaluation” that Remote Control depends on. &lt;code&gt;DISABLE_ERROR_REPORTING&lt;/code&gt; doesn’t; it only stops crash reports.&lt;span class=&quot;sidenote-ref&quot; aria-hidden=&quot;true&quot;&gt;&lt;/span&gt;&lt;span class=&quot;sidenote box fill pastel-mint&quot; role=&quot;note&quot;&gt;&lt;span class=&quot;sr-only&quot;&gt;Sidenote: &lt;/span&gt;The check is &lt;code&gt;if(process.env.DISABLE_TELEMETRY)&lt;/code&gt;, so any non-empty value switches telemetry off, including &lt;code&gt;&amp;quot;0&amp;quot;&lt;/code&gt; and &lt;code&gt;&amp;quot;false&amp;quot;&lt;/code&gt;. &lt;code&gt;DO_NOT_TRACK&lt;/code&gt; goes through a parser and needs a true-ish value.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;My own cache agrees. &lt;code&gt;cachedGrowthBookFeaturesAt&lt;/code&gt; reads 20 September 2026 at 12:02 SAST, and a week of daily sessions since has not moved it.&lt;/p&gt;
&lt;p&gt;So the setting is one line:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame has-title&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;span class=&quot;title&quot;&gt;~/.claude/settings.json&lt;/span&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;json&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-text&quot;&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;env&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;DISABLE_TELEMETRY&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;1&amp;quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span class=&quot;tk-text&quot;&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-text&quot;&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;switching-off-has-its-own-costs&quot; class=&quot;sq-1&quot;&gt;Switching off has its own costs&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Opting out doesn’t give you a neutral Claude Code. It gives you whatever Anthropic compiled in as the default, and there are three costs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Defaults still change with every release.&lt;/strong&gt; Each new version can ship new defaults, so behaviour still moves, just on a schedule you can see. If reproducibility matters, pin the version as well: &lt;code&gt;DISABLE_AUTOUPDATER&lt;/code&gt; stops the background update check, and &lt;code&gt;&amp;quot;autoUpdatesChannel&amp;quot;: &amp;quot;stable&amp;quot;&lt;/code&gt; in settings trails the latest release. Both are in the &lt;a href=&quot;https://code.claude.com/docs/en/setup&quot;&gt;setup docs&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Some features stop working.&lt;/strong&gt; Remote Control depends on flag evaluation and goes with it. Channels are gated on &lt;code&gt;tengu_harbor&lt;/code&gt;, which &lt;a href=&quot;https://github.com/anthropics/claude-code/issues/45918&quot;&gt;defaults to off&lt;/a&gt; when flags can’t be fetched.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Opting out can hide changes too.&lt;/strong&gt; The &lt;code&gt;AGENTS.md&lt;/code&gt; case is the sharpest. The flag existed so Anthropic could switch the feature off remotely if it broke something. With telemetry off the flag fell back to its default, off, and Claude Code silently ignored project rules for everyone who had opted out. An Anthropic engineer &lt;a href=&quot;https://news.ycombinator.com/item?id=49815363&quot;&gt;called it&lt;/a&gt; “a rollout artifact, we needed a way to turn this off remotely via feature flags if it broke something, and with telemetry off you don’t get those.” It was &lt;a href=&quot;https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md&quot;&gt;fixed in 2.1.281&lt;/a&gt;. The people who opted out of silent changes got one anyway, and it hit the file that tells an agent what it may and may not do.&lt;/p&gt;
&lt;p&gt;I still keep telemetry off. A default I can pin beats an arm I can’t see. But it’s a trade, and “I set the variable, so the tool is stable” is not true.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;vendors-should-tell-you-which-cohort-you-are-in&quot; class=&quot;sq-2&quot;&gt;Vendors should tell you which cohort you are in&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The best argument against me is that A/B testing is how good products get built. Anthropic can’t evaluate a plan-length change on synthetic prompts alone; it needs real users on real work, and asking every user to opt in shrinks the sample and skews it towards enthusiasts. Anthropic has also been more open than most. Its &lt;a href=&quot;https://www.anthropic.com/engineering/a-postmortem-of-three-recent-issues&quot;&gt;September 2025 postmortem&lt;/a&gt; says “we never reduce model quality due to demand, time of day, or server load”, and its engineers answer in public threads.&lt;/p&gt;
&lt;p&gt;I accept all of that. My ask is smaller than “stop testing”:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Show the cohort.&lt;/strong&gt; A line in &lt;code&gt;/status&lt;/code&gt; listing the experiments this session is enrolled in, by name and arm. The data is already in &lt;code&gt;~/.claude.json&lt;/code&gt;; it just isn’t shown.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Describe the experiment.&lt;/strong&gt; A short public note for any experiment that changes output a user will act on: plans, code, permission prompts, which instruction files get read. UI copy and nudges can stay quiet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Opt in for anything that touches the work product.&lt;/strong&gt; A flag that changes how long a plan is changes what an engineer ships. That deserves a yes from the person paying.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Log it where the user can keep it.&lt;/strong&gt; Write the arm into the session transcript, so the record survives the next cache refresh.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of this is expensive. Most of it is a display of data Claude Code already holds.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;what-to-do-on-monday&quot; class=&quot;sq-3&quot;&gt;What to do on Monday&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Run the three &lt;code&gt;jq&lt;/code&gt; commands above on your own machine and on one teammate’s. If the arms differ, you have your answer to “why does Claude do that for them and not for me?”&lt;/p&gt;
&lt;p&gt;Then decide as a team, not per laptop. If reproducibility matters more than the newest behaviour, set &lt;code&gt;DISABLE_TELEMETRY&lt;/code&gt; and &lt;code&gt;DISABLE_AUTOUPDATER&lt;/code&gt; in the &lt;a href=&quot;https://code.claude.com/docs/en/managed-settings&quot;&gt;managed settings file&lt;/a&gt; so nobody’s local settings override them. On macOS it lives at &lt;code&gt;/Library/Application Support/ClaudeCode/managed-settings.json&lt;/code&gt;, on Linux at &lt;code&gt;/etc/claude-code/managed-settings.json&lt;/code&gt;. Record the Claude Code version in your change log next to the model. If you keep experiments on, write that down as an accepted risk, and check the &lt;code&gt;jq&lt;/code&gt; output first when a result can’t be reproduced.&lt;/p&gt;
&lt;p&gt;I only went looking because the output changed shape. Most people won’t notice, and nothing tells them.&lt;/p&gt;</content:encoded><category>AI agents</category><category>AI governance</category><category>LLM security</category></item><item><title>Just-in-time access shrinks an agent&apos;s window, not its reach</title><link>https://chadnorman.fyi/writing/agent-limits-before-jit-access/</link><guid isPermaLink="true">https://chadnorman.fyi/writing/agent-limits-before-jit-access/</guid><description>Short-lived credentials limit how long an AI agent holds access, not what it can do. Put hard limits in code first, then shorten the tokens.</description><pubDate>Fri, 25 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Picture a support agent that can refund customers up to R500. An attacker opens a ticket with instructions buried in it, the agent reads them, and it refunds R50,000.&lt;/p&gt;
&lt;p&gt;The vendor pitch for this failure is just-in-time access: no standing privileges, a fresh token for every call, expiring in seconds. Now run the same ticket again. The agent asks for a token scoped to “issue refunds”, gets one that lives for 30 seconds, and issues the R50,000 refund in the first two.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Just-in-time credentials shrink how long an agent holds access, not what it can do with it.&lt;/strong&gt; For an agent, the control that matters is a hard limit enforced outside the model: the refund API that rejects anything over R500. JIT versus standing access comes second. It’s worth doing, but after the limit, not instead of it.&lt;/p&gt;
&lt;p&gt;This is for the people wiring agents into business systems and the people signing off on them, at firms where the security team might be one person. It picks up where &lt;a href=&quot;https://chadnorman.fyi/writing/least-autonomy-ai-workflows/&quot;&gt;the least-autonomy post&lt;/a&gt; left off: once you’ve decided a step needs an agent, what actually fences it in.&lt;/p&gt;

&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;a-30-second-token-still-issues-the-refund&quot; class=&quot;sq-1&quot;&gt;A 30-second token still issues the refund&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Security people have two ways to limit the damage a credential can do. One limits it in time: the token expires, so a thief gets a short window. The other limits it in scope: the token, or the system behind it, only allows certain actions with certain values.&lt;/p&gt;
&lt;p&gt;Prompt injection doesn’t steal the credential. It borrows the agent, which already holds the credential legitimately, at the moment it’s using it. However short the token’s life, the attacker’s instruction runs inside it. Only a scope limit changes the outcome.&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; role=&quot;region&quot; aria-label=&quot;Table&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;th&gt;Limits&lt;/th&gt;
&lt;th&gt;Stops the injected R50,000 refund?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Token expires in seconds&lt;/td&gt;
&lt;td&gt;Time&lt;/td&gt;
&lt;td&gt;No. The refund happens inside the window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No standing privileges, token issued per task&lt;/td&gt;
&lt;td&gt;Time&lt;/td&gt;
&lt;td&gt;No. The task is “issue a refund”&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token scoped to the refund tool only&lt;/td&gt;
&lt;td&gt;Which tool&lt;/td&gt;
&lt;td&gt;No. It’s the right tool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refund API rejects amounts over R500&lt;/td&gt;
&lt;td&gt;Value&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refunds only to the card that paid&lt;/td&gt;
&lt;td&gt;Recipient&lt;/td&gt;
&lt;td&gt;Limits it to the original customer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;At most five refunds an hour per agent&lt;/td&gt;
&lt;td&gt;Rate&lt;/td&gt;
&lt;td&gt;Caps the total loss&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;OWASP’s &lt;a href=&quot;https://genai.owasp.org/llmrisk/llm062025-excessive-agency/&quot;&gt;LLM06 Excessive Agency&lt;/a&gt; entry says it directly: implement authorisation in downstream systems rather than relying on an LLM to decide whether an action is allowed. The R500 cap in the prompt is a request. The R500 cap in the refund API is a control.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;a-policy-engine-sees-the-request-never-the-intent&quot; class=&quot;sq-2&quot;&gt;A policy engine sees the request, never the intent&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The next layer of the pitch is “intent-based” authorisation: a policy engine that checks whether the agent’s action matches what it meant to do. It sounds like the answer to injection. It isn’t, because there is no intent to check.&lt;/p&gt;
&lt;p&gt;A policy engine sees a request: the caller, the endpoint, the parameters. When a product says it evaluates intent, the intent is text the model wrote, usually a field like &lt;code&gt;reason: &amp;quot;customer requested refund for damaged goods&amp;quot;&lt;/code&gt;. The model wrote that after reading the attacker’s ticket. It’s attacker-controllable text, the same as everything else the model produces, and an injected ticket can tell the model what reason to give.&lt;/p&gt;
&lt;p&gt;What a policy engine can check reliably is the request itself:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Parameters.&lt;/strong&gt; The amount is under R500. The date range is under 90 days. The record count is under 50.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Recipients.&lt;/strong&gt; The email goes to an address already on the client’s file. The payment goes to an account on the allowlist.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rates.&lt;/strong&gt; No more than a set number of these actions per hour, per agent and per customer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pairing.&lt;/strong&gt; The refund matches an order that exists, for no more than its value.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All four are deterministic. The same request gives the same answer every time, and an attacker can’t talk them round.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;a-second-model-reads-the-same-poisoned-ticket&quot; class=&quot;sq-3&quot;&gt;A second model reads the same poisoned ticket&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Another common design splits the agent into roles: a planner, an inspector that reviews the plan, and an executor that holds the credentials. It helps only when the split removes something the attacker needs.&lt;/p&gt;
&lt;p&gt;If the inspector is another language model, it reads the same ticket the planner read, instructions and all. Models &lt;a href=&quot;https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/&quot;&gt;follow instructions in content&lt;/a&gt;, and they can’t reliably tell yours from an attacker’s. An injection that fooled the planner can carry a second paragraph addressed to the inspector. Two models in series aren’t two controls. They’re one control that runs twice. &lt;strong&gt;The inspector has to be code.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The split that works is the one that takes a leg off the &lt;a href=&quot;https://chadnorman.fyi/notes/lethal-trifecta/&quot;&gt;lethal trifecta&lt;/a&gt;. Simon Willison described it in 2023 as the &lt;a href=&quot;https://simonwillison.net/2023/Apr/25/dual-llm-pattern/&quot;&gt;dual LLM pattern&lt;/a&gt;: a quarantined model reads the untrusted content and has no tools, and a privileged model holds the tools and never sees that content directly. The component that reads the customer’s ticket should hold no credentials at all. It passes structured fields (an order number, a category, an amount) to code, and code decides what happens next.&lt;/p&gt;

&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;an-agent-never-holds-more-than-the-person-it-works-for&quot; class=&quot;sq-4&quot;&gt;An agent never holds more than the person it works for&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Most business agents act for someone: the customer on the chat, the adviser who asked for a summary, the claims clerk working a queue. The service-account habit is to give the agent one broad token that can reach every customer’s records, and then trust the model to only look at the right one.&lt;/p&gt;
&lt;p&gt;Turn that round. The agent gets a token derived from the user it’s acting for, and never more than that user could do. Usually it gets less, because the agent needs read access to one client’s file, not the adviser’s full book. OWASP’s LLM06 guidance says the same: actions taken for a user should run on downstream systems in that user’s context.&lt;/p&gt;
&lt;p&gt;OAuth has a standard for this. &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc8693.html&quot;&gt;RFC 8693, OAuth 2.0 Token Exchange&lt;/a&gt; (January 2020) defines how a service swaps one token for another, including delegation. With delegation the agent keeps its own identity, separate from the user’s, and the token’s &lt;code&gt;act&lt;/code&gt; claim records that the agent is acting for the user. An injected instruction to read another client’s file then fails at the API, because the token doesn’t reach it. The log shows both names: which agent did it, and for whom.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;approval-gates-work-only-when-theyre-rare&quot; class=&quot;sq-1&quot;&gt;Approval gates work only when they’re rare&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Every design in this area ends with a human approval step for risky actions, and OWASP recommends one for high-impact actions. I agree, with one condition: it has to fire rarely.&lt;/p&gt;
&lt;p&gt;An approval prompt that appears forty times a day trains the approver to click yes. The nearest analogy I know is MFA push fatigue, where enough prompts turn a security check into noise and a tired user eventually accepts one. An agent’s approval queue drifts the same way, and nobody has to attack it for that to happen.&lt;/p&gt;
&lt;p&gt;Keep approvals for what can’t be undone or is unusual: a payment over the cap, a first-time recipient, deleting records, anything a client will see before anyone else does. Everything routine sits under a hard limit in code and needs no click. If a gate fires often enough that people stop reading it, move the limit, not the person.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;short-lived-credentials-still-earn-second-place&quot; class=&quot;sq-2&quot;&gt;Short-lived credentials still earn second place&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Here’s the best argument for JIT, and it’s a good one. Agents leak credentials. A token ends up in an environment variable that gets printed to a log, a debug trace, a support bundle, or a compromised MCP server the agent talks to. A token that expired four minutes after it was issued is worth very little to whoever finds it. A standing key that works until someone rotates it is worth a great deal.&lt;/p&gt;
&lt;p&gt;Better still is having no secret to leak. Google Cloud’s &lt;a href=&quot;https://docs.cloud.google.com/iam/docs/workload-identity-federation&quot;&gt;Workload Identity Federation&lt;/a&gt; exists to replace service account keys, which Google itself calls powerful credentials that are a risk when mismanaged, with short-lived access tokens.&lt;/p&gt;
&lt;p&gt;Per-task tokens also fix a real audit problem. When every action runs under one shared service token, the log says the service did it. When each task gets its own token, tied to a user and a ticket, the log answers who asked for what.&lt;/p&gt;
&lt;p&gt;So do it. JIT addresses a real threat: the stolen credential. Prompt injection is a different threat, and the agent doesn’t need to lose its credential for the attacker to use it. Order the work by which threat you’re more likely to meet this year.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;a-five-person-fsp-needs-vendor-limits-not-a-policy-engine&quot; class=&quot;sq-3&quot;&gt;A five-person FSP needs vendor limits, not a policy engine&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Everything above assumes you run the agent’s code. A five-person financial services provider running one agent inside a vendor’s SaaS has no policy engine, no token broker and no developer to build either. That doesn’t leave it with nothing. The proportionate version uses what the vendor already enforces:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The narrowest OAuth scopes the integration offers: read-only where the agent only reads, one mailbox instead of all of them.&lt;/li&gt;
&lt;li&gt;Per-tool limits on the vendor’s side: spend caps, recipient allowlists, a daily action ceiling.&lt;/li&gt;
&lt;li&gt;A separate account for the agent, never a person’s login shared with it.&lt;/li&gt;
&lt;li&gt;Logs that record which user the agent acted for, and someone who reads them.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A vendor that can’t offer any of these is telling you something about how its product will fail.&lt;/p&gt;
&lt;p&gt;An agent’s credential is a service account, whatever the vendor calls it. POPIA section 19 already asks you to take appropriate, reasonable technical and organisational measures against unlawful access to personal information.&lt;span class=&quot;sidenote-ref&quot; aria-hidden=&quot;true&quot;&gt;&lt;/span&gt;&lt;span class=&quot;sidenote box fill pastel-mint&quot; role=&quot;note&quot;&gt;&lt;span class=&quot;sr-only&quot;&gt;Sidenote: &lt;/span&gt;My reading of POPIA as a practitioner, not legal advice. Your compliance officer decides how it applies to your firm.&lt;/span&gt;&lt;/p&gt;

&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;write-the-limit-down-before-you-shorten-the-token&quot; class=&quot;sq-4&quot;&gt;Write the limit down before you shorten the token&lt;/h2&gt;&lt;/div&gt;
&lt;aside class=&quot;callout box fill pastel-butter callout--tip&quot;&gt;&lt;p class=&quot;callout__label&quot;&gt;&lt;span&gt;&lt;span class=&quot;sr-only&quot;&gt;Tip: &lt;/span&gt;This week&lt;/span&gt;&lt;/p&gt;&lt;div class=&quot;callout__body&quot;&gt;&lt;p&gt;List every tool your agent can call. Next to each, write the hard limit the API enforces in code: the maximum amount, the maximum record count, the allowed recipients, the allowed actions. Where the only limit is in the prompt, that’s the first fix. Shorten credential lifetimes after that.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;
&lt;p&gt;Expect a blank next to at least one tool on the first pass. Start with the tool that can move money or send data out of the firm, and put a number on it.&lt;/p&gt;
&lt;p&gt;The R50,000 refund in the opening needed a token for two seconds. A R500 cap in the refund API would have stopped it at the first request.&lt;/p&gt;</content:encoded><category>AI agents</category><category>LLM security</category><category>Zero trust</category></item><item><title>Cloudflare&apos;s free plan is where your first project should live</title><link>https://chadnorman.fyi/writing/cloudflare-free-plan-first-project/</link><guid isPermaLink="true">https://chadnorman.fyi/writing/cloudflare-free-plan-first-project/</guid><description>This blog runs on Cloudflare&apos;s free plan for R0 a month. What a developer starting out gets there, what they still own, and where the deal stops.</description><pubDate>Fri, 25 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The site you’re reading costs R0 a month to host. It’s a static build served from Cloudflare’s edge, with a strict Content Security Policy (CSP), HSTS, a &lt;code&gt;security.txt&lt;/code&gt;, cookieless analytics and email on its own domain. None of that needed a server, and none of it needed a credit card beyond the domain.&lt;/p&gt;
&lt;p&gt;If you’re a developer shipping your first real project, or the security person a developer asks for advice, this is my answer: put it on Cloudflare’s free plan. You get a custom domain, TLS and DDoS protection that you would otherwise build and patch yourself on a VPS. Below is how this blog is set up, what went wrong, what you still own, and where the argument stops holding.&lt;/p&gt;

&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;a-static-site-on-workers-costs-nothing-to-serve&quot; class=&quot;sq-1&quot;&gt;A static site on Workers costs nothing to serve&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;As of September 2026, Cloudflare’s docs say it plainly: &lt;a href=&quot;https://developers.cloudflare.com/workers/static-assets/billing-and-limitations/&quot;&gt;“Requests to static assets are free and unlimited.”&lt;/a&gt; A Worker &lt;em&gt;script&lt;/em&gt; on the free plan gets &lt;a href=&quot;https://developers.cloudflare.com/workers/platform/limits/&quot;&gt;100,000 requests a day&lt;/a&gt;. An assets-only Worker has no script, so that limit never comes into it.&lt;/p&gt;
&lt;p&gt;That is why this site costs nothing to serve. Astro builds every page to a file in &lt;code&gt;dist/&lt;/code&gt;, and Cloudflare serves those files. There is no Worker code, no server-side rendering and nothing that runs per request. The config is short enough to read in one go:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame has-title&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;span class=&quot;title&quot;&gt;wrangler.jsonc&lt;/span&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;jsonc&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-text&quot;&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;chadnorman-fyi&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;account_id&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;&amp;lt;your account id&amp;gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;compatibility_date&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;2026-09-23&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;assets&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;directory&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;./dist/&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;html_handling&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;auto-trailing-slash&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;not_found_handling&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;404-page&amp;quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span class=&quot;tk-text&quot;&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;routes&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;: [{ &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;pattern&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;chadnorman.fyi&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;custom_domain&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;tk-number&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; }],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;workers_dev&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;tk-number&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;&amp;quot;preview_urls&amp;quot;&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;tk-number&quot;&gt;true&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-text&quot;&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;custom_domain&lt;/code&gt; route does more than it looks. When the zone is active on Cloudflare, &lt;a href=&quot;https://developers.cloudflare.com/workers/configuration/routing/custom-domains/&quot;&gt;“Cloudflare will create DNS records and issue necessary certificates on your behalf.”&lt;/a&gt; No certbot cron job, no renewal to forget.&lt;/p&gt;
&lt;p&gt;The only fixed cost is the &lt;code&gt;.fyi&lt;/code&gt; domain, renewed yearly through Cloudflare Registrar, which charges &lt;a href=&quot;https://developers.cloudflare.com/registrar/&quot;&gt;“what is charged by registries and ICANN”&lt;/a&gt; with no markup. &lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;what-the-free-plan-does-for-you-before-you-write-a-line&quot; class=&quot;sq-2&quot;&gt;What the free plan does for you before you write a line&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Here is what came with the zone on the Free plan, and what I would otherwise be running myself:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; role=&quot;region&quot; aria-label=&quot;Table&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You get, on Free&lt;/th&gt;
&lt;th&gt;On a VPS, you would&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://developers.cloudflare.com/ssl/edge-certificates/universal-ssl/&quot;&gt;Universal SSL&lt;/a&gt;: certificates issued and renewed by default&lt;/td&gt;
&lt;td&gt;Run and monitor certbot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://developers.cloudflare.com/ddos-protection/&quot;&gt;“Standard, unmetered DDoS protection (layers 3-7)”&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Hope, or pay someone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The &lt;a href=&quot;https://developers.cloudflare.com/waf/managed-rules/&quot;&gt;Cloudflare Free Managed Ruleset&lt;/a&gt; for “high-impact and widely exploited vulnerabilities”&lt;/td&gt;
&lt;td&gt;Install and tune a WAF&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://developers.cloudflare.com/email-routing/&quot;&gt;Email Routing&lt;/a&gt;, which forwards &lt;code&gt;hello@&lt;/code&gt; and &lt;code&gt;security@chadnorman.fyi&lt;/code&gt; to my inbox&lt;/td&gt;
&lt;td&gt;Run a mail server, or pay for a mailbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.cloudflare.com/web-analytics/&quot;&gt;Web Analytics&lt;/a&gt;, which “does not use any client-side state, such as cookies or localStorage”&lt;/td&gt;
&lt;td&gt;Self-host Plausible or Umami, or put up a cookie banner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A &lt;code&gt;_headers&lt;/code&gt; file for &lt;a href=&quot;https://developers.cloudflare.com/workers/static-assets/headers/&quot;&gt;security headers on every asset&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Edit nginx config&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;And the part that matters most for a security blog: &lt;strong&gt;there is no server for me to patch&lt;/strong&gt;. No SSH port, no OS, no runtime with a CVE next Tuesday. The attack surface of a static site on someone else’s edge is the build pipeline, the account and the content. That’s a much shorter list than a Linux box.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;how-this-site-gets-from-git-to-the-edge&quot; class=&quot;sq-3&quot;&gt;How this site gets from git to the edge&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The pipeline has five steps, all in the repo:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;I write a post as an MDX file and open a pull request.&lt;/li&gt;
&lt;li&gt;GitHub Actions runs type checks, unit tests, and Playwright with axe for accessibility.&lt;/li&gt;
&lt;li&gt;It builds the site. The last step of &lt;code&gt;pnpm build&lt;/code&gt;, &lt;code&gt;scripts/csp.ts&lt;/code&gt;, hashes every inline script and style in the HTML and writes one CSP into &lt;code&gt;dist/_headers&lt;/code&gt;. Markup that would need &lt;code&gt;&amp;#39;unsafe-inline&amp;#39;&lt;/code&gt; fails the build.&lt;/li&gt;
&lt;li&gt;Lighthouse runs against the build. A pull request gets a preview URL from &lt;code&gt;wrangler versions upload&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A merge to &lt;code&gt;main&lt;/code&gt; runs &lt;code&gt;wrangler deploy&lt;/code&gt; with an API token scoped to editing Workers scripts on one account. A daily scheduled run rebuilds &lt;code&gt;main&lt;/code&gt;, so a post dated in the future goes live on its day.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The git log tells the rest. The first commit landed at 01:03 SAST on 25 September 2026, and the launch-hardening phase (headers, CSP, &lt;code&gt;security.txt&lt;/code&gt;) merged at 03:47 the same morning. &lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;what-went-wrong&quot; class=&quot;sq-4&quot;&gt;What went wrong&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Three things cost me time. None of them are in the getting-started guides.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Automatic analytics cost two Lighthouse points.&lt;/strong&gt; Web Analytics’ automatic setup &lt;a href=&quot;https://developers.cloudflare.com/web-analytics/faq/&quot;&gt;“will inject the JS snippet on all pages”&lt;/a&gt; at the edge. That beacon loads from a second origin before first paint, and Lighthouse counts it. The live home page sat at 98 on performance while the same build scored 100 on a preview URL, because previews get no injection. The fix was to switch automatic setup off in the dashboard and load Cloudflare’s JS snippet myself, after the &lt;code&gt;load&lt;/code&gt; event, only on the production host. Performance went back to 100. The cost is that a visitor who leaves before the page finishes loading isn’t counted. When a live score trails a preview’s, check the live HTML for scripts you didn’t put there.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Some steps are dashboard-only.&lt;/strong&gt; Wrangler’s OAuth login deploys Workers fine, but its token cannot edit DNS records or zone rules. The &lt;code&gt;www&lt;/code&gt; to apex redirect, Email Routing and the Web Analytics setup were all clicks in the dashboard. That’s fine for a one-person blog, but it means part of the site’s config lives outside git. Write those steps down somewhere you will find them in a year.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The headers file has a line limit.&lt;/strong&gt; Each line in &lt;code&gt;_headers&lt;/code&gt; has a &lt;a href=&quot;https://developers.cloudflare.com/workers/static-assets/headers/&quot;&gt;2,000 character limit&lt;/a&gt;. A hashed CSP is one long line, and each inline script adds a 50-odd character hash. The line holds about 30 hashes, and this site uses 22 of them. The build script fails loudly past the limit rather than ship a truncated policy, which I would rather find in CI than in a browser console.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;the-defaults-you-get-and-the-ones-you-still-own&quot; class=&quot;sq-1&quot;&gt;The defaults you get, and the ones you still own&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Cloudflare handles the transport and the edge. Everything above that is still yours.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Your account is the whole estate.&lt;/strong&gt; Domain, DNS, hosting, email and analytics all hang off one Cloudflare login. Turn on &lt;a href=&quot;https://developers.cloudflare.com/fundamentals/user-profiles/2fa/&quot;&gt;two-factor authentication&lt;/a&gt;, with a security key if you have one. If someone takes that account, they take your domain and your email with it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scope your API tokens.&lt;/strong&gt; The token in CI should do one job. &lt;a href=&quot;https://developers.cloudflare.com/fundamentals/api/get-started/create-token/&quot;&gt;Cloudflare’s tokens&lt;/a&gt; can be limited by permission, by account or zone, by client IP and by expiry. Use the global API key for nothing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Secrets go in &lt;code&gt;wrangler secret put&lt;/code&gt;, not in config.&lt;/strong&gt; Cloudflare’s own docs say &lt;a href=&quot;https://developers.cloudflare.com/workers/configuration/secrets/&quot;&gt;“Do not use &lt;code&gt;vars&lt;/code&gt; to store sensitive information”&lt;/a&gt;. Keep local secrets in &lt;code&gt;.dev.vars&lt;/code&gt; and keep that file out of git.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Headers and CSP are yours to write.&lt;/strong&gt; The &lt;code&gt;_headers&lt;/code&gt; file applies to static assets only; once you add Worker code, its responses need their own headers. Nobody sets a CSP for you.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pin your dependencies.&lt;/strong&gt; This site pins every npm package exactly, with no &lt;code&gt;^&lt;/code&gt;, and pins each GitHub Action to a commit SHA. The supply chain is the part of a static site that still runs code, at build time, with your deploy token in reach.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you go on to build AI agents on Workers AI, read my note on &lt;a href=&quot;https://chadnorman.fyi/notes/lethal-trifecta/&quot;&gt;the lethal trifecta&lt;/a&gt; before you give one a tool.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;where-the-free-plan-stops-holding&quot; class=&quot;sq-2&quot;&gt;Where the free plan stops holding&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;I would still pick it, but go in knowing where it ends.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dynamic code has a ceiling.&lt;/strong&gt; A Worker script on the free plan gets 100,000 requests a day, resetting at midnight UTC. If you route asset requests through your script with &lt;code&gt;run_worker_first&lt;/code&gt;, those count too, and past the limit they &lt;a href=&quot;https://developers.cloudflare.com/workers/static-assets/billing-and-limitations/&quot;&gt;get a 429 instead of the file&lt;/a&gt;. Static stays free; logic doesn’t.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Size limits.&lt;/strong&gt; A Worker version on the free plan holds &lt;a href=&quot;https://developers.cloudflare.com/workers/platform/limits/&quot;&gt;20,000 static files, each up to 25 MiB&lt;/a&gt;. A blog will never notice. A site with a large image or video archive will.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Email only comes in.&lt;/strong&gt; Email Routing forwards mail; it doesn’t send it. Replying from your own domain needs another service.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The free WAF is narrow.&lt;/strong&gt; The Free Managed Ruleset covers widely exploited vulnerabilities. The fuller Cloudflare Managed Ruleset and the OWASP Core Ruleset are &lt;a href=&quot;https://developers.cloudflare.com/waf/managed-rules/&quot;&gt;on paid plans&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Lock-in is real, and it’s mostly the domain.&lt;/strong&gt; Cloudflare Registrar requires Cloudflare’s nameservers: &lt;a href=&quot;https://developers.cloudflare.com/registrar/faq/&quot;&gt;“all domains on Cloudflare Registrar use Cloudflare nameservers”&lt;/a&gt;. The site itself is portable, because &lt;code&gt;dist/&lt;/code&gt; is plain files that any static host can serve and the posts are MDX in git. The dashboard-only config is not.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One provider is one point of failure.&lt;/strong&gt; Registrar, DNS, hosting, email and analytics on one account means one outage or one compromised login takes all of them. For a personal blog I accept that trade. For a business, I’d at least keep the registrar and the mail provider somewhere else.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;ship-it-this-week&quot; class=&quot;sq-3&quot;&gt;Ship it this week&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Here’s the first step, sized for a weekend:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Build your project to static files, whatever the framework.&lt;/li&gt;
&lt;li&gt;Add a &lt;code&gt;wrangler.jsonc&lt;/code&gt; with an &lt;code&gt;assets&lt;/code&gt; block and a &lt;code&gt;custom_domain&lt;/code&gt; route, and run &lt;code&gt;wrangler deploy&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In the dashboard, turn on 2FA for your account, check the Free Managed Ruleset is deployed, and switch on Web Analytics.&lt;/li&gt;
&lt;li&gt;Add a &lt;code&gt;_headers&lt;/code&gt; file with HSTS, &lt;code&gt;X-Content-Type-Options: nosniff&lt;/code&gt; and a CSP, then check the result with your browser’s developer tools.&lt;/li&gt;
&lt;li&gt;Give CI a token scoped to one account and one job.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That’s a real, secure, fast site for the price of a domain. Small bill. No server.&lt;/p&gt;</content:encoded><category>Cloud security</category></item><item><title>Hello world</title><link>https://chadnorman.fyi/writing/hello-world/</link><guid isPermaLink="true">https://chadnorman.fyi/writing/hello-world/</guid><description>A sample post that shows how posts render on this site. It will be replaced.</description><pubDate>Fri, 25 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This is a sample post. It shows how each part of a post renders on this site, from the title and date down to the footnotes, and it will be replaced by a real post.&lt;/p&gt;
&lt;p&gt;It says nothing about security, GRC or GenAI. Each section below uses one kind of element, so the page doubles as a visual test.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;text-and-links&quot; class=&quot;sq-1&quot;&gt;Text and links&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A paragraph of body text sits at a comfortable line length. It can hold a &lt;a href=&quot;https://chadnorman.fyi/&quot;&gt;link to the home page&lt;/a&gt;, some &lt;strong&gt;bold text&lt;/strong&gt;, a little &lt;em&gt;italic text&lt;/em&gt; and &lt;code&gt;inline code&lt;/code&gt;. A keyboard shortcut looks like &lt;kbd class=&quot;kbd&quot;&gt;Ctrl&lt;/kbd&gt; + &lt;kbd class=&quot;kbd&quot;&gt;K&lt;/kbd&gt;.&lt;/p&gt;
&lt;p&gt;A sidenote adds an aside without breaking the flow of the paragraph it belongs to. On a wide screen it sits in the right margin; on a narrow one it appears as a box in the text.&lt;span class=&quot;sidenote-ref&quot; aria-hidden=&quot;true&quot;&gt;&lt;/span&gt;&lt;span class=&quot;sidenote box fill pastel-mint&quot; role=&quot;note&quot;&gt;&lt;span class=&quot;sr-only&quot;&gt;Sidenote: &lt;/span&gt;This is a sidenote. It holds a short aside that would clutter the main text.&lt;/span&gt;&lt;/p&gt;
&lt;div class=&quot;heading heading--h3&quot;&gt;&lt;h3 id=&quot;lists&quot;&gt;Lists&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;An unordered list:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The first item in a list&lt;/li&gt;
&lt;li&gt;A second item, long enough to wrap onto another line on a narrow screen so the indent shows&lt;/li&gt;
&lt;li&gt;A third item&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A bold lead-in.&lt;/strong&gt; Then a short line.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;An ordered list:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Write the post as an MDX file&lt;/li&gt;
&lt;li&gt;Commit it and push&lt;/li&gt;
&lt;li&gt;Read it on the site&lt;/li&gt;
&lt;/ol&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;code&quot; class=&quot;sq-2&quot;&gt;Code&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A code block with a file name shows the name in its window bar. Every code block has a copy button.&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame has-title&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;span class=&quot;title&quot;&gt;src/lib/reading-time.ts&lt;/span&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;ts&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-keyword&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; WORDS_PER_MINUTE &lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; &lt;/span&gt;&lt;span class=&quot;tk-number&quot;&gt;230&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-comment tk-italic&quot;&gt;/** Minutes to read `text`: words / 230, rounded up, at least 1. */&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-keyword&quot;&gt;export&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; &lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;function&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; &lt;/span&gt;&lt;span class=&quot;tk-function&quot;&gt;readingTime&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;(text&lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; string)&lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; number {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; words &lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; text.&lt;/span&gt;&lt;span class=&quot;tk-function&quot;&gt;split&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;/\s&lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;tk-function&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;(Boolean).length;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; Math.&lt;/span&gt;&lt;span class=&quot;tk-function&quot;&gt;max&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;tk-number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;, Math.&lt;/span&gt;&lt;span class=&quot;tk-function&quot;&gt;ceil&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt;(words &lt;/span&gt;&lt;span class=&quot;tk-keyword&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; WORDS_PER_MINUTE));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-text&quot;&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A code block without a file name shows its language instead:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame has-title&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;span class=&quot;title&quot;&gt;bash&lt;/span&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;bash&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-function&quot;&gt;pnpm&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;install&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-function&quot;&gt;pnpm&lt;/span&gt;&lt;span class=&quot;tk-text&quot;&gt; &lt;/span&gt;&lt;span class=&quot;tk-string&quot;&gt;dev&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div class=&quot;heading heading--h3&quot;&gt;&lt;h3 id=&quot;long-lines&quot;&gt;Long lines&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;A long line scrolls inside its block rather than widening the page:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame has-title&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;span class=&quot;title&quot;&gt;text&lt;/span&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;text&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;tk-text&quot;&gt;This line is deliberately long so that it runs past the edge of the code block on a narrow screen and has to scroll sideways.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;callouts&quot; class=&quot;sq-3&quot;&gt;Callouts&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Callouts come in four types. Each one has an icon and a label.&lt;/p&gt;
&lt;aside class=&quot;callout box fill pastel-sky callout--note&quot;&gt;&lt;p class=&quot;callout__label&quot;&gt;&lt;span&gt;Note&lt;/span&gt;&lt;/p&gt;&lt;div class=&quot;callout__body&quot;&gt;&lt;p&gt;A note adds context that the reader may want but does not need.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;
&lt;aside class=&quot;callout box fill pastel-butter callout--tip&quot;&gt;&lt;p class=&quot;callout__label&quot;&gt;&lt;span&gt;Tip&lt;/span&gt;&lt;/p&gt;&lt;div class=&quot;callout__body&quot;&gt;&lt;p&gt;A tip suggests a better way to do something.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;
&lt;aside class=&quot;callout box fill pastel-peach callout--warning&quot;&gt;&lt;p class=&quot;callout__label&quot;&gt;&lt;span&gt;&lt;span class=&quot;sr-only&quot;&gt;Warning: &lt;/span&gt;Check before you continue&lt;/span&gt;&lt;/p&gt;&lt;div class=&quot;callout__body&quot;&gt;&lt;p&gt;A warning flags something that can go wrong. This one has its own title.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;
&lt;aside class=&quot;callout box fill pastel-pink callout--danger&quot;&gt;&lt;p class=&quot;callout__label&quot;&gt;&lt;span&gt;Danger&lt;/span&gt;&lt;/p&gt;&lt;div class=&quot;callout__body&quot;&gt;&lt;p&gt;A danger callout marks a step that can lose data or break something.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;figures-and-tables&quot; class=&quot;sq-4&quot;&gt;Figures and tables&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A figure is an image with a caption, in an outlined card.&lt;/p&gt;
&lt;figure class=&quot;figure box&quot;&gt;&lt;img src=&quot;https://chadnorman.fyi/_astro/render-pipeline.BctDQOmZ.svg&quot; alt=&quot;Diagram of three boxes joined by arrows: an MDX file named index.mdx, then the astro build step, then the output page index.html.&quot; width=&quot;1320&quot; height=&quot;520&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;figcaption&gt;A post starts as an MDX file, and the build turns it into an HTML page.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;A table scrolls sideways on a narrow screen:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; role=&quot;region&quot; aria-label=&quot;Table&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Element&lt;/th&gt;
&lt;th&gt;Written as&lt;/th&gt;
&lt;th&gt;Rendered as&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Heading&lt;/td&gt;
&lt;td&gt;&lt;code&gt;## Heading&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A heading with an anchor link&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Callout&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;Callout type=&amp;quot;tip&amp;quot;&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A coloured box with an icon and a label&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sidenote&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;Sidenote&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A note in the margin, or a box in the text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Footnote&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[^1]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A numbered note at the end of the post&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;quotes-and-footnotes&quot; class=&quot;sq-1&quot;&gt;Quotes and footnotes&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A blockquote renders as a pull quote:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A pull quote repeats one line from the post in large type.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A footnote marker sits in the text&lt;sup&gt;&lt;a href=&quot;https://chadnorman.fyi/writing/hello-world/#user-content-fn-1&quot; id=&quot;user-content-fnref-1&quot; data-footnote-ref=&quot;true&quot; aria-describedby=&quot;footnote-label&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; and links to its note at the end of the post. Each note links back to its marker.&lt;sup&gt;&lt;a href=&quot;https://chadnorman.fyi/writing/hello-world/#user-content-fn-2&quot; id=&quot;user-content-fnref-2&quot; data-footnote-ref=&quot;true&quot; aria-describedby=&quot;footnote-label&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;section data-footnotes=&quot;true&quot; class=&quot;footnotes&quot;&gt;&lt;h2 class=&quot;sr-only&quot; id=&quot;footnote-label&quot;&gt;Footnotes&lt;/h2&gt;
&lt;ol&gt;
&lt;li id=&quot;user-content-fn-1&quot;&gt;
&lt;p&gt;This is the first footnote. &lt;a href=&quot;https://chadnorman.fyi/writing/hello-world/#user-content-fnref-1&quot; data-footnote-backref aria-label=&quot;Back to reference 1&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&quot;user-content-fn-2&quot;&gt;
&lt;p&gt;This is the second footnote. It is a little longer, to show how a footnote wraps. &lt;a href=&quot;https://chadnorman.fyi/writing/hello-world/#user-content-fnref-2&quot; data-footnote-backref aria-label=&quot;Back to reference 2&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;</content:encoded><category>Threat modelling</category></item><item><title>Give your AI workflows the least autonomy that works</title><link>https://chadnorman.fyi/writing/least-autonomy-ai-workflows/</link><guid isPermaLink="true">https://chadnorman.fyi/writing/least-autonomy-ai-workflows/</guid><description>Autonomy is a permission you grant a model, not a feature it has. Five levels for AI workflows, and how to pick the lowest one each step can live with.</description><pubDate>Fri, 25 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In July 2025, SaaStr founder Jason Lemkin told Replit’s AI agent, in plain language, to freeze all changes to the app he was building. &lt;a href=&quot;https://www.theregister.com/2025/07/21/replit_saastr_vibe_coding_incident/&quot;&gt;It deleted his production database anyway&lt;/a&gt;, then told him a rollback was impossible. It wasn’t.&lt;/p&gt;
&lt;p&gt;Replit’s fix a few days later was not a better prompt. It &lt;a href=&quot;https://www.theregister.com/2025/07/22/replit_saastr_response/&quot;&gt;separated development and production databases automatically&lt;/a&gt;, so the agent could no longer reach live data at all.&lt;/p&gt;
&lt;p&gt;The freeze lived in the prompt, and a prompt is a request. The fix lived in the permissions, and a permission is a control. That’s least privilege, and it applies to models the same way it applies to people and service accounts. Every step in an AI workflow should get the least autonomy that does the job.&lt;/p&gt;
&lt;p&gt;This post is for the people building those workflows and the people signing off on them. It sets out five levels of autonomy, what goes wrong at each, and how to pick the lowest level each step can live with.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;autonomy-is-a-permission-not-a-feature&quot; class=&quot;sq-1&quot;&gt;Autonomy is a permission, not a feature&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Anthropic’s own guidance on &lt;a href=&quot;https://www.anthropic.com/engineering/building-effective-agents&quot;&gt;building effective agents&lt;/a&gt; draws the line cleanly. In a &lt;em&gt;workflow&lt;/em&gt;, code decides the path and calls a model along the way. In an &lt;em&gt;agent&lt;/em&gt;, the model decides its own next step and which tools to use. Their advice is to find the simplest solution that works and add complexity only when it earns its keep. That’s advice from the people selling the model.&lt;/p&gt;
&lt;p&gt;OWASP makes the same point from the security side. Its &lt;a href=&quot;https://genai.owasp.org/llmrisk/llm062025-excessive-agency/&quot;&gt;LLM06 Excessive Agency&lt;/a&gt; entry names three root causes: too much functionality, too many permissions and too much autonomy. All three are decisions someone made when wiring the thing up.&lt;/p&gt;
&lt;p&gt;So for each step in a workflow, ask two questions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Who decides what happens next?&lt;/strong&gt; Code, a person or the model.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What can the model touch?&lt;/strong&gt; Which data it reads, which systems it writes to, and whether anything leaves the building.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The answers place the step on a ladder.&lt;/p&gt;
&lt;figure class=&quot;figure box&quot;&gt;&lt;img src=&quot;https://chadnorman.fyi/_astro/autonomy-ladder.BjaN2r4b.svg&quot; alt=&quot;Five pastel boxes rising left to right: 1 code only, 2 model advises and person acts, 3 code runs and model does one job, 4 model decides fenced, 5 model decides everything. An arrow below runs from &apos;code decides&apos; to &apos;model decides&apos;. Levels 1 to 4 sit over a band labelled production; level 5 sits over a band labelled sandbox.&quot; width=&quot;1320&quot; height=&quot;860&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;figcaption&gt;Each step up hands the model more of the decisions. Levels 1 to 4 belong in production; level 5 belongs in a sandbox.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;level-1-code-decides-and-nothing-guesses&quot; class=&quot;sq-2&quot;&gt;Level 1: code decides, and nothing guesses&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Plain code, webhooks, or the fixed branches of an n8n or Zapier flow. No model anywhere.&lt;/p&gt;
&lt;p&gt;It’s predictable, cheap per run, fast, and an auditor can read it. It’s also brittle. When a supplier renames a field in their API, the flow breaks.&lt;/p&gt;
&lt;p&gt;People count that as the weakness. I count it as the strength: &lt;strong&gt;level 1 fails loudly&lt;/strong&gt;. You find out the day it breaks, not three weeks later when someone reconciles the books.&lt;/p&gt;
&lt;p&gt;Payments, access provisioning, database sync and anything a regulator will ask you to explain belong here. If you can write the rule as a plain &lt;code&gt;if&lt;/code&gt; statement, write it that way. A model adds cost and a new way to be wrong, and it takes away the one thing you had.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;level-2-the-model-advises-a-person-acts&quot; class=&quot;sq-3&quot;&gt;Level 2: the model advises, a person acts&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Someone pastes a policy, a contract or a log into a chat assistant, reads what comes back, and decides what to do with it. The model has no tools and no credentials. Nothing runs unless a person runs it.&lt;/p&gt;
&lt;p&gt;This level often gets drawn above “code plus AI” on autonomy ladders, because it feels more like AI. It’s lower. Nothing executes on its own, so the model decides nothing.&lt;/p&gt;
&lt;p&gt;The risks are real but different in kind:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Data leaves.&lt;/strong&gt; Whatever gets pasted in goes to the provider. Know which tier of which product your staff use, and what its terms say about retention and training.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Review turns into rubber-stamping.&lt;/strong&gt; A person who approves forty drafts a day stops reading draft forty-one. Human review is a control only while the human is actually reviewing.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It’s the right level for policy analysis, first drafts, code review and thinking out loud. It doesn’t scale, and that’s fine: plenty of the work at a small firm happens a few times a week, not a few thousand.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;level-3-code-runs-the-flow-the-model-does-one-narrow-job&quot; class=&quot;sq-4&quot;&gt;Level 3: code runs the flow, the model does one narrow job&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This is where most production AI should live. Code owns the flow, the routing and every write. It calls the model for one bounded task: pull the fields out of an invoice, classify a support email, summarise a call note, draft a reply. Then code checks the result before the next step runs.&lt;/p&gt;
&lt;p&gt;When the model gets it wrong, the error stops at that step. The output fails schema validation, or a business rule catches it (the invoice total doesn’t match the line items), or low confidence sends it to a person’s queue.&lt;/p&gt;
&lt;p&gt;Two failure modes to design for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Valid but wrong.&lt;/strong&gt; A schema check passes a well-formed JSON object with the wrong amount in it. Validate the values against something you trust, not just the shape.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The output is the action.&lt;/strong&gt; When a model’s words reach a client without review, the words are what you did. Air Canada learnt this when its chatbot invented a bereavement refund policy; the tribunal held the airline &lt;a href=&quot;https://www.canlii.org/en/bc/bccrt/doc/2024/2024bccrt149/2024bccrt149.html&quot;&gt;responsible for everything on its website&lt;/a&gt;, chatbot included. No tool access was needed for that to cost money.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Prompt injection still matters here. A supplier’s PDF can carry instructions, and the model may follow them. But at level 3 the worst it can do is corrupt one output, which code then checks. It can’t pick a new tool or a new recipient, because it doesn’t choose either.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;level-4-the-model-decides-inside-a-fence&quot; class=&quot;sq-1&quot;&gt;Level 4: the model decides, inside a fence&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Now the model chooses its own next step: which document to search, which query to run, whether it has enough to answer. That’s what makes it an agent, and it’s worth it for work where the steps can’t be written down in advance, such as a multi-step investigation or a support case that branches differently every time.&lt;/p&gt;
&lt;p&gt;The fence is what makes this level safe enough for production, and all of it lives outside the model:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Narrow tools.&lt;/strong&gt; A search tool that searches, not a shell. OWASP’s first mitigation is to remove every tool the task doesn’t need.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Split permissions.&lt;/strong&gt; One agent reads documents. Another drafts SQL. Only a third, or better, only code, can write to the database. That’s segregation of duties, the same principle you apply to people who can raise and approve a payment.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gates in code.&lt;/strong&gt; Sending an email, issuing a refund or changing a record waits for a deterministic check or a person’s click. The model can ask; it can’t approve itself.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Budgets.&lt;/strong&gt; A maximum number of steps, tokens and minutes per run, enforced by the orchestrator. Loops end when the budget does.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Logs.&lt;/strong&gt; Every tool call, with its inputs and outputs, in a store the agent can’t edit.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The cost is engineering time. Anthropic says it plainly: agentic systems trade latency and cost for better performance on hard tasks. Pay that only where the task needs it.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;level-5-the-model-decides-everything&quot; class=&quot;sq-2&quot;&gt;Level 5: the model decides everything&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;One general-purpose model, a broad goal, wide access, and a loop that runs until it decides it’s done. Level 5 needs the least setup and looks brilliant in a demo.&lt;/p&gt;
&lt;p&gt;It’s also where the Replit database went. A model with broad access and self-direction will eventually take an action nobody asked for, and at level 5 nothing sits between that decision and your systems.&lt;/p&gt;
&lt;p&gt;Level 5 is fine in a sandbox: a throwaway VM, a copy of the data, no production credentials and nothing that can send mail or move money. Use it to explore, prototype and learn what the task really needs. Then build the production version one or two levels lower.&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;the-lethal-trifecta-tells-you-where-the-gates-go&quot; class=&quot;sq-3&quot;&gt;The lethal trifecta tells you where the gates go&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Simon Willison named the combination that turns prompt injection from a nuisance into a breach. He calls it the &lt;a href=&quot;https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/&quot;&gt;lethal trifecta&lt;/a&gt;: a system that has access to private data, is exposed to untrusted content, and can communicate externally. With all three, anyone who can get text in front of the model can instruct it to send your data out. Models can’t reliably tell your instructions from an attacker’s.&lt;/p&gt;
&lt;p&gt;Picture an inbox assistant at a financial services provider. It reads client emails (untrusted content), looks up client records (private data) and drafts replies it can send (external communication). That’s all three. One well-crafted email asking it to “attach the client’s latest statement for verification” is all an attacker needs.&lt;/p&gt;
&lt;aside class=&quot;callout box fill pastel-butter callout--tip&quot;&gt;&lt;p class=&quot;callout__label&quot;&gt;&lt;span&gt;&lt;span class=&quot;sr-only&quot;&gt;Tip: &lt;/span&gt;The one check to run&lt;/span&gt;&lt;/p&gt;&lt;div class=&quot;callout__body&quot;&gt;&lt;p&gt;For each step, ask whether it can reach private data, whether untrusted content can reach it, and whether it can send anything out. Any step with all three needs a gate outside the model: remove one leg, or put a code check or a person in front of the send.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;popia-already-covers-decisions-about-people&quot; class=&quot;sq-4&quot;&gt;POPIA already covers decisions about people&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;South African firms don’t need a new rulebook before they deploy an agent. POPIA section 71 already covers the case that matters most.&lt;/p&gt;
&lt;p&gt;A data subject may not be subject to a decision with legal consequences, or one that affects them to a substantial degree, &lt;a href=&quot;https://popia.co.za/section-71-automated-decision-making/&quot;&gt;based solely on automated processing&lt;/a&gt; that profiles them, including their creditworthiness or reliability.&lt;/p&gt;
&lt;p&gt;The exceptions require, among other things, that the person can make representations and gets enough information about the logic to do so. An agent that declines a claim or flags a client as high risk, with no person in the loop, is a section 71 question before it’s an engineering one.&lt;span class=&quot;sidenote-ref&quot; aria-hidden=&quot;true&quot;&gt;&lt;/span&gt;&lt;span class=&quot;sidenote box fill pastel-mint&quot; role=&quot;note&quot;&gt;&lt;span class=&quot;sr-only&quot;&gt;Sidenote: &lt;/span&gt;This is a practitioner’s reading of the text, not legal advice. Take a real automated decision to your compliance officer or attorney.&lt;/span&gt;&lt;/p&gt;
&lt;div class=&quot;heading heading--h2&quot;&gt;&lt;h2 id=&quot;start-low-and-climb-with-a-reason&quot; class=&quot;sq-1&quot;&gt;Start low and climb with a reason&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Take one AI workflow you run today, or the one you’re about to build. List its steps, and for each one write down:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot; role=&quot;region&quot; aria-label=&quot;Table&quot; tabindex=&quot;0&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Write down&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Who decides the next step?&lt;/td&gt;
&lt;td&gt;Code, a person or the model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What can it read?&lt;/td&gt;
&lt;td&gt;The data sets, and whether any are personal information&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What can it change or send?&lt;/td&gt;
&lt;td&gt;Systems written to; anything that leaves the firm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trifecta?&lt;/td&gt;
&lt;td&gt;Private data, untrusted content, external communication: how many of the three&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Level&lt;/td&gt;
&lt;td&gt;1 to 5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Then apply three rules. Anything at level 5 moves down or into a sandbox. Anything at level 4 with all three legs of the trifecta gets a gate in code. Anything that makes a decision about a client gets a person, or a documented section 71 exception.&lt;/p&gt;
&lt;p&gt;Most steps will land at level 1 or 3. That isn’t a failure of ambition. Predictable is what a system your clients rely on should be.&lt;/p&gt;
&lt;p&gt;Replit’s freeze was a sentence in a prompt. The control that would have saved the database was a permission the agent never had. Put your controls there.&lt;/p&gt;</content:encoded><category>AI agents</category><category>LLM security</category><category>AI governance</category></item></channel></rss>