DEV Community

jaryn
jaryn

Posted on

Build a Pre-Prompt Egress Ledger Before an Agent Reads Your Repo

"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        ?                ?                   ?            ?
Enter fullscreen mode Exit fullscreen mode

.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:

  1. Which files were in the read set for this task?
  2. Which of those files contain data that must not leave the organization?
  3. What is the token cost of a minimal read set versus the whole tree?
  4. 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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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'
Enter fullscreen mode Exit fullscreen mode

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 $@
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.tfstate and any plan output
  • *.sql dumps and fixtures generated from production
  • .har files 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)