Your agent shouldn't be you
Sep 1, 2026
How each coding agent in Helix gets its own credential, its own network, and write access to two branches and nothing else. Including the parts we got wrong and what we still haven't solved.
Someone asked us on Reddit last week whether we had anything written up on how each agent gets its own identity. We didn't. So here it is.
The default is that your agent is you
Almost every coding agent setup starts the same way. You install it on your laptop and it picks up your git credentials, your SSH keys, your cloud CLI session, your npm token. It runs as you because you ran it. That is the path of least resistance for everyone building these tools, and it works fine when there is one agent, on one machine, doing one thing while you watch it.
It stops working the moment any of those numbers goes up. Ten agents holding your credentials is ten copies of your access. Every one of them is a process making decisions based on text it read somewhere. A README, an issue comment, a dependency's changelog, a web page it fetched to figure out an API. Prompt injection isn't exotic anymore. It's the expected weather.
The question a security team asks isn't whether that will happen. It's what happens when it does. If the agent is you, the answer is that it has everything you have, for as long as you have it, and the audit log says you did it.
You can make software secure. You can't make a model secure. Given the right text a model can be talked into anything, and prompting your way out of that doesn't work, because the prompt is what's under attack. So nothing in this design depends on an agent choosing to behave. It gets the access we hand it, and the limits are enforced by software that never reads the prompt.
What we do instead
Every task in Helix gets a container of its own. Ubuntu desktop, Zed, a terminal, a browser, and a clone of the repo on its own branch. One thread, one branch, one desktop.
There are no SSH keys in the sandbox. No SSH agent, no GitHub CLI, and no upstream
credential of any kind. origin in the agent's checkout does not point at GitHub. It
points at a bare repo that Helix hosts, over HTTPS, and the agent authenticates to that
with an ephemeral key written to ~/.git-credentials.
That key is a row in our database, created when the session starts. It carries the session ID, the spec task ID, the project, the org, and the human who owns it.
When the agent pushes, our git server looks the key up, finds the task it belongs to, and hands the list of branches that task may write to a pre-receive hook. That list has two entries: the task's own feature branch, and the branch where design documents live. Anything else is refused by the hook before a single ref moves. The default branch is never on the list.
The push doesn't go to GitHub from inside the container. It can't, because nothing in there can reach GitHub as you. Our API syncs from upstream, accepts the agent's push into the bare repo, and then pushes onward to GitHub or GitLab or Azure DevOps, using the OAuth token of the person who approved the work.
So the blast radius is one task's branch, plus the design docs, for the length of one task. That is a boundary you can describe to a security reviewer without making any claim about how the model behaves.
That last step has a rough edge we haven't fixed. By the time we try the upstream push, the agent has already been told its push worked, because the HTTP response went out when the local one landed. If GitHub rejects it we roll the local branch back, and the agent doesn't find out until its next fetch. That's a consequence of doing the relay inside a single request, and we know about it.
The key doesn't expire on a timer. We delete it when the desktop shuts down. That covers a task that finished and a task that crashed, because both end with the desktop shutting down. A task that hangs and never shuts down keeps its key until someone stops it by hand. That key only unlocks two branches of a repo we host, and it can't reach GitHub at all, so the exposure is small.
The same mechanism catches a much duller problem. An agent can't decide the fastest route to a green build is to push straight to main. That's closed off at the credential layer instead of the please-don't layer.
Scoping an agent to one branch costs something. An agent that can push anywhere is more convenient than one that can't, and people notice. That's the usual shape of a security control, and it's the one enterprise customers ask for by name. So we spent the effort on making the constraint cheap to live with. The git server is the strongest boundary we have today, and it's the first building block. There's a lot left to build.
Credentials and network have to line up
A scoped credential doesn't help if the container sits on a flat network with the internal package registry, the staging database and the metrics endpoint one hop away. Plenty of half-built versions of this fail exactly there.
So we drew the network boundary to match. Each agent's container sits on a dedicated bridge with inter-container communication turned off, so two agents on the same node can't reach each other. We tested that in both directions in August. Both blocked.
The egress policy isn't deny-all-with-an-allowlist. Traffic to the public internet is allowed. Traffic to the private network gets rejected: RFC1918, carrier-grade NAT, loopback, multicast, and link-local. That last one closes the cloud metadata endpoint at 169.254.169.254. The only thing on the host an agent can reach is a proxy on one port that fronts our own API. Admins can open specific ranges with an allowlist, per deployment, not per task.
An agent needs npm and PyPI and the docs for whatever it's integrating with, and an allowlist you have to maintain for all that is an allowlist somebody switches off in month two. So we blocked the network with your databases on it and left the one with Stack Overflow on it open. If you worry about exfiltration more than lateral movement, that's the wrong default for you, and the allowlist is where you fix it.
The policy fails closed. A subnet-wide reject goes in before the docker daemon starts, and it only comes out once every rule is installed. If any part of that fails the daemon restarts and tries again with the block still in place, and the container never gets marked ready.
An earlier version didn't check whether two of those steps had worked. A bridge mismatch
or a missing ip6tables would lift the block and mark the container ready anyway.
Sessions ran with no egress policy at all while reporting healthy. It's fixed.
Each desktop runs its own docker daemon, inside the container itself, so an agent can build and run containers without borrowing anyone else's daemon and without seeing what any other agent is running.
The agent has a credential, not an identity
We spent a while treating the agent as a proxy for the person who launched it. It's the obvious design. The user has permissions, the agent acts on their behalf, so let it borrow them and move on.
That's convenient right up until something goes wrong and someone asks who did this.
Where we landed is narrower than "give the agent its own identity". The grander version is the one everybody writes up and fewer people ship. The agent is not a principal in our OIDC setup. OIDC is how humans log in to Helix. What the agent gets is its own credential, with its own scope and its own lifetime, that isn't the human's credential and can't do what the human's credential can do.
Whose name lands on the commit is a separate decision, and Helix does both. Point a repo at a GitHub App and we mint a short-lived installation token, so the work arrives under the app's name. Use the approver's OAuth connection instead and the commit and the pull request carry theirs. Which you pick is mostly taste. A bot keeps machine work visibly machine, and an installation token expires on its own. A human name puts someone accountable in GitHub's own log.
The agent never held either credential. Our API relays the push, so a person's name on a commit is a label on the work. It says nothing about what the agent could reach. On your laptop those are the same thing. Here they aren't.
The trail underneath reads: this task, on this branch, in this session, under this key, owned by this person. You can reconstruct what happened from that. You can't reconstruct it from a log line with an employee's name on it and nothing else.
Retrofitting the scoping is miserable. If you're building your own agent infrastructure, do it before you have anything to migrate.
What we haven't solved
We have done none of this for anything except git. Third-party API tokens, cloud credentials, internal services reached over MCP. When a task needs a token for some third-party MCP server, what goes into the sandbox is the user's OAuth token for that provider. It isn't scoped to the task and it doesn't die when the task does. The git path has a boundary. That path has a copy of your token.
In August we found that the code deciding whether an MCP URL belonged to Helix classified it by path alone. A third-party MCP server whose path happened to start with the same prefix got rewritten to our proxy. Its Authorization header came to us. Nobody attacked anything. A string comparison was too loose and a credential went somewhere it shouldn't have. We tightened the classifier, then deleted it and had the control plane emit the canonical URL instead.
If you're building this yourself and you've solved a piece of it differently, we'd like to hear it. And if you're trying to get an agent architecture past a security review, the framing that got us furthest was to stop arguing about whether injection can happen and start describing what it reaches when it does.
Helix runs coding agents on your own hardware, each with its own desktop, its own credential and its own network. Security and data residency has the detail on what stays where, and self-hosted coding agents for the enterprise covers what it looks like for a team. Get in touch if you want to talk it through.