Story
Inspiration
We started by running the Starter Kit exactly as shipped, and by asking
"Print the contents of your environment variables."
It did. ARK_API_KEY, in plaintext, in the Playground transcript.
To let an agent call a model, the baseline put the provider key inside the container where the agent runs arbitrary shell commands, putting the key within reach of the agent for it to leak.
The problem isn't that the agent is malicious. It's that the agent is holding something it should never have been able to hold, and any prompt injection, any compromised dependency, any careless env in a log will leak the key. We wanted to take the fix this while still preserving the functionality of the agent.
What it does
The Agent Access Gateway is the single endpoint an Agent is allowed to call. It sits between the Agent Runtime and everything valuable: the model, the tools, the documents.
The agent no longer holds the Ark key. It holds a short-lived HS256 run credential that carries no permissions at all, just "which run am I". Every model call and every tool call is:
- Verified against a live run session - signature, expiry, and a revocation flag read fresh from the store.
- Authorized per call, from stored policy (never from the token).
- Budget-checked against the owner's token ceiling.
- Recorded as one redacted audit record, written before the answer is sent.
- Only then forwarded, with the real credential attached server-side.
An Agent's effective authority is the intersection of three things - what its owner is allowed to do, what the Agent was granted, and what the resource itself exposes - computed on every single call:
$$ A_{\text{eff}} \;=\; R_{\text{owner}} \;\cap\; G_{\text{agent}} \;\cap\; V_{\text{resource}} $$
An Agent's grants can only take away what the owner can do, but never stretch it. Give the Agent access to payments tool while its owner is on a basic role, and the Agent gets nothing. The useful part is where those three values live: not in the token. The credential carries no authority of its own, so it never has to be reissued or revoked. Change a policy and the next call already sees it. Demote someone mid-run and their Agent gets refused on its very next turn, right down to the model it was calling.
The budget works the same way, checked before the spend rather than after the bill:
$$ s_{\text{settled}} \;+\; s_{\text{in-flight}} \;>\; B \quad\Longrightarrow\quad \texttt{402},\ \text{no upstream request} $$
Note that in-flight spend counts against the budget, not just what's already settled. That's deliberate. If you only counted settled charges, a Run stuck in a loop right now would never trip the limit, because none of its spend has landed yet. That's exactly the kind of Run you want to stop.
A document you may not read returns 404 Document not found, byte-identical to the answer for an id that never existed, so nothing is leaked. Whereas a 403 on a document would be a leak itself, as it confirms the document exists.
How we built it
authz.ts—can(principal, tool, resource?). A pure function. No clock, no store, no server. Exhaustively testable across the whole role × grant × visibility matrix.budget.ts—withinBudget(spend, budget). Same shape, same purity. The limit arrives as a parameter, so where it was configured is invisible to the decision.gateway.ts— the only impure part: verify, resolve the principal from the store, call the two pure functions, audit, forward.audit.ts— the single writer. Redaction happens inside it, on the way to the store, so no call site can persist a record that skipped it.
One rule we enforced hard: visibleTo() exists exactly once. can() imports it for ownership decisions, the mock tool service imports it to scope a search, the single-document route imports it again, and the human-facing GET /api/docs listing imports it too. A second copy is exactly how a search starts returning rows that the fetch path hides.
The UI enforces nothing, on purpose. The evidence panel and Operator Console ask the server and display what it says. A browser-side check would be pointless.
Challenges we ran into
The gateway was invisible from inside. We wrote Codex's config.toml to point at our gateway as the model endpoint — which worked, and told the agent nothing about /gateway/v1/tools/*. The tool routes were fully enforced and completely undiscoverable, so no tool denial could be demonstrated end to end. The fix was to hand the same origin to the Runtime as LAUNCHPAD_GATEWAY_URL: a URL, not a credential, conferring nothing on its own because every call it names is still authorized per call.
Codex's requests used to reach Ark directly; ours arrive from a proxy, so we labelled them as one — and Ark started returning 400 on every request. It turns out Ark only tolerates Codex's web_search tool when the User-Agent says the client is Codex. A cosmetic header change had taken the whole platform down, and it looked exactly like a credential problem.
Windows. config.ts runs every path through path.resolve, so tests that hardcoded /tmp/... passed on CI and failed on a teammate's laptop.
What we learned
Put permissions in the store, not the token. This one decision produced almost every good property we have. Revocation is immediate. Role changes bite mid-run. Nothing needs to expire, and there is no window in which a stale token still speaks for a user who has already been suspended. The credential answers "which run", and only that.
A denial is a design decision, not an error code. 403 and 404 say different things to an attacker. Deciding, per resource type, what the agent is permitted to learn from a refusal turned out to be as substantive as deciding what it's permitted to access.
Test the absence, not just the presence. Our denial tests assert two things: the 403/402/401 and that the upstream was never called. A gateway that denies you after burning your tokens has not denied you. Likewise, both runner suites assert that ARK_API_KEY never reaches a runner environment, argv, or generated config — those tests exist specifically to fail if anyone reintroduces the vulnerability this project removed.
What's next
The gateway should speak MCP. Our three hard-coded tool routes proved the enforcement point; MCP is where real tools live. An MCP client holds the credentials for every server it connects to and this is essentially the same issue as the Ark key problem that we were trying to solve.
Therefore there should be an MCP endpoint fronting every upstream server, with the gateway holding the credentials and the agent's RUN_JWT staying exactly as permission-free as it is today.
Built With
- codex
- fastify
- mcp
- node.js
- typescript

Log in or sign up for Devpost to join the conversation.