Most AI coding leaks on a solo repo are boring. A founder pastes a stack trace, a .env line, an internal hostname, and a customer email into a chat box, takes the patch, and ships. Nobody reviews the prompt because nobody else is in the room.
A gate beats discipline here. The artifact below is a stdlib-only redactor that runs before the prompt is written, proves itself against a fixture of planted secrets, and rehydrates the model's answer on your own machine. It adds no dependencies, no service, and no bill. It runs on whatever box already builds the project.
The three classes of payload
Inventory first. Regex written before inventory always misses the obvious case.
-
Prefixed credentials. Cloud access keys,
sk_-style API keys, bearer headers, credentials embedded in connection URLs. -
Identity and topology. Emails, private IPs,
.internal/.corphostnames, absolute home paths. - Unpatterned business context. Table names, customer names, invoice fields, unreleased flags.
Classes 1 and 2 are mechanical. Class 3 is not, and a redactor that claims to handle it will be trusted once and abandoned after the first bad paste. Treat this tool as a floor, not a ceiling.
Step 1 — Write the redactor
One file, standard library only. Rules run in a fixed order because URL credentials must be captured before the email rule shreds them.
#!/usr/bin/env python3
# redact.py - stdlib-only pre-prompt redaction with local rehydration.
import argparse, json, re
from pathlib import Path
RULES = [
('URL_CRED', re.compile(r'(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]+:[^/\s@]+@')),
('CLOUD_KEY', re.compile(r'\b(?:AKIA|ASIA)[0-9A-Z]{16}\b')),
('API_KEY', re.compile(r'\b(?:sk|rk|pk)[_-](?:live|test)?[_-]?[A-Za-z0-9]{16,}\b')),
('BEARER', re.compile(r'(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{20,}')),
('INTERNAL_HOST', re.compile(r'\b[a-z0-9][a-z0-9-]*\.(?:internal|corp|local|lan|svc)\b', re.I)),
('PRIVATE_IP', re.compile(r'\b(?:10\.\d{1,3}|192\.168|172\.(?:1[6-9]|2\d|3[01]))\.\d{1,3}\.\d{1,3}\b')),
('ABS_PATH', re.compile(r'/(?:Users|home)/[A-Za-z0-9._-]+')),
('EMAIL', re.compile(r'\b[\w.+-]+@[\w-]+\.[\w.]{2,}\b')),
]
def redact(text):
mapping = {}
out = text
for label, pattern in RULES:
def swap(match, label=label):
token = f'<{label}_{len(mapping) + 1}>'
mapping[token] = match.group(0)
return token
out = pattern.sub(swap, out)
return out, mapping
def rehydrate(text, mapping):
for token in sorted(mapping, key=len, reverse=True):
text = text.replace(token, mapping[token])
return text
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--in', dest='src', required=True)
ap.add_argument('--out', dest='dst', required=True)
ap.add_argument('--map', dest='map', required=True)
args = ap.parse_args()
raw = Path(args.src).read_text(encoding='utf-8')
red, mapping = redact(raw)
Path(args.dst).parent.mkdir(parents=True, exist_ok=True)
Path(args.dst).write_text(red, encoding='utf-8')
Path(args.map).write_text(json.dumps(mapping, indent=2), encoding='utf-8')
print(f'redacted {len(mapping)} spans -> {args.dst}')
if __name__ == '__main__':
main()
The mapping file is the whole trick. Placeholders keep the prompt readable for the model, and the map lets you swap real values back into the answer afterwards. It also creates a new secret store, which the limitations below take seriously.
Step 2 — Prove it with a leak test
A gate you cannot test is a ritual. Plant known values, redact, then assert that none survive and that clean SQL passes untouched.
# test_redact.py
import unittest
from redact import redact, rehydrate
LEAKY = '''\
debug: postgres://app:hunter2@db.corp.internal:5432/app
key: sk_live_51H8xQ2eZvKYlo2C
contact: ops@acme-billing.example
host: 10.4.19.7
path: /Users/dakota/work/client-x/pipeline.py
'''
CLEAN = 'SELECT id, total FROM orders WHERE total > 100;\n'
class RedactionTest(unittest.TestCase):
def test_every_planted_value_is_gone(self):
red, mapping = redact(LEAKY)
self.assertGreaterEqual(len(mapping), 5)
for secret in mapping.values():
self.assertNotIn(secret, red)
def test_round_trip_rehydrates(self):
red, mapping = redact(LEAKY)
self.assertEqual(rehydrate(red, mapping), LEAKY)
def test_plain_sql_is_untouched(self):
red, mapping = redact(CLEAN)
self.assertEqual(red, CLEAN)
self.assertEqual(mapping, {})
if __name__ == '__main__':
unittest.main()
Run it:
python3 -m unittest -q test_redact
----------------------------------------------------------------------
Ran 3 tests in 0.001s
OK
The round-trip test is the one that catches regressions. Widen a rule too far and it fails, because the sloppy rule swallowed text that had to survive.
Step 3 — Make the gate unavoidable
Hooks beat habits. This pre-commit redacts changed raw prompts, stages the redacted copies, refuses to commit the rehydration map, and re-runs the suite.
#!/usr/bin/env bash
set -euo pipefail
git check-ignore -q .redact-map.json || {
echo 'gate: add .redact-map.json to .gitignore first' >&2; exit 1; }
if git diff --cached --name-only | grep -qx '.redact-map.json'; then
echo 'gate: refusing to commit the rehydration map' >&2; exit 1
fi
for f in prompts/raw/*.md; do
[ -e "$f" ] || continue
base="${f##*/}"
python3 redact.py --in "$f" --out "prompts/redacted/$base" --map .redact-map.json
git add "prompts/redacted/$base"
done
python3 -m unittest -q test_redact
Install and verify the ignore rule:
printf '.redact-map.json\n' >> .gitignore
cp hooks/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
git check-ignore -v .redact-map.json
.gitignore:1:.redact-map.json .redact-map.json
Ordering matters. The ignore check runs first, so a missing .gitignore line fails before any prompt is written to disk.
Step 4 — Send the redacted file, rehydrate locally
Only prompts/redacted/*.md should ever leave the machine. Paste that file, take the answer back, and map it:
python3 - <<'PY'
import json
from pathlib import Path
from redact import rehydrate
mapping = json.loads(Path('.redact-map.json').read_text())
answer = Path('out/model-answer.md').read_text()
Path('out/answer.local.md').write_text(rehydrate(answer, mapping))
PY
The redacted prompt is portable across providers. That is deliberate, and it is why the same file works with MonkeyCode's free model access and free server option: small text-only payloads, sensitive spans already abstracted out. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The operator states a 10M-token free allowance and a free server option as of this writing; rosters, limits, and terms change, and nothing here independently verifies current numbers, so check them before planning around them.
One caveat on the free server lane: the map lives on that box. Rebuild the box and the map is gone, so previously redacted answers can no longer be rehydrated. Keep the map on the laptop that owns the secrets, not on the machine that runs the build.
Decision table
| Payload | Redactor only | Redactor + human read | Never send |
|---|---|---|---|
| Open-source file you did not write | ok | — | — |
| Your own app code, no customer data | ok | — | — |
| Stack trace from production | ok | yes | — |
| Schema plus sample rows | — | yes | — |
.env, keystore, kubeconfig |
— | — | never |
| Customer PII or payment data | — | — | never |
Two rows are absolute. Config dumps and personal data stay local regardless of how good the rule set looks, because both fail on class 3, not class 1.
Limitations and who should skip this
-
Entropy-free secrets pass through. A bare password such as
hunter2, with no prefix and no surrounding URL, matches nothing here. Prefix rules are cheap; general secret detection is not. - Base64 and screenshots defeat it. A pasted image of a terminal, or an encoded blob of config, bypasses every regex above.
- Semantic leaks remain. Table names and customer names are class 3, and no pattern in this file can see them.
- The map is now sensitive. It holds, in plain JSON on disk, the exact values the gate was hiding.
- Redaction is not compliance. Passing this gate does not make a hosted free model acceptable for regulated data.
Skip the approach if the repo touches health, payment, or identity data — no free hosted model belongs in that path, gate or not. Skip it as well if CI already runs a real secret scanner; this is a last-mile prompt gate, not an inventory tool, and it will feel redundant next to gitleaks. Skip it too if a license or client contract forbids sending source to third parties at all, because redaction does not change what the file is.
If one thing from this post gets kept, keep the leak test. The rule list is replaceable. The fixture that fails loudly when a rule rots is the part that keeps the gate honest a month from now.
Top comments (0)