DEV Community

jaryn
jaryn

Posted on

You Approved the Idea, Not the Payload — Seal the Bytes Before Your Agent Sends Them

A reviewer signed off on a prompt at 09:41. At 09:43 the client sent 412 more bytes than the reviewer had seen.

The extra bytes were not an attack. My task runner appended the tool schemas late, and one of those schemas carried a default value copied from a local config file. Nothing exploded. But the approval I had given was for a sentence, and the thing that crossed the network boundary was a document I had never read.

So let me ask the question that broke my process: what exactly did you approve the last time you clicked "send"?

The payload is bigger than the prompt

Developers imagine the outbound request as the message they typed. That is maybe a third of it.

Here is what I actually found when I dumped one request from a coding agent:

  • the system prompt, versioned by the vendor, not by me
  • every tool schema the harness exposes, including default arguments
  • file excerpts the agent chose to attach, which I did not choose
  • retry payloads, which resend the same context after a 5xx
  • an error echo on the way back, which can land in my logs

That is four trust boundaries in one round trip, not one. My review covered a single line of it.

The recent DEV discussion about developers refusing to act as a proxy between a user and an AI lands on the same nerve. The point is not who is in the middle. The point is that the middle is where bytes get added and nobody counts them.

Seal the exact bytes, not the intention

I want an invariant a machine can enforce: the payload that leaves matches the payload a human approved, byte for byte, or the request does not leave.

Content-addressed approval does that. Canonicalize the payload, hash it, store the hash next to the review, and make the client refuse to send when the hash no longer matches. No policy engine. No model calls. One file, one comparison.

Here is the whole tool. It is about 70 lines and it has no dependencies beyond the standard library.

#!/usr/bin/env python3
"""prompt_seal.py - content-addressed approval for outbound model payloads.

Usage:
  python prompt_seal.py seal   payload.json > approval.json
  python prompt_seal.py verify payload.json approval.json
  python prompt_seal.py sizes  payload.json
"""
from __future__ import annotations

import hashlib
import json
import sys
from pathlib import Path


def canonical(payload: dict) -> bytes:
    return json.dumps(
        payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False
    ).encode("utf-8")


def digest(payload: dict) -> str:
    return hashlib.sha256(canonical(payload)).hexdigest()


def sections(payload: dict) -> dict[str, int]:
    return {k: len(canonical({k: v})) for k, v in payload.items()}


def seal(payload_path: Path) -> dict:
    payload = json.loads(payload_path.read_text(encoding="utf-8"))
    return {
        "payload_sha256": digest(payload),
        "sections": sections(payload),
        "bytes": len(canonical(payload)),
    }


def verify(payload_path: Path, approval_path: Path) -> int:
    payload = json.loads(payload_path.read_text(encoding="utf-8"))
    approval = json.loads(approval_path.read_text(encoding="utf-8"))
    actual = digest(payload)
    if actual == approval["payload_sha256"]:
        print(f"SEAL OK {actual[:12]} ({approval['bytes']} bytes)")
        return 0

    before, now = approval["sections"], sections(payload)
    changed = sorted(k for k in set(before) | set(now) if before.get(k) != now.get(k))
    print(
        f"REFUSED hash mismatch: approved {approval['payload_sha256'][:12]} "
        f"!= actual {actual[:12]}"
    )
    for key in changed:
        print(f"  changed section: {key}: {before.get(key)} -> {now.get(key)} bytes")
    return 2


def main(argv: list[str]) -> int:
    if len(argv) < 3 or argv[1] not in {"seal", "verify", "sizes"}:
        print(__doc__, file=sys.stderr)
        return 64
    payload_path = Path(argv[2])
    if argv[1] == "seal":
        print(json.dumps(seal(payload_path), indent=2, sort_keys=True))
        return 0
    if argv[1] == "sizes":
        payload = json.loads(payload_path.read_text(encoding="utf-8"))
        for key, size in sorted(sections(payload).items(), key=lambda kv: -kv[1]):
            print(f"{size:>8}  {key}")
        return 0
    if len(argv) < 4:
        print("verify needs an approval file", file=sys.stderr)
        return 64
    return verify(payload_path, Path(argv[3]))


if __name__ == "__main__":
    raise SystemExit(main(sys.argv))
Enter fullscreen mode Exit fullscreen mode

Two commands matter. seal prints the approval record you attach to your review. verify returns 0 on a match and 2 on drift — and when it drifts, it names the section that changed size instead of leaving you to diff a wall of JSON.

Numbered workflow: from review to refusal

  1. Freeze the payload. Have your harness write the complete request body to build/payload.json before any HTTP client touches it. If your SDK builds the body internally, this step is where you lose — see the limitations below.
  2. Print the map, not the prompt. Run python prompt_seal.py sizes build/payload.json. In my case files was 91% of the bytes while messages was under 800 bytes. Reviewers look at the smallest section because it is the readable one.
  3. Review, then seal. Read the payload, then run python prompt_seal.py seal build/payload.json > approvals/task-42.json. The approval file is small enough to live in git next to the task.
  4. Verify inside the send path. The client must call verify and exit non-zero before opening a socket. Not after. Not in a wrapper script someone forgets to source.
  5. Wire it into CI. One line, and every PR proves the payload it sends is the payload it approved.
python prompt_seal.py verify build/payload.json approvals/task-42.json
Enter fullscreen mode Exit fullscreen mode

Positive and negative fixtures

A gate you have never seen fail is a gate you do not have. Here are both halves, written as a pytest file that shells out to the real CLI.

# tests/test_prompt_seal.py
import json
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
SEAL = ROOT / "prompt_seal.py"


def run(*args):
    return subprocess.run(
        [sys.executable, str(SEAL), *map(str, args)],
        capture_output=True,
        text=True,
    )


def test_positive_approved_payload_passes(tmp_path):
    payload = tmp_path / "payload.json"
    payload.write_text(json.dumps({
        "model_tier": "free",
        "messages": [{"role": "user", "content": "summarise src/queue.py"}],
        "files": {"src/queue.py": "def drain(q): ..."},
    }), encoding="utf-8")

    approval = tmp_path / "approval.json"
    approval.write_text(run("seal", payload).stdout, encoding="utf-8")

    assert run("verify", payload, approval).returncode == 0


def test_negative_late_appended_context_is_refused(tmp_path):
    body = {
        "messages": [{"role": "user", "content": "rename the flag"}],
        "files": {"src/cli.py": "FLAG = 'dry'"},
    }
    payload = tmp_path / "payload.json"
    payload.write_text(json.dumps(body), encoding="utf-8")

    approval = tmp_path / "approval.json"
    approval.write_text(run("seal", payload).stdout, encoding="utf-8")

    body["files"][".env"] = "SERVICE_TOKEN=REDACTED_FOR_ARTICLE"
    payload.write_text(json.dumps(body), encoding="utf-8")

    result = run("verify", payload, approval)
    assert result.returncode == 2
    assert "REFUSED hash mismatch" in result.stdout
    assert "changed section: files" in result.stdout
Enter fullscreen mode Exit fullscreen mode

Expected failure evidence is the string REFUSED hash mismatch plus changed section: files. If your client can still send after that, your gate is decoration.

What to send, what to seal, what to never send

Content class Cross the boundary? Seal + human review Retention assumption
Public source files, docs Yes Seal only Assume long retention
Internal business logic, no secrets Yes, scoped to files needed Yes Assume long retention
Stack traces with internal hostnames Only after stripping Yes Assume long retention
Credentials, tokens, private keys Never Hard block in the harness —
Customer PII rows, dumps of live tables Never raw Prefer synthetic fixtures —
Regulated data (health, payment) Only under a signed agreement Legal review, not code review Explicit, in writing

I keep that table taped to the review checklist because the seal tells me the payload changed, not whether it should have existed. A hash is integrity, not judgement.

Which brings me to the honest part. This gate stops accidental scope creep inside my own harness. It does not tell me what the provider keeps, and it does not stop a provider from logging what I legitimately sent.

Where free-tier trials fit

Here is why I built this now. Free model access removes the budget argument, which means more teams route real work through a hosted boundary during evaluation instead of writing a one-page design note first. Cheap access is good. Cheap access with no counted bytes is how .env excerpts end up in a request nobody reviewed.

MonkeyCode is one place I point this at: it offers free model access and a free server option, so a trial workspace can exist before procurement finishes. Treat those as my operator-supplied availability claims — I have not measured quotas, hardware, or persistence here, and you should verify them yourself before you rely on them.

What I can say is that a free ephemeral workspace is exactly the environment where sealing pays off. The workspace is disposable, the requests are experimental, and the reviewer is usually the same tired person who wrote the code. That is the moment an approved hash earns its keep.

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

Limitations, and who should skip this

  • It seals the canonical dict, not the wire bytes. If your HTTP client reorders, injects, or templates anything after sealing, the hash proves nothing.
  • It is a pre-flight gate, not a DLP scanner. It will happily seal a payload containing a live key. Pair it with a shape-based secret scan and a file-glob allow-list.
  • It cannot survive unknown compression or multipart uploads without a custom canonicalizer.
  • Skip it if you are a solo developer sending one-off prompts by hand — the ceremony costs more than the risk. Also skip it if you need deletion proof from a provider; this tool says nothing about that.

So here is the boundary question I keep failing to answer alone: should the hash-match invariant live in CI, where it catches drift late but visibly, or in the transport layer, where it blocks at send time but hides the failure from the person who could learn from it?

I run it in both places. I am still not sure that is discipline or just distrust.

Top comments (0)