An agent patched the wrong file during a routine extract. The completer did not invent a public API name. It filled an optional tool argument from recent tokens. The schema allowed a missing path on write. The runtime then accepted a guessed relative file path. This postmortem treats that guess as a schema defect.
Incident summary
The intended change was a local helper extract only. The applied hunk landed inside another package file. Unit tests for the original module stayed fully green. A later import failed inside a different worker process. The failure class is default-filling on optional write fields.
This write-up is a reconstructed lab incident. It is labeled as an unexecuted teaching example. No customer names or production metrics appear here.
Timeline
All stamps are local to the coding session clock.
- 14:02 — The operator requested a helper extract from
billing/utils.py. - 14:03 — The agent selected an
apply_patchtool call next. - 14:03 — The published tool schema marked
pathas optional. - 14:04 — The agent omitted
pathand sent only a unified diff. - 14:04 — The completer filled
pathwith leftoversrc/app.py. - 14:04 — That relative path still existed in the workspace tree.
- 14:05 — The runtime applied the hunk without a path confirmation.
- 14:06 — Tests under the
billing/package all passed locally. - 14:18 — A worker imported the missing helper and crashed hard.
- 14:31 — Name-only review showed the wrong file had changed.
The gap between apply and detection was twenty-six minutes. Package tests could not close that detection gap. Wrong-file writes sit outside the named test suite.
Impact
One file outside the request received a partial extract hunk. The billing package still looked healthy in local output. Downstream code lost a symbol it continued to import. The harm is a silent wrong-file write, not a crash loop.
Contributing factors
Several independent defects lined up in one session.
Schema treated path as optional
The tool JSON allowed a fully omitted path key. Optional write fields invite silent model defaults every time. Models complete missing keys from the nearest session tokens. Those tokens included another repository layout from prior work.
Shared workspace retained foreign files
A shared coding server can keep leftover trees on disk. Those leftover trees make guessed relative paths exist immediately. Existence checks then pass without proving operator intent at all. Existence is not the same thing as write authorization.
No mention-to-path coupling
The operator named billing/utils.py in plain request text. The tool accepted src/app.py without any mention overlap. Nothing required the write target to match named files. Overlap is a cheap and durable control invariant.
Tests scoped only to the named package
Green tests followed the operator's original mental model closely. They never hashed the full git name-only list. Wrong-file writes evade package-scoped assertions on every run.
Apply had no confirmation echo
The runtime printed a generic applied status line first. It did not print the resolved path before writing. Operators cannot reject a path they never see printed.
Reproduction artifact
The validator below is a concrete local check. It rejects omitted paths before any filesystem write occurs. It rejects paths absent from the operator mention set. It also rejects unknown extra properties on the payload. Run it in a clone before wiring an apply tool.
# apply_guard.py
# Labeled example: teaching validator, not production telemetry.
from __future__ import annotations
import posixpath
from pathlib import Path
from typing import Any
REQUIRED = ("path", "diff")
ALLOWED_ROOTS = ("billing/", "shared/")
class ApplyRejected(Exception):
pass
def normalize_relpath(raw: str) -> str:
if not isinstance(raw, str) or not raw.strip():
raise ApplyRejected("path must be a non-empty string")
path = raw.replace("\\", "/").strip()
if path.startswith("/") or path.startswith("~"):
raise ApplyRejected("absolute paths are forbidden")
parts = posixpath.normpath(path).split("/")
if ".." in parts:
raise ApplyRejected("parent traversal is forbidden")
norm = posixpath.normpath(path)
if norm in (".", ""):
raise ApplyRejected("path must name a file")
return norm
def mentioned_paths(user_text: str) -> set[str]:
found: set[str] = set()
for token in user_text.replace("`", " ").split():
token = token.strip(".,;:()[]{}")
if "/" in token and token.endswith(".py"):
found.add(normalize_relpath(token))
return found
def validate_apply(
payload: dict[str, Any],
user_text: str,
repo_root: Path,
) -> dict[str, str]:
extra = set(payload) - {"path", "diff", "description"}
if extra:
raise ApplyRejected(f"unknown fields: {sorted(extra)}")
for key in REQUIRED:
if key not in payload:
raise ApplyRejected(f"missing required field: {key}")
if not isinstance(payload[key], str) or not payload[key].strip():
raise ApplyRejected(f"{key} must be a non-empty string")
path = normalize_relpath(payload["path"])
if not any(path.startswith(root) for root in ALLOWED_ROOTS):
raise ApplyRejected(f"path outside allowlist: {path}")
mentions = mentioned_paths(user_text)
if path not in mentions:
raise ApplyRejected(
f"path {path} was not named in the operator request"
)
abs_path = (repo_root / path).resolve()
root = repo_root.resolve()
if root not in abs_path.parents and abs_path != root:
raise ApplyRejected("resolved path escaped repo root")
if not abs_path.is_file():
raise ApplyRejected(f"target is not an existing file: {path}")
return {"path": path, "diff": payload["diff"]}
def main() -> None:
user = "Extract helper from billing/utils.py into billing/tax.py"
bad = {"diff": "--- a/x\n+++ b/x\n"}
try:
validate_apply(bad, user, Path("."))
except ApplyRejected as err:
print("rejected:", err)
guessed = {
"path": "src/app.py",
"diff": "--- a/src/app.py\n+++ b/src/app.py\n",
}
try:
validate_apply(guessed, user, Path("."))
except ApplyRejected as err:
print("rejected:", err)
if __name__ == "__main__":
main()
Compile first. Then run the two expected rejection paths.
python3 -m py_compile apply_guard.py
python3 apply_guard.py
Expected output is two hard rejections. The first payload lacks path entirely. The second payload uses a guessed file outside mentions.
Tool schema that caused the miss
The defective schema looked like the object below. No required array existed on write fields. additionalProperties was left unrestricted for convenience. Completers may emit path, file, or target. The runtime treated any cousin key as enough signal.
{
"name": "apply_patch",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string" },
"diff": { "type": "string" },
"description": { "type": "string" }
}
}
}
Convenience here is the incident. Optional keys are a completer invitation. Write tools cannot afford that invitation.
Durable schema
Require the write target. Forbid extra keys. Constrain the path pattern to known roots.
{
"name": "apply_patch",
"strict": true,
"parameters": {
"type": "object",
"additionalProperties": false,
"required": ["path", "diff"],
"properties": {
"path": {
"type": "string",
"minLength": 3,
"pattern": "^(billing|shared)/[A-Za-z0-9_./-]+\\.py$"
},
"diff": { "type": "string", "minLength": 20 },
"description": { "type": "string" }
}
}
}
Strict mode still needs a runtime mention check. Schema regex cannot see operator text. Mention overlap stays in process code on purpose.
Confirmation echo and name-list gate
Print the resolved path before any write. Block until the operator confirms that exact target.
def confirm_write(path: str, operator_ok: bool) -> None:
print(f"WRITE TARGET: {path}")
if not operator_ok:
raise ApplyRejected("operator did not confirm write target")
Do not log applied status first. Log the target. Then apply the hunk. Then log a content hash for the file.
git diff --name-only
git hash-object billing/utils.py
Compare the name list to the mention set next. Fail the job when extra paths appear. A tiny hook keeps that rule durable.
#!/bin/sh
# pre-apply-name-check.sh
# Labeled example: local gate, not a hosted policy engine.
set -eu
mentioned_file="$1"
names="$(git diff --name-only)"
while IFS= read -r path; do
[ -z "$path" ] && continue
grep -qxF "$path" "$mentioned_file" || {
echo "unexpected path in diff: $path" >&2
exit 1
}
done <<EOF
$names
EOF
Wire it after every agent apply, before commit. Keep the mention file generated from the request text. Do not hand-edit that file during the same session.
Test plan
This plan is executable in a clean clone. It does not claim live traffic numbers. It does not claim model accuracy figures either.
- Omit
pathand expectApplyRejectedimmediately. - Send
path: "src/app.py"against a billing request and reject. - Send
../secret.envand expect a traversal rejection. - Send
/etc/passwdand expect an absolute-path rejection. - Send
billing/utils.pywith a valid diff and accept. - Add
fileas an alias field and reject extras. - Run
git diff --name-onlyafter a dry run and expect one named file. - Run package tests plus the workspace name-list check together.
Pytest sketch for the missing-path case:
import pytest
from pathlib import Path
from apply_guard import ApplyRejected, validate_apply
def test_missing_path_is_hard_error(tmp_path: Path):
(tmp_path / "billing").mkdir()
(tmp_path / "billing" / "utils.py").write_text("x=1\n")
with pytest.raises(ApplyRejected):
validate_apply(
{"diff": "---\n+++\n"},
"touch billing/utils.py",
tmp_path,
)
Run that test before enabling any write tool. A schema change without this test will regress. Optional fields tend to return during later "simplify the tool" edits.
Decision table
Use this table as the apply policy. Do not improvise per model.
| Condition | Action |
|---|---|
| Named file, required keys, allowlisted path | Echo, then apply |
Missing path
|
Reject, no default |
| Path exists but was not named | Reject, no default |
| Extra keys on the payload | Reject |
| Parent traversal or absolute path | Reject |
| Tests green with extra files in the diff | Fail the job |
Defaults are allowed only for read-only tools. Write tools take no defaults at all. That split is the durable policy after this incident.
Where a free coding environment fits
Shared workspaces make leftover paths more likely to exist. Cheap model retries make omitted keys more likely to be filled. The guardrail above stays independent of any vendor runtime.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode currently offers free model access and a free server option. Those two options help a team reproduce this failure class in a scratch workspace. They do not replace required tool fields. They do not replace path allowlists or confirmation echoes.
Limitations
The mention parser is token based and brittle. It misses paths sitting only in screenshots. It misses paths spoken without a slash separator. It also misses generated files that should be brand new. New-file applies need a separate create tool path. That create tool must still require an explicit path argument.
Regex allowlists drift as packages move. Teams must update roots after renames. Hash checks need a git index in the workspace. The validator does not review diff semantics at all. It only binds the write target to named files.
This write-up invents no quota, hardware, or benchmark claims. Model quality is out of scope for the durable fix. Schema shape is the control that survived review.
Who should not use this approach
Do not use mention overlap as the only write control. Do not use it for generated scaffolds without explicit create tools. Do not use it when operators speak in tickets without paths. Do not skip confirmation on production repositories. Do not share write credentials on a multi-tenant workspace.
Solo scratchpads can keep optional fields for read tools. Shared agent runtimes cannot keep optional write fields. Convenience on write tools is how this incident started.
Close
Optional tool arguments look friendly in a schema browser. They hide a completer behind a missing key. Completers guess from leftover workspace tokens. Guesses become writes when existence checks pass. Make write paths required, mentioned, allowlisted, and echoed. Then apply the hunk. Then hash the named files before merge.
Top comments (0)