Secret-Aware Context Filters: Stop AI Editors and Agents From Shipping Your Keys

Secret-Aware Context Filters: Stop AI Editors From Shipping Keys
13 views

The most common way an AWS key reaches a model isn’t an attack. An engineer pastes a stack trace that includes a connection string, or an agent runs cat config/local.yaml to “understand the setup”, and the key is now in a prompt, in the vendor’s request logs and possibly in the next patch the agent writes.

A secret-aware setup has three layers. Keep secret files out of reach with the tools’ own ignore and deny rules. Filter what still gets through, redacting secrets from prompts and file context before they leave your machine. Check what comes back, so a model can’t write a live credential into code or a PR. And when something does leak, rotate first. This guide covers each layer with tested code, and it merges our earlier bootcamp lesson on secret-aware context filters into this guide.

⚡ TL;DR
  • Cursor’s .cursorignore blocks files from Agent, Tab, Inline Edit and @-mentions, and .env* plus everything in .gitignore is ignored by default. But per Cursor’s docs, the agent’s terminal and MCP tools aren’t blocked, and protection isn’t guaranteed.
  • Claude Code uses Read deny rules in settings.json. They cover built-in file tools and recognized commands like cat, but not scripts that open files themselves. Its sandbox gives OS-level enforcement.
  • Add a redaction filter with high-precision detectors (AWS key IDs, GitHub and Slack token prefixes, PEM blocks) plus an entropy pass, using stable placeholders.
  • Block prompts containing secrets with a UserPromptSubmit hook, and refuse model output that contains a credential.
  • If a key leaked: deactivate it, check its last use and CloudTrail, then replace it, preferably with a role.

Who this is for: developers and platform teams using AI editors and coding agents on code that sits next to real credentials. Examples use Python, Claude Code and Cursor configuration, and the AWS CLI. Tool behavior described here was checked against Cursor’s and Claude Code’s documentation in October 2026; both change often, so recheck before you rely on a specific rule.

Where secrets enter AI context

Map the paths before choosing controls. A coding assistant can see a secret through:

  • Files it reads: indexing, @-mentions, the agent’s file tools.
  • Your prompt: pasted logs, stack traces, curl commands with headers.
  • Editor state: open buffers and selections shared with the assistant.
  • Tool output: a terminal command such as env or cat, or an MCP tool that returns configuration.

Ignore and deny rules cover the first path well, and the others only partly. That’s why the filter and the output check exist.

Figure 1. Three layers between your secrets and a model: rules keep secret files out, a filter redacts what slips into prompts and context, and an egress check stops credentials coming back in output. Diagram by CheatCoders.
Figure 1. Three layers between your secrets and a model: rules keep secret files out, a filter redacts what slips into prompts and context, and an egress check stops credentials coming back in output. Diagram by CheatCoders.

Layer 1: Keep secret files out of reach

Cursor

Cursor reads a .cursorignore file in the project root, using .gitignore syntax. Files listed there are blocked from Agent, Tab, Inline Edit and @-mention references. Cursor also ignores everything in .gitignore and a default list that includes .env* and lockfiles, and you can add global ignore patterns in user settings. Cursor’s documentation is explicit about two limits: the terminal and MCP tools used by Agent can’t block access to ignored files, and complete protection isn’t guaranteed. So list files that hold secrets but aren’t gitignored:

bash
# Cursor already ignores .env* and everything in .gitignore by default.
# List secret-bearing files that ARE committed or not gitignored:
**/*.pem
**/*.key
**/credentials.json
deploy/secrets/
terraform/*.tfvars

Test a pattern with git check-ignore -v <file>, which Cursor’s docs recommend because the syntax is the same.

Claude Code

Claude Code uses permission rules in .claude/settings.json. A Read deny rule blocks its built-in file tools, recognized file commands in Bash such as cat, head and tail, and redirections, and Claude Code makes a best-effort attempt to apply it to @file mentions and IDE context. The docs warn that it doesn’t cover commands that read files without naming them, such as grep -r, or scripts that open files themselves. For OS-level enforcement, enable the sandbox. A .claudeignore file has no effect.

json
{
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(./**/*.pem)",
      "Read(./secrets/**)"
    ]
  },
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block_secrets_in_prompt.py",
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

The hooks section wires in the prompt filter from layer 2. Commit this file so the whole team gets the same rules.

The strongest version of layer 1 is not having the secret in the working tree at all. Load credentials at runtime from AWS Secrets Manager or your identity provider, and use short-lived role credentials instead of access keys in local profiles.

Layer 2: Redact what still gets through

The filter below runs on any text about to enter a model’s context: a prompt, a file being attached, a tool result your own agent passes back. It combines high-precision detectors for formats with known prefixes, a keyword rule for assignments like password = "...", and an entropy pass for unknown token shapes, which skips lockfiles full of hashes.

python
"""Redact secrets from anything about to enter a model's context, and refuse output that re-emits them."""
import fnmatch
import hashlib
import math
import re
from collections import Counter

DENY_PATHS = ["**/.env", "**/.env.*", "**/*.pem", "**/*.key", "**/id_rsa", "**/id_ed25519",
              "**/.aws/credentials", "**/credentials.json", "**/secrets.*"]
ENTROPY_SKIP = ["**/package-lock.json", "**/pnpm-lock.yaml", "**/yarn.lock", "**/*.lock", "**/go.sum"]

DETECTORS = [
    ("aws_access_key_id", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
    ("github_token", re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{22,}\b")),
    ("slack_token", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b")),
    ("private_key", re.compile(r"-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----.*?-----END (?:[A-Z]+ )?PRIVATE KEY-----", re.S)),
    ("assigned_secret", re.compile(
        r"(?i)\b(?:secret|password|passwd|token|api[_-]?key|aws_secret_access_key)\b\s*[:=]\s*['\"]?([^\s'\"]{8,})")),
]
TOKENISH = re.compile(r"[A-Za-z0-9/+=_-]{32,}")


def _matches(path: str, patterns: list) -> bool:
    p = "/" + path.lstrip("/")
    return any(fnmatch.fnmatch(p, pat) or fnmatch.fnmatch(p, pat.replace("**/", "*/")) for pat in patterns)


def entropy(s: str) -> float:
    n = len(s)
    return -sum(c / n * math.log2(c / n) for c in Counter(s).values()) if n else 0.0


def placeholder(kind: str, value: str) -> str:
    # Same secret -> same placeholder, so the model can still reason about "the key in config.py".
    return f"[REDACTED:{kind}:{hashlib.sha256(value.encode()).hexdigest()[:8]}]"


def redact(text: str, path: str = "", threshold: float = 4.5) -> tuple:
    if path and _matches(path, DENY_PATHS):
        return f"[BLOCKED: {path} is on the deny list]", [("blocked_path", path)]
    events = []

    def sub(kind):
        def repl(m):
            value = m.group(1) if m.lastindex else m.group(0)
            events.append((kind, path))
            return m.group(0).replace(value, placeholder(kind, value))
        return repl

    for kind, rx in DETECTORS:
        text = rx.sub(sub(kind), text)
    if not (path and _matches(path, ENTROPY_SKIP)):
        def high_entropy(m):
            s = m.group(0)
            if s.startswith("[REDACTED") or entropy(s) < threshold:
                return s
            events.append(("high_entropy", path))
            return placeholder("high_entropy", s)
        text = TOKENISH.sub(high_entropy, text)
    return text, events


def assert_no_secret_egress(model_output: str) -> None:
    """Run on model output before it's written to disk, a PR or a tool call."""
    for kind, rx in DETECTORS[:4]:          # high-precision detectors only
        if rx.search(model_output):
            raise PermissionError(f"model output contains a secret ({kind}); refusing to apply")

The prefixes come from the vendors: AWS access key IDs start with AKIA, and temporary STS keys with ASIA. GitHub documents ghp_ (classic PAT), github_pat_ (fine-grained), gho_, ghu_, ghs_ and ghr_. GitHub has also started rolling out a new format for App installation tokens, so treat any length assumption in your patterns as something to re-test.

Placeholders are stable: the same secret always becomes the same [REDACTED:kind:hash]. The model can still say “the key on line 2 is hard-coded; move it to Secrets Manager” without ever seeing it. Here’s the filter on a typical config file, using AWS’s documented example key:

python
from secretfilter import assert_no_secret_egress, redact

AKID = "AKIA" + "IOSFODNN7EXAMPLE"          # AWS's documented example key ID
SRC = f'''import boto3
AWS_ACCESS_KEY_ID = "{AKID}"
aws_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
client = boto3.client("s3")
'''

out, events = redact(SRC, "app/config.py")
print(out)
print(events)
assert AKID not in out and "wJalrXUtnFEMI" not in out and "boto3.client" in out
assert redact("x", "services/api/.env.production")[1] == [("blocked_path", "services/api/.env.production")]
again, _ = redact(SRC, "app/config.py")
assert again == out                                  # stable placeholders
lock, ev = redact('"integrity": "sha512-' + "Zx9" * 20 + '"', "web/package-lock.json")
assert ev == []                                       # lockfile hashes are not secrets
try:
    assert_no_secret_egress(f"Use key {AKID} in the client")
except PermissionError as e:
    print("blocked egress:", e)

Output from that test run:

text
import boto3
AWS_ACCESS_KEY_ID = "[REDACTED:aws_access_key_id:1a5d44a2]"
aws_secret_access_key = "[REDACTED:assigned_secret:78314b11]"
client = boto3.client("s3")

[('aws_access_key_id', 'app/config.py'), ('assigned_secret', 'app/config.py')]
blocked egress: model output contains a secret (aws_access_key_id); refusing to apply

Block prompts before they’re sent

Claude Code runs UserPromptSubmit hooks before a prompt is processed. A hook that returns "decision": "block" stops the prompt from reaching Claude, and the reason is shown to you, not added to the context. This hook reuses the filter:

python
#!/usr/bin/env python3
"""Claude Code UserPromptSubmit hook: stop prompts that contain secrets before they reach the model."""
import json
import os
import sys

sys.path.insert(0, os.path.join(os.environ.get("CLAUDE_PROJECT_DIR", "."), "tools"))
from secretfilter import redact  # noqa: E402

event = json.load(sys.stdin)
_, findings = redact(event.get("prompt", ""))
if findings:
    kinds = sorted({kind for kind, _ in findings})
    print(json.dumps({
        "decision": "block",
        "reason": f"Prompt blocked: it looks like it contains {', '.join(kinds)}. "
                  "Remove the value (or reference the secret by name) and resubmit.",
        "hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "suppressOriginalPrompt": True},
    }))
sys.exit(0)

The default timeout for this hook type is 30 seconds, and by default a hook that times out is cancelled and the prompt still goes through. That’s why the settings above set "onFailure": "block", which makes a failed or timed-out check block the prompt instead (this needs Claude Code v2.1.295 or later; the default is "continue"). Keep the check fast either way. Tool output is a different matter: a PostToolUse hook runs after the tool already ran, so it can’t stop that output reaching the model. Prevent risky commands with deny rules or a PreToolUse hook instead.

Layer 3: Check what comes back

Models copy what they see. If a key ever made it into context, the next patch may hard-code it “to fix the error”. Run assert_no_secret_egress on generated output before your agent writes files, opens a PR or calls a tool, and keep a scanner such as gitleaks in pre-commit and CI (gitleaks git scans the repo history, gitleaks dir scans files, and gitleaks stdin scans piped text). The egress check uses only the high-precision detectors, so it doesn’t block legitimate output on entropy false positives.

When a key leaks anyway: rotate first

Treat any key that reached a prompt as exposed. Deactivate it immediately (deactivation is reversible, deletion isn’t), then investigate:

bash
#!/usr/bin/env bash
# Rotate first, investigate second. Usage: rotate_leaked_key.sh <iam-user> <access-key-id>
set -euo pipefail
USER_NAME="$1"; KEY_ID="$2"

# 1. Stop the leaked key working now (reversible, unlike delete).
aws iam update-access-key --user-name "$USER_NAME" --access-key-id "$KEY_ID" --status Inactive

# 2. When was it last used, and for what?
aws iam get-access-key-last-used --access-key-id "$KEY_ID"
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=AccessKeyId,AttributeValue="$KEY_ID" \
  --max-results 50 --query 'Events[].[EventTime,EventName,EventSource]' --output table

# 3. Issue a replacement only if a long-lived key is really needed (prefer roles), then delete the old one.
# aws iam create-access-key --user-name "$USER_NAME"
# aws iam delete-access-key --user-name "$USER_NAME" --access-key-id "$KEY_ID"

lookup-events searches CloudTrail’s event history, which covers the past 90 days of management events in the current Region; check other Regions and your CloudTrail trail or data lake for anything older or for data events. Replace a long-lived user key with a role where you can. For secrets in Secrets Manager, trigger rotation with aws secretsmanager rotate-secret --secret-id <name> once rotation is configured.

Worked example: a stack trace with a key in it

  1. A developer pastes a failing boto3 call into Claude Code. The traceback includes the client config with an access key ID.
  2. The UserPromptSubmit hook blocks the prompt with “it looks like it contains aws_access_key_id”. The developer removes the line and resubmits; nothing reached the model.
  3. Separately, the team’s PR bot attaches app/config.py to its context. The filter replaces both credentials with placeholders, and the bot suggests moving them to Secrets Manager.
  4. The bot’s patch passes the egress check and gitleaks in CI.
  5. Because the key was already in a committed file, the team runs the playbook anyway: deactivate, review CloudTrail, switch the service to a role and delete the key.

Troubleshooting

SymptomLikely causeFix
Agent read an ignored file anywayIt used the terminal, an MCP tool or a scriptExpected per the docs; move the secret out of the repo, use the Claude Code sandbox, and keep the filter on tool results
Too many redactions in normal codeEntropy threshold too low, or lockfiles and hashes scannedRaise the threshold, add files to the skip list, rely on prefix detectors
A real token wasn’t caughtUnknown format with no prefixAdd a detector; vendors’ secret-scanning partner formats are a good source
Prompt hook never firesWrong path, missing python3, or invalid JSON in settingsRun the hook by hand with a sample JSON on stdin; check the debug log
Hook slows every promptHeavy scanning in the hookKeep it to regexes; the default timeout for this hook is 30 seconds

Production checklist

  • ☐ No long-lived credentials in the working tree; runtime secrets from Secrets Manager; roles over access keys
  • ☐ .cursorignore and global ignore patterns for secret files that aren’t gitignored
  • ☐ Claude Code Read deny rules committed; sandbox enabled where agents run commands
  • ☐ Redaction filter on prompts, attachments and tool results your own agents pass to models
  • ☐ Prompt-blocking hook; egress check before any write or PR
  • ☐ gitleaks in pre-commit and CI
  • ☐ Rotate-first playbook documented and rehearsed

FAQ

Isn’t .gitignore enough?

No. It keeps files out of commits, and Cursor also honors it for AI access, but the agent’s terminal can still print the file, and secrets often live in files that are committed by mistake.

Can’t the model vendor filter secrets server-side?

By then the secret has already left your machine. Filter before sending; treat vendor-side controls as a backup.

Should I hash or remove secrets?

Replace them with a stable placeholder that includes a short hash. It’s not reversible, and it lets the model refer to “the same key” consistently.

Does the Claude Code deny rule stop python -c "print(open('.env').read())"?

No. The docs say deny rules don’t apply to subprocesses that open files themselves. Use the sandbox for OS-level enforcement.

Official documentation

Related on CheatCoders: PrivateLink and secret hygiene for coding agents · PII redaction before embeddings

Last updated on October 10, 2026

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

1 Comment

Leave a comment

No account needed. Name and email are optional.