My copy box was hash-verified, and it still handed back a curly quote where a code fence should be. The .txt view of the same prompt stayed clean. The on-page box, the one a human actually clicks Copy on, was off by a single character.
I spent an hour sure it was my own filter. It was WordPress’s wptexturize eating my backticks: a triple backtick came out the other side as “ (an opening curly double quote) plus a stray backtick, while the raw .txt was untouched. Here is the diff, .txt body against the rendered textarea’s .value:
# curl -s .../byte-exact-copy-paste-audit.txt | head -1
```bash
# document.querySelector('.fe-prompt-raw').value, same prompt, on the page
“bash
One character. The broken view is the one with a big Copy button on it. For an essay that does not matter; for a prompt full of code fences and --flags, it is the whole ballgame.
wptexturize runs twice, and filter priority can’t save you
wptexturize is WordPress’s typographic prettifier. It curls straight quotes and turns -- into an en-dash. The obvious fix is ordering: it hooks the_content at priority 10, so you register your copy-block builder at 20 and you are downstream of it. I did exactly that. It still corrupted.
The reason took me a while. In a block / full-site-editing theme the content gets texturized again below the_content, during the FSE block-template render. My priority-20 filter ran, produced clean bytes, and a second pass I do not control re-curled them on the way to the page. Out-prioritising the first pass does nothing about the second, because the second is not even on the same hook.
What held was telling wptexturize to never touch <textarea> content, wherever it runs (funnyenough-prompts.php, lines 24-29):
add_filter('no_texturize_tags', function ($tags) {
if (!in_array('textarea', $tags, true)) {
$tags[] = 'textarea';
}
return $tags;
});
no_texturize_tags is tag-scoped, not pass-scoped. It is a site-wide exclusion list, so neither texturize pass reaches inside the <textarea> where the byte-exact prompt lives. After this, the stored bytes, the .txt endpoint, and the on-page copy box all came out hash-identical.
One more <textarea> gotcha bit me right after. The HTML parser swallows a single leading newline inside a <textarea>, so a prompt that genuinely starts with a blank line round-trips one byte short and fails its own hash check. The carrier element prepends a literal newline to absorb that (line 678):
$block .= '<textarea class="fe-prompt-raw" readonly spellcheck="false" aria-label="Prompt text">' . "\n" . $raw_html . '</textarea>';
That prepended \n is parser food. The browser re-hash reads ta.value, from which the parser has already eaten the sacrificial newline, so what gets hashed is the prompt exactly. Pull the "\n" and every prompt that opens on a newline breaks its own receipt.
Now the surfaces agree
With both passes excluded, the three places that publish the hash line up. Run this against the live site, a couple of times:
curl -s https://funnyenough.dev/prompts/byte-exact-copy-paste-audit.txt | shasum -a 256
# 64-hex over the raw bytes
curl -sI https://funnyenough.dev/prompts/byte-exact-copy-paste-audit.txt | grep -i x-prompt-hash
# x-prompt-hash: sha256:<same hex>
curl -s https://funnyenough.dev/prompts.json \
| jq -r '.prompts[] | select(.slug=="byte-exact-copy-paste-audit") | .sha256'
# <same hex>
The “a couple of times” is not a hedge. Hit a cold CDN node mid-deploy and the .txt body can momentarily lag its own header while the edge converges; I once caught a node serving a c49bf18… body against a bccaa64… ETag for a few seconds. Re-run and they settle. A system whose pitch is “do not ship receipts that lie” cannot pretend that window does not exist, so I am pointing straight at it. The gap between a demoted prompt and a purged edge cache is the failure this design spends most of its effort closing.
What is actually sealed is narrow. The hash is SHA-256 over the raw prompt bytes after exactly one transform: CRLF and bare CR collapse to LF (lines 111-113, 151-153):
function fe_prompt_normalise_raw($value) {
return str_replace(array("\r\n", "\r"), "\n", (string) $value);
}
function fe_prompt_sha256($raw) {
return hash('sha256', fe_prompt_normalise_raw($raw));
}
No trim, no Unicode normalisation, and deliberately no wp_unslash. A second unslash would silently eat the backslashes in a regex, a JSON escape, or a Windows path. Seal and serve after that one line; everything else is verbatim UTF-8.
The bug is bigger than my copy box. It is anything you write that an agent later reads back: a prompt, an MCP tool description, a rule file, a chunk of YAML. The moment text stops being prose for a human and becomes input for a machine, the exact bytes are load-bearing, and every layer in between feels free to mutate them. A CMS, a “smart” texturizer, a CDN, the clipboard.
So I built a small prompt library around receipts. Each prompt carries a SHA-256 of its canonical bytes, sealed server-side at publish, re-checked in three independent places where the text is actually consumed, each check failing closed. It is live at funnyenough.dev/prompts.
The lint: bytes and provenance, one gate
The textarea fix stops mutation at render time. It does nothing about a smart quote you paste into the source yourself. One fe_prompt_lint() function gates both problems before anything is sealed.
First the bytes (lines 189-204): reject the small set of characters that look identical to the eye and break a machine.
// Smart quotes / dashes that mangle copy-paste.
if (preg_match('/[\x{2018}\x{2019}\x{201C}\x{201D}]/u', $raw)) {
$problems[] = 'Contains smart quotes (“ ” ‘ ’) — use straight quotes.';
}
if (preg_match('/[\x{2013}\x{2014}]/u', $raw)) {
$problems[] = 'Contains en/em dashes (– —) — use a plain hyphen if the prompt needs one.';
}
// Dangerous invisibles: NBSP, zero-width, BOM, line/paragraph separators.
if (preg_match('/[\x{00A0}\x{200B}\x{200C}\x{200D}\x{FEFF}\x{2028}\x{2029}]/u', $raw)) {
$problems[] = 'Contains invisible characters (non-breaking space / zero-width / BOM).';
}
Two scope notes. wptexturize also turns ... into an ellipsis (U+2026), and this lint does not catch that one; a literal … in a prompt is rarer than a hyphen and less likely to wreck a parser, and one more preg_match closes it if you disagree. And the lint waves Hebrew, CJK, emoji, tabs, and backslashes straight through on purpose. The ban is on look-alikes, and non-ASCII is not one.
Then provenance, because a clean hash on a prompt I never re-ran proves nothing. The same gate refuses to seal a prompt that cannot say where it came from or when it last worked (lines 205-225):
if (trim((string) ($meta['breaks_when'] ?? '')) === '') {
$problems[] = 'breaks_when is required — say how the prompt fails.';
}
if (trim((string) ($meta['born_in_post'] ?? '')) === '') {
$problems[] = 'born_in_post is required — link the post/session it came from.';
}
$lv = (string) ($meta['last_verified'] ?? '');
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $lv, $dm) || !checkdate((int) $dm[2], (int) $dm[3], (int) $dm[1])) {
$problems[] = 'last_verified must be a real YYYY-MM-DD date.';
} elseif ($enforce_age) {
$age = (time() - strtotime($lv . ' 00:00:00')) / DAY_IN_SECONDS;
if ($age > FE_PROMPTS_AGING_DAYS) {
$problems[] = sprintf('last_verified is %d days old (> %d) — re-verify before publishing.', (int) $age, FE_PROMPTS_AGING_DAYS);
}
}
The $enforce_age flag is the subtle part. The 90-day rule is a publish-time gate only. Once a prompt is live, an aging last_verified shows as an advisory freshness chip, and a separate 180-day threshold flips that chip to “stale”, but a prose edit must not silently unpublish a prompt. So the hard age gate fires on the publish transition and nowhere else. Let a prompt’s last_verified age past 90 days and the publisher refuses it (the ERROR: prefix is the publisher’s die() helper):
$ node scripts/publish-prompt.mjs byte-exact-copy-paste-audit.md --id=91 --publish
Lint problems:
- last_verified is 95 days old (> 90) — re-verify
ERROR: refusing to PUBLISH a prompt that fails the receipts lint (fail closed)
The author surface is a markdown file with YAML front matter, then the prompt body in its own fenced block. Note the four-backtick outer fence, which is the point of the next section:
---
title: Byte-exact copy-paste audit
slug: byte-exact-copy-paste-audit
kind: integrity check
breaks_when: a layer between author and reader silently rewrites a character
born_in_post: /some-post-it-came-from
last_verified: 2026-06-21
tested_with: claude
---
when to use this, the story, caveats (markdown prose up here)
```prompt
The exact prompt text, byte for byte. Use {{repo_path}} placeholders.
```
Where the prompt gets cut out, and where it gets blocked
Two refusals matter, and they live in different places.
The publisher owns the fence boundary. extractPrompt in scripts/publish-prompt.mjs pulls the prompt out of the fenced prompt block by counting fence lines, not by a backreference regex. If the body contains a fence as long as the outer one, the close is ambiguous, so it refuses rather than truncating at the first inner fence (which would still hash “valid” on the wrong bytes):
const closeRe = new RegExp('^`{' + fenceLen + '}[ \\t]*$');
const closes = [];
for (let i = openIdx + 1; i < lines.length; i++) { if (closeRe.test(lines[i])) closes.push(i); }
if (closes.length === 0) return { raw: '', error: 'unterminated ```prompt block — no closing fence' };
if (closes.length > 1) return { raw: '', error: 'ambiguous fences: use a strictly longer outer fence, e.g. ````prompt ... ````' };
That logic lives only in the publisher; the server never parses fences, it stores raw bytes.
The server owns the publish gate, and here is the attack it is built for: take a live, correctly-sealed prompt and corrupt it the sneaky way, a meta-only write that flips one straight quote to a curly one, touching no normal publish path. The sed has to be byte-exact or the demo is a no-op, so it is spelled in hex (\x22 is ", \xe2\x80\x9c is the curly “):
wp post meta update 91 prompt_raw "$(wp post meta get 91 prompt_raw | sed $'s/\x22/\xe2\x80\x9c/')"
curl -s https://funnyenough.dev/prompts.json | jq '.prompts[] | select(.slug=="byte-exact-copy-paste-audit")'
# empty — the prompt demoted itself to draft and purged its cache on the next request
It demotes because the gate is not wired to one tidy “on publish” event. WordPress has too many write paths for that, so the gate hangs off each one:
| Hook | What it catches |
|---|---|
rest_pre_insert_prompt | strips an incoming publish to draft before meta lands, so a REST create cannot flash published on un-linted meta |
wp_insert_post_data | backstops every non-REST publish (cron, classic editor, bulk or quick edit) at the data layer |
updated_post_meta | the sneaky path above: a raw meta write queues a deduped re-gate on shutdown, so you cannot corrupt a published prompt by editing its meta directly |
rest_prepare_prompt | patches the REST response status to DB truth, so the publisher sees draft, not a lying publish |
The hash itself is never writable over REST: auth_callback on prompt_sha256 returns false (line 89), so a client can read the seal but never set it. One boundary I will name rather than paper over: a raw SQL write straight to wp_postmeta, bypassing the meta API, fires none of these hooks. That one is outside the gate.
The cache: a demoted prompt the edge keeps serving
Demoting a failing prompt to draft is theatre if the CDN already cached its .txt. LiteSpeed ignores my Cache-Control: no-cache on these custom routes, so every publish, demote, or edit fires an explicit purge across every cached surface (line 261): the per-slug .txt/.json/page, plus /prompts/, /prompts.json, /prompts.min.json, and /llms.txt. Miss /prompts.min.json or /llms.txt and a poller or an LLM crawler keeps reading old facets while the human index is already fixed. That c49bf18… node from the intro is this same failure caught mid-purge: an old body and a new ETag, briefly consistent with nobody.
One related bug cost me an afternoon. The .txt ETag is the prompt’s own SHA-256, so a conditional GET should 304 cheaply. It never did. WordPress’s wp_magic_quotes() slash-escapes $_SERVER, so a client’s If-None-Match: "abc" arrives as \"abc\", and trimming the quotes off leaves the backslashes, so the match never fires. The fix is wp_unslash before stripping (line 527):
$client = isset($_SERVER['HTTP_IF_NONE_MATCH']) ? trim(wp_unslash($_SERVER['HTTP_IF_NONE_MATCH']), '"') : '';
if ($client !== '' && hash_equals($etag, $client)) {
status_header(304);
exit;
}
Only .txt uses the prompt’s own SHA as its ETag. The JSON endpoints use a truncated hash of the full JSON body, so do not reuse the prompt hash as a validator there.
Verify in the reader’s browser
A hash sealed at publish proves nothing if the reader never checks it. The on-page Copy button re-hashes in the browser before it touches your clipboard (line 938):
function verify(text){
if (!publishedSha) return Promise.resolve('none');
if (!(window.crypto && crypto.subtle)) return Promise.resolve('unverifiable');
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
.then(function(buf){ return toHex(buf) === publishedSha ? 'ok' : 'bad'; })
.catch(function(){ return 'unverifiable'; });
}
The consumer treats anything it could not confirm as a refusal:
verify(raw).then(function(state){
if (state === 'bad' || state === 'unverifiable') { // never hand over bytes we couldn't confirm
say('Bytes drifted from the published hash — do not trust this copy.', 'bad');
return;
}
// only 'none' / 'ok' reach the clipboard
The line worth copying is treating unverifiable the same as bad. The tempting move is to copy anyway when the browser cannot hash and call it graceful degradation. But “I could not verify these bytes” is a weaker claim than “these bytes are fine,” and collapsing the two hands you unchecked text exactly when something might be off. So a browser without crypto.subtle gets the same stop as a bad hash, plus a nudge to open the raw .txt yourself.
One gap on this page I would rather flag than hide. The plain Copy button verifies and copies raw. The “Copy as Cursor rule” and “Copy as Claude skill” buttons wrap that same raw in a little YAML envelope, and the seal only covers raw, which is what verify hashes. So the prompt body is sealed end to end and the editor-specific envelope around it is yours to trust. The fully sealed path is the plain Copy button, one click over; closing the wrapper gap means hashing the envelope template separately, which I have not done yet.
The MCP server: same hash, on the agent’s side of the wire
Browser checks protect a human reading the site. They do nothing for an agent that pulls a prompt over HTTP into an automated run, so the seal gets enforced a third time, on the consumer’s machine, in a tiny stdio MCP server (fe-prompts-mcp.mjs, zero dependencies, Node 18+):
async function fetchVerified(p) {
const r = await fetch(`${BASE}/prompts/${encodeURIComponent(p.slug)}.txt`, { cache: 'no-store' });
if (!r.ok) throw new Error(`GET /prompts/${p.slug}.txt -> ${r.status}`);
const text = await r.text();
if (p.sha256) {
const got = crypto.createHash('sha256').update(text, 'utf8').digest('hex');
if (got !== p.sha256) throw new Error(`byte-integrity check failed for "${p.slug}"`);
}
return text;
}
The browser hashes with crypto.subtle.digest over TextEncoder().encode(text); Node hashes with crypto.createHash('sha256').update(text, 'utf8'). Both are SHA-256 over the UTF-8 bytes, which is why the same published hex verifies on either side, Hebrew and emoji and backslashes included. On a mismatch fetchVerified throws instead of returning best-effort bytes, and the prompts/get handler turns that throw into a JSON-RPC error. The hash verifies the canonical .txt first, then {{var}} fills happen locally, so the receipt covers the published template and the argument values you fill sit outside it by design.
State the claim exactly: the template bytes the agent starts from match the bytes I sealed and that the trusted index lists. It is not a signature against a compromised server, since an attacker who controls both the .txt and the index can serve a matching pair; for that you would pin the hash out of band. Point FE_PROMPTS_BASE at another library and it verifies that one, as long as it exposes the same prompts.json shape with a sha256 per entry.
What it buys and what it costs
Receipts prove the prompt your agent runs is the template you wrote, byte for byte, across a CMS that curls quotes, a stale edge cache, and a clipboard that does not care. The cost is a publish-time gate plus a verify step at every consumer, and you feel that friction the first time a prompt will not publish over one pasted smart quote.
They prove nothing about whether the prompt is any good. A perfectly hashed prompt can still tell your agent to rm -rf the wrong directory. Reading what a prompt does before you run it stays your job; the receipt only guarantees you are judging the real thing.
If you rebuild one piece of this without WordPress, make it the serving contract: a canonical .txt whose ETag is its own SHA-256, an X-Prompt-Hash header beside it, that same hash in a prompts.json index, a lint over bytes and provenance before you seal, a purge of every cached surface on every change, and the MCP server pointed at your index. Then steal the pair that started this: no_texturize_tags plus the leading-newline trick. That is the difference between a copy box that lies and one that does not.
Comments & Reactions
Got a thought, a war story, or a “well, actually”? Sign in with GitHub and jump in.
Loading comments…