Mumega

From Approval to Action: The Executor That Re-Binds to the Proposal

The dangerous part of an agent that takes real actions is not the action. It is the gap between the moment a human says yes and the moment the side-effect happens.

We just wired the first real one of those for mupot: an agent proposes a content write, a human approves it, and an executor publishes it to the pot — a real external effect, not a log line. The whole feature is about one sentence in the middle: what, exactly, gets executed when the human clicks approve?

If the answer is “whatever the caller sends to the execute endpoint,” you have built a hole, not a gate. This post is about closing it.

TL;DR


The hole: approve-A, execute-B

Picture the obvious design. An agent calls propose({ action, payload }). The payload — title, body, target — is shown to a human, who approves. Then something calls execute(payload) to do the write.

The bug is the second payload. If the execute step accepts the content from its caller, then approval guarantees nothing about what actually runs. An attacker (or a confused agent, or a replayed request) approves a harmless draft and then submits a different body at execute time. The human reviewed A. The system wrote B.

This is the same failure class as prompt-injection, moved one step later in the pipeline. The injection target is no longer the model’s context — it is the action boundary. And it is easy to ship, because in the happy path it looks identical to the safe version. Approve, execute, content appears. The hole only shows up when the approved content and the executed content are allowed to differ.

An approval is a verdict on stored content — not a token the caller spends on content of their choosing.


Lock 1 — content-bound execution

The execute endpoint takes no content. It takes an ID.

POST /admin/departments/:dept/execute/:gateId

That is the entire input. Inside the kernel, execute(gateId) re-loads the proposal that was stored at propose-time and acts on its payload:

// CONTENT-BOUND (BLOCK-2 fix): executor hint comes from the STORED record's
// payload — NOT from any caller-supplied value. The caller passed only gateId.
// This means approve-A/execute-B-payload substitution is structurally impossible:
// the caller has no parameter to substitute.
const storedPayload = record.payload

The proposal content is written once, at propose(), into a durable department_proposals row (and a same-isolate in-memory fast-path). Execute reads it back — in-memory first, then the durable row for a cold isolate or a cross-request approval:

const record = _pendingStore.get(gateId) ?? (await _loadProposal(handle.db, gateId))
if (!record) throw new CtxError('not_approved', /* never proposed → reject */)

There is no payload argument on execute. You cannot substitute what you cannot pass.


Lock 2 — approval is a row, not a bit

The second temptation is to store approval as a flag on the proposal object: record.approved = true. Anything that can reach the object can flip the bit. In a single-bundle Worker runtime, “anything that can reach the object” is a larger set than you think.

So approval does not live in the proposal at all. It lives in the real gate store — a row in task_verdicts, written only by the authenticated verdict route that backs the human /approvals screen. The kernel reads it; nothing in the kernel can write it:

// THE GATE — approval is a real `approved` row in task_verdicts, written ONLY by
// the authenticated verdict route. No in-process function can forge it; a
// ctx-holder cannot write it. This is the structural close of the self-approve
// seam: there is no importable approval path.
const approved = await _hasApprovedVerdict(handle.db, gateId)
if (!approved) throw new CtxError('not_approved', /* requires a human verdict */)

_hasApprovedVerdict reads the latest verdict for the gate:

SELECT verdict FROM task_verdicts WHERE task_id = ?1 ORDER BY decided_at DESC LIMIT 1

That ORDER BY decided_at DESC LIMIT 1 is not cosmetic. An earlier version could be satisfied by any approved row ever written for the gate — so a later RED rejection wouldn’t actually revoke approval. The cross-vendor reviewer caught it: take the latest verdict, so a human can change their mind and have it stick. Approval is the current state of a human decision, not a permanent fact about the past.


Lock 3 — re-bind the tenant, every time

mupot is multi-tenant. A gateId is just a string; if one tenant could execute another tenant’s approved proposal, the gate would be sound and the isolation would still be broken.

So the stored record carries the tenantId and departmentKey of the context that proposed it, and execute re-checks them against the current caller’s context — not the caller’s claim, the caller’s cryptographically-scoped context:

if (record.tenantId !== tenantId || record.departmentKey !== departmentKey) {
  throw new CtxError('not_approved', /* cross-tenant/dept execute rejected */)
}

Propose-in-A, execute-in-B is rejected even when B holds a valid gateId. The binding travels with the content.


Lock 4 — fail closed, on every branch

The execute path has many ways to not succeed, and every one of them returns “did nothing,” never “threw halfway through a write”:

  • No proposal record → reject (not_approved).
  • No approved verdict → reject.
  • The adapter isn’t wired (no resolved credential) → { executed: false, reason: 'executor_not_wired' }. The adapter is inert unless the Worker boundary explicitly supplies a credential — which only happens after a per-pot connector is resolved under a human go.
  • The adapter errors (bad config, bad payload, HTTP failure) → caught, surfaced as a reason on the receipt, never thrown out of execute:
} catch (e) {
  // Fail-closed on any adapter error — never throw out of execute();
  // surface the reason for the receipt/console.
  const reason = e instanceof InkwellExecutorError ? e.reason : 'inkwell_error'
  outcome = { executed: false, reason, adapter: 'inkwell-content' }
}

The default outcome of the whole machine is nothing happened, and here is why. You have to clear every gate to get a write.


Lock 5 — the edge doesn’t trust the caller either

The executor writes by calling an internal, server-to-server endpoint on the Inkwell API. That endpoint applies its own locks, because defense-in-depth means the last mile doesn’t assume the first mile was honest.

Tenant-bound credentials. The first cut used a single shared publish secret plus a tenant_slug taken from the request body. The cross-vendor adversarial reviewer flagged it immediately: any holder of the one secret could write any tenant’s namespace by changing the body. The fix is a per-tenant secret map — a secret authorizes writes only to its own tenant:

// A secret authorises writes ONLY to its own tenant — a holder of one pot's
// secret cannot write another tenant's namespace.
const expected = secrets[tenantSlug]
if (typeof expected !== 'string' || !expected || authHeader !== `Bearer ${expected}`) {
  return c.json({ error: 'unauthorized' }, 401)
}

Status is forced. Pot writes are always drafts. The caller’s status field is read and ignored — there is no path from this endpoint to a directly-published, world-visible post.

SSRF guard. The write target is config-sourced, but the executor still refuses to be pointed at anything internal. It requires https and a public host, and it range-checks the parsed IP rather than string-matching — so IPv4-mapped IPv6, ULA (fc00::/7), link-local (fe80::/10), CGNAT (100.64.0.0/10), RFC-1918, loopback, and cloud-metadata addresses are all blocked. That guard got harder in two passes: a same-vendor reviewer added the first version, a same-vendor correctness pass extended it to the IPv6 and CGNAT evasions.

Frontmatter can’t break out. Titles and authors are serialized with JSON.stringify, which yields a valid quoted YAML scalar — a title containing a quote can’t escape the frontmatter into the document body.


How the pieces sit together

flowchart TD
A[Agent: gate.propose action + payload] —> B[Store proposal rowtenant + dept + content]
B —> C[Human reviews content in /approvals]
C —>|approve| D[Write approved row to task_verdicts]
C —>|reject| R[No verdict / RED — execute stays closed]
E[Caller: POST execute/:gateIdID only, no content] —> F{Proposal record exists?}
F — no —> X[reject: not_approved]
F — yes —> G{tenant + dept bind match?}
G — no —> X
G — yes —> H{latest verdict approved?}
H — no —> X
H — yes —> I{adapter credential resolved?}
I — no —> J[executed:false, executor_not_wired]
I — yes —> K[Write STORED payload → pot draft]
K —> L[Receipt: executed:true, artifactUrl]

The line that matters is the one feeding the write: it comes from B (the stored proposal), never from E (the caller). The caller’s only power is to name a gate that has already cleared a human.


It works — receipt, not grade

This is live, not a diagram. End to end, on the production pot:

  • An approved proposal executed and wrote a real draft to the pot’s namespace (mumega:post:…).
  • The bare public-site key for the same slug returns 404 — the write landed in the pot’s draft area, not on the public site.
  • A cross-tenant attempt — the mumega pot’s credential with a different tenant_slug — returns 401, live.

The smoke draft it produced is harmless (a draft, in-pot, marked safe to delete). The point was never the content. It was proving that the only content that can be written is content a human already saw.


Honest audit

Per editorial discipline, what this does and does not cover:

What’s solid. The content-binding is structural, not a check you can forget — there is no caller payload, so substitution has no input to attack. Approval is a DB row written by one authenticated route. The tenant binding is re-verified against scoped context. Every failure path returns “did nothing.”

What’s partial. Only the inkwell-content adapter is wired (and proven). The mcpwp adapter — the WordPress write path — is still a stub that returns executor_not_wired; it needs its own credentials and human go before it does anything. The auto-act policy (an S-loop deciding auto-approve vs human-gate without a person in the loop) is a documented seam, not built. Until it is, every real write has a human verdict behind it, which is the conservative state to be in.

What’s out of scope. The SSRF guard is a parse-time check. It does not defend against DNS-rebinding — a public hostname that re-resolves to an internal IP at fetch time. That’s acceptable while the target is fixed config; if the target ever becomes connector- or payload-driven, it needs an origin allowlist. The code says so in a comment, and so do I.


The principle

The reusable idea is small and, in our experience, widely gotten wrong:

The approve step must re-bind to what was proposed — never to what the caller sends now.

A human-in-the-loop gate is only as strong as the binding between the thing reviewed and the thing executed. Store the proposal. Make execute take an ID. Read the content back. Re-check who owns it. And keep approval somewhere the executing code can read but cannot write.

Two of the five locks here exist because a model from a different lab reviewed the code and found what the building model passed — the cross-tenant write and the latest-verdict semantics. That’s not a footnote; it’s the same lesson, one layer down. We wrote up why that works in Cross-Vendor Adversarial Review: The Bug Your Own Model Can’t See, and the parallel-gate protocol it extends in Adversarial Gate Development.

The brain decides what’s next. The gate decides whether it’s allowed. This is the third piece: the part that turns an allowed decision into a safe effect, and refuses to turn anything else into one.


Sources

  • Internal code: mupot/src/departments/kernel.ts (propose / execute, _loadProposal, _hasApprovedVerdict, content-binding and cross-tenant re-check)
  • Internal code: mupot/src/departments/executors/inkwell.ts (fail-closed adapter, assertSafeInkwellUrl SSRF guard, draft-default toPublishBody)
  • Internal code: mumega.com/workers/inkwell-api/src/routes/internal-content.ts (tenant-bound POT_PUBLISH_SECRETS, forced-draft, frontmatter hardening)
  • Build session: S4 gated-ACT executor, June 2026 (Kasra build; Codex cross-vendor gate — cross-tenant write and latest-verdict catches)
  • Companion: Cross-Vendor Adversarial Review · Adversarial Gate Development
Share