DEV Community

Cover image for Defense in Depth Before an AI Agent Publishes to the Open Web
Savage Solutions
Savage Solutions

Posted on Originally published at savagesolutions.io

Defense in Depth Before an AI Agent Publishes to the Open Web

The Problem Is Irreversibility

When an AI agent can clone a repository, mine a knowledge base, draft a post, and publish it to the open web without a human in the loop, the failure mode is not a typo. It is a leaked credential that is indexed by Google within minutes and scraped by bots within seconds. A leaked key is unrecoverable once public. Rotation helps, but the window between exposure and rotation is enough to drain a cloud account, exfiltrate a database, or compromise a CI pipeline.

We built an agentic content system at Savage Digital Solutions that does exactly this: it pulls from a git-cloned knowledge base, generates drafts in TypeScript, and publishes to Next.js-powered properties. Before we let it touch the publish button, we needed to answer one question honestly: what is the worst thing this agent could accidentally ship?

The answer was a list. API keys. Database URIs. Internal hostnames. Private IP ranges. JWT tokens. Once we had the list, we built two independent layers to stop them.

Layer One: Exclusion by Construction

The knowledge base the agent mines is built from a git clone. That single architectural decision does most of the work. Anything listed in .gitignore is excluded before the agent ever sees it.

Our secrets directory is in .gitignore. So are local environment files, certificate bundles, and any directory that holds credentials for third-party services like HeyGen or MongoDB Atlas. The agent cannot leak what it cannot read.

This is not a scrubber. It is a structural guarantee. The knowledge base is assembled from the cloned tree, and the cloned tree does not contain the secrets directory by construction. No regex, no scanning, no runtime check required at this layer.

The practical implication: if a developer accidentally commits a secret to a tracked file, that is a separate problem handled by pre-commit hooks and GitHub's secret scanning. The agent layer assumes the repository is clean and adds its own independent defense on top.

Layer Two: A Pure Pattern Scrubber on Every Draft

The second layer runs on every draft the agent produces, regardless of where the content came from. It is a pure function: it takes a string, scans it against a set of credential patterns, and returns either a pass or a block. It never modifies the content. It never cleans and ships.

The patterns it checks for include:

  • sk- prefix keys (OpenAI and similar services)
  • AKIA prefix strings (AWS access key IDs)
  • ghp_ prefix strings (GitHub personal access tokens)
  • AIza prefix strings (Google API keys)
  • eyJ prefix strings (JWT tokens in their base64-encoded form)
  • Bearer followed by a token (authorization headers that have leaked into prose)
  • Database URIs with embedded passwords (MongoDB connection strings, PostgreSQL DSNs, and similar formats where credentials appear inline)
  • RFC 1918 private address ranges (10.x.x.x, 172.16-31.x.x, 192.168.x.x)
  • Internal file paths (absolute paths that reveal server directory structure)

We describe these as patterns rather than publishing the live regular expressions here, because the scrubber itself would flag a document containing live credential-matching regex as a potential leak. That is the correct behavior.

The policy is block-on-doubt. Any match holds the post and fires an alert. A human reviews it before anything is published. This is not a soft warning. The agent cannot override it.

// Simplified structure of the scrubber interface
interface ScrubResult {
  passed: boolean;
  matches: ScrubMatch[];
}

interface ScrubMatch {
  patternName: string;
  excerpt: string; // surrounding context, not the secret itself
  position: number;
}

function scrubDraft(draft: string): ScrubResult {
  // Runs all patterns against draft
  // Returns passed: false and halts pipeline on any match
  // Never mutates draft content
}
Enter fullscreen mode Exit fullscreen mode

The function is pure and stateless. It has no side effects beyond returning the result. The pipeline that calls it is responsible for blocking and alerting. This separation makes the scrubber easy to unit test against a fixture library of known-bad strings.

Why Two Layers Instead of One

Each layer fails in a different direction.

The .gitignore exclusion fails if a developer commits a secret to a tracked file. It does nothing about secrets that the agent might construct or infer from context, and it does nothing about secrets that arrive through a prompt injection in source content.

The scrubber fails if a credential pattern is novel enough that no existing pattern matches it. It also cannot catch secrets that are semantically present but syntactically disguised (a key split across two sentences, for example).

Neither layer is sufficient alone. Together, they cover the realistic failure modes: accidental inclusion of a secrets file in the knowledge base, and accidental generation of a credential-shaped string in a draft.

The combination also satisfies a compliance requirement we care about: we can demonstrate to a client that two independent controls exist, that neither depends on the other, and that the policy is block-on-doubt rather than clean-and-ship. That last point matters. A scrubber that removes the credential and publishes anyway is not a safety control. It is a false sense of security that obscures the fact that a credential was present in the pipeline at all.

What This Looks Like in Practice

The agent pipeline runs in this order:

  1. Clone the knowledge base repository.
  2. Build the document index from the cloned tree (.gitignore exclusions apply here).
  3. Generate a draft using the indexed content.
  4. Pass the draft to scrubDraft().
  5. If passed: false, hold the post, log the ScrubMatch array, and send an alert to the review queue.
  6. If passed: true, proceed to the Next.js publishing step.

Step 5 has fired three times in our testing phase, twice on synthetic test fixtures and once on a draft that included a MongoDB Atlas URI that had been pasted into a markdown file in the knowledge base. That file was not in .gitignore. The scrubber caught it. We added the file to .gitignore and the URI pattern confirmed the scrubber was working as intended.

The alert in step 5 includes the patternName and the surrounding context (not the secret itself) so the reviewer knows what to look for without the alert itself becoming a credential leak.

Key Takeaways

  • A leaked key is unrecoverable once public. The pipeline must block before publish, not remediate after.
  • Layer one: build the knowledge base from a git clone so .gitignore exclusions apply by construction. The agent cannot leak what it cannot read.
  • Layer two: run a pure scrubber on every draft. Check for sk- keys, AKIA strings, ghp_ tokens, AIza keys, eyJ JWTs, Bearer tokens, database URIs with embedded passwords, RFC 1918 addresses, and internal file paths.
  • Policy is block-on-doubt. Never clean-and-ship. A scrubber that removes and publishes is not a safety control.
  • Keep the scrubber as a pure function with no side effects. The pipeline handles blocking and alerting. This makes the scrubber independently testable.
  • Two layers fail in different directions. Neither is sufficient alone. The combination covers accidental file inclusion and accidental credential generation.
  • The full implementation of this pipeline, including the TypeScript scrubber and the Next.js publishing integration, is part of the agentic content system built and maintained by Savage Digital Solutions (savagesolutions.io).

Top comments (2)

Collapse
 
mnemehq profile image
Theo Valmis

Block rather than clean and ship is the important decision here. Automatic redaction can turn a detected secret into a published hint that still exposes internal structure. Keeping the enforcement function pure and outside the generating agent is also the pattern we are building Mneme around for code changes: the worker should not grade its own output.

Collapse
 
peterbuildssecure profile image
Peter

The block-on-doubt framing is the important part, agreed. One layer worth adding to "independently testable": scrubDraft() being a pure function that's unit-tested in isolation proves the function returns passed: false on a bad fixture — it doesn't prove the pipeline actually halts when that happens. The wiring between step 4 and step 5 is a separate integration point a pure-function unit test can't see, and it's exactly the kind of thing that silently degrades — someone changes the pipeline glue code, the block becomes a logged warning instead of a hard stop, and every existing unit test for scrubDraft() still passes. Worth having a periodic end-to-end test that runs a known-bad fixture through the entire agent pipeline, not just through the scrubber, and asserts step 6 never fires — a negative control on the wiring, not just the detector.