A staging agent wrote its memory file at 02:14. The file lived on a complimentary shared host. The next prompt replayed a stale database password.
The password arrived as a tool result payload. Nobody classified that cache as durable state. The host stalled during a noisy neighbor event.
This field guide shows when not to park that state. It also shows when a free model must not rewrite it. The artifact is a scanner, a table, and exit gates.
What counts as durable agent state
Agents look like loops from the outside. The loop is not the primary risk. The files the loop leaves behind are the risk.
Durable agent state means any artifact a later turn rereads:
-
memory.jsonfiles the agent appends after each tool call - Tool-result caches keyed by a prompt hash
- Session transcripts that still hold raw HTTP bodies
- Local vector stores built from prior tool output
- Plan files the next scheduled turn will load
- Identity hints copied into working notes without review
None of these files look like production databases. All of them can replay credentials and prior decisions. Treat every reread path as a store.
A labeled night-shift scenario
Consider a labeled scenario, not a measured incident. An on-call agent drafts a database migration plan. The tool returns a live database connection string.
The model writes a helpful recap into notes/memory.md. A free host then sleeps that disk without warning. A later turn boots cold and rereads the recap.
The agent retries the migration against yesterday's cluster. The bug is not model quality. The bug is persistence without eviction rules.
Red flags before the first write
Stop and reclassify the workload when any item below is true. Two red flags mean the write does not proceed. Move the store before the next turn.
Data red flags
- Tool output may contain tokens, cookies, or connection strings
- Transcripts include customer names, emails, or ticket bodies
- Memory files mix plans with live environment identifiers
- Embeddings are built from production tool traces
Control red flags
- The free host is also the writer's only disk
- No separate backup or export path exists today
- Eviction needs a ticket to a third-party operator
- The same free model both plans and mutates memory
Process red flags
- Nobody owns deletion of
scratchpad*files - Memory is treated as logs, not as a store
- Agents resume after host preemption without a checksum
- On-call cannot name last night's notes path
Decision table: hold, copy, or refuse
Use this table before an agent opens a write handle. The table is a proposal. Teams should adapt the rows to local policy.
| State class | Example path | Free host as source of truth | Free model may write | Required alternative |
|---|---|---|---|---|
| Ephemeral plan | /tmp/plan-*.md |
Yes, TTL under one hour | Yes, if no secrets | Local tmpfs, wipe on exit |
| Tool-result cache | .agent/cache/*.json |
No if raw bodies remain | No | Redacted store on controlled disk |
| Long-term memory | memory.json |
No | No | Versioned store with IAM |
| Session transcript | logs/session-*.jsonl |
No | No | Append-only audit bucket |
| Embeddings of tools | vectors/*.index |
No | No | Isolated vector service |
| Public docs draft | drafts/readme.md |
Yes | Yes, with human review | Any scratch host |
No means the file must not be the resume path. A copy for a drill can exist. A later turn must not boot from that copy.
Artifact: inventory the workspace before resume
The script below is a local scanner. It does not call a network model. Run it in the agent working directory before resume.
Label: this is an unexecuted example. Operators should test it on a fixture first.
#!/usr/bin/env python3
"""Scan an agent workspace for durable-state red flags."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
SECRETISH = re.compile(
r"(api[_-]?key|token|password|secret|bearer\s+[A-Za-z0-9._\-]+"
r"|postgres://|mysql://|mongodb://)",
re.I,
)
MEMORY_NAMES = {
"memory.json",
"memory.md",
"scratchpad.md",
"notes.md",
"agent-state.json",
}
CACHE_DIRS = {".agent", ".cache", "vectors", "transcripts", "sessions"}
def classify(path: Path, text: str) -> dict:
flags = []
if path.name.lower() in MEMORY_NAMES:
flags.append("durable_memory_name")
if any(part.lower() in CACHE_DIRS for part in path.parts):
flags.append("cache_or_vector_dir")
if SECRETISH.search(text):
flags.append("secret_pattern")
if path.suffix in {".faiss", ".index", ".sqlite"}:
flags.append("binary_store")
severity = "refuse" if flags else "ok"
if flags and "secret_pattern" not in flags and "binary_store" not in flags:
severity = "copy_then_evict"
return {
"path": str(path),
"flags": flags,
"severity": severity,
"bytes": path.stat().st_size,
}
def scan(root: Path) -> list[dict]:
findings = []
for path in root.rglob("*"):
if not path.is_file():
continue
if path.stat().st_size > 2_000_000:
findings.append(
{
"path": str(path),
"flags": ["oversize"],
"severity": "refuse",
"bytes": path.stat().st_size,
}
)
continue
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
item = classify(path, text)
if item["flags"]:
findings.append(item)
return findings
def main() -> int:
root = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
findings = scan(root)
refuse = [f for f in findings if f["severity"] == "refuse"]
print(json.dumps({"findings": findings, "refuse_count": len(refuse)}, indent=2))
return 2 if refuse else 0
if __name__ == "__main__":
raise SystemExit(main())
Run the scanner like this:
python3 scan_agent_state.py ./agent-workspace
echo $?
Exit code 2 means resume is forbidden tonight. Fix the store before the next turn. Exit code 0 means no refuse-class files were found.
Exit zero does not mean the host is trusted. It only means this pass found no refuse rows. Keep that distinction in the runbook.
Keep a fixture pair in CI
Add a bad tree and a clean tree. The pair keeps the scanner honest over time.
mkdir -p fixtures/bad/.agent fixtures/clean
printf '%s\n' '{"note":"postgres://ops:demo@db/app"}' > fixtures/bad/memory.json
printf '%s\n' 'draft outline only' > fixtures/clean/readme.md
python3 scan_agent_state.py fixtures/bad; echo bad:$?
python3 scan_agent_state.py fixtures/clean; echo clean:$?
Expect bad to exit 2. Expect clean to exit 0. Fail the pipeline if those codes drift.
Exit criteria: leave the free host
Write the exit down before the first best-effort disk write. Use these gates as hard stops.
- A refuse-class finding appears in CI or on resume.
- Host preemption interrupts a turn that had open memory.
- A transcript contains a secret pattern once.
- On-call cannot name the memory path quickly.
- The agent must survive a host that disappears tonight.
Any single gate is enough to leave. Do not wait for all five. Waiting is how recaps become source of truth.
The exit path should stay boring and ordered:
# proposal: copy then cut, never cut then copy
rsync -a --delete ./agent-workspace/memory.json \
"$CONTROLLED_STORE/agent/memory.json"
sha256sum ./agent-workspace/memory.json \
"$CONTROLLED_STORE/agent/memory.json"
# only after hashes match
rm -f ./agent-workspace/memory.json
printf '%s\n' "memory_uri=$CONTROLLED_STORE/agent/memory.json" \
> ./agent-workspace/MEMORY_POINTER
After the cut, the workspace holds a pointer. It does not hold the store. Resume reads the pointer on a controlled path.
Better alternatives by state class
Do not replace a free disk with hope. Replace it with a named store. Keep writers and holders on different trust lanes.
- Memory and plans: a versioned object key with IAM and retention
- Transcripts: append-only logs outside the agent user
- Tool caches: redacted records; drop raw response bodies
- Vectors: a service with a destroy switch, not a folder
- Drafts: stay on scratch disks; never resume production from them
A free model may draft the pointer file. It must not draft the memory blob. Split those roles in the runner, not the prompt.
# proposal: runner-enforced split
FORBIDDEN_WRITERS = {"free_lane", "best_effort"}
DURABLE = {"memory.json", "scratchpad.md"}
def allow_write(lane: str, path: str) -> bool:
name = path.rsplit("/", 1)[-1]
if name in DURABLE and lane in FORBIDDEN_WRITERS:
return False
return True
Put allow_write in the tool gateway. Do not put it in the system prompt. A prompt is not an access control list.
Where a free lane still belongs
Complimentary inference and complimentary hosts remain useful. They fit drills, scanners, and public drafts. They do not fit the memory authority role.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode offers free model access and a free server option. Those lanes can run the inventory script above. They can also host a redacted fixture workspace for rehearsal.
They should not hold live memory.json files. They should not mutate production transcripts. Keep the product on the rehearsal path. Keep it off the resume path.
A short lab sequence, labeled as a proposal:
# rehearsal only: no production secrets in this tree
python3 scan_agent_state.py ./fixtures/clean
# optional: review the JSON report on a free lane
# do not send fixtures/bad if it still looks live
Review the report on a controlled display. Do not pipe refuse-class files into any model. Operators rehearsing eviction can use that free server on fixtures only.
Who should not use this approach
Skip this field guide's copy-then-cut path in some shops. Stopping the agent is safer than relocating memory badly.
- Teams with no controlled store should stop agent memory entirely
- Regulated workloads need a reviewed platform, not rsync and regex
- Multi-tenant agents need isolation stronger than filename rules
- Shops that cannot run CI on the workspace should not resume agents
The scanner is a tripwire. It is not encryption. It is not a vault.
Pattern matching misses well-formed secrets. Oversize binary stores are refused, not inspected. Filename rules fail when agents invent new paths.
Limitations
The decision table is not a benchmark. No latency or uptime numbers appear here. None were measured for this article.
Host names and model names are omitted on purpose. Free-lane terms change. Re-read current product terms before each drill. Do not cache old quota stories or hardware claims.
Regex redaction fails closed only if the runner honors allow_write. A model that shells out around the gateway will bypass it. Fix the gateway. Do not patch the prompt and call it policy.
Practical rule
If a later turn will reread the file, the file is a database. Databases do not live on best-effort disks. Free models do not own their schemas.
Inventory first. Classify second. Refuse the write. Then resume from a store the team can evict.
Top comments (0)