It was a cloudy November 2nd in SΓ£o JosΓ© dos Campos (SP). I had just moved to the city with my family, and things were finally settling down. Since it was a holiday in Brazil and I woke up early, I turned on my workstation to catch up on a few things after spending the previous week relocating. Impressed by my recent sessions with Gemini Argon on Graft, I remembered how productive I had felt with Claude Opus 4.7 a few months earlier while building Lantana, my honeypot-as-code project. That gave me an idea: ask Gemini to audit the Lantana codebase.

The goal was simple: find stale documentation or dead code. Instead, after the agent finished interviewing me and running its discovery pass, my jaw dropped while reviewing its findings: my Shodan API key had been sitting in the public repository for months 😱. I rotated the key immediately. Even though it was a free-tier key with strict rate limits, it made me stop and think about how much worse it could have been. Here is how the leak slipped past every control I had in place, the five-layer guardrail architecture Gemini and I built to prevent it from happening again, and what surfaced when we ran those checks across all 22 of my repositories.

The Leak

The leaked key was sitting inside a unit test file β€” test_runner.py:

def test_shodan_url_key_redacted(self) -> None:
    msg = (
        "Client error '429 Too Many Requests' for url "
        "'https://api.shodan.io/shodan/host/1.2.3.4?key=vN794DcKiMpK48MnhhKik2SwKtXxG4SJ'"
    )
    out = _sanitize_error_message(msg)
    assert "vN794DcKiMpK48MnhhKik2SwKtXxG4SJ" not in out
    assert "key=REDACTED" in out

The first irony is when and where it happened. Unlike most APIs that take a token in an Authorization header, Shodan expects your API key in the URL query string β€” ?key=.... When Shodan rate-limits a request with HTTP 429 Too Many Requests, Python’s httpx includes the full request URL (key and all) inside the exception text. On May 22, worried about those raw exception strings leaking my key into Lantana’s SQLite error logs, I asked Claude to write a redactor β€” _sanitize_error_message. It shipped commit b094a94 β€” fix(enrichment): sanitise API keys in error messages before persistence. The commit that built the secret redactor is the exact commit that leaked the secret. πŸƒ

The second irony is that every automated check on that pull request stayed green. I had CLAUDE.md instructions, pytest, ruff, mypy --strict, shellcheck, terraform validate, ansible-lint, CodeQL, GitHub Secret Scanning, and GitHub Push Protection enabled since day one. I took that stack for granted and assumed the chance of leaking a secret was near zero.

When we dug into the root cause, the leak turned out to be a compound failure across four layers:

  1. Over-literal context copying: My first instinct was to blame AI hallucination, but the model didn’t invent that 32-character string β€” it copied it verbatim from runtime context. During our first live honeypot run, Shodan returned an HTTP 429 Too Many Requests error whose exception text included the raw ?key=... URL. At the time, lantana/CLAUDE.md had no rule requiring synthetic test fixtures; worse, it explicitly instructed the agent: “TDD: write tests first” and “Tests must mirror production shape, not the convenient shape.” Claude followed that rule literally, pasting the real 429 error URL from the session context straight into test_runner.py. πŸ’€
  2. Why abstract prompt rules decay: Even after I later added a generic “Never commit… credentials” line to my global instructions, an abstract rule is fragile on its own. To an LLM, an HTTP 429 error string pasted inside tests/ looks like a test fixture, not a credential. Add a growing context window (where model attention degrades as tool outputs pile up, a failure mode Fabio Akita has covered well), and static prompt instructions eventually get ignored.
  3. GitHub Secret Scanning and Push Protection blind spots: Default GitHub Secret Scanning matches structured partner token prefixes (ghp_, AKIA, sk-) to keep false positives near zero at platform scale. Shodan uses unprefixed 32-character alphanumeric strings (?key=[A-Za-z0-9]{32}), which aren’t on GitHub’s supported patterns list. Worse, when we tested enabling secret_scanning_non_provider_patterns via the GitHub API across my public repos, GitHub returned 200 OK while silently clamping the field to disabled on personal free-tier accounts β€” and returned an empty {} configuration on private repositories. πŸ’€
  4. CodeQL and the tests/ exclusion trap: CodeQL was running on every push, but it is an interprocedural semantic taint-analysis engine built to trace dataflow vulnerabilities (like SQL injection or SSRF), not an entropy and regex scanner for hardcoded strings. Meanwhile, many teams configure secret scanners to ignore tests/** to cut down on fixture noise β€” blinding themselves to the exact directory where AI coding agents are most likely to paste realistic strings from context.

Fortunately, this was a free-tier Shodan key. Because I spend most of my time building Graft against live cloud security tenants now, a similar mistake with a cloud credential or webhook could have been much worse. AI is non-negotiable in my workflow today, but it is an engine β€” it still needs a steering wheel, brakes, and guardrails.

Guardrailing AI Work

Right after rotating the key, I opened a fresh session with Gemini to design a defense-in-depth architecture and audit every repository under my GitHub account. We structured the controls into five layers, from L0 to L4 β€” two probabilistic AI bookends (L0 and L4) wrapping three deterministic security gates (L1, L2, and L3):

flowchart LR
    L0["<b>L0: Rules</b><br/>AGENTS.md"] --> L1["<b>L1: Local Hooks</b><br/>Gitleaks"]
    L1 --> L2["<b>L2: Central CI</b><br/>Gitleaks + Opengrep + Trivy"]
    L2 --> L3["<b>L3: GitHub</b><br/>Push Protection"]
    L3 --> L4["<b>L4: Peer Review</b><br/>/review-security"]
  • L0 (probabilistic): Explicit, mechanical sanitization and synthetic-fixture rules in my personal AGENTS.md, shared across every AI coding harness.
  • L1 (deterministic): Gitleaks wired into global pre-commit and pre-push Git hooks on my workstation.
  • L2 (deterministic): A central reusable GitHub Actions security workflow in lopes/.github running Gitleaks, Opengrep (SAST), and Trivy (IaC and dependency vulnerability scanning) across both public and private repositories, with zero exclusions for tests/.
  • L3 (deterministic): API-verified GitHub Secret Scanning and Push Protection across all active public repositories.
  • L4 (probabilistic): A separate, clean-context agent session running /review-security to review the pull request with offensive-security eyes before merging.

L0: Probabilistic AI Constitution

Because an abstract “never commit credentials” instruction doesn’t stop an agent from copying a runtime error log into a unit test, we rewrote the rule in lopes/dotfiles into concrete, mechanical constraints and symlinked that single file across ~/.config/agents/AGENTS.md, ~/.config/gemini/AGENTS.md, and ~/.claude/CLAUDE.md β€” while migrating repo-level CLAUDE.md files to vendor-agnostic AGENTS.md files:

## 5. Sanitization, Secret Prevention & Local State Hygiene
- Never commit local workstation paths, internal hostnames, corporate identifiers/usernames, internal tool codenames, customer names, or credentials to any repository.
- **Synthetic-only fixtures in code, `tests/`, and docs:** Never copy verbatim strings, URLs, query parameters, headers, IPs, or tokens from runtime logs, terminal output, environment variables, or databases into source files, test fixtures, or documentation. Always construct synthetic values using reserved documentation ranges (RFC 5737: `192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`; RFC 3849: `2001:db8::/32`; RFC 2606: `example.com`) and unmistakable dummy literals (`SYNTHETIC_TEST_<NAME>_000000`).
- **Zero raw secret reads into context:** Never read, print, or dump raw secret stores (`.env*`, `*secret*`, `*vault*`, `~/.config/zsh/private.sh`, SSH private keys, cloud credential files) into stdout or conversation context.
- Keep `~/.config/agents/`, `~/.config/gemini/`, and `~/.claude/` strictly for static user instructions; store persistent runtime state in `~/.local/state/`.

Two rules do the heavy lifting here: keeping raw secrets out of the conversation context in the first place (if the agent never reads the token into stdout, it cannot leak it into a test), and requiring RFC 5737/2606 ranges plus SYNTHETIC_TEST_<NAME>_000000 literals for every fixture. Still, L0 is strictly best-effort β€” LLMs are non-deterministic, which is why L1 through L3 exist.

L1: Deterministic Local Git Hooks

When we ran Gitleaks against the full Git history of Lantana (293 commits, 2.34 MB), it finished in 682 milliseconds and flagged commit b094a94 immediately β€” generic-api-key, Shannon entropy 4.351.

To make sure no commit ever bypasses it locally, we configured a global core.hooksPath (~/.config/git/hooks) in lopes/dotfiles with both pre-commit (gitleaks git --pre-commit --staged --redact) and pre-push hooks, backed by a global gitleaks.toml that includes a custom url-query-api-key rule for unprefixed 16–64 character keys in URL query parameters β€” ?key=, ?api_key=, ?token=. Both hooks scan every path (including tests/) in under a second and chain any repository-local .git/hooks/* script (such as make check in this blog repository) so existing per-project checks keep running.

L2: Deterministic Central CI

Local hooks protect the workstation, but they can be bypassed with --no-verify, a web edit, or an agent running inside a cloud sandbox. For L2, we looked for an existing GitHub Actions security workflow that fit our requirements and decided to build our own in a central account-level repository: lopes/.github.

GitHub treats <owner>/.github specially: it hosts account-wide default community health files (like our default SECURITY.md disclosure policy) and reusable workflows that any repository (public or private) can call in a short .github/workflows/security.yml stub. On every push and pull request, that central workflow runs three open-source scanners with zero tests/ exclusions:

  1. Gitleaks: Scans the full Git commit graph (fetch-depth: 0) with --redact.
  2. Opengrep: An open-source multi-language AST static analysis (SAST) engine scanning Python, Rust, Bash, Terraform, and GitHub Actions for dangerous execution sinks, TLS verification bypasses, and injection patterns.
  3. Trivy: Scans lockfiles (uv.lock, requirements.txt, Cargo.lock), Dockerfiles, Terraform, and Ansible configurations for HIGH and CRITICAL CVEs and infrastructure misconfigurations.

It can be added to any project with a .github/workflows/security.yml file like the one in Lantana, though I encourage you to fork lopes/.github and customize it for your own needs:

name: Security

on:
  pull_request:
  push:
    branches: [main]
  schedule:
    - cron: "17 4 * * 1"

permissions:
  contents: read
  security-events: write

jobs:
  security:
    uses: lopes/.github/.github/workflows/security.yml@main

L3: Platform Enforcement

At the outer boundary, we verified via the GitHub API (gh api repos/lopes/<repo>) that secret_scanning and secret_scanning_push_protection are active across all 10 of my active public repositories. When a commit contains a recognized partner-prefixed credential (ghp_, AKIA, sk-), GitHub rejects the push at the server-side git-receive-pack layer before the commit ever lands in the public object store β€” while L1 and L2 cover the unprefixed tokens, private repositories, SAST rules, and dependency CVEs that free-tier Push Protection misses.

L4: Adversarial Review Session

Deterministic scanners excel at known patterns, AST rules, and CVE databases, but they cannot reason about business logic or architectural intent. For L4, my development workflow splits into two isolated agent sessions β€” one to build, and a fresh one to attack and review what was built:

  1. Development session (Builder): Starts the same way as before with /plan + /grill-me, plan review with TDD, my personal L0 instructions, and the project instructions. At each local commit and remote push, L1, L2, and L3 run automatically to test the code for security flaws. If any issue is found, the commit is blocked locally or the PR fails in CI and cannot be merged until fixed. Locally, the LLM reads the scanner output directly (these security tools do a great job providing context on why a check failed and how to fix it), reasons about the fix, and implements it in new commits that go through the same local and remote gates.
  2. Adversarial review session (Reviewer β€” /review-security): Once the pull request is open and all deterministic tests and scanners pass, I spin up a separate, clean session (same model and harness, zero context from the build session) and run a dedicated /review-security skill. In the end, that is the exact pattern that caught the Lantana leak months later, minus the six-month wait: a fresh session has no sunk-cost bias toward the code it just wrote. Like an offensive-security operator, the skill starts with attack-surface discovery, runs whatever security scanners are already installed locally (suggesting missing ones without installing anything on its own), inspects the code and tests/ for logic flaws, trust-boundary gaps, or subtle leaks that static tools can’t see, and runs a secondary pass for documentation drift and leftover artifacts. Once it proposes fixes, implements and tests them, and pushes the changes back through L1–L3, the PR is ready to merge.
Important

Of course, if a leak ever makes it to a remote branch on GitHub, the secret still has to be rotated β€” especially in a public repository. Even then, catching it on the pull request before the commit reaches the main branch shrinks the exposure window and buys time for a proper response.

Results

Once the pipeline was ready, we cloned all my 22 repositories (1,481 total commits) under github.com/lopes and ran gitleaks across their entire commit history. The scan took under 45 seconds:

  • Zero live or unrotated secrets exist across all 18 of my public repositories. We intentionally left the rotated Shodan key in commit b094a94 of lopes/lantana (documented in lantana/.gitleaks.toml) so the links in this post remain verifiable.
  • In one private repository, Gitleaks found three archived Slack webhook URLs in historical commits, which we redacted in the working tree and documented in .gitleaks.toml.

Then we rolled out the L2 workflow (gitleaks, opengrep, and trivy) across every active repository. The results hit me in the face: while no new secret leaks appeared, Opengrep flagged 5 high-severity SAST issues and Trivy surfaced 11 critical, 62 high, 64 medium, and 5 low dependency vulnerabilities across my active projects, for example:

Repository Scanner Day-1 Findings Resolution
foxglove Trivy 59 fixable CVEs (7 CRITICAL, 52 HIGH) in uv.lock Upgraded pillow (13.0.0), pypdf (6.21.4), anyio (4.16.0), and certifi (2026.10.23).
patchbay Trivy 11 fixable CVEs (3 CRITICAL, 8 HIGH) in uv.lock Upgraded cryptography (50.0.2), pyjwt (2.15.1), anyio (4.16.0), and certifi (2026.10.23).
netbox-scanner Trivy + Opengrep 3 fixable CVEs (1 CRITICAL, 2 HIGH) in requirements.txt + 1 SAST error (unverified-ssl-context) Upgraded certifi and urllib3; audited and annotated the intentional if not self.tls_verify: opt-out for on-prem Cisco Prime appliances.
lantana, graft Opengrep 2 SAST errors (dangerous-subprocess-use-audit on subprocess.run / os.execvpe) Exposed a cross-linter blind spot: both sites already used shell=False and carried # noqa: S603 for ruff, forcing a second-pass audit of each argument vector before adding # nosemgrep.

Because the scanners produce deterministic, structured output (exact file, line number, rule ID, CVE, and fixed package version), Gemini was able to triage, upgrade lockfiles, run every repository’s test suite, and open clean pull requests across the entire fleet in a single session.

Claude vs. Gemini

If you’ve read this far, you might think my takeaway is that Claude is worse than Gemini. It isn’t. Back in May, when I built Lantana, Claude Opus 4.7 was clearly ahead of Gemini for agentic coding. Today, I find Gemini Argon slightly stronger β€” and comparing Argon against Opus 4.7 isn’t a fair fight anyway.

Frontier LLMs have reached a point where raw model capability is rarely the bottleneck; it’s getting harder to spot dramatic differences between top-tier models on day-to-day code generation. Where vendors are making the biggest leaps today is the harness (how the surrounding CLI or IDE orchestrates tool permissions, manages memory, compacts long sessions, and feeds the right context to the model at the right time) β€” a point Fabio Akita laid out well in LLMs + Agents = Competent Enough. Even so, using two different model families adversarially (building a project with one agent and auditing it months later with another) proved to be a great way to catch blind spots that the original sessions normalized. πŸ’‘

Takeaways

When I started working heavily with AI coding agents back in May, I made two classic mistakes: I trusted an abstract instruction in CLAUDE.md to override an agent’s urge to copy real data from its context window, and I trusted a platform checkbox (GitHub Secret Scanning) without verifying what patterns it actually covered. AI is an engine ready to take you places that would otherwise be out of reach for a solo engineer, but an engine still needs a steering wheel, airbags, and bumpers.

Non-deterministic tools are great at creation, yet they lose attention in long contexts, take instructions too literally, and cannot reliably tell whether a 32-character string inside an HTTP 429 log is a live secret or a harmless test fixture. Deterministic guardrails (L1 + L2 + L3) are essential to close that loop: just as TDD gives an AI agent a deterministic oracle for behavior, tools like Gitleaks, Opengrep, and Trivy give it a deterministic oracle for security β€” along with the exact diagnostic context the model needs to reason about the fix, even if a human engineer still has to judge the real-world impact.

As a security practitioner, I treated a leaked free-tier API key on a personal project as a high-severity incident: rotate within minutes, preserve the forensic trail, audit the entire fleet, and engineer permanent controls so the same class of failure cannot recur. My years in Security Operations paid off. My new AI workflow is ready to roll. πŸ‘Š