DEV Community

Taylor Zhu
Taylor Zhu

Posted on

Promotion Packets for Agent Branches: Four Gates That Fail Closed

You do not promote an agent branch because it compiles. You promote it only when a fail-closed gate can show four pieces of evidence: a clean workspace record, a secret-clean diff, a human-owned lockfile decision, and a rollback pointer that already exists.

A cheap draft is not a merge. A shared scratch host can carry a leftover env file, a warm package cache, or a prompt from the previous session. Green tests on your laptop do not prove the patch was born clean. Hold the branch until the packet is complete.

The failure you are actually preventing

The interesting bug is not a red test. The interesting bug is a green branch that was born on a borrowed machine.

You asked an assistant to fix a flaky client. It edited three files. CI passed. Nobody asked whether the worktree started clean, whether the patch touched a credential path, or whether a lockfile moved because that host already had a different registry cache.

That gap is a promotion problem, not a model-quality problem. Treat the agent branch as untrusted input. Untrusted input does not get a protected remote until the evidence packet is complete. If a required file is missing, the answer is no. A warning is not a pass.

What you copy, and what you refuse to assume

Copy the gates, the evidence filenames, and the exit codes. Do not copy a quota, a hardware spec, or a permanence claim from outreach copy or from an old post.

This checklist is not a general production-readiness review, and it is not a substitute for code review. It answers one question: may this branch leave the scratch host for the protected remote?

You also do not hard-code a token budget from memory. Plans change. If a number is not on the current product page you opened today, it is not an input to this gate.

Four gates

1. Workspace record

Before the assistant edits anything, record the host and the base. A human wrapper writes this file. The model's final chat message is not evidence.

Require these fields:

  • host_id: a name you assigned to the scratch machine
  • started_clean: true only if git status --porcelain was empty
  • base_sha: the commit you will diff against
  • recorded_at: an ISO timestamp from that host

No file, no promote. A record that says started_clean: false is also a fail, unless you are in a deliberate dirty spike and a separate human override file exists. Do not flip the boolean by hand to get a green run.

2. Secret-clean diff

List every path in git diff --name-only <base_sha>. Reject the branch if any path hits a denylist.

Start boring:

  • .env and any .env.*
  • id_rsa, id_ed25519, *.pem, *.p12
  • credentials.json and anything under a secrets directory

A filename check is a tripwire, not a secret scanner. Keep the scanner you already trust. Do not let the tripwire be the only control, and do not let a missing scanner become a silent pass. Missing evidence fails closed.

3. Lockfile decision

If the diff touches a lockfile, a human owns that change in writing.

Watch the usual names: package-lock.json, pnpm-lock.yaml, yarn.lock, poetry.lock, uv.lock, Pipfile.lock, go.sum, Cargo.lock, and Gemfile.lock.

An assistant on a scratch host will often regenerate these from whatever index that host can see. That is how you ship a different tree than the one your team reviewed. If none of those names changed, this gate passes. If any changed, require evidence/lockfile-decision.json with human_owner, reason, and the same base_sha. An empty reason is a fail.

4. Rollback pointer

Create the rollback pointer before you push, not after the incident channel opens.

evidence/rollback.txt holds one rev the team already knows: a tag or a full SHA. The checker runs git rev-parse --verify on it. An empty file, or a rev this clone cannot resolve, stops the push.

You are not proving the rollback will be painless. You are proving you named a known rev before the new branch moved. If your remote deletes tags, add a remote check you control. Local resolution alone is not a remote guarantee.

Decision table

Use this as the review card. Do not negotiate it in the moment.

Evidence Valid Missing or false Action
Workspace record, clean start Pass Fail Stop. Recreate the worktree.
Denylist against diff paths No hits Any hit Stop. Remove the secret path from the branch.
Lockfile decision Present if lockfiles changed Changed, no owner Stop. Revert the lockfile or get a human owner.
Rollback rev Resolves in this clone Empty or unknown Stop. Tag or record the base first.
Checker process Exit 0 Exit 2, crash, or skip Do not push. A skipped checker is a fail.

Fail closed means a non-zero exit. A crashed checker is a failed promotion, not an exception you ignore because the diff looks small.

The promotion sequence

Run the steps in order. Skipping a step to save time is how the packet rots.

  1. Clone onto a disposable worktree. Confirm git status --porcelain is empty.
  2. Write evidence/workspace.json and evidence/rollback.txt from the base SHA.
  3. Let the assistant edit only that worktree. Keep protected-remote credentials off the scratch host.
  4. Diff against base_sha. If a lockfile moved, add a human decision file or revert it.
  5. Run the checker. Exit 0 is necessary, not sufficient. A human still reads the diff.
  6. Push only from a machine that is allowed to hold the protected remote.

A checker you can copy

The script below is an unexecuted example. It is not a benchmark, and it is not a log from a run on your repo. Read it, then point it at a scratch clone. The intended contract is simple: missing evidence prints FAIL and exits 2. That is how the branches are written. It is not a captured measurement.

#!/usr/bin/env python3
"""Fail-closed promotion gate. Unexecuted example. Exit 0 only if evidence holds."""
import json, subprocess, sys
from pathlib import Path

ROOT = Path(".").resolve()
EVID = ROOT / "evidence"
DENY_NAMES = {".env", "id_rsa", "id_ed25519", "credentials.json"}
DENY_SUFFIXES = (".pem", ".p12")
LOCKS = {
    "package-lock.json", "pnpm-lock.yaml", "yarn.lock",
    "poetry.lock", "uv.lock", "Pipfile.lock",
    "go.sum", "Cargo.lock", "Gemfile.lock",
}

def fail(msg: str) -> None:
    print(f"FAIL: {msg}", file=sys.stderr)
    sys.exit(2)

def git(*args: str) -> str:
    proc = subprocess.run(["git", *args], cwd=ROOT, text=True, capture_output=True)
    if proc.returncode != 0:
        fail("git " + " ".join(args) + ": " + proc.stderr.strip())
    return proc.stdout.strip()

def denied(path: str) -> bool:
    name = Path(path).name
    if name in DENY_NAMES or name.startswith(".env"):
        return True
    if name.endswith(DENY_SUFFIXES):
        return True
    return "secrets" in Path(path).parts

def main() -> None:
    record_path = EVID / "workspace.json"
    if not record_path.is_file():
        fail("missing evidence/workspace.json")
    record = json.loads(record_path.read_text())
    for key in ("host_id", "started_clean", "base_sha", "recorded_at"):
        if key not in record:
            fail("workspace.json missing " + key)
    if record["started_clean"] is not True:
        fail("workspace did not start clean")
    base = str(record["base_sha"])
    git("rev-parse", "--verify", base)

    paths = [p for p in git("diff", "--name-only", base).splitlines() if p]
    hits = [p for p in paths if denied(p)]
    if hits:
        fail("denylist paths in diff: " + ", ".join(hits))

    lock_hits = [p for p in paths if Path(p).name in LOCKS]
    if lock_hits:
        decision_path = EVID / "lockfile-decision.json"
        if not decision_path.is_file():
            fail("lockfile changed with no lockfile-decision.json")
        decision = json.loads(decision_path.read_text())
        if not decision.get("human_owner") or not decision.get("reason"):
            fail("lockfile decision needs human_owner and reason")
        if decision.get("base_sha") != base:
            fail("lockfile decision base_sha does not match workspace")

    rollback_path = EVID / "rollback.txt"
    if not rollback_path.is_file():
        fail("missing evidence/rollback.txt")
    rev = rollback_path.read_text().strip()
    if not rev:
        fail("rollback.txt is empty")
    git("rev-parse", "--verify", rev)
    print("PASS: promotion evidence holds for", record["host_id"])

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

Collect the packet before the assistant edits, then run the checker before any push:

mkdir -p evidence
if [ -z "$(git status --porcelain)" ]; then clean=true; else clean=false; fi
sha=$(git rev-parse HEAD)
stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
cat > evidence/workspace.json <<EOF
{
  "host_id": "scratch-host-a",
  "started_clean": ${clean},
  "base_sha": "${sha}",
  "recorded_at": "${stamp}"
}
EOF
printf '%s\n' "$sha" > evidence/rollback.txt
python3 promote_gate.py
Enter fullscreen mode Exit fullscreen mode

If started_clean is false, fix the worktree. Do not edit the JSON to force a pass. Break the gates on purpose once: delete rollback.txt, touch a lockfile, add a fake .env. You want exit code 2 each time, before you trust the script enough to put it next to an assistant.

Where a free model and a free server fit

Use the cheap workspace for the draft. Do not treat it as the source of truth.

MonkeyCode offers free model access and a free server option you can use to try this gate on a scratch branch. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Those two availability claims were supplied for this draft. They are not a model list, a token quota, a hardware spec, a duration, or a promise that the offer stays fixed. This article deliberately does not state a token figure, because no primary plan page was attached and an old number would be the wrong kind of evidence. Open the current docs and confirm limits before you depend on them.

The free server is a reasonable place to run promote_gate.py against a disposable clone. It is a bad place to store long-lived credentials, production remotes, or the only copy of a rollback tag. Keep protected-remote credentials off that host. Push only after the checker exits 0 and a human has read the diff.

If you delete every product name from this page, the gate still stands. The product is a convenient scratch host, not the evidence.

Limitations

  • The denylist misses secrets pasted into ordinary source files. Pair it with a scanner you already trust.
  • git rev-parse proves the rev exists in this clone. It does not prove the remote still has it.
  • A human name in JSON is not an approval system. If you need SSO-backed review, use your forge's pull request rules. This file is a local tripwire.
  • Clock skew can make recorded_at look fresh on a drifted host. Do not treat the timestamp as a legal audit log.
  • The script matches lockfiles by basename. A rename that hides a lockfile under a new path needs a human look.
  • Fail-closed scripts fail open the day someone aliases them to true. Pin the command in CI, or do not pretend you have the gate.

Who should not use this

Skip the checker if the spike will be deleted the same day and will never be pushed. Also skip it if your platform already blocks secret files, lockfile drift, and unsigned pushes with controls you have tested. A second local script will not make a mature control plane safer.

Do not park customer data, production keys, or regulated traces on a free shared server because the draft was cheap. This checklist assumes the scratch host is disposable and untrusted. If that assumption is false, stop and use an isolated runner your security team already accepts.

One next step

Pick one repo you already maintain. Add the checker on a branch that has no assistant in it. Break each gate on purpose and confirm exit code 2. Only then point an assistant at a scratch clone.

If you want that clone on a disposable host, MonkeyCode's free server option is enough to start, after you read the current limits yourself. Leave the protected remote for the machine that passed the gate.

Top comments (0)