Blog// 18 MIN READ

Accepted Is Not Applied: When a Security Parameter Silently Does Nothing

AnyIO accepted extra_groups=[] and kept the parent's supplementary groups. Silent inaction is worse than absence, because the caller believes the work is done.

EVIDENCE → INTERPRETATION → DECISION

AnyIO accepted the argument that drops privileges and did not drop them. The library takes the POSIX extra_groups parameter on its process helpers, forwards a different variable to the backend, and returns a running child process holding the supplementary groups the caller believed had been cleared. One library, three advisories published on 18 September, and all three share a single shape: the security-relevant behaviour was accepted, and did nothing.

This is not the failure mode I have been writing about for the past two weeks. The controls in those pieces were absent, or skipped on one code path, or resolved against the wrong subject, or decorative because the endpoint was thought to be unreachable. Here the caller did everything right. The parameter was named correctly, the value was the correct empty list, the call was on the documented code path and the call succeeded. The only thing that did not happen was the effect.

That distinction matters more than it looks, and it is the subject of this piece. An absent parameter produces a gap somebody can find. An ignored parameter produces confidence nobody will question. This field note is about the second kind, why it is the more dangerous of the two, and the three checks that catch it.

The three advisories, and the one shape they share

All three were published against AnyIO, an asynchronous compatibility layer that sits underneath a large part of the Python ecosystem. They are independent bugs with a common structure.

The privilege drop that kept the privileges. AnyIO 4.14.0 accepts the POSIX extra_groups argument on anyio.run_process() and anyio.open_process(). The documentation-level intent is the standard privilege-dropping idiom: run a child process with control over which supplementary groups it holds, so that you can launch something less privileged than yourself. The implementation forwards the wrong variable to the backend, assigning kwargs["extra_groups"] = group rather than extra_groups, so the argument the caller supplied never reaches the call that would apply it.

The advisory’s own reproduction is the cleanest way to see it. In a disposable Linux container, the synchronous standard-library equivalent with an empty group list clears a synthetic parent supplementary group list, while the async helper with the same empty list preserves the parent’s groups. Same argument, same apparent operation, different outcome, no error. Pass an unrelated second parameter alongside it and the call does raise a TypeError, which means the bug announces itself only in the case that is not the security case.

The name compared under the wrong standard. Internationalised domain names have more than one encoding, and the encoding used to compare a hostname against a certificate is a security-relevant choice rather than a formatting preference. AnyIO compared under an obsolete encoding. An attacker can obtain a legitimate certificate for that obsolete encoding of a domain, offer it to a connecting client, and have it validate correctly, which turns a name check that ran into a name check that approved the wrong name.

The stream the documentation said was redirected. The process pool starts workers with their error stream connected to a pipe that nothing ever drains, while the documentation states that all three standard streams are redirected. A worker that writes enough to its error stream fills the pipe, blocks before it can return its response, and wedges the call that is waiting on it. The protection being relied upon here is the documented redirection, and the documentation is wrong in the direction that costs you.

Three cases from one library where a security-relevant parameter or documented behaviour was accepted and had no effect, each showing the caller's request, the property that did not change, and the successful return that hid it.
Three parameters, three successful returns, no effect. In each case the caller did the right thing and the property did not change.

The asymmetry: absent makes a gap, ignored makes confidence

These two failures look similar from a distance and behave completely differently inside an organisation, which is the reason this class deserves its own piece rather than a footnote.

When a security parameter is absent, the property is unset for an obvious reason, and the absence is visible. It is visible in the diff that added the call without the argument, in the review that asked why the sandbox was not dropping privileges, and in the configuration that never declares the value. Nobody downstream is relying on a guarantee, because no guarantee was ever requested. The failure is a gap, and gaps get found because the shape of the code has a hole in it.

When a security parameter is ignored, every one of those signals inverts. The argument is present in the call, so a reviewer reading the call sees the protection and moves on. The API accepted it, so there is no exception to alert on, no warning in the log and no error path that ran. The call returned success, which everyone reads as confirmation. And the person who wrote the call believes the work is done, which means they are not just missing the control, they are actively confident about it.

That is the whole of it: an absent control leaves a hole, and an ignored control leaves a belief. The belief travels further than the hole would have, because a hole stops at the point where the code physically does not do something, while a belief propagates into every decision made on top of it, including decisions about how much risk the system carries.

A comparison of an absent security parameter, which produces a visible gap, against an ignored parameter, which produces false confidence, followed by the three states of accepted, applied and verified, and the tests that catch a silent no-op.
Absent makes a gap. Ignored makes confidence. Both leave the property unset, and only one leaves anyone looking for the problem.

Accepted, applied and verified are three different states

The reason a successful return gets misread is that we collapse three separate states into one word, and the word we usually reach for is the weakest of the three.

Accepted means the API did not reject what you gave it. That is a statement about a signature, a parser and a type check, and it is the only thing a successful return actually reports. It says nothing whatsoever about the caller’s intent being honoured. In the privilege case, the argument was accepted and the plumbing between acceptance and application was miswired, so acceptance was all that happened.

Applied means the value changed the behaviour of the thing you were trying to protect. This is the state that matters and it is the one no return value reports, because a return value can only speak for the layer that produced it and the effect lives in a different layer, often in a different process or a different kernel call.

Verified means something asserted, independently of the call, that the value took effect. A startup assertion that reads back the group list, a test that compares two runs, a property checked against the running system. This is the only state that is evidence, and it is the state that almost no code reaches for, because the call returned success and success feels like the end of the transaction.

The practical formulation: a security parameter is a promise made by the caller, and a successful return is a receipt from the parser. Neither is the control. The control is whatever the system actually does when the process runs, and the only way to know that is to ask the system after the fact rather than the function before it.

The check ran, and compared the wrong strings

The internationalised-domain case deserves its own section because it is the subtlest of the three, and because it is a category of bug that will keep appearing as long as identifiers can be written more than one way.

When a domain name contains non-ASCII characters, it has to be encoded into ASCII before it goes on the wire. There are two generations of the standard that describes how to do this, and they do not always produce the same string for the same name. A verifier that compares a hostname under one encoding and a certificate that was issued under the other can end up comparing two strings that neither party intended to be the subject of the comparison.

That is what makes this case instructive. Nothing in the failing path is obviously broken. The TLS handshake completed. A certificate chain was validated. A hostname was extracted and compared against the certificate’s name. Every step that a reviewer would look for happened. What did not happen is the comparison that was intended, because the string on one side of it had been normalised under a standard the other side did not use.

An attacker’s route through this is not exotic. Obtain a legitimate certificate for the domain as encoded under the obsolete standard, arrange for the connection to be redirected to your server, and present that certificate. The client’s checking machinery does its job on the strings it was given, and the strings it was given are wrong in a way that happens to point at you. The advisory’s own remedy is the give-away that the bug is about normalisation rather than about validation: it tells you to encode the host name with the current library before connecting, which is to say the check works if you feed it the right strings.

The generalisable lesson is narrower than “validate certificates” and more useful: whenever an identifier can be represented more than once, the comparison must happen after normalisation to a single chosen form, and the choice of form is part of the security boundary. A check that compares unnormalised identifiers is not a weak check, it is a check against a different question than the one being asked.

Documentation that promises more than the code delivers

The third case is the least dramatic and the most common in practice. The process pool’s documentation states that workers have all three standard streams redirected. The implementation redirects two of them, connects the third to a pipe that nobody reads, and trusts that no worker will ever write enough to fill it.

The reason this belongs in the same piece is that the protection being relied upon is not a check in the code at all. It is a sentence in documentation, which means the caller’s defence against it is a belief rather than a mechanism. When the code and the documentation disagree about a security property, the caller has no way to detect the disagreement short of reading both and testing the difference, and almost nobody does that for a property the documentation states plainly.

There is a version of this that is easy to dismiss as a robustness bug, and a version that is not. A wedged worker pool is a denial of service when the thing writing to the error stream is attacker-influenced, which the advisory explicitly considers. But the more durable point is about where trust was placed. A documented guarantee that is not implemented is worse than an undocumented one, because an undocumented guarantee is not relied upon. The sentence that said the streams were redirected is what turned a missing feature into a security assumption.

Why this lands hardest on agent sandboxes

None of these three is an AI system vulnerability, and I am not going to pretend otherwise. They are library correctness bugs in a general-purpose async toolkit. What makes them worth a field note in this series is what they break when the caller is an agent platform, because the specific idiom that failed is the specific thing that agent sandboxes do.

When an agent is given a code-execution tool, the platform’s job is to run a subprocess that is less privileged than the agent process itself. The standard POSIX construction for that is precisely the one in the first advisory: create the child with a controlled group list and drop the supplementary groups it would otherwise inherit. Everything about the containment argument rests on that drop having happened, and nothing else in the design is checking. The container boundary is doing its work in parallel, but the in-process privilege drop is the layer that keeps a compromised tool from inheriting the groups the agent itself holds.

So the failure is not that an agent platform is newly vulnerable. It is that the layer those platforms lean on for a specific security property returned success while not providing it, and that the correct-looking call is indistinguishable from the broken one at the call site. An operator who reviewed that code would have seen the privilege drop, because the privilege drop is written there. It is in the argument list. It was accepted.

The same shape reaches the other two. An agent platform that makes outbound TLS connections on behalf of a tool, to destinations that come from model output or from a retrieved page, is making name-based trust decisions about hostnames that may include internationalised names, and it depends on the name comparison being a comparison of the intended strings. And any platform that pools workers to execute tool code is relying on the pool’s documented stream handling when it decides that a worker cannot be made to wedge the executor.

The pattern to carry forward is that agent containment is assembled from general-purpose libraries, and a containment argument is only as strong as the silent parts of those libraries. Every one of the three failures here was silent, and silence is exactly what a containment argument cannot tolerate, because the containment argument is a belief about what the libraries do.

What does not follow

Three things I am not claiming.

This is not an argument that the library is unsafe or that these are severe in the sense that a nine-something host takeover is severe. One is a critical by virtue of what a broken name check can enable, one is a high, one is a medium, and all three are fixed or being fixed in a patch release. The reason they are worth writing about is the shape, not the blast radius.

This is not an argument against using the library, or against async process helpers, or against pooling workers. Every one of these is the right construction for the problem it solves, and the alternative to a well-designed helper with one miswired argument is a hand-rolled version with several. The lesson is about how you trust a helper, not about avoiding helpers.

And this is not claims about specific agent products. I have not tested any agent platform’s sandbox against these advisories, and I am not asserting that any particular system is affected. What I am asserting is structural and checkable: if a platform drops privileges for tool subprocesses through this idiom, then the idiom’s correctness is load-bearing, and the way to know is to check the installed version and the actual group state of a spawned child rather than the presence of the argument.

How to tell whether a parameter is doing anything

The practical value of this class is that it is testable, and the tests are cheap. Three of them, in increasing order of effort.

Read it back. After the call, ask the running system what the property actually is rather than trusting the return value. For a privilege drop that means inspecting the child’s actual group list, not the parent’s argument list. For a stream that is supposed to be redirected, it means checking where the file descriptor points. The rule is that the observable state of the child is evidence and the return value of the helper is not.

Differentially test it. Run the same operation twice, once with the parameter and once without, and compare the observable effect. In the privilege case, one of these two runs clears the group list and the other does not, and a test that asserted the difference would have failed on the broken version immediately. This is the single highest-value test for this class, because a parameter that is wired up changes something, and a parameter that is not changes nothing, and the difference between those two is directly observable.

Answer the silent case in the API. Where you control the interface, a security parameter that cannot be honoured should be an error rather than a no-op, and a documented guarantee should be asserted at startup in a development build. The upstream bug here is a plumbing mistake, but the reason it survived is that its API contract let acceptance stand in for application. Making the ignored case loud is the durable fix, and it is the part that generalises.

The reason to do the differential test rather than the review is the asymmetry from earlier. A review finds a missing argument, because a missing argument is a hole in the text. A review does not find a miswired argument, because a miswired argument is a line that looks correct and reads correctly and names the right concept in the wrong slot. Only the effect distinguishes them.

Evidence table

Advisory Severity What was accepted What did not happen Weakness
GHSA-3w57-8xmc-8v26 · CVE-2026-63349 High A POSIX supplementary-groups argument on the async process helpers The argument never reached the call that applies it, so a child launched with an empty group list kept the parent’s groups CWE-266, CWE-269
GHSA-82r6-8w77-94w6 · CVE-2026-63374 Critical A hostname to connect to, for internationalised domain names The comparison happened under an obsolete name encoding, so a certificate for the obsolete encoding validated CWE-295, CWE-297
GHSA-5p39-cfhj-2xmp · CVE-2026-64847 Medium The pool’s documented guarantee that worker streams are redirected The error stream went to a pipe nobody drains, so a chatty worker wedges the waiting call CWE-770

The first column worth reading twice is the third. In each case the caller supplied the correct thing, and in each case the correct thing was taken. The failures are all in what happened between acceptance and effect, which is the part of a library that callers never see and therefore never test.

Why this matters for agent platforms specifically

Agent systems are, structurally, a collection of containment arguments. A tool runs in a subprocess that is supposed to be less privileged. A model’s outbound request goes to a host that is supposed to be the one it named. Untrusted code runs in a pool that is supposed to be isolated. Each of those arguments is a belief about what a library does, and each of them is assembled from general-purpose components that were not written with the containment argument in mind.

The three failures here are a reminder that the belief is the part that never gets tested. The privilege drop was in the argument list and the argument list was reviewed. The name check ran and the handshake succeeded. The stream was documented as redirected and the documentation was read. In every case the evidence available to the operator was the evidence of intent, and the property itself was one layer further down.

That is the case for reading the effect rather than the call, and it is why I keep coming back to the same recommendation in different clothes. Containment claims should be checked against the running system, in the configuration that actually ships, on a schedule, because a library can change what a correct-looking call does in a patch release and nothing in the call site will look different afterwards. The argument will still be there. It will still be accepted.

Frequently asked questions

What does it mean for a security parameter to be silently ignored?

It means the API accepted the value, returned success, and never applied it, so the property the caller asked for did not change and nothing indicated that. In the AnyIO case a child process launched with an empty supplementary group list kept the parent’s groups, while the synchronous standard-library equivalent with the same argument cleared them.

Why is a silently ignored parameter worse than a missing one?

Because a missing parameter leaves a visible gap in the code and nobody believes the property is set, while an ignored parameter leaves the argument present in the call and the caller confident that the control is in place. The gap is a defect somebody can find in review. The confidence propagates into every decision built on top of it and is not visible in the text at all.

How can I tell whether a security parameter is actually doing anything?

Read the property back from the running system after the call instead of trusting the return value, and run the operation twice with and without the parameter to compare the observable effect. A parameter that is wired up changes something and a parameter that is not changes nothing, and that difference is directly testable rather than something to establish by reading the code.

What is the IDNA 2003 issue in the AnyIO advisory?

Internationalised domain names can be encoded into ASCII by more than one generation of the standard, and they do not always produce the same string. AnyIO compared hostnames under the obsolete encoding, so an attacker could obtain a legitimate certificate for the obsolete encoding of a domain and have it validate on the client. The fix is to encode the host name with the current library before connecting.

Does this mean agent sandboxes are broken?

No, and that is not the claim. These are library correctness bugs in a general-purpose async toolkit. The structural point is that agent containment is assembled from general-purpose libraries, and the specific privilege-dropping idiom that failed is the one agent sandboxes use. The right response is to check the actual group state of a spawned child and the installed version, rather than to assume either way.

What is the generalisable lesson?

Accepted, applied and verified are three different states, and a successful return reports only the first. A security parameter is a promise made by the caller; the control is whether the value changed the behaviour of the thing being protected, and the only way to know is to measure the effect rather than read the call.

Primary sources

Related from this series: a read-only sandbox proxy that handed over the host’s credentials, which is the same question of whether the boundary does what its description says, and a control turned off by renaming a configuration key, which is the failure one layer out: there the control was never configured, here it was configured and never applied.

One next action

If you run an agent that executes tools, AgentCrash is an open-source harness that crash-tests it in a synthetic workplace: synthetic tools, visual evidence and regression tests. It is built for exactly this problem, turning a containment claim into something you can run against the system instead of arguing about it in review.