Back to Insights
agentsAI-native developmentsecurity architectureDevSecOps

Failure Modes of Parallel Agent Sessions

Running several coding agents against one codebase breaks in ways that have nothing to do with model quality. Worktrees are not an isolation boundary, a container on a Mac does not stop what you think it stops, deny rules do not survive a plumbing command, and an agent cannot be the source of evidence about its own work.

Levente Simon

Levente Simon

creator of dethernety

October 20, 2026·10 min read
Failure Modes of Parallel Agent Sessions

Failure Modes of Parallel Agent Sessions

What I can demonstrate breaks, and where the enforcement has to live

One human, several coding agent sessions, one codebase. Everything below is either something I verified on a machine while writing this, or something that follows from how the tools are built. Where I could not check a claim, it is not here.

The thing that shapes all of it: you can revoke a person's access and they carry that forward. They remember, they adjust, they have a reputation that survives the incident. An agent carries nothing between sessions. It cannot be held responsible, cannot be fired, and will not learn from the consequence, so the consequence lands on whoever delegated. Delegation to an agent is a loan of your own authority with no return path, which is why the authority has to be bounded on the way out rather than corrected on the way back.

Worktrees are not an isolation boundary

This is the assumption I see most often and it is wrong. A git worktree per session feels like separation, and it separates almost nothing.

git worktree add writes a .git file pointing back into the main repository. What you get per worktree is the pseudorefs (HEAD, MERGE_HEAD, ORIG_HEAD and friends), the index, the HEAD reflog, and three ref namespaces: refs/bisect, refs/worktree, refs/rewritten. Everything else is shared: every ref under refs/, meaning branches, tags, remotes, notes and the stash, plus the object database. .git/config. .git/hooks.

Three worktrees each holding only HEAD, the index, the HEAD reflog and the bisect, worktree and rewritten ref namespaces, all pointing into a single shared .git directory holding the object database, every branch, tags, remotes, the stash, config and hooks

You can confirm it in any repository in one command:

$ git -C some-worktree rev-parse --git-path hooks
/path/to/main/.git/hooks

Note what is not in the per-worktree list: branches. So one session can move or delete a branch another session has checked out. Git appears to stop you, which is how the assumption survives:

$ git branch -f peer HEAD
fatal: cannot force update the branch 'peer' used by worktree at '../wt-peer'

That refusal is porcelain politeness. The plumbing has no such scruples:

$ git update-ref refs/heads/peer HEAD     # exit 0, silent
$ git update-ref -d refs/heads/peer       # exit 0, silent

After the second one, the peer's checkout is sitting on a branch that no longer exists. Nothing warned either session.

The shared stash is worse than it sounds, because git stash pop takes no argument and indexes from the top. Session A stashes, session B stashes, session A pops, and B's work lands in A's tree. No error on either side.

And because .git/hooks is shared, a hook written by one session runs in every other session in that worktree set, including the main checkout.

If you want sessions isolated from each other, give each one its own clone. A local clone copies neither hooks nor local config, which worktrees do share. Use worktrees for cheap disk and fast branch switching, which is what they are for.

Get the sessions off your host, and check what that bought you

If sessions run as your own user on your own machine, the controls you reach for are not boundaries. Not because of anything subtle: a boundary the agent can opt out of is not a boundary. A harness deny rule governs the harness's own tools and the agent writes the file with sed. A pre-commit hook is --no-verify. Enforcement material cannot be read-only to a process running as the user who owns it.

So put the sessions in a container or a VM that your host is not in. That one boundary is what stops a session reading your cloud credentials, your kube contexts, your SSH agent socket and your git credential store. It is cheap and it is the highest-value thing on this page.

Then check what you actually bought, because the defaults give most of it back.

The Docker socket. Mounting /var/run/docker.sock is host root in one command. It is also the most common ask for an agent that runs compose or Testcontainers. Same family: --privileged, --pid=host.

The repository mount itself. This one is easy to miss. The agent writes package.json scripts, a Makefile target, .envrc, .vscode/tasks.json, a git hook, a .gitattributes filter driver. Then your host executes it the next time you build, open the editor or cd into the directory. A writable repo mount is a code-execution path back across the boundary you just built.

Networking, which on a Mac does not work the way the advice says. I tested this while writing, on Docker 29.8.1 under macOS. A service bound to the Mac's 127.0.0.1:38421, reachable from the host. From a container on --network host: nothing, because host networking hands over the Linux VM's loopback, not yours. From a container on the plain default bridge: HTTP 200, via host.docker.internal, which resolves to 192.168.65.254.

So on macOS, declining --network host buys you nothing against loopback exposure, and the default already reaches your local services. The same structure applies to Colima, Rancher Desktop and OrbStack, which are all Linux VMs with a host gateway. If that matters to you, block the gateway address from the container, and note that blocking the name does nothing because the raw IP works.

Last thing worth knowing about one shared container: it separates the sessions from you, not from each other. Inside it they share a uid, a filesystem, a loopback interface and a PID namespace, so a session can read a peer's /proc/<pid>/environ and recover whatever was passed to it in the environment. A container per session is what makes session-to-session controls real.

Deny rules are a speed bump

Denying push at the tool layer is worth doing and is not a control. The verb moves:

$ git -C /path/to/worktree push origin main     # push is not argv[1]
$ git -c alias.yolo='push origin main' yolo     # the verb is in config
$ git send-pack /path/to/remote.git main        # no "push" token at all

send-pack is the plumbing behind push. No configuration, no flags, no mention of the word.

The config surface is worse, because git -c reaches arbitrary execution directly:

$ git -c alias.pwn='!id' pwn
uid=501(...) gid=20(staff) ...

So a deny-list of config keys is not a control either. core.fsmonitor and diff.external execute the same way, through git -c or GIT_CONFIG_*, with no file write anywhere.

What holds instead is two things. Credential scope, so a push fails because the token cannot reach that remote. And server-side authorization: rulesets, required checks, pre-receive hooks. That is the only layer a session cannot modify. One precondition on that, and it is load-bearing: branch protection exempts administrators by default, and rulesets have bypass actors that can include apps. A ruleset with a non-empty bypass list is advisory, exactly like everything else here.

An agent cannot be the evidence for its own work

"The tests pass" is a claim, and a transcript is written by the thing making the claim. A session can print the sentence. A run ID pasted into a transcript is no better, unless somebody fetches that run from the API.

The genuine anchors are a check run attributed to a required GitHub App, which is enforced server-side, and artifact attestations (actions/attest, verified with gh attestation verify), which are the cryptographic version. Bind them to the commit, and remember that an amend or rebase produces new SHAs, so a real green no longer covers the new head.

There is a specific way this goes wrong that is worth knowing, because it inverts the obvious guess. A renamed or deleted required check does not pass; the pull request hangs forever waiting for a name that never reports. What passes is a skipped job. GitHub's own documentation: "A job that is skipped will report its status as 'Success'. It will not prevent a pull request from merging, even if it is a required check." Required checks are satisfied by success, skipped or neutral.

So the way an agent makes its own gate green is a job-level if: condition, not a rename. And since workflows for pull_request events run from the merge ref, an agent that can edit .github/workflows/ controls the gate that judges it. The fix is to make the CI definition a path that needs human review, source the actual checks from a workflow in a repository no session can write, and make the single required check an aggregator that runs with if: always() and fails unless its dependencies succeeded.

An approval is not a credential

A coordinator telling a worker that the owner approved something is passing a description of an approval. Nothing in current harnesses lets the receiving session verify audience, scope or expiry.

The property it needs has been specified for decades. Macaroon caveats narrow a credential and cannot be removed from it. OAuth token exchange, RFC 8693, issues downscoped tokens the authorization server enforces. There are IETF drafts now on attenuated agent tokens and on delegation chains for agent tool calls. None of it is wired into the harnesses, which is the vocabulary to use when asking a vendor what they intend to do about it.

Until then, be honest about the status: there is no control for relay fidelity, only containment. A decision register written by the coordinator cannot evidence that the coordinator relayed correctly. What does hold is keeping outward actions with the person, because that is enforced somewhere else.

One mechanism is available now. Bind the approval to the artifact it was granted over and have the gate refuse a mismatch. Branch protection does a version of this already when it dismisses approvals on a new push. Pick your hash deliberately, though: a message-only amend leaves the tree SHA unchanged and changes the commit SHA, so binding to the tree lets a rewritten commit through on an old approval, and binding to the commit costs a re-approval for every trivial amend.

Rules decay, so stop writing rules

Sessions compact their context and start fresh. Any rule enforced by recall will eventually meet a session that does not have it.

A rule that breaks once gets written down. A rule that breaks twice becomes a mechanism: a hook, a deny rule, a gate, a wrapper. Then test the mechanism the way you test code, including a test that it rejects what it should reject. A hook that has only been checked for allowing the right things has not been tested.

The same logic caps delegation. If workers can delegate, delegation recurses, so allow it on the coordinator only and cap the depth explicitly. CrewAI's own guidance is to enable its delegation flag on the manager alone for exactly this reason.

What this does not cover

Each of these deserves its own treatment by someone who has checked it properly, and that is not this article.

Collisions outside the repository. Two sessions generating database migrations, allocating the same topic name, or writing the same shared cache. The shape differs by framework and the mechanisms differ by resource, and it is the failure that costs most in practice.

Supply chain. Lockfile regeneration smuggling dependency bumps through a conflict resolution, pinning actions to commit SHAs, vetting MCP servers and plugins.

Test integrity. An agent that makes tests pass by widening an assertion or mocking the thing under test produces a genuine green run, which the evidence rule above does not catch.

Untrusted input. A session reading a dependency README, an issue body or a web page is consuming attacker-controlled content while holding credentials.

Provenance. Showing which code in production was agent-generated and what control it passed. A ledger in the repository is a working record, not an audit trail.

More than one human. Everything here assumes one owner.

The longer version, with controls stated as what they prevent and what they cost, is in governed semi-autonomous development. CC BY 4.0, and a draft.

Related Insights

AWSIAM

Customer isolation from the infrastructure up

How per-customer blast radius containment works across layers of AWS infrastructure — IAM, network, DNS, identity, storage, state, compute, and ingress — each enforcing isolation on its own.