DEV Community

Sam Rivera
Sam Rivera

Posted on

Build a Silent-Default Kill List for Agent Diffs

The agent finished a patch while I made coffee.
Green tests sat next to a config I never requested.
What did it assume while I stepped away?

My goal tonight is merging nothing with silent defaults.
The budget is ninety minutes, then I abandon the branch.
I wanted a packet I could copy, not a lecture.

Cheap diffs still cost review hours

Agents write fast, but review hours do not scale.
That gap is exactly where silent defaults live.
A missing flag becomes a production surprise later.

Have you seen a helpful force flag you never typed?
Green tests still pass when that flag appears.
Green tests do not prove the agent asked permission.

This piece is not another architecture survival guide.
I do not need twenty agentic buzzwords tonight.
I need a fail-closed list and one command.

What the packet contains

Three files make the whole copyable artifact tonight.
You keep an allowlist, a scanner, and evidence.
If extras appear, then the merge dies immediately.

I already shipped a broader readiness checklist once.
Tonight the scope stays intentionally narrower than that.
We only hunt values that nobody actually declared.

Time box this to ninety minutes from clone to decision.
Keep paid APIs at zero if a free path exists.
Any undeclared default kills the branch without debate.

Gate 1 — freeze the question first

Write the task in one sentence before anyone runs.
If you cannot, you are simply not ready.
The agent will fill that silence for you.

# task.txt
Add a --timeout-s flag. Default 15. Nothing else.
Enter fullscreen mode Exit fullscreen mode

Ask yourself a harder question about required absences.
What must stay missing from this patch entirely?
Write those absences down as real requirements.

Absence is a requirement here, not a vague vibe.

# forbidden.txt
force
retry
insecure
auto_approve
Enter fullscreen mode Exit fullscreen mode

Gate 2 — declare allowed defaults

Put every legal default in one boring JSON file.
Skip comments and skip hidden environment fallbacks tonight.
If a value is not here, it is illegal.

{
  "timeout_s": 15,
  "output": "json",
  "max_tokens": 512
}
Enter fullscreen mode Exit fullscreen mode

Why JSON instead of a markdown checklist alone?
I can diff it and I can grep it.
I cannot grep a vibe during a tired review.

Gate 3 — pin tools and paths

Agents assume extra tools when the prompt stays soft.
List the allowed tools and list the allowed files.
Everything else counts as a failed gate immediately.

# allowed-tools.txt
read_file
replace_in_file
run_tests
Enter fullscreen mode Exit fullscreen mode
# allowed-paths.txt
src/cli.ts
src/config.ts
tests/cli.test.ts
Enter fullscreen mode Exit fullscreen mode

Did the agent touch a deploy file you never named?
That is a silent default of scope, not help.
Kill the branch and do not negotiate with the diff.

Gate 4 — run the scanner

Here is the copyable utility I keep in tools.
It is intentionally boring Python, and that is the point.
Boring is a feature when agents start to improvise.

This scanner is a template.
Run the fixture before you trust it.
Save the file as kill_defaults.py in the repo.

#!/usr/bin/env python3
"""Fail closed when an agent diff introduces silent defaults."""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path


def load_json(path: Path) -> dict:
    return json.loads(path.read_text(encoding="utf-8"))


def load_lines(path: Path) -> list[str]:
    text = path.read_text(encoding="utf-8")
    return [ln.strip() for ln in text.splitlines() if ln.strip()]


def added_lines(diff_text: str) -> list[str]:
    out: list[str] = []
    for line in diff_text.splitlines():
        if line.startswith("+++") or line.startswith("---"):
            continue
        if line.startswith("+"):
            out.append(line[1:])
    return out


def added_paths(diff_text: str) -> list[str]:
    paths: list[str] = []
    for line in diff_text.splitlines():
        if line.startswith("+++ b/"):
            paths.append(line[6:])
    return paths


def observed_from_lines(lines: list[str]) -> dict:
    observed: dict[str, str] = {}
    skip = {"const", "let", "var", "return", "export"}
    for line in lines:
        if ":" not in line:
            continue
        left, right = line.split(":", 1)
        key = left.strip().strip("\"'{} ")
        if not key or " " in key or key in skip:
            continue
        observed[key] = right.strip().strip(",'\"")
    return observed


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--allowlist", required=True)
    parser.add_argument("--forbidden", required=True)
    parser.add_argument("--allowed-paths", required=True)
    parser.add_argument("--diff", required=True)
    parser.add_argument("--out", required=True)
    args = parser.parse_args()

    out = Path(args.out)
    out.mkdir(parents=True, exist_ok=True)

    allow = load_json(Path(args.allowlist))
    forbidden = load_lines(Path(args.forbidden))
    allowed_paths = load_lines(Path(args.allowed_paths))
    diff_text = Path(args.diff).read_text(encoding="utf-8")

    added = added_lines(diff_text)
    paths = added_paths(diff_text)
    observed = observed_from_lines(added)

    extras = {k: v for k, v in observed.items() if k not in allow}
    forbidden_hits = sorted(
        {
            *[k for k in observed if k in forbidden],
            *[w for w in forbidden if any(w in line for line in added)],
        }
    )
    path_hits = [p for p in paths if p not in allowed_paths]

    (out / "declared.json").write_text(json.dumps(allow, indent=2) + "\n")
    (out / "observed.json").write_text(json.dumps(observed, indent=2) + "\n")
    (out / "extras.json").write_text(json.dumps(extras, indent=2) + "\n")
    (out / "forbidden_hits.txt").write_text(
        "\n".join(forbidden_hits) + ("\n" if forbidden_hits else "")
    )
    (out / "path_hits.txt").write_text(
        "\n".join(path_hits) + ("\n" if path_hits else "")
    )

    failed = bool(extras or forbidden_hits or path_hits)
    verdict = "FAIL_CLOSED" if failed else "PASS"
    (out / "verdict.txt").write_text(verdict + "\n")
    print(verdict)
    if extras:
        print("extras:", json.dumps(extras))
    if forbidden_hits:
        print("forbidden:", ", ".join(forbidden_hits))
    if path_hits:
        print("paths:", ", ".join(path_hits))
    return 2 if failed else 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

Run it against the agent patch like this:

git diff main > /tmp/agent.patch
python3 kill_defaults.py \
  --allowlist allowed-defaults.json \
  --forbidden forbidden.txt \
  --allowed-paths allowed-paths.txt \
  --diff /tmp/agent.patch \
  --out review-packet
echo $?
Enter fullscreen mode Exit fullscreen mode

Exit code zero means the packet stayed clean.
Any other code means I stop the merge.
I do not allow a just-this-once exception here.

Gate 5 — demand evidence files

The scanner must write a folder, not a feeling.

review-packet/
  declared.json
  observed.json
  extras.json
  forbidden_hits.txt
  path_hits.txt
  verdict.txt
Enter fullscreen mode Exit fullscreen mode

If extras.json is not an empty object, fail closed.
If verdict.txt is missing, treat that as failure.
Missing evidence is a failure, never a skip.

Copyable decision table

Gate Evidence file Fail closed when
Freeze the task task.txt The sentence is missing or fuzzy
Allowlist allowed-defaults.json Observed keys are not declared
Forbidden names forbidden_hits.txt Any hit exists
Path pin path_hits.txt Any path sits outside the list
Verdict verdict.txt File missing or not PASS

A failure fixture you can replay

I keep a dirty patch just for the scanner.
Do not trust a gate you have never failed.
This fixture must break the command on purpose.

fixtures/silent-retry.patch looks like this:

--- a/src/config.ts
+++ b/src/config.ts
@@
-export const defaults = { timeout_s: 15 };
+export const defaults = { timeout_s: 15, retry: 99 };
Enter fullscreen mode Exit fullscreen mode

Who asked for retry set to ninety-nine?
Not me, and not the allowlist file either.

python3 kill_defaults.py \
  --allowlist allowed-defaults.json \
  --forbidden forbidden.txt \
  --allowed-paths allowed-paths.txt \
  --diff fixtures/silent-retry.patch \
  --out /tmp/packet-fail
echo $?   # expect 2
cat /tmp/packet-fail/extras.json
Enter fullscreen mode Exit fullscreen mode

You should see retry land in extras.json.
If the scanner stays quiet, throw the scanner away.
A mute gate is only another silent default.

Gate 6 — time box and rollback

Ninety minutes, then I pick exactly one exit.

  1. Merge only when extras.json stays an empty object.
  2. Strip the extras and then rerun the scanner.
  3. Abandon the branch and keep the packet.

Rollback is not a speech, it is a command.

git restore .
git switch -c abandon/silent-defaults-$(date +%Y%m%d)
mkdir -p abandoned
cp -R review-packet "abandoned/$(date +%Y%m%dT%H%M)"
Enter fullscreen mode Exit fullscreen mode

Why keep the packet after you abandon the branch?
So next weekend I do not repeat the miss.
The extras file is the actual written lesson.

Where a free model path fits

I needed a throwaway box for the agent run.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode is an open-source project with free model access.
It also publishes a free server option for experiments.
Treat that box as disposable compute, never as production.

The packet does not depend on that vendor at all.
Swap the model tomorrow and keep the same gates.
If extras.json still fills, you still stop cold.

Need a free-model path for a weekend packet?
Their public project is one place to try.

Who should not use this

Do not use this packet as a security audit.
It will not catch prompt injection in tool output.
It will not catch leaked secrets in extra files.

Do not use this on huge generated refactors either.
A three-thousand-line agent PR defeats the time box.
Split the task or reject the run outright.

Skip this if you already have formal change control.
Skip this if nobody on the team reads diffs.
A packet cannot replace a careful human eyeball.

Limitations I will not hide

The scanner is a blunt regex over a patch.
It misses computed defaults hiding inside helper functions.
It also misses values loaded from the environment.

Semantic assumptions still slip through this cheap net.
A declared timeout can still be the wrong number.
The gate only proves the value was declared first.

I still read the diff after the scanner passes.
The packet only makes silence expensive to ship.
That cost is the entire point of the ritual.

What I copy tomorrow

Copy the three files into the repo tonight.
Commit them before the next agent session starts.
Then run the failure fixture exactly one time.

If the fixture does not fail, you stop.
Fix the scanner and do not start the agent.
A green fixture is a lie you should not keep.

Which extra field does your agent keep injecting lately?
Should that field be allowlisted, or killed on sight?

Top comments (0)