Codex encrypted its sub-agent prompts. Gate the spawn plan.
Pre-dispatch Authorization for AI Sub-agents
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 vs Pre-dispatch Gate
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
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 Post-hoc Dies 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
Comments
No comments yet. Start the discussion.