DEV Community

Morgan Xu
Morgan Xu

Posted on

Postmortem: Optional Tool Args Let an Agent Invent a Write Path

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.

  1. 14:02 — The operator requested a helper extract from billing/utils.py.
  2. 14:03 — The agent selected an apply_patch tool call next.
  3. 14:03 — The published tool schema marked path as optional.
  4. 14:04 — The agent omitted path and sent only a unified diff.
  5. 14:04 — The completer filled path with leftover src/app.py.
  6. 14:04 — That relative path still existed in the workspace tree.
  7. 14:05 — The runtime applied the hunk without a path confirmation.
  8. 14:06 — Tests under the billing/ package all passed locally.
  9. 14:18 — A worker imported the missing helper and crashed hard.
  10. 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()
Enter fullscreen mode Exit fullscreen mode

Compile first. Then run the two expected rejection paths.

python3 -m py_compile apply_guard.py
python3 apply_guard.py
Enter fullscreen mode Exit fullscreen mode

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" }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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" }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

  1. Omit path and expect ApplyRejected immediately.
  2. Send path: "src/app.py" against a billing request and reject.
  3. Send ../secret.env and expect a traversal rejection.
  4. Send /etc/passwd and expect an absolute-path rejection.
  5. Send billing/utils.py with a valid diff and accept.
  6. Add file as an alias field and reject extras.
  7. Run git diff --name-only after a dry run and expect one named file.
  8. 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,
        )
Enter fullscreen mode Exit fullscreen mode

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)