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.
Top comments (0)