"This test is failing. Just fix it."
That is the entire prompt. The agent picks a read set, the read set is the repository, and the repository contains .env.local, a curl transcript under logs/, a deploy.yaml pointing at prod-db.internal, and a notebook with a traceback in it. Forty minutes later the test is green, and the context window has seen all four.
There is no attack in this story. No exploit, no malicious dependency, no clever payload. A read set was assembled once, by a tool, without an inventory, and the transcript turned up in a ticket two days later. I keep watching that sequence happen often enough that I stopped treating it as bad luck.
This week's DEV front page is heavy on AI fatigue and browser-side AI features. Underneath both sits one unglamorous question: what does the tool get to read, and who can prove it afterwards? That question has a boring, testable answer. So let's build the test instead of arguing about it.
1. The boundary moved. .gitignore never claimed to be a privacy control.
Most teams have exactly one rule for sensitive files: do not commit this. They have zero rules for do not send this. Those are different sets, and only the first set has tooling behind it.
Here is the pipeline nobody draws in the design doc:
your repo ──▶ read set ──▶ prompt assembly ──▶ model request ──▶ retention
│ │ │ │
.gitignore ? ? ? ?
.gitignore guards the first arrow and nothing else. Once the agent decides a file is useful, a committed-ignore rule has no opinion about it at all.
Why does that bite so reliably? Because the component choosing the read set is optimizing for recall. More context means better answers. Nothing in that loop asks whether a file was ever meant to leave the building.
2. Turn "be careful" into four answerable questions
Vague guidance does not survive a deadline. Four questions do:
- Which files were in the read set for this task?
- Which of those files contain data that must not leave the organization?
- What is the token cost of a minimal read set versus the whole tree?
- Which layer fails the build when question 2 has a non-empty answer?
If your honest answer to question 4 is "a comment in code review," you do not have a gate. You have a hope with a reviewer attached.
3. Step 1 — build the dirty fixture before you build the detector
If you write the classifier first, you will tune it until it passes on the leak you already know about. That is the one case you did not need help with. Build the fixture first, watch it fail, then make the detector fail with it.
mkdir -p fixtures/egress/docs fixtures/egress/src fixtures/egress/logs \
fixtures/egress/conf fixtures/egress/notebooks
printf 'Runbook: rotate the staging key before the demo.\n' > fixtures/egress/docs/runbook.md
printf 'def handler(event):\n return {"ok": True}\n' > fixtures/egress/src/handler.py
printf 'API_KEY=REDACTED_PLACEHOLDER_0000000000\n' > fixtures/egress/.env.local
printf "curl -H 'Authorization: Bearer PLACEHOLDER_TOKEN_VALUE_0001' https://api.example.com/v1/orders\n" > fixtures/egress/logs/curl.txt
printf '2026-10-15T02:10:44Z GET /v1/orders 200 X-Request-Id: 8f3c1a7b peer=10.0.4.7\n' > fixtures/egress/logs/api.log
printf 'db_host: prod-db.internal\n' > fixtures/egress/conf/deploy.yaml
printf 'Traceback (most recent call last):\n File "handler.py", line 1, in handler\n' > fixtures/egress/notebooks/repro.txt
mkdir -p fixtures/clean/src
printf 'def add(a, b):\n return a + b\n' > fixtures/clean/src/mathlib.py
Every value above is a placeholder, not a live credential. That matters: a fixture repository that contains real secrets is just a second incident waiting to happen.
Why keep two fixtures instead of one? Because a detector that flags everything is as useless as one that flags nothing. The clean tree is the negative control, and it is the only thing stopping your team from disabling the gate in week two.
4. Step 2 — write the ledger
Four design constraints, chosen so this survives contact with a free-tier container: standard library only, deterministic output, fail closed on credentials, and honest token estimates.
#!/usr/bin/env python3
"""tools/egress_ledger.py -- classify a workspace before any of it becomes a prompt."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from pathlib import Path
SKIP_DIRS = {'.git', 'node_modules', '.venv', 'venv', 'dist', 'build', 'target', '__pycache__'}
SKIP_EXT = {'.png', '.jpg', '.jpeg', '.gif', '.ico', '.pdf', '.zip', '.gz', '.woff2', '.mp4', '.so'}
MAX_BYTES = 2 * 1024 * 1024
UNKNOWN_WEIGHT = 10
RULES = (
('SECRET', 100, re.compile(
r'AKIA[0-9A-Z]{16}'
r'|-----BEGIN [A-Z ]*PRIVATE KEY-----'
r'|(?i:\b(?:api[_-]?key|secret|passwd|password|token)\b\s*[:=]\s*[\x22\x27]?[A-Za-z0-9_\-/+]{16,})'
r'|(?i:\bauthorization\s*:\s*(?:bearer|basic)\s+\S+)'),
'credentials, private keys, auth headers'),
('CUSTOMER', 100, re.compile(
r'(?i:\b(?:customer[_-]?id|account[_-]?number|invoice[_-]?id|ssn|date[_-]?of[_-]?birth)\b)'),
'customer identifiers or PII fields'),
('INTERNAL', 50, re.compile(
r'\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b'
r'|\b192\.168\.\d{1,3}\.\d{1,3}\b'
r'|\.internal\b|\.corp\b'
r'|(?i:\bprod-db\b|\bstaging-[a-z0-9-]+\b)'),
'internal hosts, environments, infra names'),
('TRACE', 30, re.compile(
r'Traceback \(most recent call last\)'
r'|(?i:\bx-request-id\b|/var/log/|FAIL_TO_PASS)'),
'runtime traces, log paths, request ids'),
)
def iter_files(root: Path):
for path in sorted(root.rglob('*')):
if not path.is_file():
continue
if any(part in SKIP_DIRS for part in path.parts):
continue
if path.suffix.lower() in SKIP_EXT or path.stat().st_size > MAX_BYTES:
yield path, None
continue
yield path, path.read_text(encoding='utf-8', errors='replace')
def classify(text: str):
hits = []
for name, weight, pattern, why in RULES:
found = pattern.findall(text)
if found:
hits.append({'class': name, 'weight': weight, 'matches': len(found), 'why': why})
return hits
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument('--root', default='.')
ap.add_argument('--out', default='egress-ledger.json')
ap.add_argument('--internal-budget', type=int, default=0)
args = ap.parse_args()
root = Path(args.root).resolve()
entries, weight_sum = [], 0
for path, text in iter_files(root):
rel = path.relative_to(root).as_posix()
size = path.stat().st_size
if text is None:
weight_sum += UNKNOWN_WEIGHT
entries.append({'path': rel, 'class': 'OPAQUE', 'weight': UNKNOWN_WEIGHT,
'bytes': size, 'tokens_est': None, 'sha256': None, 'hits': []})
continue
hits = classify(text)
hit_weight = sum(h['weight'] for h in hits)
weight_sum += hit_weight
entries.append({
'path': rel,
'class': hits[0]['class'] if hits else 'PUBLIC',
'weight': hit_weight,
'bytes': size,
'tokens_est': len(text) // 4, # estimate only, not a tokenizer
'sha256': hashlib.sha256(text.encode('utf-8')).hexdigest(),
'hits': hits,
})
manifest = {'root': str(root), 'files': entries,
'totals': {'files': len(entries), 'weight': weight_sum,
'tokens_est': sum(e['tokens_est'] or 0 for e in entries)}}
Path(args.out).write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8')
hardest = []
for e in entries:
if e['class'] in ('SECRET', 'CUSTOMER'):
hardest.append(e['path'])
print('[FAIL] %-9s %s' % (e['class'], e['path']))
elif e['class'] in ('INTERNAL', 'TRACE', 'OPAQUE'):
print('[WARN] %-9s %s' % (e['class'], e['path']))
print('[LEDGER] files=%d tokens_est=%d weight=%d -> %s'
% (len(entries), manifest['totals']['tokens_est'], weight_sum, args.out))
if hardest:
return 30
if weight_sum > args.internal_budget:
return 20
return 0
if __name__ == '__main__':
sys.exit(main())
Label check before you run anything: these scripts are templates I am handing you, not a report on your repository. Nothing below claims your repo leaks. Run the fixture, diff the exit code, and then point it at your own tree.
Notice what the script does not try to be. It is not a secret scanner with a maintained rule pack, and it is not a data-loss-prevention product. It is a 90-line inventory that answers question 2 from section 2 and returns an exit code other tools can depend on.
5. Step 3 — run it and diff the exit codes
python3 tools/egress_ledger.py --root fixtures/egress --out /tmp/dirty.json; echo "exit=$?"
python3 tools/egress_ledger.py --root fixtures/clean --out /tmp/clean.json; echo "exit=$?"
head -40 /tmp/dirty.json
| Fixture | Expected stdout | Expected exit |
|---|---|---|
fixtures/egress |
[FAIL] SECRET .env.local, [FAIL] SECRET logs/curl.txt, plus [WARN] lines for deploy.yaml, api.log, repro.txt
|
30 |
fixtures/clean |
[LEDGER] ... weight=0 and nothing else |
0 |
The contract is the point: 0 means the read set is within budget, 20 means infrastructure and trace metadata exceeded the budget, 30 means credentials or customer data are present. Three codes, three different conversations.
Now wrap both in an acceptance test so nobody has to remember the numbers:
#!/usr/bin/env bash
set -uo pipefail
run() { python3 tools/egress_ledger.py --root "$1" --out "$2" >/dev/null 2>&1; echo $?; }
dirty=$(run fixtures/egress /tmp/dirty.json)
clean=$(run fixtures/clean /tmp/clean.json)
printf 'dirty=%s clean=%s\n' "$dirty" "$clean"
[ "$dirty" -eq 30 ] || { echo 'FAIL: the dirty fixture must fail closed with 30'; exit 1; }
[ "$clean" -eq 0 ] || { echo 'FAIL: the clean fixture must exit 0'; exit 1; }
echo 'OK: egress gate behaves as specified'
If your first run against a real repository returns 30, do not delete the gate. Open the manifest, read the match, and choose one of three actions: rotate the credential, redact the file, or allowlist the path with a comment explaining why. All three are decisions. Deleting the check is just deferring one.
6. Step 4 — make the gate a dependency, not a habit
A gate that lives in a README is a suggestion. Wire it into the step that builds the prompt:
.PHONY: egress-gate
egress-gate:
python3 tools/egress_ledger.py --root . --out egress-ledger.json --internal-budget 40
prompt.md: egress-gate
@test -s egress-ledger.json
python3 tools/assemble_prompt.py --scope diff --out $@
And in CI, run the negative fixture every time so the test itself cannot rot:
name: egress-gate
on: [pull_request]
jobs:
ledger:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: negative fixture must fail closed
run: |
set +e
python3 tools/egress_ledger.py --root fixtures/egress --out /tmp/dirty.json
test $? -eq 30 || { echo 'negative fixture did not fail closed'; exit 1; }
- name: gate the real tree
run: python3 tools/egress_ledger.py --root . --out egress-ledger.json --internal-budget 40
- uses: actions/upload-artifact@v4
if: always()
with:
name: egress-ledger
path: egress-ledger.json
Treat egress-ledger.json itself as INTERNAL, by the way. It is a complete file inventory with paths and hashes. Uploading it as a public artifact would hand over the map you just drew.
7. The prevent / detect / recover table
| Asset | Boundary crossed | Failure mode | Prevent | Detect | Recover |
|---|---|---|---|---|---|
.env.local values |
workspace to model request | key echoed into a request log or shared transcript | ledger exits 30
|
SECRET hit plus prompt log review | rotate key, invalidate sessions |
| Internal hostnames | repo to model context | topology leak inside a completion | --internal-budget 0 |
[WARN] INTERNAL lines |
rename hosts, treat as recon |
| Customer rows in notebook output | notebook to prompt | PII inside a retention window | strip outputs in pre-commit | [FAIL] CUSTOMER |
follow your notification path |
| Trace logs with headers | log file to agent context | bearer token in a provider trace | scrub before assembly | TRACE rule plus header regex | rotate, shorten retention |
| Whole-repo paste | agent read set | over-broad read set | diff-scoped reads |
tokens_est versus scope |
re-run with a narrower scope |
Three columns, three different owners. Prevention belongs to whoever owns the repo. Detection belongs to whoever owns CI. Recovery belongs to whoever owns the incident channel, and they should know their row before they need it.
8. What not to send at all
Some files should not be argued about, only excluded:
-
.env*,*.pem,*.key,id_rsa,service-account.json -
kubeconfig,.aws/credentials,.npmrc,.pypirc -
terraform.tfstateand any plan output -
*.sqldumps and fixtures generated from production -
.harfiles and browser network exports - CI logs with masked-but-recoverable tokens
- Notebook outputs, which often carry stack traces and sample rows you forgot were there
The list is short on purpose. A deny list nobody reads is worse than no list, because it creates the feeling of coverage.
9. Where the free tier actually fits
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The workflow above has one awkward property: it wants to run where the read set is assembled, not on your laptop. MonkeyCode is an open-source AI development platform, and its operator offers free model access plus a free server option (the operator currently quotes a 10M-token allowance; quotas, regions, and terms change, so check the current ones before you plan around them). That combination makes the negative fixture cheap to run: the free server gives you a workspace container, and the free model tier gives you something to assemble a prompt against without a billing conversation.
The honest reason it belongs in this article is narrower than availability. Prompt assembly happens on the platform side, so a gate that only runs on your local checkout is guarding a different tree from the one the agent reads. Run egress_ledger.py with --root pointed at the workspace the agent actually sees, and diff the two manifests. If your local run is clean and the workspace run exits 30, you have just learned that the two trees are not the same tree. Free tier or paid, that diff is the interesting artifact.
10. Limitations, and who should not use this
Regex classification has false negatives by construction. Base64-encoded secrets, values split across two lines, non-UTF-8 files, and anything larger than 2 MB land in OPAQUE and are counted as unknown rather than clean. Unknown is not clean — that is a stance, not a measurement.
The tokens_est field is len(text) // 4. It is a rough planning number, not a tokenizer, and it must never be used for billing or quota arithmetic.
The gate is prevention-only. It says nothing about what already left, and it produces no evidence about a vendor's retention behavior. If you need a data-flow inventory signed by a processor, this script is not that document.
Skip this approach if your secrets live in the CI environment rather than in files — your boundary is different and this ledger will look deceptively clean. Skip it if you need a guarantee instead of a gate. And skip it if you cannot commit to keeping the negative fixture green, because a classifier nobody re-tests decays into a regex that matches nothing and reports success.
Which invariant belongs in CI?
Two candidates, and they are not equivalent. "No file in the read set may be classified SECRET or CUSTOMER" is a hard invariant with a hard exit code. "The read set must be scoped to the diff" is a policy that shrinks the blast radius without ever being provable from a single run.
I put the first one in CI and the second one in the prompt-assembly step. Which one would your team actually enforce on a Friday afternoon — and which layer would have to own it for that to be true?
Top comments (0)