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.
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
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
}
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
# allowed-paths.txt
src/cli.ts
src/config.ts
tests/cli.test.ts
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())
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 $?
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
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 };
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
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.
- Merge only when extras.json stays an empty object.
- Strip the extras and then rerun the scanner.
- 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)"
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)