GUIDE · HOW-TO 8 min ·

Stop your coding agent from printing secretsThe leak no commit scanner watches

The secret you keep out of your repo with a .gitignore line is the easy case. The one your agent reads at runtime and prints into its tool output leaks through a path no commit scanner watches, and the same output is the channel an injection uses to exfiltrate it.

TL;DR· the answer, in twenty seconds

What happens: Your agent runs a tool that reads a live credential, prints it into stdout, and the harness feeds that output back into the model's context. The secret now sits in the transcript, the request to the model vendor, and any write path the agent can reach.

The minimum fix: Scan the tool-output boundary, not only your commits. GitGuardian's ggshield hook scans tool outputs from inside Claude Code; for agents without a hook, wrap shell commands in a regex-plus-entropy redactor before the result reaches context.

The lesson: A redactor is a net under a leak that already happened. The fix that closes the category is to never let the raw secret reach the output, which means brokering the credential so the agent gets the result and never the key.

The secret you keep out of your repo with a .gitignore line is the easy case. The one your coding agent reads at runtime and prints into its own output is the case nobody scopes for, and it leaks through a path no commit scanner watches.

Why agent output is its own leak

Most secret-leak advice points at the input side: keep the .env out of context, scan commits, harden the .gitignore. That covers the credential the agent reads going in. It says nothing about the credential the agent prints coming out.

A coding agent runs tools. It cats a config file, curls an endpoint, runs printenv to debug a failing build, queries a database and the connection string scrolls past in an error. Each of those lands in the tool's stdout, and the agent's harness feeds that stdout back into the model's context as the result of the call. The secret now sits in three places it was never meant to be: the transcript on disk, the request body sent to the model vendor, and the running context the next tool call reads from.

Picture the ordinary version. You ask the agent to fix a failing migration. It runs the test suite, the connection fails, and your ORM prints the full DSN into the stack trace: postgresql://app:hunter2@db.prod.internal:5432/app. The agent reads that trace as the tool result, reasons about it, and writes a summary that quotes the error verbatim, password and all. No commit happened. The credential traveled from your database config into a stack trace, into the model's context, into the request your harness sent the vendor, and into the transcript on disk. Four copies, and the one in your repo was never touched. A commit scanner sees none of it, because nothing was committed.

GitGuardian's 2026 State of Secrets Sprawl report counted 29 million hardcoded secrets in public GitHub during 2025, with AI-service credentials up 81% year over year. Those are the ones that reached a commit. The output-side leak skips the commit. Knostic's writeup on IDE assistants walks the path: a credential that enters the model's working memory can surface in generated code, logs, or suggestions later, well after the call that read it.

Then there is the write path. Aonan Guan's Comment and Control disclosure showed the full chain across Claude Code, Gemini CLI, and Copilot: untrusted input tells the agent to read a secret, and the same agent holds a tool that writes data out, so the value gets laundered into a PR comment or a webhook. The output is the channel the theft runs through.

Telling the agent not to print it does not work

The first instinct is to add a line to CLAUDE.md or the system prompt: never print secrets. It reads like a fix and it is not one. The model echoes what it sees, and an instruction competes with the tool result already sitting in context. Worse, the Comment and Control payloads routed around this kind of guard by framing the read as a compliance task and slicing the value before printing, so the output no longer matched a "do not print secrets" rule the model was trying to honor. A prompt is a request. The redaction has to happen at a boundary the model does not control.

Put a scanner on the output boundary

The fastest fix scans what the agent prints, the same way you scan what it commits. GitGuardian shipped this in its agent-skills repo: the Claude Code hook, which needs ggshield 1.49.0 or later, scans prompts, tool calls, and tool outputs from inside Claude Code, and blocks on a hit. Help Net Security covered the ggshield AI hook when it landed in April. The install:

# ggshield 1.49.0+ required
pipx install ggshield
ggshield auth login

# from the agent-skills repo: wires ggshield into the agent's hooks
git clone https://github.com/gitguardian/agent-skills
cd agent-skills && ./install-git-hooks

The hook sits on the tool boundary. Before a tool result returns to the model, ggshield runs its detectors over the text. A match stops the result from reaching context and flags it. That closes the re-ingestion loop for the common patterns: AWS keys, sk- tokens, database URLs, JWTs.

The same repo ships a honeytoken skill that plants decoy AWS credentials in your tree. A decoy never gets used by a legitimate process, so any read of one is a signal that something walked your files looking for secrets. That gives you a tripwire on the input side to match the scanner on the output side.

A scanner on the output catches known shapes. It does not catch a credential format it has no detector for, and it runs after the secret already hit the tool's own stdout buffer. Treat it as the floor, not the ceiling.

Roll your own redactor when there is no hook

Not every agent exposes a hook API. For the ones that shell out, you wrap the command and filter its output before anything downstream sees it. The pattern is a stream filter with two passes: a regex pass for known prefixes, and an entropy pass for the long random strings that have no fixed shape.

#!/usr/bin/env bash
# redact.sh — pipe a command's output through pattern + entropy redaction
# usage: redact.sh -- <command> [args...]

redact() {
  sed -E \
    -e 's/(sk-[A-Za-z0-9]{20})[A-Za-z0-9]+/\1__REDACTED__/g' \
    -e 's/(AKIA)[A-Z0-9]{16}/\1__REDACTED__/g' \
    -e 's#(postgres|mysql|mongodb(\+srv)?)://[^:]+:[^@]+@#\1://__REDACTED__@#g' \
    -e 's/(gh[pousr]_)[A-Za-z0-9]{36}/\1__REDACTED__/g'
}

shift  # drop the leading --
"$@" 2>&1 | redact

The regex pass handles the credentials that announce themselves: sk-, AKIA, ghp_, a URL with a password in the authority. The connection-string rule earns its place because a Postgres or Mongo URL is the most common thing an agent prints when a query fails, and it carries the password inline.

The regex pass misses the high-entropy blob with no prefix: a base64 session token, a raw hex key. For those you score Shannon entropy and redact anything over a threshold. gitleaks and trufflehog both ship this logic, so you can borrow their defaults instead of tuning from scratch. A short Python redactor that adds the entropy pass:

import math, re, sys

def entropy(s):
    if not s:
        return 0.0
    counts = {c: s.count(c) for c in set(s)}
    return -sum((n / len(s)) * math.log2(n / len(s)) for n in counts.values())

TOKEN = re.compile(r'[A-Za-z0-9+/=_\-]{20,}')

for line in sys.stdin:
    out = line
    for m in TOKEN.finditer(line):
        if entropy(m.group()) > 4.0:        # near gitleaks default
            out = out.replace(m.group(), m.group()[:4] + '__REDACTED__')
    sys.stdout.write(out)

Run the agent's shell commands through one of these and the raw value never reaches the transcript. Set the entropy threshold against your own known tokens. Four bits per character catches most base64 secrets without eating ordinary git SHAs, which sit lower.

To make this stick, point the agent's shell at the wrapper instead of bare bash. In Claude Code that is a PreToolUse hook on the Bash tool that rewrites the command to run through redact.sh; in a plainer harness it is an alias or an override of the shell the agent invokes. The agent keeps working as before and never sees the line where the wrapper swapped the value out.

Before you trust it, prove it fails closed. Echo a fake token shaped like the real thing and confirm the wrapper eats it:

./redact.sh -- printf 'token=sk-ABCDEFGHIJKLMNOPQRSTuvwxyz0123\n'
# token=sk-ABCDEFGHIJKLMNOPQRST__REDACTED__

If the raw value comes through, your pattern is wrong before the agent ever runs. Run the same check with a long base64 blob to exercise the entropy pass, since that is the path that catches the tokens you did not write a rule for.

This buys you redaction at the terminal. It does not reach the agent's in-process tools, the Read tool that opens a file without shelling out. Those bypass the wrapper, and that gap is the limit worth naming out loud.

The leak a filter cannot reach

A redactor scrubs the value after a tool produced it. The value still existed, in the tool's memory, for the length of that call, and any in-process tool that skipped your wrapper saw it whole. Detection on the output is a net under a leak that already happened.

The move that closes the category keeps the raw secret out of the agent's output. If the agent never holds the plaintext, the tool result has nothing to redact. That means brokering: the credential lives outside the agent, the agent asks a broker to run the privileged call, and the broker returns the result with the secret stripped. The agent gets the data it needed and never the key that fetched it.

This flips the question. A redactor asks, "did a secret reach the output, and can I catch it before it spreads?" A broker asks, "was the secret ever in the agent's reach to begin with?" The second question has a cleaner answer, because a value the process never received cannot leak from it through any channel, output included. A broker like hasp runs the command with the credential injected into that one process and keeps the plaintext out of the agent's environment, so a stray printenv or a failing query has nothing to print. Pair that with a redact pass on whatever still flows through, and the output stops being a credential store. The OWASP Agentic Top 10 files this under sensitive-information disclosure because the output channel is an attack surface before it is a logging problem.

Audit what your agent already printed

You can check the damage in five minutes. Your transcripts and CI logs are plaintext, and a leaked secret in them is a leaked secret whether or not anyone has read it yet.

# Claude Code transcripts (adjust for your agent's log path)
grep -rnE 'sk-[A-Za-z0-9]{20}|AKIA[A-Z0-9]{16}|gh[pousr]_[A-Za-z0-9]{36}' \
  ~/.claude/projects/ 2>/dev/null

# database URLs with inline credentials
grep -rnE '(postgres|mysql|mongodb)(\+srv)?://[^:]+:[^@]+@' \
  ~/.claude/projects/ ~/.config/ 2>/dev/null

# CI logs, if you archive them
grep -rnE 'AKIA[A-Z0-9]{16}|sk-[A-Za-z0-9]{20}' ./ci-logs/ 2>/dev/null

If any of those print, you have a credential sitting in a log file, and the response is the same as any leak: rotate it. The file is not the boundary. The credential reached the file because nothing scrubbed the output that carried it there, and rotation is the only move that helps once a key may have been read.

Widen the search to wherever the agent's output comes to rest. A coding agent's plaintext does not stop at its own transcript. It lands in the request logs your model vendor keeps, the CI job output your platform archives, the Slack thread where someone pasted a run, and the issue comment the agent wrote to close the loop. You cannot grep the vendor's logs, which is the point: once a working secret leaves your machine in a request body, you no longer decide who reads it. The greps above tell you what is local. The rotation tells you the rest does not matter, because the value is dead.

What this means for your stack

The minimum this week: put a scanner on the output boundary, not only on commits, and run the three greps above against your agent's transcripts and any CI logs you keep. Rotate anything they surface. That covers the credentials with a known shape and tells you what already leaked.

The pattern that fixes the category keeps the plaintext out of the output in the first place. An agent that never receives the raw secret has nothing to print and nothing to launder into a write path. Redaction is the net; brokering is the floor under it.

hasp is one working implementation of that floor. hasp run -- <command> injects a credential into one process, keeps it out of the agent's environment, and redacts managed values from text the agent does handle. Source-available (FCL-1.0), local-first, macOS and Linux, no account.

The test does not depend on which tool you pick. Open your agent's last transcript, search it for the four secret prefixes above, and see what comes back. The output was always a place secrets could land. The only question is whether anything was watching it.

Sources· cited above, in one place

NEXT STEP~90 seconds

Stop handing the agent your real keys.

hasp keeps secrets in one local encrypted vault, brokers them into the child process at exec, and never lets the agent read the value.

  • Local, encrypted vault — no account, no cloud, no telemetry by default.
  • Brokered run — agent gets a reference, the child process gets the value.
  • Pre-commit + pre-push hooks catch managed values before they ship.
  • Append-only HMAC audit log answers "did the agent touch the prod token?" in seconds.
→ okvault unlocked · binding ./api
→ okgrant once · pid 88421
→ okagent never read

macOS & Linux. Source-available (FCL-1.0, converts to Apache 2.0). No account.

Browse all clusters· eight threads, one index