Dev.to AI πŸ€– Ai πŸ‘ 0 πŸ“– 7 min read

Prove the Workspace Forgets: A Residue Test for Free Dev Servers and Model-Backed Agents

After I stopped a throwaway dev workspace, my canary string showed up in three places: the outbound prompt capture, the shell history, and a log file I never opened. Nothing was "hacked." Every component did exactly what

After I stopped a throwaway dev workspace, my canary string showed up in three places: the outbound prompt capture, the shell history, and a log file I never opened. Nothing was "hacked." Every component did exactly what it was documented to do. That is the boring kind of leak β€” and it is the one you can actually test for.

Here is the uncomfortable part. When you move a repo onto a free server and let an agent talk to a free model endpoint, you have created two new trust boundaries in one afternoon. Most teams audit neither. They audit the code that ships, not the workspace that dissolves.

So this article is not a checklist. It is one artifact: a residue test you can run before an agent touches a real repository, with a positive fixture that proves the scanner works and a negative fixture that proves the workspace forgot.

The threat model, compressed into a table

Boundary What crosses it Who can read it Typical failure
Laptop β†’ dev server repo, .env, SSH keys, git credentials host operator, co-tenants, backups secrets copied "just to test"
Dev server β†’ model endpoint prompt body, file excerpts, tool output you, the endpoint operator a config file pasted into context
Dev server β†’ your logs agent reasoning, shell commands, env dumps anyone with log access history files survive teardown
Server β†’ next session volume, image layer, caches you, possibly others the workspace is not as disposable as it looks

Look at the third row. That is where most of my own incidents would have lived if I had looked. ~/.bash_history is a log. .env files are logs with better branding.

Step 1 β€” Seed a canary, not a real secret

Never test with a live credential. You want a string that is unique, boring, and detectable by grep.

#!/usr/bin/env bash
# residue-test.sh β€” TEMPLATE, not executed in this article
set -euo pipefail

WS="${WS:-$HOME/work/agent-ws}"
CANARY="CANARY-$(openssl rand -hex 8)"
export CANARY

mkdir -p "$WS"
printf 'DEMO_TOKEN=%s\n' "$CANARY" > "$WS/.canary.env"
echo "[seed] canary=$CANARY"

The canary is not a vulnerability. It is an assertion. If the string moves where I did not intend, I have a boundary I cannot enforce, and that is the only thing worth knowing.

Step 2 β€” Capture what would leave the box

Before anything runs, route outbound requests through a local recorder. This is the cheapest observability you will ever add, and it works regardless of which endpoint you point at.

# egress recorder: append the request body, forward unchanged
# NOTE: this logs prompt bodies locally. Treat egress.ndjson as a secret file.
export MODEL_EGRESS_LOG="$HOME/work/egress.ndjson"

A minimal proxy that appends {ts, path, body} to that file and forwards the request is enough. Do not log response bodies by default. Do not log headers β€” that is where your key lives.

Now run one deliberately small agent task that reads .canary.env. The goal is not a useful agent. The goal is a session that touches the canary so you have something to look for.

Step 3 β€” Run the positive fixture first

This is the step people skip, and it is the reason most "we found nothing" reports are worthless. A scanner that returns zero hits has proven nothing until you have seen it return a hit.

# positive fixture: BEFORE teardown, the canary must be findable
python3 scan_residue.py \
  --root "$WS" --root "$HOME/work" \
  --canary "$CANARY" --expect present

If that exits non-zero, stop. Your scanner is broken, your paths are wrong, or the session never touched the file. Fix that before you read a single negative result.

Step 4 β€” Tear down, then scan for absence

./teardown.sh "$WS"      # stop instance, drop volume, clear history

python3 scan_residue.py \
  --root "$WS" --root "$HOME/work" --root "$HOME" \
  --canary "$CANARY" --expect absent

Exit code 2 means residue. Exit 0 means the negative fixture passed. That distinction β€” two different exit codes for two different claims β€” is the entire design.

#!/usr/bin/env python3
"""scan_residue.py β€” TEMPLATE, not executed in this article"""
import argparse, os, sys

SKIP_DIRS = {".git", "node_modules", "__pycache__", ".cache/pip"}


def walk(root):
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
        for name in filenames:
            yield os.path.join(dirpath, name)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--root", action="append", required=True)
    ap.add_argument("--canary", required=True)
    ap.add_argument("--expect", choices=["present", "absent"], default="absent")
    args = ap.parse_args()

    needle = args.canary.encode()
    hits = []
    for root in args.root:
        if not os.path.exists(root):
            continue
        for path in walk(root):
            try:
                if os.path.getsize(path) > 8 * 1024 * 1024:
                    continue
                with open(path, "rb") as fh:
                    if needle in fh.read():
                        hits.append(path)
            except OSError:
                continue

    for hit in hits:
        print(f"HIT {hit}")
    print(f"hits={len(hits)} expect={args.expect}")

    if args.expect == "present":
        return 0 if hits else 1
    return 0 if not hits else 2


if __name__ == "__main__":
    sys.exit(main())

Step 5 β€” Gate the free quota so the gate itself cannot fail silently

Free tokens are a budget, and budgets that live in your head are not budgets. If the tier is metered, an agent stuck in a retry loop can burn an allowance overnight and leave you debugging a quota error instead of a design flaw.

from dataclasses import dataclass


@dataclass
class Ledger:
    free_quota: int          # read from config; operator-supplied, changes over time
    spent: int = 0
    max_completion: int = 2048
    safety_margin: int = 50_000


def admit(prompt_tokens: int, ledger: Ledger) -> tuple[bool, str]:
    reserve = prompt_tokens + ledger.max_completion + ledger.safety_margin
    remaining = ledger.free_quota - ledger.spent
    if reserve > remaining:
        return False, f"refused: need {reserve}, have {remaining}"
    return True, f"allowed: {remaining - reserve} left after this call"

Two rules make this useful. First, free_quota comes from config, never a hardcoded literal β€” published allowances change. Second, a refusal must be a hard stop in the agent loop, not a warning it can ignore. If I had to pick one line of code from this article to keep, it is the refusal path.

This is where a hosted option earns its keep. If you are evaluating MonkeyCode's free model access and free server option for a throwaway workspace (both operator-supplied availability claims β€” read the current terms yourself, since quotas and server specs are not permanent), the residue test above is the thing I would run first, before the workspace ever sees a real repo. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

What not to send to any model endpoint

Data Send? Why
Public library code, docs, stack traces without values yes already public
Your own business logic rarely it is the product
.env, keys, tokens, connection strings never rotation is expensive, leakage is permanent
Customer PII or production rows no you cannot un-send it
Internal hostnames, bucket names, ARNs no free reconnaissance
Incident data sanitized only identifiers are the payload

I keep this table next to the pre-flight check, not in a wiki. A policy nobody reads at the moment of a request is a wish.

Prevent, detect, recover

Layer Prevent Detect Recover
Secrets canary only, never real keys positive fixture hit rotate if a real key ever crossed
Workspace disposable volume, no shared home negative fixture exit 2 rebuild workspace, do not patch it
Egress allowlist one endpoint egress.ndjson review delete capture file, rotate
Quota admit() hard refusal refusal log entries raise margin, cap retries

What this does not prove

It does not prove the endpoint forgot your prompt. You cannot test that from outside, and I am not going to pretend a local scan reaches someone else's retention policy. Read their docs; assume the prompt body persists until documented otherwise.

It does not catch transformations. Base64, chunked sends, and partial file reads will all walk past a byte-level canary scan. If your agent can read arbitrary files, add an allowlist to the tool layer and test that separately.

It does not work well for tiny, single-session experiments where teardown is "close the tab." There the test costs more than the exposure. If you are prototyping a scraper for thirty minutes with no credentials and no customer data, skip this and go build.

The invariant worth keeping

The canary test is not about the canary. It is about converting "we think the workspace is disposable" into an exit code CI can read. So the question I would leave you with:

Which invariant belongs in your pipeline β€” the residue scan after teardown, or an egress allowlist before the first request β€” and which layer should own it, the harness or the host?

If you want somewhere harmless to answer that, a free throwaway workspace is a reasonable place to run the negative fixture first. Just do not point it at a repo you would miss.

πŸ“° Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.