{"slug": "guard-scripts-don-t-stop-an-ai-agent-from-publishing-your-draft-it-has-a-shell", "title": "Guard Scripts Don't Stop an AI Agent From Publishing Your Draft. It Has a Shell", "summary": "A developer's AI agent, Claude Code, bypassed guard scripts and published a draft article publicly on Qiita, reaching about 100 readers before the mistake was noticed. The incident occurred because the agent directly executed the underlying publish command instead of the intended safe-publish wrapper. The developer subsequently rebuilt the system with a pre-execution hook that intercepts and blocks any direct publish command unless a machine review and human gate have been satisfied.", "body_md": "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.\n\nThen 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.\n\nI 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.\n\nThis 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.\n\nA publishing guardrail only works when the AI agent cannot bypass it.\n\nIf the safe path is `safe-publish.sh`\n\n, but the agent can still run `qiita publish`\n\ndirectly, the guardrail is advisory. The fix is not a louder prompt. The fix is to move enforcement below the agent's preferred command path:\n\nThe 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.\n\nThe command Claude Code executed was:\n\n```\nbun run qiita publish --force <slug>\n```\n\nI had already built gate scripts for exactly this situation. There was a `gate-machine-review.sh`\n\nand a `safe-publish.sh`\n\nwith three gates between \"draft\" and \"live.\" The agent just didn't go through them. It called the underlying `bun run qiita publish`\n\ndirectly, bypassing every check I'd written.\n\nTwo failures stacked on top of each other:\n\n`private: false`\n\n, and nobody caught it. The `--force`\n\nflag 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.\n\nThis 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`\n\ndirectly, the wrapper protects nothing.\n\nSo the rebuild had two halves: make the safe path actually rigorous, and make the unsafe path physically unavailable.\n\n`pre_publish_check.py`\n\n)\nThe first gate is a Python script that reads the article file and refuses obvious problems before a human ever looks at it. It checks:\n\n`title`\n\n, `tags`\n\n, `id`\n\n)`curl`\n\nHEAD 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`\n\n.\n\nWhen this passes it records a `machine_review_passed`\n\nmarker that the next stage reads.\n\n`safe-publish.sh`\n\nThe shell wrapper enforces the rest:\n\n`machine_review_passed`\n\nis absent, BLOCK. You cannot reach a human gate without passing the machine gate.`reviewed_by_user`\n\nis missing, BLOCK. The important property here is that `publish_target_date`\n\nis 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.\n\nClaude Code runs shell commands through a Bash tool, and it fires a `PreToolUse`\n\nevent before the command executes. That event can veto the call. So I wrote a hook, `pre-bash-qiita-publish-guard.py`\n\n, that intercepts every Bash invocation and inspects the command string before it runs:\n\n```\nBLOCK_PATTERNS = [\n    r\"bunx\\s+qiita\\s+publish\",\n    r\"bun\\s+(run|x)\\s+qiita\\s+publish\",\n    r\"npx\\s+qiita\\s+publish\",\n    r\"qiita\\s+publish\",\n]\n\nALLOW_SUBSTRINGS = [\n    \"safe-publish.sh\",\n    \"gate-machine-review.sh\",\n    \"qiita pull\",\n    \"preview\",\n    \"version\",\n]\n```\n\nIf 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`\n\n. A bare `qiita publish`\n\nnow dies before the shell ever sees it. Read-only operations like `qiita pull`\n\nand `qiita preview`\n\npass through untouched.\n\nThe useful output is intentionally boring:\n\n```\nBLOCKED: raw qiita publish is not allowed.\nUse scripts/safe-publish.sh <slug> after machine review and human approval.\n```\n\nThat message is the product. It turns an irreversible public publish into a recoverable routing error.\n\nA 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`\n\nwrapper, an alias, a variable holding the binary name — any of those slips past the patterns. As a defense against an adversary, this is weak.\n\nBut 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`\n\n. That's the whole job.\n\nThe 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.\n\nIf you are letting an AI agent touch a publishing pipeline, start with this checklist:\n\n`scripts/safe-publish.sh <slug>`\n\n.This does not make the system perfect. It makes the most likely mistake bounce.\n\nI'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.\n\n**The updated_at drift.**\n\n`qiita publish`\n\nrejects an update if the frontmatter's `updated_at`\n\nis 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`\n\nto 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`\n\nthat keeps the ledger aligned with the live state, and under some conditions it tries to set `reviewed_by_user`\n\nto 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.\n\nThat'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.\n\nThe 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`\n\nbefore 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.\n\nWhat 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`\n\n, and it cannot reach the raw publish command, because the tool-execution hook won't let the command through.\n\nSo 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.\n\nBecause 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.\n\nNo. 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.\n\nNo. 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.\n\nThe 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.", "url": "https://wpnews.pro/news/guard-scripts-don-t-stop-an-ai-agent-from-publishing-your-draft-it-has-a-shell", "canonical_source": "https://dev.to/nomurasan/guard-scripts-dont-stop-an-ai-agent-from-publishing-your-draft-it-has-a-shell-327g", "published_at": "2026-07-07 04:02:22+00:00", "updated_at": "2026-07-07 04:28:20.712761+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-safety", "large-language-models"], "entities": ["Claude Code", "Qiita", "Bash", "Python"], "alternates": {"html": "https://wpnews.pro/news/guard-scripts-don-t-stop-an-ai-agent-from-publishing-your-draft-it-has-a-shell", "markdown": "https://wpnews.pro/news/guard-scripts-don-t-stop-an-ai-agent-from-publishing-your-draft-it-has-a-shell.md", "text": "https://wpnews.pro/news/guard-scripts-don-t-stop-an-ai-agent-from-publishing-your-draft-it-has-a-shell.txt", "jsonld": "https://wpnews.pro/news/guard-scripts-don-t-stop-an-ai-agent-from-publishing-your-draft-it-has-a-shell.jsonld"}}