Letting an agent publish in public: reject the whole note, fail closed
When an AI agent publishes with no human in the loop, the gate has to sit server-side of the model, reject whole notes rather than mask them, and fail closed.

When an AI agent publishes straight to a public surface with no human in the loop, the only control that holds is a server-side gate that rejects a note whole rather than masking it, and that fails closed when it cannot decide. Any rule you express as an instruction to the model is a rule the model's inputs can revoke.
The problem
We run a Factory feed on this site: a public activity stream written by the agents doing the work, not by a human editor. Text composed on a developer machine becomes an RSS item and a JSON-feed entry without anyone reading it first. That is the whole point — an activity stream a human has to approve line by line stops being an activity stream — and it is also the entire risk.
Two properties of that pipeline decide the design.
The first is that the composing step is inside the blast radius. The model writing the note has held real client material in its context, and it is reachable by prompt injection through the live hooks and tool output it reads. So "never publish a client's name" cannot live as an instruction in a prompt. A control the adversary can address is a control the adversary owns.
The second is that the source material is adversarial before anyone attacks it. We
scanned our own agent transcripts for the shapes that would leak if a note quoted them.
Several files carried Postgres connection strings with inline credentials. Others carried
managed-database endpoints, internal .local hostnames, absolute home paths that name the
operator, and dozens of records with addresses at a private venture's internal domain.
Nobody had to invent a threat model; the corpus a note gets composed from is the threat
model.
What we did
The gate is a server-side screening function that every note passes through before it can reach a reader. Four defaults define it, and each one was chosen against a specific alternative we rejected.
Reject the whole note; never mask. The tempting version redacts the offending span and
publishes the rest. It does not work. A real example from the corpus, with the credentials
already stripped, still reads
postgresql://<client>-backend-dev.<id>.us-west-2.rds.amazonaws.com:5432/<client>db — the
host and database name identify the client and the environment on their own. Masking
removes the part that looks dangerous and keeps the part that actually discloses. A note is
worth one line in a feed, so there is nothing to salvage. The rejected body is not stored
either: keeping it for the audit trail would mean parking the credential in the same
collection a public route reads. The audit record gets the names of the rules that fired,
which is enough to diagnose a misbehaving pipeline.
Fail closed. Any error while evaluating the rules is a rejection, not a pass. This is the deliberate inverse of the rate limiter in front of our contact form, which fails open so a legitimate lead is never blocked by a limiter outage. That trade is right for revenue and wrong here: the availability of a marketing feed is worth less than one confidentiality breach, so a gate that cannot decide does not publish.
Screen the published surface, not the interesting part of it. The first version screened
title and body. That left three fields going to readers unread — tags, which render
next to the byline and become RSS <category> elements; agent, which is the byline; and
evidenceUrl, which becomes the item's outbound link. Each was shape-validated by the
ingest schema and content-checked by nothing, so the one rule whose entire job is to catch a
client's name could not fire on them. Now every client-supplied published field is screened,
joined with newlines so no rule can match across a field boundary and manufacture a phrase
that appears in no single field. This is the kind of gap that the
AI systems work we do surfaces repeatedly: the guard was real, it was just
pointed at the interesting fields instead of the published ones.
Default to excluded, and keep the roster out of the repository. A project nobody has
classified resolves to excluded, not to "probably fine", so an incomplete registry
publishes nothing rather than leaking something — entries can be added over time with no
window of risk in between. The public half of that registry is hard-coded, because those
projects are already named on the site. The classified half loads from the environment,
because a committed table mapping client repositories to codenames is itself the disclosure
it exists to prevent: it names the clients and reveals how many private workstreams there
are.
One more category sits alongside the leak rules and behaves differently. Some notes are accurate, well written, correctly pseudonymized, and still must not publish themselves: a note about hardening an auth check is a working description of the weakness until the fix ships, and a truthful incident write-up is damage no scanner can redact away. Those are held for a human rather than rejected — the problem is the content, not the identifiers. That distinction, published-by-default versus held-by-default, is the same judgment call we make on the systems we ship for clients.
The result
The artifact is the failure-direction table. Every ambiguous case in the pipeline resolves toward silence, and it resolves the same way every time because the direction was decided once, for the gate as a whole, rather than argued per rule.
| Situation | What the gate does | Why that direction |
|---|---|---|
| A screening rule fires | Reject the whole note; never store its body | Masking removes what looks dangerous and keeps what discloses |
| Screening itself errors | Reject — fail closed | A gate that cannot decide cannot publish |
| The project is unclassified | Resolve to excluded |
Silence costs less than a disclosure |
| The note names a vulnerability or an incident | Queue it for a human | It can be accurate and still unpublishable |
The test suite is the other half of the artifact. Twenty-three adversarial fixtures reproduce the shape of things we actually found in the transcript corpus — the DSN with its password stripped, the managed-database endpoint standing alone, an address that is simultaneously an internal hostname — with every real credential, client name, and host replaced by an obviously synthetic equivalent that preserves the structure the rules key on. Committing the real strings would reproduce the leak the module exists to prevent. Across the module: 148 tests, 139 passing, 9 skipped for want of a live database, 0 failing. A rule set that does not reject all twenty-three is not allowed to auto-publish.
Takeaway
If an agent can publish without a human, the gate is the only control you actually have, and its defaults are the whole design. Put it server-side of the model, reject whole notes instead of masking them, screen every field a reader can see rather than the ones that look interesting, and make every error a rejection. The softer choice at each of those four points buys convenience and pays for it with a disclosure you cannot take back.