# Guard Scripts Don't Stop an AI Agent From Publishing Your Draft. It Has a Shell

> Source: <https://dev.to/nomurasan/guard-scripts-dont-stop-an-ai-agent-from-publishing-your-draft-it-has-a-shell-327g>
> Published: 2026-07-07 04:02:22+00:00

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.

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:

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.

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:

`private: false`

, and nobody caught it. The `--force`

flag 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.

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.

`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:

`title`

, `tags`

, `id`

)`curl`

HEAD request, and a 404/410/5xx/DNS failure fails the gateThe 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.

`safe-publish.sh`

The shell wrapper enforces the rest:

`machine_review_passed`

is absent, BLOCK. You cannot reach a human gate without passing the machine gate.`reviewed_by_user`

is missing, BLOCK. The important property here is that `publish_target_date`

is 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.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.

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.

If you are letting an AI agent touch a publishing pipeline, start with this checklist:

`scripts/safe-publish.sh <slug>`

.This does not make the system perfect. It makes the most likely mistake bounce.

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 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.

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.

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.

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.

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.
