DEV Community

Mukunda Rao Katta
Mukunda Rao Katta

Posted on

tool-secret-scrubber: Strip Secrets from Tool Logs Before They Reach Your LLM

An agent I was debugging had a tool that called an external search API. The search tool needed an API key to authenticate. I passed the key in as part of the tool arguments dict because it was the simplest thing that worked at the time. The tool ran, returned results, and I logged the whole thing for debugging purposes. A week later I was reviewing the agent trace and noticed something: the tool call arguments, including the API key, were being echoed back into the LLM as part of the tool result context. My API key had been in the prompt context for every call this agent made.

It was not going anywhere dangerous in that specific case because the traces stayed on my local machine. But the key was there, in plaintext, in a structure that could end up in a log file, in a trace aggregator, in a prompt cache, or anywhere else I sent those traces. The architecture had accidentally created a path where credentials flowed through the LLM loop.

Tool arguments are not always just data. Sometimes credentials sneak in because the integration was written quickly. Sometimes the tool needs auth to do its job and the simplest design was to pass it inline. When those arguments get echoed back into the LLM context as tool results, or get written to structured logs that land in an aggregator, you have a secret in places you did not intend. tool-secret-scrubber is the filter you add at the boundary to catch those before they leave.

Shape of the fix

from tool_secret_scrubber import Scrubber

scrubber = Scrubber()

# Scrub a tool args dict before logging or before passing to LLM
tool_args = {
    "query": "sales data Q2",
    "api_key": "PLACEHOLDER_API_KEY_VALUE",
    "auth_token": "PLACEHOLDER_TOKEN_VALUE",
    "limit": 100,
    "include_metadata": True,
}

clean = scrubber.scrub(tool_args)
# clean["api_key"]          == "[REDACTED]"
# clean["auth_token"]       == "[REDACTED]"
# clean["query"]            == "sales data Q2"
# clean["limit"]            == 100
# clean["include_metadata"] == True

# Scrub a raw string directly
from tool_secret_scrubber import scrub_string

raw_header = "Authorization: Bearer PLACEHOLDER_BEARER_TOKEN_VALUE"
clean_header = scrub_string(raw_header)
# "Authorization: Bearer [REDACTED]"

# Custom scrubber: extend the field-name list
custom_scrubber = Scrubber(
    secret_field_names={
        "api_key", "token", "secret", "password",
        "credential", "key", "auth", "access_key",
        "private_key", "client_secret",
    }
)
Enter fullscreen mode Exit fullscreen mode

The scrubber does not modify the input dict. It returns a new dict with redacted values. Non-string values that match field names (for example an integer field named key) are left as-is because integers cannot match token patterns.

What it does NOT do

It does not understand context. It operates on field names and string patterns, not on the meaning of the content. If your API key appears inside a long string that also contains unrelated text, the pattern will match the token shape and redact the field value as a whole. It does not do partial in-string redaction by default: the whole field value is replaced with [REDACTED], not just the matched portion. Use scrub_string for partial string redaction.

It does not traverse nested dicts or lists automatically in the current version. If your tool args have a nested structure, call scrubber.scrub on each nested dict explicitly. Automatic deep traversal is on the backlog. It does not verify whether a matched string is actually a live credential: it cannot distinguish a real AWS access key from a string that happens to be 20 uppercase alphanumeric characters. False positives are possible and are the safer failure mode compared to false negatives.

Inside the lib

Nine regex patterns cover the most common credential shapes that appear in tool arguments: AWS access key IDs (20 uppercase alphanumeric characters starting with AKIA), AWS secret access keys (40-character base64-like strings), GitHub PATs (the ghp_ prefix followed by alphanumeric), Slack bot tokens (xoxb- prefix), Slack OAuth tokens (xoxp- prefix), Google service account key JSON blobs containing "private_key_id", generic bearer tokens found in authorization header values, JWT strings (three base64url segments separated by dots where segment lengths match typical JWT structure), and high-entropy alphanumeric strings of 32 characters or more.

The field-name check runs before pattern matching. If a field name (lowercased) is in the secret names set, the value is redacted without even running the regex patterns. This catches credentials that do not match a known token shape but are stored under a field name that signals they are sensitive. The default secret names set covers api_key, secret, password, token, auth_token, access_token, private_key, credential, and key.

Scrubbing is intentionally strict. When in doubt, redact. A false positive means a benign value shows up as [REDACTED] in your logs, which is annoying but visible and fixable. A false negative means a real secret leaks silently, which may not be visible until much later.

Important note on test fixtures: if you write tests for scrubber behavior, use obviously synthetic placeholder strings such as PLACEHOLDER_API_KEY_VALUE rather than realistic token shapes. GitHub's push protection will flag anything that looks like a real credential, even in test files. The library's own tests use this convention throughout.

20 tests cover: field-name redaction, each of the nine regex patterns, custom secret-name sets, non-string value handling, the scrub_string function, empty dicts, and dicts with no secrets.

When useful

  • Middleware that wraps tool call and result logging in an agent framework before writing to disk or a log aggregator
  • Pre-context sanitization before including tool results in the LLM prompt on the next turn
  • Security audit runs where you pass existing agent trace files through the scrubber to look for past leakage
  • CI checks: scrub all tool call fixtures and assert that the scrubbed output matches expected redacted shapes
  • Any pipeline where tool arguments flow through a path that was not originally designed to be secret-aware

When not useful

  • Repository-level secrets scanning (use Gitleaks, truffleHog, or GitHub's built-in push protection for that)
  • Streaming content where you need to redact on the fly without buffering the full value
  • Binary or base64-encoded payloads that need structural parsing rather than regex matching
  • Anywhere the redaction needs to be reversible, for example for audit trails where you need to recover the original value

Install

pip install tool-secret-scrubber
Enter fullscreen mode Exit fullscreen mode

Zero dependencies. Python 3.9+.

Siblings

Library Language What it does
tool-secret-scrubber-rs Rust Original Rust implementation with the same pattern set
llm-pii-redact Python PII redaction including Luhn-checked credit card numbers
agenttap Python Wire-level prompt introspection for tracing what goes into the LLM
tool-error-classify Python Closed ErrorKind enum for structured tool exception handling
agentguard TypeScript Egress allowlist for agent calls to prevent unexpected destinations

What's next

Nested dict and list traversal is the top item on the backlog. Most real tool argument payloads have at least one level of nesting and the current flat scrub misses secrets buried deeper than the top level. After that, in-string partial redaction for values that mix credentials with other content would cover the cases that the current whole-value replacement handles poorly.


Part of the Hermes Agent Challenge sprint. Source at github.com/MukundaKatta/tool-secret-scrubber. PyPI: pip install tool-secret-scrubber.

Top comments (0)