attenu

Blog · · by , Attenu

A sub-agent in Open SWE pushed a workflow file its parent was not allowed to push

GHSA-wpjg-64mw-3qj9 · Open SWE < 0.2.8 · disclosed to LangChain 7 September 2026, fixed the same day

Open SWE, LangChain's autonomous coding agent, holds a git push back for human approval when the push touches .github/workflows/. That is the right call: workflow changes can alter CI behaviour and may reach credentials available to the jobs they trigger, subject to the repository's permissions and protections.

The guard worked. Then the agent delegated the push to a sub-agent, and the sub-agent pushed it without asking anyone.

I reported it on 7 September. LangChain merged a fix twelve hours later, and published GHSA-wpjg-64mw-3qj9 on 18 September. The advisory lists the affected configurations, hosted or self-managed: the workflow-push approval guard is enabled, the general-purpose sub-agent is available, and the GitHub credentials Open SWE pushes with can push workflow changes. If you run Open SWE, upgrade to 0.2.8 or later.

The bug, before 0.2.8

Open SWE's approval check is WorkflowPushGuardMiddleware, in the parent agent's middleware stack (agent/server.py:1334, pre-fix). Middleware in this architecture is per-agent: it wraps the tool calls of the agent it is attached to, and nothing else.

The general-purpose sub-agent is compiled separately, with its own stack, and Open SWE declares that sub-agent itself. It hands the child a filtered copy of the parent's static tools (agent/server.py:436, built from the list at :1267), so the child has the same execute, against the same sandbox, with the same repository credentials. And it builds the child's middleware from scratch in _subagent_middleware() (agent/server.py:391-399, pre-fix): the dynamic-tool middleware, the tool exclusions and the model middleware. The workflow guard was not in that list.

deepagents 0.7.11 behaves as designed here. A sub-agent the application declares itself gets its middleware from deepagents' base stack plus whatever that spec lists (graph.py:666-702); the parent's own custom middleware is not added to it (graph.py:882). deepagents does inherit parent middleware into the general-purpose sub-agent it adds for you, but only middleware overriding a default slot (graph.py:774-777), and that path is skipped when the application declares a sub-agent of that name itself (graph.py:750), which Open SWE does (agent/server.py:424). The composition is where the guard went missing, and the composition is what the fix changed.

So the same push takes two different paths:

Who pushes Path Result
Parent agent through WorkflowPushGuardMiddleware held for approval
Sub-agent, via task its own middleware stack, no guard landed on the remote

The child is not more privileged by design. It is more privileged by accident, because the thing that constrained the parent was attached to the parent rather than to the authority the parent was working under.

PullRequestCreationGuardMiddleware escaped by exactly the same path. I flagged it in the report as an attribution problem rather than a security one, and it is not part of the advisory. LangChain fixed it in open-swe#2965, merged 18 September.

How I found it

I was working through agent frameworks asking one question of each: does a control attached to a parent still apply to the sub-agent it delegates to. In Open SWE that meant reading how the middleware is composed, and the answer was visible in the wiring before anything ran.

So I wrote a harness that proves it, with a scripted model, a local bare git remote, no API key and no network. It uses none of my own code: it drives Open SWE's own agents and reports whether the push landed. It did.

Only afterwards did I record the same run through attenu-guard in observe mode, which is how I keep evidence of this kind: it records every tool call and every hand-off that passes through its hook, and refuses nothing, so the ledger shows which agent did what. The integration is real work, not a flag: you map the app's tools to scopes, install the hook on the parent and on each sub-agent spec, run the agents normally, and read the ledger. A call that does not pass through the hook is not in the ledger. It is a camera, not a lock.

The ledger of the delegated run, rendered one line per entry:

n0            spawn general-purpose         -> n1
n1            ALLOW  execute                git push origin feature

The remote received the workflow change. The identical push attempted by the parent, as the control case in the same harness, was held.

That is the whole finding. No exploit chain, no clever payload. The same action takes two different paths depending on who performed it, and the harness shows it without any of my code in the loop; the ledger is the recording of it.

How it stays held

Same unpatched tree. One rule, declared once, on the root Authority:

ceilings=[Deny("workflow_push", {"yes"})]

The harness computes workflow_push from the diff of the branch being pushed and passes it as context on the execute call; attenu-guard evaluates the rule against that context. It does not read git itself.

Now the ledger shows the rule travelling, same rendering:

n0            spawn general-purpose         -> n1   [constraint: workflow_push]
n1            DENY   execute                git push origin feature

Nothing reaches the remote. The parent's own workflow push is denied on n0 the same way, in the matching control case. An ordinary push, touching no workflow file, still lands.

The part that matters is what travels. The hook is installed on both agents, like any middleware, and that installation is the integration: a sub-agent spec without the hook is outside the boundary, not narrowed by it. With the hook in place, the rule is declared once, on the root Authority, and a child's authority is the meet of what it asks for with what its parent holds, so it cannot hold workflow_push if the parent does not. Nothing is re-declared per agent. Add a sub-agent type tomorrow, with the hook on it, and it inherits the ceiling; what is left to forget is the hook, not a per-agent copy of the rule.

The fix I sent

I did not propose that anyone adopt attenu-guard to close this. The fix I proposed was native to Open SWE: install the workflow-push guard in the sub-agent's middleware stack, where the parent's stack does not reach.

LangChain shipped that in open-swe#2496, commit 2ad5524, in 0.2.8. If you run Open SWE, upgrade to 0.2.8 or later. If you cannot yet, the advisory's workaround is to disable sub-agent delegation, or remove the agent's permission to push under .github/workflows/.

About the reproducer

The harness scripts the model's tool calls, so it tests the hand-off rather than the model's judgement. That makes it deterministic: it runs against a local bare repository with no API key and no network, and the same run either reaches the remote or does not. It went to LangChain with the report on 7 September. It is not published here: it imports Open SWE's own middleware, so it runs only inside an Open SWE checkout, and it reproduces only against the tree before the fix.

What I did not show

The impact depends on an attacker being able to steer a run into delegating the push, and I did not demonstrate that.

LangChain's advisory records the same limit: reliable steering from untrusted input was not demonstrated in the original report.

So: a real gap in an authorization boundary, with the reachability question left open rather than assumed. LangChain rated it Moderate and classified it as CWE-862, Missing Authorization. With the reachability question open rather than assumed, that reads right to me.

The general problem

Strip out the specifics and the shape is one I keep finding:

A control attached to a parent agent does not, by itself, cover the sub-agents it delegates to.

Middleware, hooks, interceptors, wrappers: they are per-agent by construction. A sub-agent that is compiled separately gets its own stack, and a custom control on the parent covers it only if it is applied there too, or enforced at an execution boundary both agents share. In Open SWE that control was the workflow guard, and the fix was to apply it on the sub-agent. That is the whole of what this finding shows about middleware.

What it does not show is that frameworks hand sub-agents nothing. deepagents 0.7.11 does inherit: a declarative sub-agent that declares no permissions, interrupt_on or tools gets the parent's (graph.py:663, :719, :727). What it does not do is intersect. A sub-agent that declares its own permissions "replaces the parent's rules entirely" (graph.py:475), and the same holds for its tools and its interrupt_on. It inherits when the sub-agent declares nothing and replaces when it declares something; at no point does it compute the child's set as a subset of the parent's. deepagents documents this; it is the designed behaviour. Open SWE's declared sub-agent is the case where it applies.

I have found this same shape, under different names, in more than one agent framework. Where those reports are already public, they are linked below. Where they are not, they are not named here: those projects have not shipped a fix yet, and naming them now would move the risk from me onto their users.

The fix that generalises is to attach the constraint to the authority the agent holds rather than to the agent, and check every action against the authority in force where the action happens. Delegation then narrows that authority instead of restating it.

That is what attenu-guard does, once its hook is installed on each agent in the chain, and it is why the rule above is one line rather than a second copy of the guard.

If you run agents that delegate

The check is cheap. Map your tools to scopes, install the hook on each agent and sub-agent spec (the adapters cover LangGraph and deepagents, CrewAI, OpenAI Agents, Google ADK and the rest in the README), attach it in observe mode, and run whatever you normally run. Give the agents wide authority and nothing is refused; every tool call and every hand-off that goes through the hook is recorded. If a child made a call its parent would have been stopped on, and the call went through the hook, it is in the ledger.

pip install attenu-guard

github.com/attenu-io/attenu-guard

Timeline

Date
7 September 2026, 07:40Z Reported to [email protected] with a patch, a reproducer and both ledgers
7 September 2026, 19:52Z Fix merged (open-swe#2496)
18 September 2026 Advisory published

Thanks to the LangChain security team. They reviewed a draft of this piece for technical accuracy on the Open SWE finding, and I took their corrections. That is the extent of their review. They did not evaluate attenu-guard, and nothing in this piece about attenu-guard, or about the general pattern, carries their validation or endorsement. Those conclusions are mine.

xerrors/Yuxi#1000 — the same class in a different codebase, a per-agent control that sub-agents did not pass through: the default approval mode hid write_file, edit_file and execute from sub-agents without intercepting them before execution. Reported and fixed.


Rafael Asor — Attenu attenu-guard is Apache-2.0: github.com/attenu-io/attenu-guard

Rafael Asor is the founder of Attenu and the maintainer of attenu-guard and attenu-derive, open-source Python libraries for AI agent permissions across sub-agent handoffs. He is the author of the IETF Internet-Draft draft-asor-wimse-agent-delegation-chain and is based in Tel Aviv.