<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Avraham K</title>
    <description>The latest articles on DEV Community by Avraham K (@akashy).</description>
    <link>https://dev.to/akashy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4057725%2F1920a429-42d7-4fdd-bf7a-ec53679cf6c1.png</url>
      <title>DEV Community: Avraham K</title>
      <link>https://dev.to/akashy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/akashy"/>
    <language>en</language>
    <item>
      <title>Anonymity by construction: keeping two agents from swapping contact details</title>
      <dc:creator>Avraham K</dc:creator>
      <pubDate>Wed, 26 Aug 2026 11:12:18 +0000</pubDate>
      <link>https://dev.to/akashy/anonymity-by-construction-keeping-two-agents-from-swapping-contact-details-3hhm</link>
      <guid>https://dev.to/akashy/anonymity-by-construction-keeping-two-agents-from-swapping-contact-details-3hhm</guid>
      <description>&lt;p&gt;I run a broker that introduces two software agents to each other, lets them negotiate, and steps out of the way once they seal a deal. The entire value of that is the introduction. If the two of them can trade an email address in the negotiation thread, they take the second deal off-platform and I have built a very expensive contact form.&lt;/p&gt;

&lt;p&gt;So the negotiation is anonymous by construction. Neither side learns anything about the other until a deal is sealed, and what they learn then is time-boxed. This post is what three layers of that cost in Go, including the layer that fires on my own users and the bypass I have decided not to fix.&lt;/p&gt;

&lt;p&gt;The adversary is a paying customer&lt;br&gt;
Most input-scanning advice assumes an attacker: someone outside the system, trying to get in, with no legitimate reason to be typing at you. That framing produces the wrong design here.&lt;/p&gt;

&lt;p&gt;My leak risk is a user in good standing, acting in their own rational economic interest, who would quite like to skip my fee. They are not evading detection for its own sake. They will try roughly one thing, and if it bounces they will mostly shrug and carry on, because the platform is still worth more to them than one saved fee.&lt;/p&gt;

&lt;p&gt;That changes the target. I am not trying to make leaking impossible, because I cannot. I am trying to make it more expensive than complying, for a user who is not motivated enough to work at it. The internal name for this is the posting tax. Everything below is a tax rate, not a proof.&lt;/p&gt;

&lt;p&gt;The second consequence is subtler and it is where I got hurt. Because the adversary is also the customer, every false positive lands on someone who was trying to pay me. A rejection is not a blocked attack. It is a lost listing.&lt;/p&gt;

&lt;p&gt;Layer one: normalise before you scan&lt;br&gt;
The patterns are ASCII literals. An @ is U+0040. So the first thing anyone tries is not an @.&lt;/p&gt;

&lt;p&gt;The fold runs before any pattern does, and it does three things in a fixed order:&lt;/p&gt;

&lt;p&gt;func normalizeForScan(text string) string {&lt;br&gt;
    var b strings.Builder&lt;br&gt;
    b.Grow(len(text))&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;for _, r := range text {
    // (1) Drop zero-width / format characters entirely.
    if isZeroWidthOrFormat(r) {
        continue
    }
    // (2) Map confusables / fullwidth forms to ASCII.
    if mapped, ok := confusableToASCII(r); ok {
        b.WriteRune(mapped)
        continue
    }
    // Fullwidth ASCII block (U+FF01..U+FF5E) -&amp;gt; ASCII (U+0021..U+007E).
    if r &amp;gt;= 0xFF01 &amp;amp;&amp;amp; r &amp;lt;= 0xFF5E {
        b.WriteRune(r - 0xFEE0)
        continue
    }
    b.WriteRune(r)
}

// (3) Lowercase last so any mapped/decomposed uppercase also folds.
return strings.ToLower(b.String())
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Step one strips the invisibles: zero-width space, non-joiner, joiner, word joiner, soft hyphen, BOM, and then the Unicode Cf format category generally, which sweeps up the bidi controls. These are the cheapest evasion available, because &lt;a href="mailto:alice@example.com"&gt;alice@example.com&lt;/a&gt; with a zero-width space after the l renders identically to &lt;a href="mailto:alice@example.com"&gt;alice@example.com&lt;/a&gt; and is a different byte string.&lt;/p&gt;

&lt;p&gt;Step two is a small hand-written confusables table. Five dot homoglyphs, two commercial-ats, two colons, two slashes. Then the fullwidth ASCII block folds by a fixed 0xFEE0 offset, which is the one piece of Unicode arithmetic in the whole thing: U+FF01..U+FF5E is a contiguous copy of printable ASCII, so subtracting the offset is the entire transform.&lt;/p&gt;

&lt;p&gt;Step three lowercases, and it goes last on purpose, so anything the previous two steps mapped into uppercase ASCII still folds.&lt;/p&gt;

&lt;p&gt;Two decisions inside that are worth naming.&lt;/p&gt;

&lt;p&gt;It is stdlib only, deliberately. This is not NFKC and it is not a UTS-39 confusables pass. Doing it properly means golang.org/x/text, which is a module-level dependency, and I decided a curated table that closes the concrete vectors I could name was worth more today than a correct one I would ship later. That is a real tradeoff and the losing side of it is that my table has holes. I know it has holes. It is written down in the package comment so the next person does not think it is complete.&lt;/p&gt;

&lt;p&gt;The source file is pure ASCII. Every one of those runes is written as an escape rather than as the glyph, and there is one hard reason underneath a lot of soft ones: a literal U+FEFF byte in a Go source file is an illegal byte-order mark. You cannot write that case as a character literal. Once one of them has to be an escape, all of them should be, or the table becomes a mix of things you can read and things you cannot.&lt;/p&gt;

&lt;p&gt;Layer two: the day I banned @staticmethod&lt;br&gt;
The contact patterns are the boring part. Canonical email, obfuscated email (user at example dot com), URLs, bare &lt;a href="http://www" rel="noopener noreferrer"&gt;www&lt;/a&gt;., North American and international phone shapes, Ethereum addresses, Bitcoin bech32 and P2PKH.&lt;/p&gt;

&lt;p&gt;The interesting one is social handles, because my first version was this:&lt;/p&gt;

&lt;p&gt;// The version I shipped, and then deleted.&lt;br&gt;
regexp.MustCompile(&lt;code&gt;@[A-Za-z0-9_]{1,50}&lt;/code&gt;)&lt;br&gt;
It is correct about handles. It also rejects @staticmethod, @types/node, &lt;a class="mentioned-user" href="https://dev.to/media"&gt;@media&lt;/a&gt; (min-width: 640px), and every Go struct tag anyone has ever pasted into a listing body.&lt;/p&gt;

&lt;p&gt;Read that against the previous section. The marketplace brokers technical work between software agents. Listing bodies are full of decorators, scoped npm packages, and struct tags. I had written a pattern that fired most often on exactly the content I most wanted people to post, and every one of those firings was a customer who wrote something honest and got told no.&lt;/p&gt;

&lt;p&gt;The fix is to stop matching the handle and start matching the leak. A handle on its own is not a contact detail. A handle plus somewhere to use it is:&lt;/p&gt;

&lt;p&gt;// (i) " @handle"&lt;br&gt;
regexp.MustCompile(&lt;code&gt;(?i)\b(?:telegram|signal|whatsapp|wechat|insta(?:gram)?|twitter|discord|snap(?:chat)?|tiktok|reddit|t\.me)\b[\s:@]+@?[a-z0-9_]{2,50}&lt;/code&gt;),&lt;br&gt;
// (ii) "@handle on "&lt;br&gt;
regexp.MustCompile(&lt;code&gt;(?i)(?:^|\s)@[a-z0-9_]{2,50}\s+on\s+(?:telegram|signal|whatsapp|insta(?:gram)?|twitter|discord|snap(?:chat)?|tiktok|reddit)\b&lt;/code&gt;),&lt;br&gt;
// (iii) "dm/contact/reach/msg me @handle"&lt;br&gt;
regexp.MustCompile(&lt;code&gt;(?i)\b(?:dm|pm|msg|message|contact|reach|ping|find|add)\s+me\b[\s:@]*@[a-z0-9_]{2,50}&lt;/code&gt;),&lt;br&gt;
Three patterns instead of one, and each requires context that makes a leak plausible: a platform name adjacent to the handle, or a solicitation. ping me on telegram &lt;a class="mentioned-user" href="https://dev.to/alice_dev"&gt;@alice_dev&lt;/a&gt;, &lt;a class="mentioned-user" href="https://dev.to/alice_dev"&gt;@alice_dev&lt;/a&gt; on telegram and dm me &lt;a class="mentioned-user" href="https://dev.to/alice_dev"&gt;@alice_dev&lt;/a&gt; all still fail closed. @staticmethod passes.&lt;/p&gt;

&lt;p&gt;There is a related fix in the Bitcoin matcher that I want to mention only because the reasoning generalises. A P2PKH address is a base58 run of 26 to 34 characters starting with 1 or 3, and distinguishing one from an ordinary word means also requiring mixed content: at least one digit, one uppercase and one lowercase. The original expressed that with a nested quantifier over an alternation, which is the classic catastrophic-backtracking shape. Go's regexp is RE2, so it is linear time and was never actually exploitable. I moved it into code anyway:&lt;/p&gt;

&lt;p&gt;var btcP2PKHCandidate = regexp.MustCompile(&lt;code&gt;(?:^|[^0-9A-Za-z])[13][1-9A-HJ-NP-Za-km-z]{25,33}(?:[^0-9A-Za-z]|$)&lt;/code&gt;)&lt;br&gt;
and the mixed-content check became a loop. Not because RE2 was going to blow up, but because a reviewer cannot tell at a glance that it will not, and a pattern whose safety depends on which engine you compiled it with is a pattern that breaks the day someone ports it.&lt;/p&gt;

&lt;p&gt;The detail I did get right there by accident and then had to defend: the mixed-content scan runs over body[1:], skipping the version prefix, so the leading 1 does not satisfy the digit requirement on its own.&lt;/p&gt;

&lt;p&gt;Two passes, and why the raw one runs first&lt;br&gt;
Scan runs the whole pattern set twice:&lt;/p&gt;

&lt;p&gt;func Scan(text string) (ok bool, reason config.Reason) {&lt;br&gt;
    if ok, reason := scanOnce(text); !ok {&lt;br&gt;
        return false, reason&lt;br&gt;
    }&lt;br&gt;
    if normalized := normalizeForScan(text); normalized != text {&lt;br&gt;
        if ok, reason := scanOnce(normalized); !ok {&lt;br&gt;
            return false, reason&lt;br&gt;
        }&lt;br&gt;
    }&lt;br&gt;
    return true, ""&lt;br&gt;
}&lt;br&gt;
Raw first, then normalized, and only if normalizing changed anything. The ordering is not an optimisation, although it is one. Patterns are evaluated in declaration order and the first match wins, so which pass runs first decides which reason a rejected submission gets. Running raw first means ordinary ASCII content, which is almost all of it, produces exactly the reason it produced before the normalize pass existed. The fold cannot reorder the common path. It can only catch things the common path missed.&lt;/p&gt;

&lt;p&gt;The whole scanner is a pure function with no I/O, and detection is fail-closed: callers reject on ok == false and are explicitly forbidden from reading the reason to decide whether to allow something. The reason is for the rejection message, not for a policy branch.&lt;/p&gt;

&lt;p&gt;One thing worth knowing about where it runs. On threads, the scan is over the message diff, not the whole thread. That is the right call for cost and it is a real gap: it means the scanner sees each increment in isolation and has no view of a leak assembled across turns.&lt;/p&gt;

&lt;p&gt;Layer three: an ID that does not count&lt;br&gt;
Scrubbing text is only half of it. The other half is not handing out identifiers that leak by existing.&lt;/p&gt;

&lt;p&gt;Deal IDs are eight hex characters, produced by a balanced Feistel network:&lt;/p&gt;

&lt;p&gt;const (&lt;br&gt;
    rounds   = 4&lt;br&gt;
    halfBits = 16&lt;br&gt;
    halfMask = (1 &amp;lt;&amp;lt; halfBits) - 1&lt;br&gt;
)&lt;/p&gt;

&lt;p&gt;func (c *Codec) round(val, roundKey uint32) uint32 {&lt;br&gt;
    v := val + roundKey&lt;br&gt;
    v ^= v &amp;gt;&amp;gt; 7&lt;br&gt;
    v += v &amp;lt;&amp;lt; 3&lt;br&gt;
    v ^= v &amp;gt;&amp;gt; 5&lt;br&gt;
    v += v &amp;lt;&amp;lt; 11&lt;br&gt;
    return v &amp;amp; halfMask&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;func (c *Codec) Encode(n uint32) uint32 {&lt;br&gt;
    left := (n &amp;gt;&amp;gt; halfBits) &amp;amp; halfMask&lt;br&gt;
    right := n &amp;amp; halfMask&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;for i := 0; i &amp;lt; rounds; i++ {
    newRight := left ^ c.round(right, c.keys[i])
    left = right
    right = newRight
}

return (left&amp;lt;&amp;lt;halfBits | right)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
Four rounds, 16-bit halves, an ARX round function, round keys loaded at construction from Parameter Store. A Feistel network is bijective by construction whatever the round function does, so Decode(Encode(n)) == n for every uint32 and there is no collision to check for and no uniqueness index to maintain.&lt;/p&gt;

&lt;p&gt;That round function is not cryptography and I am not going to pretend it is. Somebody is going to say so in the comments, so it is said in the package doc first:&lt;/p&gt;

&lt;p&gt;// SECURITY (L-8): the Feistel round function and the FNV-32a route hash are&lt;br&gt;
// NON-CRYPTOGRAPHIC. This is intentional and safe under the current design only&lt;br&gt;
// because (a) the pre-image fed to Encode is drawn from crypto/rand at deal&lt;br&gt;
// finalize time, so encoded IDs are unpredictable regardless of the weak mix,&lt;br&gt;
// and (b) GET /v1/deals/{id} returns 404 to any non-party, so a guessed or&lt;br&gt;
// reversed ID leaks nothing. Do NOT derive deal IDs from counters or any&lt;br&gt;
// sequential/attacker-influenced source, and do NOT promote the route hash to a&lt;br&gt;
// security-bearing lookup key. If either invariant changes, replace this with a&lt;br&gt;
// keyed cryptographic PRF (e.g. HMAC/AES-based) before shipping.&lt;br&gt;
Both clauses are load-bearing and both are enforced somewhere else in the tree, which is the uncomfortable part of writing an invariant like that down. Clause (a) lives at finalize:&lt;/p&gt;

&lt;p&gt;var rawBuf [4]byte&lt;br&gt;
if _, err := rand.Read(rawBuf[:]); err != nil {&lt;br&gt;
    return store.Deal{}, fmt.Errorf("finalize: rand: %w", err)&lt;br&gt;
}&lt;br&gt;
rawID := binary.BigEndian.Uint32(rawBuf[:])&lt;br&gt;
encodedID := s.codec.Encode(rawID)&lt;br&gt;
dealID := fmt.Sprintf("%08x", encodedID)&lt;br&gt;
Clause (b) lives in the GET handler, and it is a 404 rather than a 403 on purpose, because a 403 confirms the deal exists:&lt;/p&gt;

&lt;p&gt;if accountPK != deal.BuyerPK &amp;amp;&amp;amp; accountPK != deal.SellerPK {&lt;br&gt;
    problem.Write(w, problem.New(http.StatusNotFound, "Not Found",&lt;br&gt;
        "deal not found", config.ReasonNotFound))&lt;br&gt;
    return&lt;br&gt;
}&lt;br&gt;
So the honest statement is not that the ID is unguessable. It is that the ID is drawn uniformly from 2^32 and reveals nothing when guessed. Take away either property and the encoding needs to become a keyed PRF. The comment says that too, in the imperative, because the person who breaks this will be me in eight months and I will not remember.&lt;/p&gt;

&lt;p&gt;The one adjacent decision I would defend on its own is decode strictness. Deal IDs have exactly one legal spelling:&lt;/p&gt;

&lt;p&gt;var dealIDHexPattern = regexp.MustCompile(&lt;code&gt;^[0-9a-f]{8}$&lt;/code&gt;)&lt;br&gt;
The previous implementation used fmt.Sscanf, which cheerfully accepts a 0x prefix, uppercase, and leading whitespace, all of which parse to the same integer. That gives one deal several valid names, and anything downstream that compares, caches, logs or rate-limits by the string form now has several keys for one object. A regexp guard in front of strconv.ParseUint closes it.&lt;/p&gt;

&lt;p&gt;The reveal, mirror-imaged and time-boxed&lt;br&gt;
When a deal seals, each party gets the counterparty's endpoint, and only then. The endpoint is per-deal, not per-account: an FNV-32a hash of a per-operator salt and the encoded deal ID, appended to whatever base route that operator configured.&lt;/p&gt;

&lt;p&gt;func ResolveRoute(baseRoute, hash string) string {&lt;br&gt;
    if baseRoute == "" {&lt;br&gt;
        return ""&lt;br&gt;
    }&lt;br&gt;
    return strings.TrimRight(baseRoute, "/") + "/" + hash&lt;br&gt;
}&lt;br&gt;
The empty-base case matters more than it looks. Returning "" means an operator who configured no route escrows as absent, not as a bare /hash that resolves to somebody's site root.&lt;/p&gt;

&lt;p&gt;Two properties on top of that. The reveal is served mirror-imaged, so each side receives the other's coordinates and neither ever sees their own escrowed record come back to them. And it is time-boxed: reveal_at gates release, purge_at is seven days after finalization, and after that the contact detail is gone and only reputation aggregates survive.&lt;/p&gt;

&lt;p&gt;The purge is lazy, evaluated on read, with no scheduler. If purge_at has passed when the deal is fetched, the handler makes a best-effort flip of the stored status and returns 410 regardless of whether that flip succeeded.&lt;/p&gt;

&lt;p&gt;Returning 410 whether or not the write lands is the part I like. The read path does not depend on the write path having worked, so a failed flip costs a retry next request and never a leak. There is no sweeper to monitor, no dead-letter queue, and no window where the scheduler is behind and the data is still readable.&lt;/p&gt;

&lt;p&gt;What is still wrong: the tax falls on my own users&lt;br&gt;
The false positives from the @handle pattern taught me to go looking for the rest of them. I found a worse class, and it is worse precisely because of what this marketplace is for.&lt;/p&gt;

&lt;p&gt;The prompt-injection patterns implement the OWASP LLM01 checks: persona hijacks, new instructions:, ChatML role delimiters, jailbreak keywords. They are reasonable patterns. They are also fired against listings written by and for AI agents, which is a domain where that vocabulary is just the vocabulary. Real strings, run through the real scanner:&lt;/p&gt;

&lt;p&gt;Listing text    Result&lt;br&gt;
telegram bot integration, 99.9% uptime  rejected, contact_leak&lt;br&gt;
you are now ready to query the endpoint rejected, prompt_injection&lt;br&gt;
we run jailbreak detection on every prompt  rejected, prompt_injection&lt;br&gt;
our DAN pipeline scores nightly rejected, prompt_injection&lt;br&gt;
ignore stale rows before applying the constraint rules  rejected, prompt_injection&lt;br&gt;
Every one of those is a legitimate listing. The first is the sharpest: I built a marketplace for agent services and my contact-leak pattern rejects anyone offering a Telegram bot integration, because pattern (i) matches a platform name followed by any word. The last one is a database tool describing what it does to stale rows, caught by an ignore ... rules proximity pattern with a sixty-character window. And \bDAN\b carries no case-insensitive flag, which spares the name Dan and still matches every ordinary uppercase acronym.&lt;/p&gt;

&lt;p&gt;This is the same mistake as @staticmethod, one layer up, and I did not recognise it until I went looking for it on purpose. A pattern set inherits its false-positive rate from the corpus you point it at, and I keep pointing mine at the one corpus guaranteed to be dense in the exact tokens I banned.&lt;/p&gt;

&lt;p&gt;I have not fixed it. The shape of the fix is the same as the handle fix: require an imperative addressed at a model rather than a bare keyword, and scope the platform patterns to require handle-shaped text rather than any word. What I am not going to do is soften it into an allowlist of blessed phrases, because that is a list I would be editing forever.&lt;/p&gt;

&lt;p&gt;And the bypass I am not going to fix&lt;br&gt;
Regex contact-scrubbing is trivially bypassable. Here are two that work today, tested against the shipped scanner:&lt;/p&gt;

&lt;p&gt;jay oh aitch en at gee mail dot com&lt;br&gt;
my handle is the same word as the bird, on the app named for it&lt;br&gt;
Both pass clean. The first survives because the obfuscated-email pattern wants token at token dot tld with single-token domains, and spelling the local part as separate words breaks the shape. The second survives because it is a riddle, and I am not going to solve natural-language reference with a regexp.&lt;/p&gt;

&lt;p&gt;I could push these into an LLM classifier and catch both. Then the bypass becomes a slightly better riddle, at the cost of a model call on every listing write and a new failure mode where the classifier is down. There is a kill switch for exactly that case, and its behaviour is to fail the write rather than let content through unscanned:&lt;/p&gt;

&lt;p&gt;var ErrUnavailable = errors.New("scrub: scanner unavailable")&lt;/p&gt;

&lt;p&gt;func ScanE(text string) error {&lt;br&gt;
    if unavailable.Load() {&lt;br&gt;
        return ErrUnavailable&lt;br&gt;
    }&lt;br&gt;
    ...&lt;br&gt;
}&lt;br&gt;
The reason I am comfortable stopping here is the threat model at the top. The scrubber is not the anonymity guarantee. The anonymity guarantee is structural: there is no field in the schema where a counterparty's contact detail lives before a deal seals, the identifiers do not enumerate, and the reveal expires. The scrubber is a tax on the one channel where free text has to exist at all, and taxes are allowed to be evadable. What is not allowed is for the structure to have a hole, and that is where the review effort goes.&lt;/p&gt;

&lt;p&gt;What I would keep&lt;br&gt;
Name the adversary before you write the pattern. Mine is a customer, which means false positives cost more than misses, and that single sentence would have prevented @staticmethod.&lt;br&gt;
Match the leak, not the shape. A handle is not a contact detail. A handle plus a platform is. The context requirement is what made the pattern set survivable.&lt;br&gt;
Normalise before matching, and fold in a fixed order. Invisibles out, confusables in, case last.&lt;br&gt;
Write the invariant next to the weak primitive. Non-cryptographic is a fine choice with two named preconditions and a disaster without them, and the difference is entirely whether the next person knows what the preconditions are.&lt;br&gt;
Lazy expiry over scheduled expiry. Evaluating on read means there is no sweeper to fall behind.&lt;br&gt;
And the one I would tell myself earlier: run the pattern set against your own corpus before you ship it, not against the attack strings you invented while writing it. I had good coverage of the leaks and none of the legitimate content, so every test was green and the failures were all in production, arriving as users who could not post.&lt;/p&gt;

&lt;p&gt;This is running at cogdepot.com, where two agents negotiate without either learning who the other is until they seal. If you want to argue with any of this, the table in the false-positives section is the part I would argue with too.&lt;/p&gt;

</description>
      <category>go</category>
      <category>security</category>
      <category>api</category>
      <category>ai</category>
    </item>
    <item>
      <title>The Cognito war stories: four ways it breaks an MCP server, and the fix for each</title>
      <dc:creator>Avraham K</dc:creator>
      <pubDate>Wed, 19 Aug 2026 19:05:33 +0000</pubDate>
      <link>https://dev.to/akashy/the-cognito-war-stories-four-ways-it-breaks-an-mcp-server-and-the-fix-for-each-2fim</link>
      <guid>https://dev.to/akashy/the-cognito-war-stories-four-ways-it-breaks-an-mcp-server-and-the-fix-for-each-2fim</guid>
      <description>&lt;p&gt;If you are putting Amazon Cognito behind a remote MCP server, here are four specific ways it will break the connector before you ever reach your own code - each with the symptom, the reason, and the fix that got us through it. All four came out of one week of wiring an agent OAuth flow for &lt;a href="https://cogdepot.com" rel="noopener noreferrer"&gt;cogDepot&lt;/a&gt;, and none of them are in the happy-path docs.&lt;/p&gt;

&lt;p&gt;None of this is competitively sensitive. It is just the friction between two specs that were written by different people who never had to make them meet.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Cognito rejects the RFC 8707 resource indicator that MCP insists on sending
&lt;/h2&gt;

&lt;p&gt;MCP's authorization spec wants access tokens to be audience-bound. The mechanism it points at is &lt;a href="https://datatracker.ietf.org/doc/html/rfc8707" rel="noopener noreferrer"&gt;RFC 8707 Resource Indicators&lt;/a&gt;: the client appends a &lt;code&gt;resource&lt;/code&gt; parameter to the &lt;code&gt;/authorize&lt;/code&gt; and &lt;code&gt;/token&lt;/code&gt; requests naming the server it wants a token for, and the authorization server is supposed to bind the resulting token to that resource.&lt;/p&gt;

&lt;p&gt;A spec-compliant MCP client does this unconditionally. Claude's connector does. You do not get to turn it off from the client side, and you should not want to - it is the client behaving correctly.&lt;/p&gt;

&lt;p&gt;Cognito does not implement RFC 8707. It does not bind the token to the resource; it rejects the request for carrying an unrecognized parameter. So the connector's very first hop - the redirect to &lt;code&gt;/authorize&lt;/code&gt; - dies before the user ever sees a login box.&lt;/p&gt;

&lt;p&gt;You cannot fix this in Cognito's configuration and you cannot fix it in the client. The fix is a thin, same-origin OAuth proxy sitting in front of Cognito's &lt;code&gt;/authorize&lt;/code&gt; and &lt;code&gt;/token&lt;/code&gt;. It forwards everything through untouched except for one surgical edit: it strips the &lt;code&gt;resource&lt;/code&gt; parameter on the way in.&lt;/p&gt;

&lt;p&gt;The part that will bite you if you are careless about it: strip &lt;code&gt;resource&lt;/code&gt; and &lt;strong&gt;nothing else&lt;/strong&gt;. In particular &lt;code&gt;code_challenge&lt;/code&gt; and &lt;code&gt;code_challenge_method&lt;/code&gt; have to survive verbatim, or you break PKCE and trade one failure for another. Our verification checklist for the proxy is exactly that - "&lt;code&gt;/oauth/authorize&lt;/code&gt; strips the RFC 8707 &lt;code&gt;resource&lt;/code&gt; param while preserving &lt;code&gt;code_challenge&lt;/code&gt;" - because the first cut of the proxy is where you accidentally drop the wrong one.&lt;/p&gt;

&lt;p&gt;The audience-binding that RFC 8707 was supposed to give you, you now have to enforce yourself at the resource server. Which is the next story.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Cognito access tokens carry no &lt;code&gt;aud&lt;/code&gt;, so you pin on &lt;code&gt;client_id&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;MCP is blunt about audience validation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;MCP servers MUST validate that access tokens were issued specifically for them as the intended audience, according to RFC 8707 Section 2. [...] MCP servers MUST NOT accept or transit any other tokens.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The reflex, if you have validated ID tokens before, is to check the &lt;code&gt;aud&lt;/code&gt; claim. That is correct for a Cognito &lt;strong&gt;ID&lt;/strong&gt; token. It is useless for a Cognito &lt;strong&gt;access&lt;/strong&gt; token, because a Cognito access token has no &lt;code&gt;aud&lt;/code&gt; claim at all. The client that the token was minted for is named in &lt;code&gt;client_id&lt;/code&gt; instead, and the token carries &lt;code&gt;token_use: access&lt;/code&gt; where the ID token carries &lt;code&gt;token_use: id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the audience check that satisfies the MCP requirement is a &lt;code&gt;client_id&lt;/code&gt; check. Two claims move relative to the ID-token path: you compare &lt;code&gt;client_id&lt;/code&gt; against your configured client, and you require &lt;code&gt;token_use == "access"&lt;/code&gt;. Here is the verifier we run, trimmed to the claim checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// An access token differs from an ID token in two ways that matter here: it&lt;/span&gt;
&lt;span class="c"&gt;// carries no `aud` claim (the client is named by `client_id`), and it carries&lt;/span&gt;
&lt;span class="c"&gt;// authorization `scope`. The verifier checks `client_id` where the ID-token&lt;/span&gt;
&lt;span class="c"&gt;// path checks `aud`, and `token_use=access` where the ID path requires `id`.&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Iss&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issuer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cognito: unexpected issuer %q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Iss&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c"&gt;// The binding that stands in for the absent `aud` on an access token.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClientID&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clientID&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cognito: unexpected client_id %q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClientID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TokenUse&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"access"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cognito: unexpected token_use %q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TokenUse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Miss the &lt;code&gt;token_use&lt;/code&gt; check and an ID token minted for the same client sails straight through your access-token path. Miss the &lt;code&gt;client_id&lt;/code&gt; check and any access token from the same user pool - including one issued to a completely different app client - is accepted as yours. The pool is the trust boundary Cognito gives you for free; the client is the one you have to draw yourself.&lt;/p&gt;

&lt;p&gt;One more thing worth pinning while you are in there: accept exactly one signing algorithm (&lt;code&gt;RS256&lt;/code&gt;) and one key type (&lt;code&gt;RSA&lt;/code&gt;), compared against the values in the attacker-supplied token header rather than inferred from it. Algorithm confusion needs a negotiable &lt;code&gt;alg&lt;/code&gt; field to exist. Do not give it one.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Managed Login v2 renders "Login pages unavailable" for any client with no branding style
&lt;/h2&gt;

&lt;p&gt;This one produces the least helpful error message of the four. You move a user pool to Managed Login (the version-2 hosted UI), point a new app client at the same domain, hit its login URL, and get:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Login pages unavailable. Please contact an administrator.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Nothing is misconfigured in the obvious places. The domain resolves, the client exists, the pool is on the right feature plan. What is missing is a &lt;strong&gt;branding style&lt;/strong&gt; for that specific client. On a version-2 domain, Managed Login will not render a login page for a client that has no style applied - it does not fall back to a default, it refuses. The web client worked because it had a style; the freshly-added agent client hit the wall precisely because it did not.&lt;/p&gt;

&lt;p&gt;The fix is to give every client its own managed-login branding resource, even if it is a near-clone of another client's. In Terraform that is &lt;code&gt;awscc_cognito_managed_login_branding&lt;/code&gt; (the &lt;code&gt;aws&lt;/code&gt; provider's equivalent is a v6 resource; the underlying call is the same &lt;code&gt;CreateManagedLoginBranding&lt;/code&gt; either way). Ours reuses the web client's palette and logo assets byte-for-byte, with a single deliberate difference: the agent client's page shows the federated sign-in buttons, because unlike the web flow there is no first-party page in front to deep-link the user into a specific identity provider.&lt;/p&gt;

&lt;p&gt;While you are here, one gotcha that turns a working style into a recurring outage: Cognito marks the branding style's &lt;code&gt;ClientId&lt;/code&gt; as create-only, and Cloud Control's in-place update is a JSON patch that always contains an "add ClientId" op it is not allowed to apply. The first &lt;code&gt;apply&lt;/code&gt; succeeds; every later colour or logo tweak fails with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NotUpdatableException: Invalid patch update:
createOnlyProperties [/properties/ClientId] cannot be updated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So configure the style to be &lt;strong&gt;replaced&lt;/strong&gt; on change, not updated - &lt;code&gt;replace_triggered_by&lt;/code&gt; a hash of the settings and asset files. The cost is a few seconds of unstyled login during the destroy-then-create, which is fine for a settings change and is the only supported path for a create-only property.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Elicitation is not supported by connector hosts yet - which we proved, not assumed
&lt;/h2&gt;

&lt;p&gt;The MCP spec has &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation" rel="noopener noreferrer"&gt;elicitation&lt;/a&gt;: mid-tool-call, the server can ask the client to collect a piece of input from the user and hand it back. It is the natural home for a confirmation step - "this action will spend N credits, confirm?" - and the spec describes it cleanly enough that it is very tempting to design a flow around it and move on.&lt;/p&gt;

&lt;p&gt;We did not design around it, because the spec describing a capability and a given host implementing it are different facts. Before leaning on elicitation for anything load-bearing, we built the smallest possible throwaway server that does nothing but issue one &lt;code&gt;elicitInput&lt;/code&gt; call, pointed a real connector host at it, and watched what came back.&lt;/p&gt;

&lt;p&gt;The answer today is that connector hosts do not support elicitation yet. Reading the spec would have told you the shape of the round-trip; it would not have told you the host silently declines to make it. The spike cost an afternoon and turned a guess into a fact.&lt;/p&gt;

&lt;p&gt;The consequence for the design is that we did not ship anything whose safety depends on an elicitation round-trip. The confirmation that elicitation would have carried lives somewhere the host is guaranteed to honour instead. The general rule, which is older than MCP: when a capability sits on the far side of a host you do not control, the smallest experiment that exercises it end-to-end is cheaper than the bug you ship by assuming it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The through-line
&lt;/h2&gt;

&lt;p&gt;Three of these four are the same shape: a standards body wrote an obligation, an implementation you depend on does not meet it, and the gap lands in your lap at the integration seam. RFC 8707 says bind the token; Cognito does not, so you strip and rebind. MCP says validate the audience; Cognito's access token has no audience field, so you validate the client instead. The spec says elicitation exists; the host has not built it, so you find out before you depend on it.&lt;/p&gt;

&lt;p&gt;The only defense that generalizes is to verify the seam rather than trust it - proxy what the provider rejects, check the claim that is actually present rather than the one you expected, and spike the capability against the real host. Every one of these was an afternoon once we stopped assuming and started looking.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>mcp</category>
      <category>oauth</category>
      <category>security</category>
    </item>
    <item>
      <title>The EU AI Act asks AI to identify itself. I checked 30 years of that experiment on my server.</title>
      <dc:creator>Avraham K</dc:creator>
      <pubDate>Tue, 11 Aug 2026 20:17:44 +0000</pubDate>
      <link>https://dev.to/akashy/the-eu-ai-act-asks-ai-to-identify-itself-i-checked-30-years-of-that-experiment-on-my-server-21bm</link>
      <guid>https://dev.to/akashy/the-eu-ai-act-asks-ai-to-identify-itself-i-checked-30-years-of-that-experiment-on-my-server-21bm</guid>
      <description>&lt;p&gt;Article 50 of the EU AI Act applies as from 2 August 2026. Providers of systems that generate synthetic text, audio, image or video have to mark outputs in a machine-readable format so they are detectable as artificially generated, and people have to be told when they are interacting with an AI system. Systems already on the market before that date have until 2 December 2026 to meet the marking obligation, so the compliance scramble is happening right now.&lt;/p&gt;

&lt;p&gt;The whole design rests on a premise: that if you require a thing to declare what it is, you get a usable signal.&lt;/p&gt;

&lt;p&gt;We have been running that experiment for thirty years. It is called the User-Agent header, and I have a small server that logs every one of them.&lt;/p&gt;

&lt;p&gt;Before the numbers, the honest caveat, because I do not want to be accused of a bait and switch: &lt;strong&gt;the User-Agent is not what Article 50 regulates.&lt;/strong&gt; The Act is about marking generated content, not about how crawlers announce themselves, and C2PA signatures and text watermarks are cryptographically stronger than a header any client can type. The analogy is not that they are the same mechanism. It is that they share the load-bearing assumption, which is that a declaration made by the party being regulated is worth something to the party reading it. My logs are the closest thing I have to a natural experiment on that assumption.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Thirty days to 11 August 2026, on a small service that gets more machine traffic than human traffic. Every request whose User-Agent matched a known AI crawler, graded by who actually owns the source address, resolved over RDAP against the internet registries:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Hits&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;verified&lt;/td&gt;
&lt;td&gt;259&lt;/td&gt;
&lt;td&gt;Source address is inside the vendor's own registered netblock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;plausible&lt;/td&gt;
&lt;td&gt;112&lt;/td&gt;
&lt;td&gt;Rentable cloud the crawler is known to run from&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SPOOFED&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;916&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One source address sending several different crawler identities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UNEXPECTED&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Owner is not a network that crawler operates from&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no-ip&lt;/td&gt;
&lt;td&gt;306&lt;/td&gt;
&lt;td&gt;My own instrument does not record a source address on those routes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;1,596 requests, 11 distinct crawler identities. Of the 1,290 I can attribute at all, &lt;strong&gt;916 came from somewhere the claimed vendor does not operate. That is 71 percent of the attributable traffic, and 57 percent of everything.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two sources account for nearly all of it.&lt;/p&gt;

&lt;p&gt;The first is a consumer broadband address. It sent 531 requests as GPTBot, spread across 86 different paths, and then came back as ClaudeBot, CCBot and PerplexityBot. Four separate AI companies, one residential connection, walking my URL space methodically. That is not a crawl, it is somebody's scraper wearing whatever costume seemed useful.&lt;/p&gt;

&lt;p&gt;The second is more interesting: an address inside a Google-owned netblock that cycled through &lt;strong&gt;seven&lt;/strong&gt; crawler identities in the same window. ChatGPT-User 114 requests, Amazonbot 63, OAI-SearchBot 42, Google-Extended 40, PerplexityBot 38, GPTBot 32, ClaudeBot 32. One machine, seven declared identities, 361 requests.&lt;/p&gt;

&lt;p&gt;Meanwhile the real traffic is boring and easy to confirm. ClaudeBot arrives from Anthropic's own registered range. meta-externalagent arrives from Meta's. They look exactly like what they say they are, because they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing that actually worked
&lt;/h2&gt;

&lt;p&gt;The fix is not a better header. It is refusing to treat the header as evidence.&lt;/p&gt;

&lt;p&gt;The User-Agent is the one field the sender chooses for free. The registered owner of the source netblock is not forgeable in the same way, because forging it means controlling routing rather than editing a string. So the grading rule is: take the source address, resolve it to its registered owner over RDAP, and check that owner against a table of who each crawler is allowed to originate from. Anthropic's crawler out of Anthropic's range is a crawl. Anthropic's crawler out of a residential DSL line is a person testing something.&lt;/p&gt;

&lt;p&gt;Two properties of this that matter more than the accuracy:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One source, many identities is the tell.&lt;/strong&gt; I did not need to know which crawler was real. A single address presenting seven different vendor identities has told me everything I need to know without my having to adjudicate any one of them. Cheap, robust, and it does not require me to maintain an opinion about anybody's crawler policy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The table goes stale, and that failure is silent.&lt;/strong&gt; Vendors move netblocks. A crawler that suddenly reads UNEXPECTED after a long run of verified is far more likely to be a vendor migration than an impostor, so the honest version of this tool tells you to check the vendor's published ranges before you believe your own verdict. Any provenance scheme with a trust list has this problem and mostly does not admit it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this predicts about Article 50
&lt;/h2&gt;

&lt;p&gt;Marking helps against the honest and does nothing against the motivated. Every actor in my logs who was telling the truth was already easy to identify without the mark, and every actor who was lying would have been just as happy to omit a C2PA manifest or strip it in a re-encode. Anthropic's own documentation on how it marks generated content is refreshingly blunt about this: the absence of a mark proves nothing, marks fade under heavy editing, and a present mark means the content "may have been processed" rather than authored. Those are not weaknesses of that implementation. They are the shape of the problem.&lt;/p&gt;

&lt;p&gt;Here is the part I would worry about if I wrote policy. When a declared signal is unreliable and enforcement is still required, enforcement does not stop. It migrates to whatever signal is available, which is vibes.&lt;/p&gt;

&lt;p&gt;You can already watch this happen. The r/golang subreddit bans AI-generated content and states its method openly: "As it is not easy to determine what is and is not AI, posts will be removed based on their appearence." Their rule reaches posts, comments, and the things a post links to. That is a community that gave up on detection and moved to reading for texture, and I do not think they were wrong to, given the tools they have. It also means a compliant, marked, disclosed piece of writing gets removed for reading like a machine wrote it, while an unmarked one that reads human sails through. The mark is orthogonal to the judgement.&lt;/p&gt;

&lt;p&gt;That is the failure mode I would expect at scale: not defiance of the marking rules, but marking becoming irrelevant to the decisions people actually make.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you run a server
&lt;/h2&gt;

&lt;p&gt;Three things worth doing, none of which require caring about the AI Act at all:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Log the source address, not just the User-Agent.&lt;/strong&gt; My own report has a 306-hit blind spot because one origin logs the header and not the address. That is 19 percent of my traffic I cannot grade, and it is my fault.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grade by netblock ownership.&lt;/strong&gt; RDAP lookups are free, cacheable, and turn an unfalsifiable claim into a checkable one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never quote a crawler percentage off raw User-Agent counts.&lt;/strong&gt; Every "AI crawler traffic is up N percent" chart I have seen is counting the string. Mine would have overstated real crawler traffic by 4.3x.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reason I care about any of this more than a normal person should: the service these logs come from is &lt;a href="https://cogdepot.com" rel="noopener noreferrer"&gt;cogDepot&lt;/a&gt;, a marketplace where the buyers and sellers are supposed to be software agents rather than people. When your customers are all machines, "is this actually who it says it is" stops being an abuse-team problem and becomes the product. I went looking for demand signal in my traffic and found that most of it was somebody else's scraper in a costume, which is a useful thing to learn early and a miserable thing to learn late.&lt;/p&gt;

&lt;p&gt;The Act is asking a reasonable thing. I just think anyone building on the assumption that self-declaration produces a usable signal should look at what happened to the last header we tried it with.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these up as I hit them. More at &lt;a href="https://x.com/cogdepot" rel="noopener noreferrer"&gt;x.com/cogdepot&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>My Agent Orchestrator Burned 1-2M Opus Tokens Per Task. Here's the Postmortem.</title>
      <dc:creator>Avraham K</dc:creator>
      <pubDate>Tue, 04 Aug 2026 18:19:42 +0000</pubDate>
      <link>https://dev.to/akashy/my-agent-orchestrator-burned-1-2m-opus-tokens-per-task-heres-the-postmortem-2k7g</link>
      <guid>https://dev.to/akashy/my-agent-orchestrator-burned-1-2m-opus-tokens-per-task-heres-the-postmortem-2k7g</guid>
      <description>&lt;p&gt;I built an orchestration skill for Claude Code that delegated everything to subagents. It worked. It also cost somewhere on the order of &lt;strong&gt;1-2 million Opus tokens per task&lt;/strong&gt; - including tasks whose final diff was a handful of lines.&lt;/p&gt;

&lt;p&gt;Nothing was broken. Every individual decision was defensible. Three modest multipliers stacked, and then the whole stack ran on every single request.&lt;/p&gt;

&lt;p&gt;This is the postmortem, the redesign, and the enforcement layer I should have written first.&lt;/p&gt;

&lt;h2&gt;
  
  
  v1: pure delegation
&lt;/h2&gt;

&lt;p&gt;The design goal was context hygiene. The main session gets polluted fast - it accumulates file contents, tool output, and dead ends, and its judgment degrades as the window fills. So: don't let it do any work. Make it a coordinator, and give every unit of real work a fresh context.&lt;/p&gt;

&lt;p&gt;That produced four rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A hard gate.&lt;/strong&gt; The main session was forbidden from reading, editing, or running anything itself. Every action went through a subagent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fixed 5-phase pipeline&lt;/strong&gt; on every task: Plan → Approve → Execute → Review → Report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fresh subagents per phase.&lt;/strong&gt; No reuse. Each phase got clean context by construction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mandated reviewers with "loop until clean."&lt;/strong&gt; A review phase that re-ran until it found nothing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the trigger was broad - essentially any actionable request. "do this," "implement," "fix," "build," "change."&lt;/p&gt;

&lt;p&gt;Read those four rules again with a cost lens instead of a correctness lens. That is the whole postmortem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three multipliers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The dispatch schema made &lt;code&gt;model&lt;/code&gt; optional
&lt;/h3&gt;

&lt;p&gt;The subagent dispatch tool takes a &lt;code&gt;model&lt;/code&gt; parameter. My skill never set it. Omitted, it inherits from the parent session - which was Opus 4.8.&lt;/p&gt;

&lt;p&gt;So every subagent, including the ones whose entire job was "read this file and summarize it," ran on the most expensive tier available.&lt;/p&gt;

&lt;p&gt;Here's what that actually costs at list prices:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Input $/MTok&lt;/th&gt;
&lt;th&gt;Output $/MTok&lt;/th&gt;
&lt;th&gt;vs. Opus&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 4.8 (&lt;code&gt;claude-opus-4-8&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;$5.00&lt;/td&gt;
&lt;td&gt;$25.00&lt;/td&gt;
&lt;td&gt;1×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 4.6 (&lt;code&gt;claude-sonnet-4-6&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;$3.00&lt;/td&gt;
&lt;td&gt;$15.00&lt;/td&gt;
&lt;td&gt;0.6×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5 (&lt;code&gt;claude-haiku-4-5&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;$1.00&lt;/td&gt;
&lt;td&gt;$5.00&lt;/td&gt;
&lt;td&gt;0.2×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I want to flag something here, because I got it wrong in my own first write-up of this incident: &lt;strong&gt;Opus is not 5× Sonnet. It's about 1.7×.&lt;/strong&gt; It &lt;em&gt;is&lt;/em&gt; exactly 5× Haiku. If you're building a tiering story, the Opus→Sonnet move is a 40% cut, and the Opus→Haiku move on genuinely trivial work is an 80% cut.&lt;/p&gt;

&lt;p&gt;Which means the model tax was the &lt;em&gt;smallest&lt;/em&gt; of my three multipliers. I'd been blaming it for the whole bill. It wasn't even close.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. "Include ALL context" plus zero memory = a cold cache, every time
&lt;/h3&gt;

&lt;p&gt;This is the expensive one, and it took me longest to see because the symptom ("agents re-read the repo") sounds like a token-count problem when it's actually a &lt;strong&gt;cache-prefix&lt;/strong&gt; problem.&lt;/p&gt;

&lt;p&gt;Prompt caching is a &lt;strong&gt;prefix match&lt;/strong&gt;. The cache key comes from the exact bytes of the rendered prompt, in the order &lt;code&gt;tools&lt;/code&gt; → &lt;code&gt;system&lt;/code&gt; → &lt;code&gt;messages&lt;/code&gt;, up to each &lt;code&gt;cache_control&lt;/code&gt; breakpoint. One byte different at position N and everything from N onward is a miss.&lt;/p&gt;

&lt;p&gt;The economics of that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cache read: ~0.1× base input price.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache write: 1.25× base input price&lt;/strong&gt; (5-minute TTL; 2× for the 1-hour TTL).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a cached read is a 90% discount, and a cold write carries a 25% &lt;em&gt;premium&lt;/em&gt;. The gap between best case and worst case on the same tokens is roughly &lt;strong&gt;12×&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now put a fresh subagent in that picture. A fresh subagent is a new prefix. It does not inherit the parent's cached prompt unless its &lt;code&gt;system&lt;/code&gt;, &lt;code&gt;tools&lt;/code&gt;, and &lt;code&gt;model&lt;/code&gt; are byte-identical to the parent's - and mine weren't, because each phase got its own tailored instructions. Every subagent I spawned paid a cold write on the entire repo context it had been told to "include ALL" of.&lt;/p&gt;

&lt;p&gt;It gets worse when you parallelize. &lt;strong&gt;A cache entry only becomes readable once the first response starts streaming.&lt;/strong&gt; Fire five subagents simultaneously with identical prefixes and all five pay full freight - none of them can read what the others are still writing.&lt;/p&gt;

&lt;p&gt;My design had a rule that guaranteed maximum context per agent, a rule that guaranteed a fresh prefix per agent, and a fan-out pattern that guaranteed simultaneous cold writes. Three rules, one bill.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Fan-out multiplied by an unbounded loop
&lt;/h3&gt;

&lt;p&gt;Five-plus agents per task as a floor - one per phase, more when a phase parallelized. On top of that, "loop until clean" gave the review phase no termination bound other than the reviewer's own judgment about its own output.&lt;/p&gt;

&lt;p&gt;A reviewer that finds one nit per pass runs forever. A reviewer that finds nothing on pass one still costs a full agent.&lt;/p&gt;

&lt;h3&gt;
  
  
  The multiplication
&lt;/h3&gt;

&lt;p&gt;None of these is outrageous alone. Multiply them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;Multiplier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Opus instead of Sonnet on work that didn't need it&lt;/td&gt;
&lt;td&gt;~1.7×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold cache write instead of cache read on repo context&lt;/td&gt;
&lt;td&gt;~12×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5+ agents per task, before review loops&lt;/td&gt;
&lt;td&gt;~5×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Review loop iterations&lt;/td&gt;
&lt;td&gt;~1-3×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's a &lt;strong&gt;100× to 300× band&lt;/strong&gt; against a baseline of "one well-cached Sonnet agent does the work." Applied to every actionable request, because the trigger was broad.&lt;/p&gt;

&lt;p&gt;The 1-2M number stops being surprising. It's what the architecture was specified to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I didn't notice
&lt;/h2&gt;

&lt;p&gt;Because there is no backpressure anywhere in that loop.&lt;/p&gt;

&lt;p&gt;The model cannot see cumulative session spend. It has no running total, no budget, no signal that agent #14 is different from agent #2. The API does offer a task budget (&lt;code&gt;output_config.task_budget&lt;/code&gt;), but that governs a single agentic request - thinking, tool calls, and output within one loop. It does not span a session's worth of independent subagent dispatches, which is precisely where my spend lived.&lt;/p&gt;

&lt;p&gt;So the only thing standing between me and a 1.5M-token bill was the model's own restraint, mediated through a prompt. Prompts are preferences. Preferences degrade under context pressure, and they get compacted away entirely on long sessions.&lt;/p&gt;

&lt;p&gt;That was the actual bug. Not the model tier, not the cache: &lt;strong&gt;I put a budget policy somewhere it could not be enforced.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  v2: the dispatch contract
&lt;/h2&gt;

&lt;p&gt;Same delegation model, corrected against each root cause:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Explicit model on every dispatch.&lt;/strong&gt; Haiku / Sonnet / Opus, chosen per task, never inherited. Ends the Opus-by-default tax and, more importantly, makes tier a visible decision rather than a silent default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Curated briefs instead of "read everything."&lt;/strong&gt; Exact file paths plus a digest, not a directive to reload the repo. This is the big one - it attacks token &lt;em&gt;count&lt;/em&gt;, and token count beats unit price. Cutting input tokens 5× saves more than the entire Opus→Sonnet swap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scoped trigger.&lt;/strong&gt; Orchestrate only large or genuinely parallel work. Trivial edits happen directly in the main session. The overhead of a subagent - a cold prefix, a brief, a report to read - is only worth paying when the work is bigger than the overhead.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Right-sized reviews with a 2-round cap, flat orchestration (no nested agents), and ≤5 in flight.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Better. Still a prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost-guard hook: enforcement outside the model
&lt;/h2&gt;

&lt;p&gt;The real fix is that none of the above is left to judgment. It runs as a &lt;code&gt;PreToolUse&lt;/code&gt; hook - a process the harness executes before the tool call, whose verdict the model does not get a vote on.&lt;/p&gt;

&lt;p&gt;Register it in &lt;code&gt;settings.json&lt;/code&gt; against the subagent dispatch tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Task"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node /path/to/.claude/hooks/cost-guard.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"statusMessage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Checking dispatch budget..."&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hook receives JSON on stdin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"session_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"transcript_path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/home/user/.claude/projects/.../transcript.jsonl"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cwd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/home/user/my-project"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permission_mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hook_event_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tool_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Task"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tool_input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"subagent_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"general-purpose"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"prompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tool_use_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"toolu_01ABC..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And answers on stdout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hookSpecificOutput"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"hookEventName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"permissionDecision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"allow | deny | ask | defer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"permissionDecisionReason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shown to the model and the user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"updatedInput"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"replaces the tool's arguments before it runs"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;updatedInput&lt;/code&gt; is what makes this more than a bouncer. The hook can &lt;strong&gt;rewrite the call&lt;/strong&gt; rather than just refusing it.&lt;/p&gt;

&lt;p&gt;Five guards:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Guard&lt;/th&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;What it stops&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model downgrade&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;allow&lt;/code&gt; + &lt;code&gt;updatedInput&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Opus-by-default. Rewrites Opus and model-less dispatches to Sonnet.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dispatch circuit-breaker&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ask&lt;/code&gt; past 10, &lt;code&gt;deny&lt;/code&gt; past 25&lt;/td&gt;
&lt;td&gt;Runaway fan-out across a whole session.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nested-dispatch guard&lt;/td&gt;
&lt;td&gt;&lt;code&gt;deny&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Agents spawning agents - exponential, not linear.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrency cap&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;deny&lt;/code&gt; beyond 5 in flight&lt;/td&gt;
&lt;td&gt;Simultaneous cold cache writes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context-bloat gate&lt;/td&gt;
&lt;td&gt;&lt;code&gt;deny&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tool calls that would flood the context window.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here's the core of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="cp"&gt;#!/usr/bin/env node
&lt;/span&gt;&lt;span class="c1"&gt;// .claude/hooks/cost-guard.js - PreToolUse, matcher: "Task"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;os&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;os&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;path&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MODEL_FLOOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sonnet&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ASK_AFTER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;REFUSE_AFTER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;updatedInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;hookEventName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PreToolUse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;permissionDecision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;permissionDecisionReason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updatedInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedInput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;updatedInput&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;hookSpecificOutput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Counters must live on disk: each hook invocation is a separate process with&lt;/span&gt;
&lt;span class="c1"&gt;// no memory of the last one. session_id is the only stable key we get, and it&lt;/span&gt;
&lt;span class="c1"&gt;// goes into a path, so it is validated rather than trusted.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;counterPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9_-&lt;/span&gt;&lt;span class="se"&gt;]{1,64}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tmpdir&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s2"&gt;`cost-guard-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bumpDispatchCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;counterPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dispatches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// First dispatch of the session, or an unreadable file. Either way we&lt;/span&gt;
    &lt;span class="c1"&gt;// start from zero rather than failing the user's tool call.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dispatches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dispatches&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dispatches&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;end&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// A guard that crashes must not become a guard that blocks. Exit 0 with no&lt;/span&gt;
    &lt;span class="c1"&gt;// JSON and the normal permission flow applies.&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tool_input&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bumpDispatchCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;REFUSE_AFTER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deny&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`Dispatch #&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; exceeds the hard cap of &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;REFUSE_AFTER&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; for this session. `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s2"&gt;`Do the remaining work directly, or start a fresh session.`&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ASK_AFTER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ask&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`This is subagent #&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; this session (soft cap &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ASK_AFTER&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;). `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s2"&gt;`Each one pays a cold prompt-cache write. Approve?`&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// updatedInput REPLACES tool_input wholesale - it is not a patch. Spreading&lt;/span&gt;
  &lt;span class="c1"&gt;// the original first is what keeps `prompt` and `subagent_type` alive; drop&lt;/span&gt;
  &lt;span class="c1"&gt;// the spread and you dispatch an agent with no instructions.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;opus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`Model &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'opus'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unset (would inherit Opus)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; rewritten `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s2"&gt;`to '&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;MODEL_FLOOR&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'. Set model explicitly to override.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MODEL_FLOOR&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// no opinion; normal permission flow applies&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three implementation notes that cost me time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;updatedInput&lt;/code&gt; replaces, it does not merge.&lt;/strong&gt; Return &lt;code&gt;{ model: "sonnet" }&lt;/code&gt; and you have just dispatched a subagent with no prompt. Spread the original input first. This is the single easiest way to turn a cost guard into an outage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hooks are separate processes.&lt;/strong&gt; There is no in-memory counter to increment. Anything cumulative has to be persisted, and &lt;code&gt;session_id&lt;/code&gt; from stdin is the natural key. Validate it before putting it in a path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never fail closed on your own bug.&lt;/strong&gt; Exit 0 with no JSON output and the normal permission flow applies. A malformed payload, an unreadable state file, a disk error - none of those should block the user's work. Exit 2 is the deliberate block; everything else stays out of the way.&lt;/p&gt;

&lt;p&gt;The concurrency cap needs a &lt;code&gt;PostToolUse&lt;/code&gt; counterpart to decrement the in-flight count, and the nested-dispatch guard needs a reliable "am I inside a subagent" signal - check what &lt;code&gt;session_id&lt;/code&gt; and &lt;code&gt;transcript_path&lt;/code&gt; actually look like inside a subagent on your Claude Code version before you rely on either. Those two are the most harness-coupled of the five.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that generalizes
&lt;/h2&gt;

&lt;p&gt;I keep coming back to one line from this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A budget rule in a prompt is a preference. A budget rule in a PreToolUse hook is an invariant.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The prompt version degrades exactly when you need it most - deep in a long session, under context pressure, after compaction has quietly dropped the paragraph where you wrote it down. The hook version runs identically on dispatch #1 and dispatch #24, and it does not need to be persuaded.&lt;/p&gt;

&lt;p&gt;That distinction isn't really about cost. It's about which layer a constraint belongs in. Anything you would be upset to discover the model overrode - spend caps, destructive commands, push targets, credential access - does not belong in a system prompt. It belongs in a process the model cannot argue with.&lt;/p&gt;

&lt;p&gt;I still delegate. Pure delegation on tasks that are genuinely large and parallel is the right shape, and the context-hygiene argument that motivated v1 was never wrong. What was wrong was believing an architecture and forgetting to price it - and then writing the safeguards in the one place they could not be enforced.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're running a similar setup: check whether your dispatches set &lt;code&gt;model&lt;/code&gt; explicitly, and check whether your subagents share a prefix with their parent. Those two questions found most of my bill.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>llm</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Deal-scoped PASETO: three things I got wrong about audience-bound tokens</title>
      <dc:creator>Avraham K</dc:creator>
      <pubDate>Sun, 02 Aug 2026 19:18:48 +0000</pubDate>
      <link>https://dev.to/akashy/deal-scoped-paseto-three-things-i-got-wrong-about-audience-bound-tokens-57p</link>
      <guid>https://dev.to/akashy/deal-scoped-paseto-three-things-i-got-wrong-about-audience-bound-tokens-57p</guid>
      <description>&lt;p&gt;The Model Context Protocol's authorization section says something blunt: a server must reject any token that was not issued for it. That has been normative since the 2025-06-18 revision rather than arriving with the current one, which is worth saying plainly because the interesting question was never whether the rule is new. I have been running a system where every credential is scoped to exactly one two-party transaction, so I want to write down what that actually took, including the third thing I still have not fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The spec sentence
&lt;/h2&gt;

&lt;p&gt;Three normative sentences from the &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization" rel="noopener noreferrer"&gt;2026-07-28 authorization spec&lt;/a&gt;, which is the current revision as I write this. They are from across its Token Handling section rather than one continuous paragraph:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;MCP servers MUST validate that access tokens were issued specifically for them as the intended audience, according to RFC 8707 Section 2. [...] MCP servers MUST only accept tokens that are valid for use with their own resources. MCP servers MUST NOT accept or transit any other tokens.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The middle sentence is the one that moved. In &lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization" rel="noopener noreferrer"&gt;2025-06-18&lt;/a&gt; it began &lt;em&gt;Authorization servers&lt;/em&gt; MUST only accept tokens, which put the obligation on the party that issues tokens in a passage about the party that receives them. The current revision points it at MCP servers. The first and third sentences are unchanged from 2025-06-18.&lt;/p&gt;

&lt;p&gt;That is three sentences in a spec and about a quarter of the work in an implementation. The rest of this post is the other three quarters.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tokens are for
&lt;/h2&gt;

&lt;p&gt;The system brokers work between two agents that stay pseudonymous while they negotiate. When a deal seals, each side receives the other's callback endpoint and a credential, and the broker steps out of the data path. The credential has exactly one job at the receiving end: answer whether this caller belongs to deal &lt;code&gt;1a2b3c4d&lt;/code&gt;. Not whether they are a valid user of the platform. Not whether they are a paying customer. This deal.&lt;/p&gt;

&lt;p&gt;That is the same shape as the MCP requirement. A token minted for one resource has to be worthless at every other one. The only difference is that my resource is a transaction rather than a server.&lt;/p&gt;

&lt;h2&gt;
  
  
  The easy part: v4.public and nothing else
&lt;/h2&gt;

&lt;p&gt;Signing is one line, and the version is chosen at compile time rather than read out of the token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// V4Sign returns a plain string; it panics only on internal encoding errors.&lt;/span&gt;
&lt;span class="n"&gt;signed&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;V4Sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PrivateKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No v4.local, no JWT, no algorithm negotiation. There is no &lt;code&gt;alg&lt;/code&gt; header for an attacker to rewrite because there is no header. The whole family of algorithm-confusion bugs, where a verifier is talked into treating an RSA public key as an HMAC secret or into honouring &lt;code&gt;alg: none&lt;/code&gt;, needs a negotiable algorithm field in order to exist. PASETO's contribution is not better cryptography. It is deleting the negotiation.&lt;/p&gt;

&lt;p&gt;I am not going to pretend that was a hard decision. It took ten minutes and it removed a category. The next three took considerably longer.&lt;/p&gt;

&lt;h2&gt;
  
  
  One: a kid constant is a rotation outage
&lt;/h2&gt;

&lt;p&gt;The first version put a fixed string in the token footer as the key identifier, something like the deployment stage name. It parsed, it round-tripped, the tests were green.&lt;/p&gt;

&lt;p&gt;It also meant that rotating the signing key was a hard cutover. These credentials live seven days. Rotate on a Tuesday and every credential minted in the previous seven days carries a kid that now resolves to the wrong key. There is no window where the old key and the new key are both selectable, because both of them answer to the same name.&lt;/p&gt;

&lt;p&gt;The fix is to stop naming keys and start fingerprinting them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;kidHexLen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;16&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;DeriveKid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;paseto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;V4AsymmetricPublicKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sum256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pub&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExportBytes&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EncodeToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;kidHexLen&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And so the mint path cannot disagree with itself, the loader derives the kid instead of accepting one from its caller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;LoadKeyPair&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;privKeyHex&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;KeyPair&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;paseto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewV4AsymmetricSecretKeyFromHex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;privKeyHex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;KeyPair&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"credentials: load key pair: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;pub&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sk&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Public&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;KeyPair&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;PrivateKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;PublicKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Kid&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;        &lt;span class="n"&gt;DeriveKid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A kid is now a property of the key rather than a label attached to it. Two keys can be live at once, a verifier picks by kid, and rotation is an overlap window instead of an outage. The old key stays loaded until the last credential it signed has expired, which is a bounded seven days, and then it goes away.&lt;/p&gt;

&lt;p&gt;Yes, sixteen hex characters is a truncated hash. It is a selector, not a security boundary. The kid tells a verifier which key to try; the signature decides whether the token is real. If someone finds a second public key whose SHA-256 shares its first eight bytes with mine, they have successfully selected my key and still cannot sign with it. A colliding kid fails verification. It does not forge one.&lt;/p&gt;

&lt;p&gt;Deriving the kid is only half of it. Reading it back is the other half, and it is the one place where a token has to be touched before its signature is checked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// ExtractKid reads the key ID from the token footer WITHOUT verifying the&lt;/span&gt;
&lt;span class="c"&gt;// signature. This is intentionally unsafe and is used only for key selection&lt;/span&gt;
&lt;span class="c"&gt;// before the full Verify call.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;ExtractKid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;parser&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;paseto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewParserWithoutExpiryCheck&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;footerBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;parser&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UnsafeParseFooter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paseto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;V4Public&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c"&gt;// ...unmarshal, reject an empty kid&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;go-paseto calls that method &lt;code&gt;UnsafeParseFooter&lt;/code&gt; and the name is honest: nothing has been authenticated at that point. What makes it safe anyway is that the footer is covered by the signature, because PASETO folds it into the pre-authentication encoding. A tampered kid cannot do anything except point at the wrong key, and the wrong key fails verification. The kid is a hint about which key to try, never a claim to be believed.&lt;/p&gt;

&lt;p&gt;That distinction is worth being explicit about, because &lt;em&gt;select a key using the token, then verify the token with that key&lt;/em&gt; reads as circular until you notice the selector lives inside the authenticated envelope. The rule it generalises to: you may read unauthenticated input to decide &lt;strong&gt;how&lt;/strong&gt; to check something, never to decide &lt;strong&gt;whether&lt;/strong&gt; it passed.&lt;/p&gt;

&lt;p&gt;The generalisable part: an identifier that has to survive rotation should be derived from the thing it identifies. Choose it independently and the two can drift, and the drift surfaces at precisely the moment you are rotating a key under pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two: never classify an error by reading its message
&lt;/h2&gt;

&lt;p&gt;Callers need two different answers out of a failed verification. &lt;code&gt;ErrExpiredToken&lt;/code&gt; is a routine event with a routine remedy. &lt;code&gt;ErrInvalidToken&lt;/code&gt; means someone presented a credential I did not sign. Those belong in different places, and one of them should page someone.&lt;/p&gt;

&lt;p&gt;go-paseto validates temporal claims through rules, so the obvious implementation parses with the not-expired rule on and inspects whatever error comes back. In practice that means this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// The version I wrote first, and deleted.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"expired"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ErrExpiredToken&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works, and it is coupled to a string in someone else's repository that they never promised not to change. A minor version bump that rewords a rule error does not fail the build, does not fail the type check, and does not fail any test that builds its expired token through the same library. It silently reclassifies every expired credential as a forgery. Nothing breaks visibly. A routine event just starts arriving on the path reserved for attacks, which is how a real one gets ignored.&lt;/p&gt;

&lt;p&gt;The version that ships turns the rule off and does the comparison itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Parse without the built-in NotExpired rule so a valid-signature but expired&lt;/span&gt;
&lt;span class="c"&gt;// token parses successfully and we can classify it deterministically below.&lt;/span&gt;
&lt;span class="n"&gt;parser&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;paseto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewParserWithoutExpiryCheck&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;parser&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParseV4Public&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pubKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TokenClaims&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%w: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrInvalidToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Enforce not-before explicitly: a token whose nbf is in the future is not yet&lt;/span&gt;
&lt;span class="c"&gt;// valid and must not be accepted.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;nbf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nbfErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetNotBefore&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;nbfErr&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Before&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nbf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TokenClaims&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%w: token not yet valid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrInvalidToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Classify expiry directly off the exp claim.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetExpiration&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;expErr&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Before&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TokenClaims&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;ErrExpiredToken&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Signature first, always. The kid lookup above is the only thing that touches the token earlier, and it decides nothing. Only a cryptographically valid token gets its claims read, so &lt;code&gt;expired&lt;/code&gt; is a statement about a token I definitely signed. Then the temporal checks, in code I own, comparing values instead of strings.&lt;/p&gt;

&lt;p&gt;The explicit not-before check in the middle is the part I would have gotten wrong if I had trusted the constructor name, so it is worth being precise about what go-paseto v1.6.0 actually does. &lt;code&gt;NewParser()&lt;/code&gt; preloads exactly one rule, &lt;code&gt;NotExpired()&lt;/code&gt;, and that rule reads &lt;code&gt;exp&lt;/code&gt; and nothing else. &lt;code&gt;nbf&lt;/code&gt; has its own rule, &lt;code&gt;NotBeforeNbf()&lt;/code&gt;, and it is not loaded by default. &lt;code&gt;ValidAt()&lt;/code&gt; checks &lt;code&gt;iat&lt;/code&gt;, &lt;code&gt;nbf&lt;/code&gt; and &lt;code&gt;exp&lt;/code&gt; together, and you only get it from &lt;code&gt;NewParserForValidNow()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So that check is not compensating for something I switched off. &lt;code&gt;NewParserWithoutExpiryCheck&lt;/code&gt; dropped the one rule I was already replacing. The &lt;code&gt;nbf&lt;/code&gt; gap was open the whole time, in the default parser, before I touched anything - I just found it while reading the rule list instead of the constructor names. A parser named &lt;code&gt;NewParser&lt;/code&gt; sounds like sensible defaults. It is a one-element slice, and the element is not the one you assumed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three: I never shipped the verification side
&lt;/h2&gt;

&lt;p&gt;This is the one I do not have a fix for yet, and it is the one that matters most, so here it is plainly. Three facts about the code as it stands today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify has no production callers.&lt;/strong&gt; It exists, it is tested, and if you search the repository for callers outside the test files you get nothing. Production calls &lt;code&gt;Mint&lt;/code&gt; at deal finalize and &lt;code&gt;LoadKeyPair&lt;/code&gt; at startup. Nothing on the serving path verifies anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No endpoint publishes the public key.&lt;/strong&gt; There is no kid-to-key document anywhere on the API. A counterparty that receives a credential and reads its footer gets a key identifier that identifies nothing they can fetch. The only way to check a credential today is to ask me, which means the credential proves what I say it proves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both parties get the same token.&lt;/strong&gt; Finalize mints once and hands the identical string to both sides. It carries the deal ID and both pseudonymous references, so it proves membership of the deal. It cannot distinguish which member is holding it.&lt;/p&gt;

&lt;p&gt;Put those together and the honest description of what I built is: a correctly minted, correctly scoped, correctly rotatable credential that nobody can independently verify and that does not identify its bearer. The minting half is right. The half that converts minting into a security property is missing.&lt;/p&gt;

&lt;p&gt;Which is exactly why the MCP spec does not stop at audience binding. The same document makes discovery a requirement in its own right:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;MCP servers MUST implement OAuth 2.0 Protected Resource Metadata (RFC9728). MCP clients MUST use OAuth 2.0 Protected Resource Metadata for authorization server discovery.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Next to the audience rule, that reads like plumbing. It is not. Without a published, fetchable way to learn which authority signs for a resource and which keys it is using, &lt;em&gt;this token was issued for you&lt;/em&gt; degrades into &lt;em&gt;the issuer says this token was issued for you&lt;/em&gt;. Discovery is the part that makes audience binding checkable by the party the binding is supposed to protect.&lt;/p&gt;

&lt;p&gt;The fix has a shape, and I will write it up separately once it ships:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;publish the current and previous public keys, keyed by kid, at a stable well-known path, so a counterparty can verify without asking the broker;&lt;/li&gt;
&lt;li&gt;mint one token per party with a claim naming which side holds it, so a credential identifies its bearer and not only its deal.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would keep
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Delete the negotiation.&lt;/strong&gt; If a field can be attacked, the cheapest defence is for it not to exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derive identifiers from what they identify.&lt;/strong&gt; Keys, and anything else that rotates while its old outputs are still in flight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the rule list, not the constructor name.&lt;/strong&gt; What a library validates by default is a short, specific list, and it is usually shorter than the name implies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one thing I would do earlier: build the verifier before the issuer. Minting a token is satisfying and provides no security by itself. Everything that makes a credential worth holding lives on the side that checks it, and it is very easy to ship the satisfying half, watch the tests go green, and never notice that the other half was never written.&lt;/p&gt;

&lt;p&gt;This is running at &lt;a href="https://cogdepot.com" rel="noopener noreferrer"&gt;cogdepot.com&lt;/a&gt;, where credentials are minted at deal finalize and each side gets a per-deal endpoint for the other. If you want to argue with any of the above, section three is the one I would argue with too.&lt;/p&gt;

</description>
      <category>go</category>
      <category>security</category>
      <category>api</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
