DEV Community

Harper Xu
Harper Xu

Posted on

Hash the Envelope Before You Generate

You do not control generation until inputs are pinned. A chat box is not an interface. It is an unbounded failure domain.

Most teams argue about models and prompts. That debate skips the real contract. The contract is everything the model may see.

Think of a compiler, not a conversation. You do not feed it your home directory. You pass sources, flags, and a working directory. Generation needs that same envelope.

Constraints you already have

Your apply host already holds secrets. It holds deploy keys and cloud tokens. Config files still name production hosts.

Dump the working tree into a prompt and you copy that surface. The model host becomes a second production context. That copy almost never has your real controls.

You also lose replay. Yesterday's tree is not today's tree. A floating context window cannot be audited later. Reviewers then guess which files the model actually saw.

So the constraint stays simple. The generate plane may read a hashed envelope. Nothing else crosses that boundary. If a file is not named, it does not exist.

Data flow that actually holds

Start from the apply host, not a chat UI. The apply host already knows the branch. It already knows the ticket. It should write a generation request as a file.

That file names allowed paths and a base commit. It names the task in one short sentence. It also names forbidden globs so secrets stay out.

You hash every included blob. You hash the request document itself. Then you send only that bundle to the generate host. The host never mounts your working tree. It never inherits your shell environment.

It writes a patch and a receipt. The receipt repeats the hashes. Reviewers check receipt against request. Apply happens only after that match. Chat logs are leftover debris. Receipts are the audit trail.

Here is a concrete envelope. Treat it as a checked-in contract. Do not treat it as a prompt.

{
  "apiVersion": "generate.local/v1",
  "kind": "GenerationEnvelope",
  "metadata": {
    "id": "ticket-1842-rename-retry",
    "createdAt": "2026-09-18T12:00:00Z"
  },
  "spec": {
    "baseCommit": "9f3c1a0e8b77d2c1a4e6f0b9c8d7e6f5a4b3c2d1",
    "task": "Rename retry helper; keep public signatures.",
    "include": [
      "src/retry.ts",
      "src/retry.test.ts"
    ],
    "excludeGlobs": [
      "**/.env",
      "**/*secret*",
      "**/*.pem",
      "**/node_modules/**"
    ],
    "maxBytes": 65536,
    "output": {
      "format": "unified-diff",
      "maxFiles": 4,
      "maxBytes": 32768
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Keep that document small on purpose. Small envelopes fail closed. Giant envelopes only pretend to be architecture.

A validator you can run today

Do not trust a human to pack the bundle. Humans paste extra files under time pressure. A script should refuse the model call instead.

Save this as pin_envelope.py. It reads the envelope and copies allowed files. It writes a tarball plus SHA-256 sums. If it exits non-zero, you stop.

#!/usr/bin/env python3
"""Build a pinned generation envelope. Refuse unbounded context."""
from __future__ import annotations

import hashlib
import io
import json
import sys
import tarfile
from pathlib import Path

MAX_FILE_BYTES = 32 * 1024


def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def load_envelope(path: Path) -> dict:
    doc = json.loads(path.read_text(encoding="utf-8"))
    if doc.get("kind") != "GenerationEnvelope":
        raise SystemExit("kind must be GenerationEnvelope")
    spec = doc.get("spec") or {}
    if not spec.get("baseCommit"):
        raise SystemExit("spec.baseCommit is required")
    if not spec.get("include"):
        raise SystemExit("spec.include must list files")
    return doc


def assert_allowed(root: Path, rel: str, exclude_globs: list[str]) -> Path:
    path = (root / rel).resolve()
    if not str(path).startswith(str(root.resolve())):
        raise SystemExit(f"path escapes workspace: {rel}")
    if not path.is_file():
        raise SystemExit(f"missing file: {rel}")
    for glob in exclude_globs:
        if path.match(glob) or Path(rel).match(glob):
            raise SystemExit(f"excluded by glob {glob}: {rel}")
    return path


def main() -> None:
    if len(sys.argv) != 4:
        raise SystemExit("usage: pin_envelope.py ENV.json WORKDIR OUTDIR")
    env_path = Path(sys.argv[1])
    workdir = Path(sys.argv[2]).resolve()
    outdir = Path(sys.argv[3])
    outdir.mkdir(parents=True, exist_ok=True)

    doc = load_envelope(env_path)
    spec = doc["spec"]
    exclude = spec.get("excludeGlobs") or []
    max_bytes = int(spec.get("maxBytes") or 65536)

    blobs = []
    total = 0
    for rel in spec["include"]:
        path = assert_allowed(workdir, rel, exclude)
        data = path.read_bytes()
        if len(data) > MAX_FILE_BYTES:
            raise SystemExit(f"file too large: {rel}")
        total += len(data)
        if total > max_bytes:
            raise SystemExit("envelope exceeds spec.maxBytes")
        blobs.append((rel, data, sha256_bytes(data)))

    receipt = {
        "envelopeSha256": sha256_bytes(env_path.read_bytes()),
        "baseCommit": spec["baseCommit"],
        "files": [
            {"path": p, "sha256": h, "bytes": len(d)}
            for p, d, h in blobs
        ],
    }
    receipt_path = outdir / "receipt.json"
    receipt_path.write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8")

    tar_path = outdir / "context.tar"
    with tarfile.open(tar_path, "w") as tar:
        tar.add(env_path, arcname="envelope.json")
        tar.add(receipt_path, arcname="receipt.json")
        for rel, data, _ in blobs:
            info = tarfile.TarInfo(name=f"tree/{rel}")
            info.size = len(data)
            tar.addfile(info, fileobj=io.BytesIO(data))

    print(f"wrote {tar_path}")
    print(f"envelope sha256 {receipt['envelopeSha256']}")


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

Run it against a throwaway tree first. Confirm the happy path before you argue policy.

mkdir -p /tmp/demo/src /tmp/out
printf 'export function retry() { return 1 }\n' > /tmp/demo/src/retry.ts
printf 'test("retry", () => {})\n' > /tmp/demo/src/retry.test.ts
printf 'SECRET=not-for-models\n' > /tmp/demo/.env
python3 pin_envelope.py envelope.json /tmp/demo /tmp/out
sha256sum /tmp/out/context.tar /tmp/out/receipt.json
Enter fullscreen mode Exit fullscreen mode

Then force a negative path. Point include at .env and watch it die. That failure is the architecture working. Do not "fix" it by widening the glob list.

python3 - <<'PY'
import json
from pathlib import Path
doc = json.loads(Path("envelope.json").read_text())
doc["spec"]["include"] = [".env"]
Path("/tmp/bad.json").write_text(json.dumps(doc, indent=2))
PY
python3 pin_envelope.py /tmp/bad.json /tmp/demo /tmp/out-bad
# expected: excluded by glob **/.env
Enter fullscreen mode Exit fullscreen mode

If that command succeeds, your exclude list is theater. Stop there and repair the envelope. Do not call a model with a broken packer.

Failure domains this split creates

The envelope builder can fail. It can hash the wrong commit. That failure stays local and reviewable. It does not talk to a model. You fix the document and rerun the hasher.

The generate host can fail next. It can ignore the envelope and ask for more files. Your network policy must deny that fetch. No extra clone. No extra archive. No "just one more path."

The model can fail after that. It can emit a diff outside include. The apply plane must reject that diff. A receipt that names two files cannot apply three. Pretty summaries do not override path math.

Review can fail last. People rubber-stamp fluent prose. Force the review UI to show hashes first. Show the path list before any explanation. Hide the story until the receipt matches.

Notice the chat transcript is not in this chain. If your only record is a thread, you have no architecture. You have a memory of a conversation.

Where a free generate host fits

You still need somewhere to run the model call. That host should not be your laptop checkout. It should not be the deploy runner either. Those two machines already hold the blast radius you are trying to shrink.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode offers free model access and a free server option. Those two facts matter as generate-plane placement, not as a slogan. You upload a pinned tarball. You take back a diff and a receipt. You do not mount the apply workspace on that server.

If you already have an isolated generate runner, keep it. The envelope does not require a vendor. It requires a boundary you can hash. A free server is useful when you lack a spare isolate. It is harmful if you paste a checkout into a web chat. That paste is the old unbounded interface in a new tab.

What you change next

Store envelopes in git beside the ticket branch. Reviewers then see the input contract. They stop guessing from a dumped thread. The pull request should include envelope.json and receipt.json before any patch.

Make CI refuse generate jobs without receipt.json. A job that only stores a patch is incomplete. Incomplete jobs should never reach humans. Incomplete jobs should never reach git apply.

Shrink include until the task actually breaks. Then add one file, not ten. That pain shows which files were ritual. Ritual context is how secrets leak into prompts.

Bind output paths to the same allowlist. A retry rename should not rewrite Dockerfile. If the model needs more files, it requests a new envelope. It does not scrape the disk. A second envelope is cheaper than a silent extra file.

An apply-side check that stays boring

After the model returns, do not merge on tone. Verify the diff against the receipt. Keep the check smaller than the model call.

#!/usr/bin/env bash
set -euo pipefail
receipt="$1"
diff="$2"
python3 - "$receipt" "$diff" <<'PY'
import json, re, sys
receipt = json.load(open(sys.argv[1]))
allowed = {row["path"] for row in receipt["files"]}
text = open(sys.argv[2]).read()
paths = set(re.findall(r'(?m)^(?:--- |\+\+\+ )[ab]/(.+)$', text))
extra = sorted(p for p in paths if p not in allowed)
if extra:
    raise SystemExit("diff escapes envelope: " + ", ".join(extra))
print("diff stays inside envelope")
PY
Enter fullscreen mode Exit fullscreen mode

Wire it like this against a known-good receipt.

chmod +x check_diff.sh
./check_diff.sh /tmp/out/receipt.json proposed.patch
Enter fullscreen mode Exit fullscreen mode

That script is boring on purpose. Architecture that needs a speech is usually a leak. If the check and the hasher disagree, you have two sources of truth. Delete one.

Limitations and who should skip this

This workflow assumes you can name the files. Broad refactors across two hundred modules will fight the allowlist. That fight is useful. It is also slow. Do not pretend otherwise.

The hasher does not judge code quality. It only pins bytes. You still need tests on the apply plane. You still need a human at the review gate. A green hash is not a green build.

Do not put customer data in include just because the script allows it. Pinning is not a privacy policy. It is a blast-radius cap. Caps fail when you stuff the envelope with everything "just in case."

Skip this in a scratch repo with no secrets. A teaching demo can stay in a chat window. Production checkouts cannot. Skip this if the generate host can still read $HOME. An envelope on paper with a mounted home directory is theater.

You now have a typed inbound envelope, a hasher, and an apply check. The model sits downstream of those three. Keep it there. If you try a free generate host, send the tarball, not the repo. The receipt must echo the hashes you printed locally. Anything else is a different system than the one you reviewed.

Top comments (0)