Codex encrypted its sub-agent prompts. Gate the spawn plan.


Pre-dispatch authorization for AI sub-agents means checking a child spawn’s grant envelope (its role, tools, path scope and token budget) against the parent’s policy in the last plaintext moment before handoff, not by reading a trace afterwards. When the orchestrator encrypts the handoff, the after-view goes blind. The before-view does not, because it sits earlier on the timeline.

On July 14, “Codex starts encrypting sub-agent prompts” hit 408 points and 240 comments on Hacker News in a day. The tracking bug behind it is filed as openai/codex#28058, titled “Regression: encrypted MultiAgentV2 messages remove readable task audit trail.” A change encrypted the orchestrator-to-sub-agent payload, and the plaintext task record humans used to read after the fact turned into ciphertext. People who had been inspecting what their sub-agents were told, after dispatch, could no longer read it.

That is a good thing to notice, and a worse thing to fix by asking for the plaintext back.

AI disclosure. I wrote subagent_dispatch_gate.py with an AI assistant and ran it myself: Python 3.13.5, offline, standard library only, no network, no keys, no funds. Every number, exit code and sha256 below is pasted from a real local run. I ran the whole demo twice and the two output.txt files are byte-for-byte identical (sha256 5af48191642d66f7c364c429c50d2ad1a021f09004f5566ba878c7be87fcaaf1). The one synthetic part is clearly marked: encrypt_artifact() models the observable consequence of encryption (opaque bytes you cannot parse back into fields), not Codex’s real crypto. And every fact about Codex here comes from that HN thread and that issue. I did not reproduce their system.

In short:

  • The issue people filed asks to restore the readable audit copy. That restores tracking. It does not restore control, and I think the two got conflated in the thread.
  • Post-hoc inspection genuinely worked the day before the change. In my contrast run it recovered a plan with 4 fields when the artifact was plaintext, then 0 fields once the artifact was the opaque blob. The only variable is encryption. No strawman.
  • A pre-dispatch gate reads the plan the parent still holds in-process, before the handoff boundary. Same spawn, both worlds, children=1, invariant to whether the artifact is later encrypted.
  • The gate is not a tautology. One plan granting fs.write, a fs:* wildcard, shell.run, a path of ~ and a 5,000,000-token budget produces PASS: 1 BLOCK: 1, exit 1. The same two roles scoped read-only inside the workspace under cap produce PASS: 2 BLOCK: 0, exit 0.
  • An absent or empty plan is exit 2, never a silent pass. A gate that treats “no plan found” as “nothing to block” fails open, and that is the failure mode I most wanted to kill.

What is pre-dispatch authorization for AI sub-agents?

It is a check that runs on the parent, before the parent hands a task to a child agent. The parent holds a spawn plan: for each child, a role, a set of tool grants, a set of paths it may touch, and a token budget. Pre-dispatch authorization compares each of those against a policy the parent already owns, and refuses the spawn if any child asks for more than the policy allows. It happens while the plan is still a plain object in memory. Nothing has been sent, nothing has been encrypted, nothing has run.

The distinction that matters: this is authorization of a spawn, not authorization of an action, and not reconciliation of a trace. It is the same family as a pre-execution gate for AI agents, moved up one level, from “may this action run” to “may this child exist with these powers.”

The incident: the audit trail went dark, and the thread asked for it back

Read issue #28058 in its own words. A PR added encryption to the MultiAgentV2 message payloads. The intent was reasonable: the model-facing channel between orchestrator and sub-agent should not sit around in plaintext on disk. The side effect was that the local rollout history, the thing a human opened to review what task got delegated to which sub-agent, became unreadable. The issue’s ask is to keep a human-readable audit copy alongside the encrypted delivery.

I understand the ask. I also think it quietly gives up the game. If your control over what a sub-agent may do lives in your ability to read the transcript afterwards, then your control was always downstream of the work. The sub-agent already ran. The tokens already burned. The shell.run already happened. Restoring the readable copy lets you write a better postmortem. It does not stop the next spawn.

This is the line I keep coming back to across this whole cluster: tracking is not control. A trace tells you what happened. It does not decide what is allowed. When the trace is plaintext you can pretend the two are the same, because you can always go read it and intervene. Encryption breaks the pretense. It takes away the after-view and forces the question: if you could not read the trace at all, where would your control actually live?

Why is post-hoc dead while pre-dispatch survives?

Here is the honest version of that question, run as code. Same spawn, two worlds, one variable.

def encrypt_artifact(plan_obj):
    """Model the at-rest / handoff artifact AFTER the orchestrator encrypts it.
    Not real crypto: models the observable consequence -- bytes a reader without
    the key cannot parse back into fields. sha256 stands in for 'ciphertext'."""
    raw = json.dumps(plan_obj, sort_keys=True).encode()
    return "ENC:" + hashlib.sha256(raw).hexdigest()

def posthoc_inspect(artifact):
    """A session inspector reading the artifact AFTER dispatch.
    Plaintext JSON -> recovers the plan (worked yesterday).
    Opaque ENC blob -> recovers nothing (blind today)."""
    if isinstance(artifact, str) and artifact.startswith("ENC:"):
        return None, 0            # blind: zero fields recoverable
    obj = json.loads(artifact) if isinstance(artifact, str) else artifact
    if isinstance(obj, dict) and isinstance(obj.get("plan"), list):
        fields = sum(len(c) for c in obj["plan"] if isinstance(c, dict))
        return obj["plan"], fields
    return None, 0

The point of posthoc_inspect is that it is not rigged. Hand it plaintext and it does its job. Run python3 subagent_dispatch_gate.py --contrast:

CONTRAST: post-hoc inspection vs pre-dispatch gate (same spawn)
  world=PLAINTEXT  post-hoc: recovered plan=yes fields=4
  world=ENCRYPTED  post-hoc: recovered plan=no fields=0  <- BLIND
  pre-dispatch reads parent's in-process plan: children=1 (invariant to artifact encryption)
  => post-hoc control SURVIVES encryption: no
  => pre-dispatch control survives encryption: yes
sha256(contrast)=0abca3747138eaf4260ed298ed001c1df6fd7ac67e89ce56f60c7b5fe8b9ef11

Yesterday’s inspector recovered 4 fields. Today’s recovers 0. It did not get worse at its job. The world moved out from under it. The pre-dispatch view reads the plan the parent constructs in memory, before encrypt_artifact ever touches it, so it reports the same children=1 in both worlds. That is the whole argument in five lines of output: encryption is fatal to the after-view and irrelevant to the before-view, and I am not shaving points off post-hoc to get there. It honestly recovers the plan when the plan is readable.

The reason the before-view can exist at all is boring and load-bearing: the parent has to build the plan in plaintext to send it. Even Codex’s own encryption sits around delivery and at-rest storage. The payload is assembled in the clear first. There is a moment, every single spawn, when the full plan is a plain object the parent is holding. That moment is where the gate goes.

The gate: authorize the spawn plan, not the trace

Four checks, one policy, one child at a time. Nothing clever.

WILDCARD_PATH_MARKERS = ("~", "$HOME", "*")
WRITE_CAPS = {"fs.write", "fs.delete", "shell.run", "wallet.transfer"}

def _escapes_workspace(path, root):
    """True if `path` is not provably inside `root` (fail-closed on ambiguity)."""
    if not isinstance(path, str):        # a non-string path spec is not provably safe
        return True
    p = path.strip()
    root_norm = root.rstrip("/")
    if p == "":
        return True
    for m in WILDCARD_PATH_MARKERS:      # home / wildcard touches everything
        if m in p:
            return True
    if ".." in p.split("/"):             # parent-traversal (absolute OR relative)
        return True
    if p == "/":                         # filesystem root
        return True
    if p.startswith("/"):                # absolute: must be inside root
        return not (p == root_norm or p.startswith(root_norm + "/"))
    return False

def check_child(child, policy):
    reasons = []
    allowed_tools = set(policy["allowed_tools"])
    cap  = policy["budget_cap_tokens"]
    root = policy["workspace_root"]
    allow_write = policy.get("allow_write", False) is True   # only a real True grants write

    tools = child.get("tools")
    if not isinstance(tools, list) or not tools:
        reasons.append("child declares no tools (fail-closed: unauthorizable)")
    else:
        for t in tools:
            if t not in allowed_tools:
                reasons.append("tool '%s' not in parent allowlist (deny-by-default)" % t)
            if t in WRITE_CAPS and not allow_write:
                reasons.append("tool '%s' is a write/destructive capability but "
                               "policy allow_write=false" % t)

    paths = child.get("paths")
    if not isinstance(paths, list):
        reasons.append("child.paths must be a list")
        paths = []
    for p in paths:
        if _escapes_workspace(p, root):
            reasons.append("path '%s' escapes workspace root '%s'" % (str(p).strip(), root))

    budget = child.get("budget_tokens")
    if not isinstance(budget, int) or isinstance(budget, bool):
        reasons.append("budget_tokens missing or non-integer (unbounded spawn)")
    elif budget > cap:
        reasons.append("budget_tokens %d over parent cap %d" % (budget, cap))
    elif budget <= 0:
        reasons.append("budget_tokens %d must be positive" % budget)

    return child.get("role", "<no-role>"), ("PASS" if not reasons else "BLOCK"), reasons

Two design choices are doing the real work. First, fs.write is in the allowlist and still gets blocked, because it is a write capability and the policy says allow_write=false. Allowlisting a tool and granting write are two different decisions, and collapsing them is how “read-only agent” quietly becomes “agent that can write.” Second, _escapes_workspace is deny-by-default on ambiguity. I do not try to be clever about what ~ might resolve to. If a path is not provably inside the root, it escapes. I got this wrong in the first draft: my wildcard list literally contained "/", so the substring check flagged every absolute path, and the legitimate PASS fixture came back as exit 1. The live run is what caught it. That is the entire reason I run these before I write about them.

The scope side of this is a sibling problem to scoring a single API key’s blast radius: same “how wide is this grant” axis, different object and different output. This gate is a binary refusal on a whole spawn envelope, not a 0-100 score on one credential.

Run it in sixty seconds

Here is the child that should never be allowed to spawn. Save it as plan_block.json:

{
  "policy": {
    "allowed_tools": ["fs.read", "fs.write", "http.get"],
    "workspace_root": "/repo/workspace",
    "budget_cap_tokens": 200000,
    "allow_write": false
  },
  "plan": [
    { "role": "home-cleaner", "tools": ["fs.write", "fs:*", "shell.run"],
      "paths": ["~", "/repo/workspace/task-1"], "budget_tokens": 5000000 },
    { "role": "doc-reader",   "tools": ["fs.read", "http.get"],
      "paths": ["/repo/workspace/docs"], "budget_tokens": 40000 }
  ]
}

home-cleaner is the kind of spawn you do not want dispatched sight-unseen: broad file powers, a shell, a home-directory reach, an absurd budget. Run it:

$ python3 subagent_dispatch_gate.py plan_block.json
SUBAGENT-DISPATCH-GATE REPORT
policy: 3 tool(s) allowed, root=/repo/workspace, budget_cap=200000, allow_write=False
children in spawn plan: 2
  - home-cleaner -> BLOCK
      x tool 'fs.write' is a write/destructive capability but policy allow_write=false
      x tool 'fs:*' not in parent allowlist (deny-by-default)
      x tool 'shell.run' not in parent allowlist (deny-by-default)
      x tool 'shell.run' is a write/destructive capability but policy allow_write=false
      x path '~' escapes workspace root '/repo/workspace'
      x budget_tokens 5000000 over parent cap 200000
  - doc-reader -> PASS
PASS: 1   BLOCK: 1
VERDICT: 1 subagent spawn(s) refused BEFORE dispatch
sha256(report)=afed5edb835afdafeac5496dd299770581701fb4489232b7e2baf469fa311812
exit=1

Note doc-reader passes in the same plan. The gate is not a wall that blocks everything. It refused one child on six specific grounds and let the scoped one through. Now flip only the scope, keep the two roles. In plan_pass.json, home-cleaner is fs.read on /repo/workspace/task-1 with a 40,000-token budget:

$ python3 subagent_dispatch_gate.py plan_pass.json
SUBAGENT-DISPATCH-GATE REPORT
policy: 3 tool(s) allowed, root=/repo/workspace, budget_cap=200000, allow_write=False
children in spawn plan: 2
  - home-cleaner -> PASS
  - doc-reader -> PASS
PASS: 2   BLOCK: 0
VERDICT: all spawns within policy; dispatch authorized
sha256(report)=de81c4d8f75cd4683796b9715c9501f81b77bd618286f0ebbf172aad8f21a5d9
exit=0

Exit 1 to exit 0, decided on the real difference between the two plans. If a gate cannot produce both outcomes it is a decoration, not a control. This one produces afed5edb… for the refusal and de81c4d8… for the authorization, and both hashes are stable across runs.

Why does bad input fail closed?

The failure I care about most is the quiet one. A gate that returns “all clear” when it was handed nothing is worse than no gate, because it launders “I did not check” into “I approved.” So an absent or empty plan is exit 2, and it says why:

$ python3 subagent_dispatch_gate.py plan_empty.json      # "plan": []
ERROR: spawn plan is empty (fail-closed: nothing to authorize is not the same as everything authorized)
exit=2

$ python3 subagent_dispatch_gate.py plan_missing.json    # no "plan" key at all
ERROR: no spawn plan present (fail-closed: absence of a plan is NOT authorization -- a gate that passes an empty plan fails open)
exit=2

The same reflex runs inside each child. A spawn that declares no tools is blocked, not waved through as harmless. A budget_tokens that is missing or non-integer is blocked, because an unbounded spawn is the one that quietly forks children in a loop and bills you for all of them. Empty is not safe. Empty is unknown, and unknown fails closed. A previous tool in this series died in review for getting this backwards, treating a zero-byte input as a pass, and I would rather over-correct.

Where this sits next to the rest

This is one spoke of a cluster, and the edges matter more than usual here.

The near neighbor is an authz gate that reconciles the trace against what was allowed. That gate assumes the trace exists. It reads a span log after the fact and checks each recorded action against policy. Issue #28058 is the moment that assumption dies: encrypt the payload and there is no trace to reconcile. So the control does not disappear, it relocates earlier on the timeline, from the span log that is now ciphertext to the spawn plan that is still plaintext. If you have been running post-hoc reconciliation, that article is where you were, and this one is where you go when the log goes dark.

It is also a pre-run manifest check like the lethal-trifecta gate, which asks a different question of the same manifest: can untrusted input reach private data and then reach an exfiltration channel? Same “decide from the declared plan before anything runs,” different property. And it is a new spoke on the pre-execution gate pillar: authorize the child, not the action.

What this is NOT

I would rather you find the holes than a commenter find them for me.

  1. I did not reproduce Codex. Every claim about their system comes from HN 48905028 and issue #28058. The encryption there covers delivery and the at-rest copy; the sub-agent’s output is a separate matter, and MultiAgentV2 is an experimental, off-by-default path. I am reasoning about the shape of the problem, not their internals.
  2. This is not a decryptor or a bypass. The gate never touches ciphertext. It stands on the parent’s own boundary and reads the plaintext plan the parent is already holding, before handoff. If your orchestrator does not expose that pre-dispatch moment, this tool has nothing to read.
  3. encrypt_artifact() is a model, not AES. It produces opaque bytes to stand in for “ciphertext you cannot parse back into fields.” It demonstrates the consequence of encryption for a reader without the key. It is not a reproduction of any real cipher, and I am not claiming it is.
  4. It is not a runtime enforcer. It statically authorizes a manifest and returns an exit code. To actually stop a spawn you need a pre-dispatch hook inside your orchestrator that consults this verdict and refuses the handoff. This shows what the hook should refuse and why. It does not install the hook.
  5. The budget check is a declared integer, not a tokenizer. It compares budget_tokens to a cap. It trusts the plan’s own declared number and does not estimate real token cost. If a child lies about its budget, the gate believes the lie. Treat it as a ceiling on the ask, not a measurement of the spend.
  6. The policy language is a toy. An allowlist, a workspace root, a budget cap, an allow_write flag. That is not OPA, not a real PDP/PEP, not a capability system. It is the smallest thing that makes the argument concrete and runnable. Real deployments will want a real policy engine.
  7. A blocked plan is not a safe plan. The gate checks the four things it checks. A child that stays inside all four can still do something dumb with a legitimate grant. Passing this gate means “within the envelope you declared,” nothing stronger.

What I would do on Monday

Find the point in your orchestrator where the parent has assembled the child’s task and grants but has not sent them yet. That point exists, because the parent has to build the thing in the clear to hand it over. Emit the plan as a plain object there, one dict per child with its tools, paths and budget. Run those four checks against the parent’s policy. Refuse the handoff on any BLOCK, and log the reasons. Then, separately, keep whatever after-the-fact audit you have. The audit is still useful for the postmortem. It just cannot be the place your control lives, because one encryption change already proved it can be taken away.

Here is the question I actually want answered, and I do not have a clean answer myself. For anyone running a multi-agent orchestrator in anger: where does authorization of the spawn live in your stack right now? Is it a real check before the parent hands off, or is it a trace you read afterwards and hope you get to in time? Because if it is the trace, issue #28058 is a preview of the day it goes dark.


I write about pre-execution control for AI agents: gates that decide before the work runs, not dashboards that explain it after. Every post ships a tool you can run offline, with no keys. Follow along if that is your kind of thing, and if your orchestrator has a pre-dispatch story, put it in the comments. I am especially interested in anyone who has bolted an authorization check onto a spawn and had it survive contact with a real agent loop.