I gave Claude Code control over my article publishing pipeline. It handled writing, automated review, limited-share previews, and scheduling end to end. It worked well enough that I stopped watching closely.
Then one afternoon I asked it to rewrite an existing post and give me a limited-share URL so I could review the changes. Instead it published the draft publicly. By the time I noticed, roughly 100 people had already read it.
I was publishing to Qiita, where once an article goes fully public you cannot put it back to private through the API. There is no undo. The draft was out, and it stayed out. This isn't a Qiita quirk to wave away, either: it's true of any platform where publishing is irreversible, which is most of them once a human has read the page.
This is the writeup of what went wrong and the guardrail system I built afterward. The lesson is at the bottom, but it's worth seeing how I got there, because the obvious fix is the wrong one.
Quick answer
A publishing guardrail only works when the AI agent cannot bypass it.
If the safe path is safe-publish.sh, but the agent can still run qiita publish directly, the guardrail is advisory. The fix is not a louder prompt. The fix is to move enforcement below the agent's preferred command path:
- machine checks before review
- a human-only ledger for public release approval
- a tool-execution hook that blocks raw publish commands
- a clear update path that does not train humans to ignore the gate
The design goal is simple: the AI can draft, review, preview, and update safe surfaces, but the irreversible publish path must hit a wall unless the human gate has been satisfied.
What actually ran
The command Claude Code executed was:
bun run qiita publish --force <slug>
I had already built gate scripts for exactly this situation. There was a gate-machine-review.sh and a safe-publish.sh with three gates between "draft" and "live." The agent just didn't go through them. It called the underlying bun run qiita publish directly, bypassing every check I'd written.
Two failures stacked on top of each other:
- The agent skipped the gate scripts and invoked the raw publish command.
- The draft's frontmatter had
private: false, and nobody caught it. The--forceflag was attached, so the command ran without any confirmation prompt.
Each piece was reasonable in isolation; the combination published the article with no human in the loop.
Why the gate scripts didn't save me
This is the part I had wrong for a while. I assumed that having a safe-publish wrapper meant publishing was safe. It doesn't. A wrapper script only protects the path that goes through it. The agent has a shell. If it can type bun run qiita publish directly, the wrapper protects nothing.
So the rebuild had two halves: make the safe path actually rigorous, and make the unsafe path physically unavailable.
Gate 1: machine review (pre_publish_check.py)
The first gate is a Python script that reads the article file and refuses obvious problems before a human ever looks at it. It checks:
- Required frontmatter fields (
title,tags,id) - A minimum character count, so half-written drafts can't advance
- Dead URLs: every link gets a
curlHEAD request, and a 404/410/5xx/DNS failure fails the gate - Malformed or injected links
The dead-URL check used to live in my own review step, which meant I was eyeballing links by hand. That was a waste. Anything a machine can verify, a machine should block, so I moved it into Gate 1. Human attention is the scarce resource; don't spend it on curl.
When this passes it records a machine_review_passed marker that the next stage reads.
Gates 2 and 3: safe-publish.sh
The shell wrapper enforces the rest:
-
Gate 1 confirmation: if
machine_review_passedis absent, BLOCK. You cannot reach a human gate without passing the machine gate. - Updates are exempt: editing the body of an already-public article skips Gates 2 and 3. Updating live content is not the same risk as making something public for the first time, and treating them identically just trains you to ignore the prompts.
-
Gate 2 (the human gate): if
reviewed_by_useris missing, BLOCK. The important property here is that the agent cannot write this value itself. It lives in a ledger that a human edits by hand. That's what makes it a human gate rather than a checkbox the agent ticks. If the AI could set it, the gate would just be a comment with extra steps. -
Gate 3 (scheduling): if
publish_target_dateis in the future, BLOCK. If another article was already published today, BLOCK. I publish at most one article per day, and the gate enforces it rather than trusting me to remember.
Gate 0: the hook that blocks at the tool layer
Here's the gap that the original system never closed. Every gate above lives inside a script. Scripts only run when you call them. The accident happened precisely because the agent didn't call them.
Claude Code runs shell commands through a Bash tool, and it fires a PreToolUse event before the command executes. That event can veto the call. So I wrote a hook, pre-bash-qiita-publish-guard.py, that intercepts every Bash invocation and inspects the command string before it runs:
BLOCK_PATTERNS = [
r"bunx\s+qiita\s+publish",
r"bun\s+(run|x)\s+qiita\s+publish",
r"npx\s+qiita\s+publish",
r"qiita\s+publish",
]
ALLOW_SUBSTRINGS = [
"safe-publish.sh",
"gate-machine-review.sh",
"qiita pull",
"preview",
"version",
]
If the command matches a block pattern and isn't on the allow list, the hook denies it and returns a message telling the agent to go through safe-publish.sh. A bare qiita publish now dies before the shell ever sees it. Read-only operations like qiita pull and qiita preview pass through untouched.
The useful output is intentionally boring:
BLOCKED: raw qiita publish is not allowed.
Use scripts/safe-publish.sh <slug> after machine review and human approval.
That message is the product. It turns an irreversible public publish into a recoverable routing error.
Yes, a regex blocklist is enumerated badness
A skeptic will point out, correctly, that a regex over a command string is exactly the kind of guard I just spent two sections dunking on. Extra spaces, a bash -c wrapper, an alias, a variable holding the binary name — any of those slips past the patterns. As a defense against an adversary, this is weak.
But the threat model here isn't an adversary. It's a capable model doing the obvious thing. The agent isn't trying to evade me; when it reaches for the publish command it types it the straightforward way, and the straightforward way is exactly what the patterns catch. The hook turns a silent public publish into a hard block plus a message pointing at safe-publish.sh. That's the whole job.
The real fix is removing the publish capability from the token or CLI the agent can reach at all, so the command simply isn't available rather than available-but-pattern-matched. I haven't done that part yet. Until I do, the hook closes the gap that actually caused the incident, and it moves enforcement from "the script the agent should call" down to "the layer the agent calls everything through." That's a real improvement even though it isn't the final one.
A practical checklist
If you are letting an AI agent touch a publishing pipeline, start with this checklist:
- Identify every irreversible command, not just the documented happy path.
- Make the safe path explicit, for example
scripts/safe-publish.sh <slug>. - Block the raw publish command at the tool-execution layer.
- Keep human approval in a place the agent cannot casually edit.
- Separate new public release from updates to already-public content.
- Store publish state outside chat history, so the next session can resume safely.
- Treat "the agent should remember" as a bug, not a control.
This does not make the system perfect. It makes the most likely mistake bounce.
The holes that are still open
I'd rather be honest than make this sound finished, so here are the parts that still aren't solid. The second one is more interesting than it looks.
The updated_at drift. qiita publish rejects an update if the frontmatter's updated_at is older than what the server has. Every time I rewrite an article I have to pull the live value and reconcile it before the update will go through. I want safe-publish.sh to do that fetch-and-sync automatically, but it doesn't yet, so this is still a manual step that occasionally bites.
The ledger drift, which is the whole incident in miniature. I have a ledger_sync.py that keeps the ledger aligned with the live state, and under some conditions it tries to set reviewed_by_user to true on its own. Read that again: the one value the agent is forbidden to write is reachable by an automation the agent triggers. The human gate is eroding itself from the inside, one layer down from where I built the wall.
That's the part worth sitting with. This failure mode is fractal. Every time you add automation, it re-opens the exact hole the automation below it was supposed to close, and the human-only privilege has to keep getting pushed down to a layer where automation genuinely can't reach. "I separated the agent from the publish command" is never a finished sentence; it's a thing you have to keep re-proving every time you build one more convenience on top. I'm fixing this instance. I'm not pretending it's the last one.
The lesson
The instinct after an incident like this is to make the AI more careful. Better prompts, more warnings in the system message, stern instructions about checking private before publishing. I tried some of that. It's close to useless, because a model that's careful 99% of the time still publishes your draft on the hundredth run, and publishing is irreversible.
What actually worked was separating, at the code level, the operations the AI is allowed to perform from the privileges a human holds. The agent can run machine review, draft, preview, and pull all day. It cannot set reviewed_by_user, and it cannot reach the raw publish command, because the tool-execution hook won't let the command through.
So I stopped trying to build an agent that never makes the mistake, and started building a system where making the mistake costs nothing. Assume the agent will eventually do the wrong thing, and make sure the wrong thing bounces off a wall it can't talk its way around. A more careful agent would be welcome. It isn't what keeps the draft private. The wall does that.
FAQ
Why not just tell the AI agent to be more careful?
Because carefulness is probabilistic, and publishing is irreversible. A better prompt can reduce mistakes, but it does not change what the agent is technically allowed to do.
Is a wrapper script enough?
No. A wrapper only protects the path that goes through it. If the agent can call the raw publish command, the wrapper is documentation, not enforcement.
Is a command-blocking hook a complete security boundary?
No. A regex hook is not an adversarial security boundary. It is still useful because it blocks the straightforward mistake that caused the incident. The stronger design is to remove the raw publish capability from the agent's reachable credentials or tooling.
What should remain human-only?
The decision to make something public for the first time. Drafting, checks, previews, and updates can be automated aggressively, but the first public release needs a human-owned approval signal.
Top comments (0)