Project title
MandateFlow: Stop unsafe data composition before an Agent reaches a protected tool
Inspiration
Every "MCP security" conversation in 2026 keeps circling back to the same hard case: the confused deputy problem. An Agent with a legitimate, narrowly-scoped permission can still be tricked — or simply reasons its way — into using that permission on data it should never have touched, because access control only asks "is this tool call allowed?", never "should this specific piece of data reach this tool?"
This isn't a hypothetical we invented for a hackathon. SANS wrote about it
directly this year: "An analytics agent with snowflake_query access can
still be injected to run a query it shouldn't, because the grant doesn't
constrain the query content, only which tool is called." Microsoft's
security team, the Coalition for Secure AI, and a systematic study of
taint-style vulnerabilities in MCP servers (VIPER-MCP) all published on the
exact same gap this year: OAuth authenticates the connection to an MCP
server, but carries no delegation chain, no scope attenuation, and no
provenance binding for what happens inside that connection, call after
call.
We wanted to build the smallest concrete thing that actually closes that gap for the Agent Launchpad starter platform — not a paper, not an enterprise gateway behind a sales call, something that runs, that has receipts, and that fails the way it says it will.
What it does
MandateFlow sits between a Codex Agent and five protected operations
(support.list_tickets, payments.list_failures, cases.lookup_subject,
crm.resolve_customer, payments.aggregate_failures) as the only path
those operations are reachable through. Every call carries a short-lived,
capability-scoped grant; every protected reference the Agent touches is
minted and tracked server-side, never as a raw ID the Agent can forge or
copy.
The demo scenario is deliberately narrow so the claim is falsifiable:
- The Agent resolves a Support-derived case through CRM. Allowed.
- The Agent resolves a Payment-derived case through the exact same
CRM tool, with the exact same permission grant, using the exact same
public reference type. Denied, before the CRM fixture is ever invoked
— because the reference's tracked provenance says
PAYMENT_AGGREGATE_ONLY, and the pinned policy denies that transition into CRM specifically. - The Agent recovers safely with
payments.aggregate_failures, then completes a second, fresh Support→CRM resolution. Not stuck, not broken — just correctly narrower than a plain tool-scope check would have been. - Retry it. A denied call gets a brand-new Run, a brand-new disposable Runtime, a brand-new short-lived capability — and is denied again, because the policy context and the reference's provenance persist across the retry. Recreating the Runtime does not erase what the system already knows about where the data came from.
- Revoke the mandate mid-run. The next protected call — and the one after that — is denied on a decision that was persisted before the Runtime was cancelled, not after.
Here is one real, complete run, verbatim from GET /api/runs/:id/evidence
(only the fields that matter, per protected call):
{
"crmCounter": 2,
"receipts": [
{ "tool": "support.list_tickets", "decision": "ALLOW" },
{ "tool": "cases.lookup_subject", "decision": "ALLOW" },
{ "tool": "crm.resolve_customer", "decision": "ALLOW" },
{ "tool": "payments.list_failures", "decision": "ALLOW" },
{ "tool": "cases.lookup_subject", "decision": "ALLOW" },
{ "tool": "crm.resolve_customer", "decision": "DENY",
"ruleId": "NO_PAYMENT_REIDENTIFICATION",
"reason": "Payment-derived references are aggregate-only and cannot be resolved through CRM" },
{ "tool": "payments.aggregate_failures","decision": "ALLOW" },
{ "tool": "support.list_tickets", "decision": "ALLOW" },
{ "tool": "cases.lookup_subject", "decision": "ALLOW" },
{ "tool": "crm.resolve_customer", "decision": "ALLOW" }
]
}
Ten calls, one policy context, one DENY sitting between two
identical-looking crm.resolve_customer calls. crmCounter: 2 — not 3 —
is the tell: the denied call never reached the protected fixture at all.
How we built it
Two processes, one enforcement point.
Browser → React Playground → Fastify → AgentService
→ disposable Codex Runtime → Go MCP Gateway → protected fixtures
- Node/TypeScript side (
CodeJam/): the untouched Agent Launchpad starter (React UI, Fastify API, AgentService, Codex Runner) plus exactly the integration the starter's own diagram calls for: Fastify gains mandate/retry/revoke/evidence routes, AgentService owns the policy-context and Run-grant lifecycle, AgentRunner injects a fresh per-Run capability into the disposable Runtime's environment — never into argv, never into the generated Codex config. AgentRunner Interface itself needs no change — a retried or fresh Run is just another Run through the same code path. - Go sidecar (
middleware/mandateflow/): a real Streamable HTTP MCP server (the officialmodelcontextprotocol/go-sdk), backed by a single-connection, WAL-safe SQLite store (_txlock=immediate,busy_timeout=5000,foreign_keys=ON— concurrent calls against a one-shot budget are serialized by construction, not by hope). It authenticates the bearer capability (SHA-256 hash only, constant-time compare), evaluates static scope, walks the reference's provenance ancestry, evaluates the one pinned policy rule, and — allow or deny — writes a redacted decision receipt in the same transaction as the decision itself. - Trust boundary: the Runtime never holds a Payment or CRM credential.
Calling the Gateway with
curlinstead of MCP is not a bypass — the same capability and policy checks run either way. The MCP listener rejects any request carrying anOriginheader, closing the browser/DNS-rebinding path into a supposedly loopback-only port.
Challenges we ran into
Three real ones, in the order we hit them:
- A bash 3.2 landmine. The local launcher does
mcp_publish_args=()then conditionally fills it — fine on Linux/newer bash, but macOS ships bash 3.2 by default (frozen since 2007 over GPLv3), where expanding an empty array underset -uthrowsunbound variable. Reproduced it directly, one line:bash -uc 'arr=(); f(){ echo "${arr[@]}"; }; f'→ the exact error. Fixed by seeding the array with a guaranteed first element before the branch, so it's never empty regardless of profile. - Groq's gpt-oss tool-calling under real load. Running the live Codex
Runtime against Groq surfaced two distinct, real failure modes: a
413 Request too large(a single Codex turn's tool schema runs ~9,100 tokens, over an 8,000 TPM tier cap on the model we defaulted to), and — on the smaller model, for the five-tool security proof specifically — anoutput_parse_failedmalformed tool-call generation, consistent with community reports oftool_use_failedon long tool chains for this model family. Neither is a defect in MandateFlow's own code; both are exactly why the deterministic fixture Runtime exists as the reliable path for judging the middleware itself, independent of any one model's tool-calling reliability on a given day. - Deciding what not to build. The original design draft included a generic policy language, multi-agent delegation, and full information-flow control. We cut all three from P0. A tool-allowlist check is cheap and provable; a general IFC engine is a research project. Naming that boundary explicitly — see Limitations — was as much engineering work as writing the code that shipped.
Accomplishments that we're proud of
- A denial that's provably a denial, not a guess.
crmCounterunchanged across the denied call is a structural invariant, checked by an automated test — not a narrative claim. - Go test suite covers the adversarial cases, not just the happy path: forged/expired/revoked capabilities, cross-context reference handles, wrong-owner resource access, retry continuity, redaction of receipts, and revocation ordering (persist-then-cancel) — 4/4 packages passing, race-detector clean.
- Constant-time everything that touches a secret. Capability
comparison and the control-plane bearer token both use
crypto/subtle.ConstantTimeCompare, not==. - We found our own bug before a judge did. The bash nounset issue only reproduces on a stock macOS shell — exactly the machine most judges will actually test on.
What we learned
Access control and data-flow control are different problems, and conflating them is the actual root cause of the confused-deputy pattern. You do not need general information-flow control to close the specific, common case where a single new fact — where did this reference come from — is sufficient to make the right call. Scoping to that one fact is what kept this buildable in three days instead of being a research agenda.
What's next for MandateFlow
- Supervisor→Worker delegation, using the same
child = parent ∩ requestedattenuation function already proven for single-mandate Runs. - Cascade revocation through a delegation tree, not just a single mandate.
- A second, independently-verified policy rule, to prove the rule engine generalizes past the one P0 case.
- A hardened multi-tenant Runtime boundary — today's disposable-container isolation is a hackathon-scale POC boundary, not a production sandbox, and we say so.
Log in or sign up for Devpost to join the conversation.