A free agent box is not your merge queue.
You still need a local, boring, repeatable gate.
Would you ship code from a laptop you do not own?
That is the whole article, stated up front.
The rest is a catalog of ways teams skip it.
I wrote the checks as commands you can run.
Cheap inference changed the cost of a first draft.
It did not change who owns the blast radius.
Did your policy file follow the model, or stay in chat?
I keep six failures on a short review list.
Each failure looks like speed on a busy afternoon.
Each failure still ships as untracked engineering debt.
The scratch server printed a passing test line.
The pull request skips CI because the agent ran tests.
Main now trusts a machine you do not control.
People confuse a demo host with a trusted runner.
A free server is a sandbox, not an attesting builder.
Green output is not a signed provenance record.
Replay every agent patch on your own runner.
Fail the merge when local tests never executed.
Keep the remote box for drafts, not releases.
#!/usr/bin/env bash
set -euo pipefail
if [[ ! -f .agent/origin.json ]]; then
echo "missing .agent/origin.json" >&2
exit 1
fi
replayed="$(jq -r '.replayed_locally' .agent/origin.json)"
if [[ "${replayed}" != "true" ]]; then
echo "patch was not replayed locally" >&2
exit 1
fi
if [[ "${CI:-}" != "true" ]]; then
echo "refusing to bless a merge outside CI" >&2
exit 1
fi
git diff --check
pytest -q
Did those agent tests even use your lockfile?
If you cannot answer, you cannot merge yet.
Someone pasted a dotenv file into the agent thread.
The free server now holds a production token.
Rotation starts only after the screenshot already leaked.
Convenience still beats threat modeling under time pressure.
A shared scratch box is not your secret store.
Free compute does not include any free confidentiality.
Redact first, then inject secrets only in local CI.
I keep a dumb scanner in the pre-push path.
It looks ugly, and it is enough for drafts.
from __future__ import annotations
import re
import sys
from pathlib import Path
PATTERNS = [
re.compile(r"AKIA[0-9A-Z]{16}"),
re.compile(r"-----BEGIN (RSA |OPENSSH )?PRIVATE KEY-----"),
re.compile(r"(?i)(api[_-]?key|secret)\s*=\s*\S+"),
]
def main(paths: list[str]) -> int:
failed = False
for raw in paths:
path = Path(raw)
if not path.is_file():
continue
text = path.read_text(errors="ignore")
for pat in PATTERNS:
if pat.search(text):
print(f"secret-like match in {path}")
failed = True
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
git diff --name-only --cached -z | xargs -0 -r python secret_scan.py
Would you paste that token into a hallway laptop?
Then do not paste it into a free agent server.
The system prompt says never touch production files.
The agent still edits the deploy workflow anyway.
Nobody encoded the rule as a failing check.
Natural language feels like a real control plane.
Models drift over sessions, while files stay put.
A policy that cannot fail CI is a wish.
Put deny paths in an executable policy file.
I like a tiny allow and deny list in-repo.
The agent may read it, but CI must enforce it.
deny_globs:
- ".github/workflows/**"
- "infra/prod/**"
- "**/*.env"
- "**/credentials.json"
allow_globs:
- "src/**"
- "tests/**"
- "docs/**"
python
from fnmatch import fnmatch
from pathlib import Path
import subprocess
import sys
import yaml
policy = yaml.safe_load(Path(".agent/policy.yml").read_text())
diff = subprocess.check_output(
["git", "diff", "--name-only", "origin/main...HEAD"],
text=True,
).splitlines()
blocked = False
for path in diff:
if any(fnmatch(path, glob) for glob in policy["deny_globs"]):
print(f"denied path: {path}")
blocked = True
sys.exit(1 if blocked else 0)
If the check is optional, the anti-pattern remains.
Make it blocking, then argue exceptions in the PR.
One long agent thread spans three private repositories.
Context from repo A leaks into a patch for B.
The summary sounds confident, but the imports are wrong.
Sessions are cheap, and isolation is not the default.
A free server often outlives your actual attention.
Yesterday's stack trace becomes today's invented client API.
Use one repo, one worktree, and one short session.
Destroy the thread when the branch finally has a name.
Record the origin file before you close the tab.
{
"generated_at": "2026-09-23T12:00:00Z",
"repo": "payments-api",
"base_sha": "REPLACE_WITH_LOCAL_SHA",
"session": "scratch-only",
"replayed_locally": false
}
Set replayed_locally true only after local CI passes.
Anything else is still a draft, not a candidate.
Someone says the model needs a realistic payload.
A customer export then lands on the scratch server.
The model is free, but that dataset is not.
Realism gets confused with actual processing permission here.
Free inference still does not grant data-processing rights.
A redacted fixture is slower, and it is legal.
Build a tiny fixture set inside the repository.
I would rather ship boring JSON than a real dump.
Name the file fake, and then keep it fake.
{
"order_id": "ord_test_001",
"amount_cents": 1999,
"region": "lab",
"email": "user@example.test"
}
if git diff --name-only | grep -E '(customers|pii|prod-dump)'; then
echo "prod-shaped filename in the diff" >&2
exit 1
fi
Do you have a deletion ticket for that upload?
If not, you already lost the data conversation.
The patch arrived as a blob from the chat.
Nobody can regenerate it from a recorded prompt.
Reviewers argue with a screenshot, not a command.
Generation starts to feel like real authorship too quickly.
Authorship without a replay path is just folklore.
Folklore does not bisect when production later breaks.
Export a patch file, then apply it locally.
Run the same tests your CI will run tomorrow.
If apply fails, the remote box lied about the tree.
#!/usr/bin/env bash
set -euo pipefail
base="$(jq -r '.base_sha' .agent/origin.json)"
git checkout -B "replay/${USER}" "${base}"
git apply --check /tmp/agent.patch
git apply /tmp/agent.patch
tmp="$(mktemp)"
jq '.replayed_locally = true' .agent/origin.json > "${tmp}"
mv "${tmp}" .agent/origin.json
pytest -q
Can a stranger reproduce this without the original chat?
If they cannot, you do not have a change.
You only have a vibe from a closed tab.
I print this table near the merge button.
I still miss a row when the review is rushed.
The table is the review, not the model output.
| Signal | Merge | Why |
|---|---|---|
| Remote tests only | No | Host is not your runner |
| Secrets in the thread | No | Rotate first, then rewrite |
| Policy only in the prompt | No | Encode a failing check |
| Mixed-repo session | No | Split and regenerate |
| Real customer payload | No | Replace with fixtures |
| Local apply and CI green | Yes | Now it is your patch |
I still want a scratch pad for ugly first drafts.
Throwaway refactors do not deserve a paid cluster.
They also do not deserve a silent path to main.
MonkeyCode offers free model access and a free server option.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
I treat that option as a disposable editor, nothing more.
The workflow stays boring on purpose for a reason.
Drafts can be remote without becoming merge artifacts.
Merges cannot be remote without a local replay.
Skip step one if your code cannot leave the building.
Skip none of the later steps if it can.
This catalog will not make a model honest.
It only makes a dishonest merge much harder.
That is the actual job of these gates.
Do not use a free scratch server in these cases.
I also will not claim tokens, uptime, or model names.
Those numbers go stale by the next product page.
Trust the commands you can rerun after lunch.
Clone the repo, apply the patch, scan, then test.
If a step fails, the agent did not finish.
The chat window is not a merge witness.
Want a disposable box for that first ugly draft?
Use a free server, then run the local gate at home.