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

> Source: <https://dev.to/alex_spinov/codex-encrypted-its-sub-agent-prompts-gate-the-spawn-plan-47j7>
> Published: 2026-07-16 00:48:38+00:00

**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](https://news.ycombinator.com/item?id=48905028) in a day. The tracking bug behind it is filed as [openai/codex#28058](https://github.com/openai/codex/issues/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 theobservable consequenceof 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:**

`fs.write`

, a `fs:*`

wildcard, `shell.run`

, a path of `~`

and a 5,000,000-token budget produces `PASS: 1 BLOCK: 1`

, `PASS: 2 BLOCK: 0`

, 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](https://finops.spinov.online/blog/pre-execution-gate-for-ai-agents/), moved up one level, from "may this action run" to "may this child exist with these powers."

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?

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

``` python
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.

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](https://finops.spinov.online/blog/blast-radius-ai-agent-api-key/): 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.

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:

``` bash
$ 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:

``` bash
$ 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.

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:

``` bash
$ 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.

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](https://finops.spinov.online/blog/authz-gate-trace-vs-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](https://finops.spinov.online/blog/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](https://finops.spinov.online/blog/pre-execution-gate-for-ai-agents/) pillar: authorize the child, not the action.

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

`encrypt_artifact()`

is a model, not AES.`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 `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.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.*
