DEV Community

Emery Li
Emery Li

Posted on

The Egress Ledger: What Leaves Your Laptop When an Agent Spills to a Free Server

The Egress Ledger: What Leaves Your Laptop When an Agent Spills to a Free Server

A pattern keeps resurfacing in code review. A two-person team writes a nightly summarizer that runs comfortably on a laptop, then moves one leg of it to a hosted inference endpoint because the local model is slow. The job keeps working, the latency looks better, and nobody revisits the threat model. Weeks later a reviewer asks one plain question, which is what exactly left the machine, and the honest answer is a shrug plus a grep through logs that were never designed to answer it.

That composite is not a scare story; it is the ordinary cost of an unobserved boundary. Local-first does not mean local-only. It means the machine you control runs the default path, and every crossing of the network edge is deliberate, recorded, and reversible.

The fix is not to ban remote inference. It is to make the spill observable, so that sending work to a server becomes an engineering decision backed by evidence instead of a convenience nobody audits.

What actually crosses the boundary

Developers usually picture the crossing as a single model call. In practice an agent loop assembles a request object from several sources, and the payload is wider than the intent. Prompt templates, retrieved file contents, absolute repository paths, tool schemas, and a few environment fragments tend to travel together.

The travel itself is not the problem. The problem is that a request assembled at runtime rarely matches the mental list of fields the author believed were being sent. Paths leak internal naming and org structure, ticket identifiers leak customers, and a single stray environment variable can leak a credential that has nothing to do with the task.

A useful rule is that unknown fields fail closed. Any key that no policy has classified is treated as never-spill, which converts an accident into a loud failure at development time rather than a quiet one in production.

Step 1 — Classify every field before you pick a host

Classification comes first, because it decides which destinations are even legal for a given job. Three classes cover most real payloads, and the default for anything unlisted is the strictest one.

Field class Examples Default destination Why
public task id, prompt template, published docs local or free server low blast radius if exposed
redact repo paths, internal hostnames, ticket ids local, or hashed before send identifiers reveal structure and customers
never credentials, env vars, raw PII, auth headers local only residency, contract, and basic hygiene

The table is deliberately small. A large taxonomy invites arguments; three rows invite decisions. When a new field appears, the schema test in Step 3 forces someone to place it before the code merges.

Step 2 — Build the egress ledger in front of the sender

The ledger is a thin wrapper that runs immediately before serialization. It records what each field is, how many bytes it would put on the wire, and which tripwire patterns it matched, then returns a scrubbed payload for the remote leg.

# egress_ledger.py
from __future__ import annotations

import hashlib
import json
import re
from dataclasses import dataclass, field
from typing import Any, Mapping

SECRET_PATTERNS = [
    re.compile(r'sk-[A-Za-z0-9]{16,}'),
    re.compile(r'AKIA[0-9A-Z]{16}'),
    re.compile(r'-----BEGIN [A-Z ]*PRIVATE KEY-----'),
    re.compile(r'[\w.+-]+@[\w-]+\.[\w.]{2,}'),
]

DEFAULT_POLICY = {
    'task_id': 'public',
    'prompt': 'public',
    'repo_paths': 'redact',
    'env': 'never',
    'auth_header': 'never',
    'customer_email': 'never',
}

@dataclass
class Leg:
    name: str
    field_class: str
    digest: str
    bytes_out: int
    flags: list = field(default_factory=list)

def digest(value: Any) -> str:
    blob = json.dumps(value, sort_keys=True, default=str).encode()
    return hashlib.sha256(blob).hexdigest()[:12]

def scan(value: Any) -> list:
    text = json.dumps(value, default=str)
    return [p.pattern for p in SECRET_PATTERNS if p.search(text)]

def build_spill(payload: Mapping, policy: Mapping = DEFAULT_POLICY):
    out, ledger = {}, []
    for key, value in payload.items():
        cls = policy.get(key, 'never')  # unknown fields fail closed
        findings = scan(value)
        if cls == 'never' or findings:
            ledger.append(Leg(key, 'blocked', digest(value), 0, findings or ['policy']))
            continue
        if cls == 'redact':
            value = digest(value)  # send a token, keep the value local
        encoded = json.dumps(value, default=str).encode()
        out[key] = value
        ledger.append(Leg(key, cls, digest(value), len(encoded)))
    return out, ledger

def render(ledger: list) -> str:
    return '\n'.join(
        f'{l.name:20} {l.field_class:8} {l.bytes_out:6} {",".join(l.flags)}'
        for l in ledger
    )
Enter fullscreen mode Exit fullscreen mode

Calling the wrapper on a representative payload produces a printed ledger whose rows are stable across runs, because digests replace raw values. The output below is illustrative formatting, not a measured benchmark from any particular machine.

$ python -c 'from egress_ledger import *; print(render(build_spill(SAMPLE)[1]))'
task_id              public        18
prompt               public       236
repo_paths           redact        12
env                  blocked        0       policy
customer_email       blocked        0       [\w.+-]+@[\w-]+\.[\w.]{2,}
Enter fullscreen mode Exit fullscreen mode

Two rows are blocked for different reasons, which matters operationally. The environment map is blocked by policy, while the email address is blocked by pattern match even though nobody had classified it yet.

Step 3 — Turn the ledger into a failing test

An audit that only runs when someone remembers to run it decays within a sprint. The ledger earns its place when the classification is enforced by tests that fail on schema drift.

# tests/test_egress.py
import json

from egress_ledger import build_spill, DEFAULT_POLICY

PAYLOAD = {
    'task_id': 'job-4412',
    'prompt': 'Summarize the attached thread.',
    'repo_paths': ['/Users/dev/acme-billing/internal/ledger.py'],
    'env': {'HOME': '/Users/dev', 'AWS_SECRET_ACCESS_KEY': 'placeholder'},
    'customer_email': 'someone@example.com',
}

def test_every_field_is_classified():
    unclassified = [k for k in PAYLOAD if k not in DEFAULT_POLICY]
    assert unclassified == [], f'fail-closed fields: {unclassified}'

def test_never_class_fields_do_not_spill():
    spill, _ = build_spill(PAYLOAD)
    assert 'env' not in spill
    assert 'customer_email' not in spill

def test_redacted_fields_lose_their_value():
    spill, _ = build_spill(PAYLOAD)
    assert spill['repo_paths'] != PAYLOAD['repo_paths']
    assert len(spill['repo_paths']) == 12

def test_no_secret_shape_survives_serialization():
    spill, _ = build_spill(PAYLOAD)
    blob = json.dumps(spill)
    assert 'AWS_SECRET' not in blob
    assert 'someone@example.com' not in blob
Enter fullscreen mode Exit fullscreen mode

Run the suite in CI on every change to the payload builder, and treat a new unclassified key as a red build rather than a warning.

$ python -m pytest tests/test_egress.py -q
4 passed
Enter fullscreen mode Exit fullscreen mode

Step 4 — Decide with a table, not a vibe

With classification in place, the local-versus-remote question becomes comparable. The three columns below describe distinct execution shapes rather than competing products, and most mature setups run all three at once.

Constraint Local only Free hosted server Hybrid spill
Offline requirement satisfies fails degrades to local path
Cold-start latency bounded by your disk depends on network mixed
Secret residency full depends on the operator redacted fields only
Burst capacity capped by the laptop elastic within free limits elastic for public work
Auditability process logs request logs plus your ledger ledger plus policy tests
Cost of being wrong wasted heat and time data leaves the boundary contained by classification

A free server legitimately wins for bursty work that is public by construction, for schema exploration on redacted payloads, for evaluation runs where the answer matters more than the machine, and for teaching a workflow without provisioning hardware. It loses badly when the job must run offline, when the payload cannot be classified, or when wall-clock stability is a product requirement rather than a preference.

Where free hosted capacity fits in this workflow

MonkeyCode is an open-source coding-agent project, and the operator supplies free model access plus a free server option that can serve as the remote leg in the harness above. The operator describes the free tier as including model access on the order of ten million tokens alongside that server option, and any figure of that kind should be confirmed in the project's current documentation before you design around it, because free-tier terms move.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The practical use here is narrow and that is the point. Run the local path by default, let the ledger decide which fields may cross, and use the hosted leg for the public, bursty slice where a laptop would otherwise queue overnight. If you want a remote leg without opening a billing conversation, the free tier is a reasonable place to rehearse this pattern before you commit to paid capacity.

Limitations and who should not use this

Pattern matching is not data loss prevention. The tripwires in this harness catch recognizable shapes and miss paraphrased secrets, binary attachments, and anything encoded before it reaches the serializer. The ledger records what your process intended to send, so a second code path that bypasses the wrapper stays invisible until someone reads the call graph.

Hashing redacted fields is a mitigation, not anonymity. A small set of plausible paths can be brute-forced against a known digest, so treat the digest as a fingerprint for correlation rather than as a guarantee of privacy. Teams handling regulated personal data should not adopt this pattern without a data processing agreement and a legal review, and teams that require deterministic latency should keep the critical path local regardless of free capacity.

You should also skip this approach if nobody owns the policy file. An unmaintained classification table silently rots, and a policy that fails closed on every new field will simply be loosened by whoever is paged at midnight.

The takeaway

Local-first and remote inference are not opposing camps; they are two legs of one job with very different trust properties. The ledger is what makes the difference visible, cheap, and reviewable in a pull request instead of in an incident. Start by classifying the five fields your agent already sends, add the tripwire test, and only then decide which jobs deserve a server.

Top comments (0)