<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Security on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/security/</link><description>Recent content in Security on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Sat, 12 Sep 2026 09:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/security/index.xml" rel="self" type="application/rss+xml"/><item><title>Completing Keycloak for MCP: the protected resource no SDK hands you ready-made, and the enterprise extension that skips the whole flow</title><link>https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/</link><pubDate>Sat, 12 Sep 2026 09:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/</guid><description>&lt;blockquote>
&lt;p>A follow-up to &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a>, which flagged the gap: resource indicators are not supported and protected resource metadata belongs to the MCP server. This post tries to close it. Verified against revision 2026-07-28 of the specification, the official Python and TypeScript SDKs, Keycloak 26.7.3 and LiteLLM 1.102.0.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>The specification splits three obligations and the authorisation server covers only one.&lt;/strong> The MCP server must publish its protected resource metadata. The client must send the resource indicator in both requests, and must do so even if the authorisation server does not support it. The MCP server must validate that the token was issued for it. Keycloak does not understand the indicator and considers the metadata someone else&amp;rsquo;s, so all three end up in the resource layer.&lt;/p>
&lt;p>&lt;strong>The Python SDK mounts the metadata on its own, but degraded.&lt;/strong> As soon as the resource server URL is configured, the route appears. What that automatic route publishes carries a single authorisation server, reuses as its scope catalogue the ones the middleware requires, and leaves the name and the documentation null. To publish a complete document you have to mount the route by hand.&lt;/p>
&lt;p>&lt;strong>Audience validation comes switched off in Python and does not exist in TypeScript.&lt;/strong> In Python, if the resource URL is configured and the corresponding flag is not enabled, the SDK emits a deprecation warning and behaves as if it were disabled; the docstring itself promises that version 3 will set it to true. In TypeScript the bearer verification does three things, and none of them is looking at the audience. Neither of the two ships a signature verifier with public keys.&lt;/p>
&lt;p>&lt;strong>Token passthrough is forbidden in writing, and the substitute has a name.&lt;/strong> The specification says the MCP server must not accept or relay tokens that were not issued for it, and that if it calls upstream APIs the token must be a different one. The mechanism is token exchange, which Keycloak supports in its standard version with a limitation you need to know: the audience is a client identifier, not a resource URL.&lt;/p>
&lt;p>&lt;strong>There is an official extension that changes the whole picture, and it is stable.&lt;/strong> Enterprise-managed authorisation replaces the redirect to the MCP authorisation server with an exchange at the corporate identity provider, which evaluates the policy and issues a grant based on an identity assertion. It has been stable since June 2026 and there are servers in production.&lt;/p>
&lt;p>&lt;strong>Keycloak implements it halfway and in experimental.&lt;/strong> It only knows how to act as receiver, not as issuer, behind a feature flag, and against draft 01 when the working group is on 04. The official documentation literally says not to use it in production.&lt;/p>
&lt;p>&lt;strong>LiteLLM already implements it on the client side and does not document it.&lt;/strong> In 1.102.0 there is an authentication mode towards MCP servers that runs the two stages of that grant. There is not a single documentation file that mentions it.&lt;/p>
&lt;h2 id="you-are-here-the-resource-side">You are here: the resource side&lt;/h2>
&lt;p>The previous post walked through the identity provider. This one walks through what sits on the other side of the token, which is where the MCP specification puts almost all its normative obligations.&lt;/p>
&lt;p>The natural reading order is &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows up&lt;/a> for the basic setup, &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a> for the identity piece, and this one for the missing layer. The MCP gateway seen from the inside is &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in its own post&lt;/a>.&lt;/p>
&lt;h2 id="the-analogy-the-visa-and-the-border-checkpoint">The analogy: the visa and the border checkpoint&lt;/h2>
&lt;p>A consulate issues visas. A border post checks them. They are two different offices, and the classic mistake consists of assuming that because the visa is genuine it is good for entering through any door.&lt;/p>
&lt;p>The visa has written on it which country it is good for. That is the token&amp;rsquo;s audience, and here the problem appears: the consulate we work with does not know how to write the destination the traveller asks for, because it does not understand that field on the form. It knows how to write a destination if asked for it under a different name, by way of a pre-agreed stamp, which is the workaround of scopes and the audience mapper.&lt;/p>
&lt;p>And then there is the other half, the one almost nobody builds. The border post has to exist, it has to announce where it is and from which consulates it accepts visas, and it has to read the destination written on the visa before letting anyone through. If the border post limits itself to checking that the stamp is genuine and does not look at the destination, any valid visa from the same consulate is good for entering. That is exactly what the two official SDKs do by default.&lt;/p>
&lt;h2 id="part-1-the-split-of-obligations">Part 1. The split of obligations&lt;/h2>
&lt;p>From revision 2026-07-28, with the normative words as they are:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Obligation&lt;/th>
&lt;th>Whose&lt;/th>
&lt;th>Status with Keycloak&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Publish protected resource metadata (RFC 9728)&lt;/td>
&lt;td>MCP server, &lt;strong>MUST&lt;/strong>&lt;/td>
&lt;td>Out of its scope, by design&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Send the resource indicator (RFC 8707) in authorisation and in token&lt;/td>
&lt;td>Client, &lt;strong>MUST&lt;/strong>, even if the authorisation server does not support it&lt;/td>
&lt;td>It does not understand it&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Validate that the token was issued for oneself&lt;/td>
&lt;td>MCP server, &lt;strong>MUST&lt;/strong>&lt;/td>
&lt;td>The resource&amp;rsquo;s responsibility&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Not accept or relay other tokens&lt;/td>
&lt;td>MCP server, &lt;strong>MUST NOT&lt;/strong>&lt;/td>
&lt;td>The resource&amp;rsquo;s responsibility&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Validate the issuer of the authorisation response (RFC 9207)&lt;/td>
&lt;td>Client, &lt;strong>MUST&lt;/strong>; authorisation server &lt;strong>SHOULD&lt;/strong> emit it&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Consent for each dynamically registered client&lt;/td>
&lt;td>MCP proxy with a static identifier, &lt;strong>MUST&lt;/strong>&lt;/td>
&lt;td>Configurable&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The most confusing part of that split is the second line. The specification obliges the client to send the parameter &lt;strong>regardless of whether the authorisation server supports it&lt;/strong>. With Keycloak, that parameter is lost: the official documentation says it cannot recognise it, and the standard behaviour towards an unknown parameter is to ignore it. The client complies, the token comes out, and what does not come out is the correct audience. The failure appears later and somewhere else, which is the worst kind of failure.&lt;/p>
&lt;p>It is worth knowing where that work stands. The matter is issue 14355 of the project, open, with a milestone in 26.8.0. There was a complete implementation in pull request 35711, with a mapper of its own and an extension point for resolving resources, which &lt;strong>went back to draft in October 2025&lt;/strong> because of a total lack of tests and because it was decided to redo it in phases, starting with a single resource per client. In March 2026 a new issue was opened for experimental support, also with a milestone in 26.8.0 and with no associated pull request. In 26.7.x there is no feature flag that enables it.&lt;/p>
&lt;h3 id="what-is-new-in-the-revision-and-affects-the-setup">What is new in the revision and affects the setup&lt;/h3>
&lt;p>Four things changed with respect to the previous revision and are worth collecting:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>RFC 9207.&lt;/strong> A new section on validating the authorisation response. The client must record the issuer from the validated metadata document, and the comparison is literal: you are not allowed to normalise case, elide the default port, add or remove the trailing slash or re-encode characters before comparing.&lt;/li>
&lt;li>&lt;strong>Offline tokens leave the catalogue.&lt;/strong> A new section on refresh tokens: MCP servers &lt;strong>should not&lt;/strong> include the corresponding scope either in the challenge header or in the supported scopes of their metadata.&lt;/li>
&lt;li>&lt;strong>Dynamic registration becomes deprecated.&lt;/strong> It goes from optional to optional and deprecated, retained for compatibility with authorisation servers that do not support client identifier metadata documents. And a new requirement is added to registration: the application type is mandatory, and a client must not reuse credentials from another authorisation server, it has to register again.&lt;/li>
&lt;li>&lt;strong>Step-up elevation is rewritten.&lt;/strong> The menu of three server strategies disappears and is replaced by two rules: the challenge&amp;rsquo;s scope attribute describes what is needed for the requested resource, with no obligation to include what has already been granted, and scope accumulation becomes the client&amp;rsquo;s responsibility. With a new requirement for the server: it &lt;strong>must&lt;/strong> take scope hierarchies into account, where a broad one implies the narrow ones.&lt;/li>
&lt;/ul>
&lt;h2 id="part-2-what-the-sdks-give-and-what-they-do-not">Part 2. What the SDKs give, and what they do not&lt;/h2>
&lt;p>Here is the uncomfortable part, and it is the main reason for writing this post. Verified by reading the code of the Python SDK and of the TypeScript 2.0.0-alpha one.&lt;/p>
&lt;h3 id="protected-resource-metadata">Protected resource metadata&lt;/h3>
&lt;p>&lt;strong>In Python the complete model exists and it mounts on its own.&lt;/strong> The metadata class carries the RFC fields: resource, authorisation servers with a minimum of one, key set URL, supported scopes, bearer methods with a default value of header, name, documentation, policy, terms, and the client certificate and DPoP fields. The handler serves the document with a one-hour cache directive, and the URL is built by inserting the well-known path in front of the resource path, as the RFC requires.&lt;/p>
&lt;p>As soon as the resource server URL is configured, the route appears with no further work. &lt;strong>But what that automatic route publishes is degraded in three points&lt;/strong>: it passes a single authorisation server, it takes as its scope catalogue the ones the middleware requires (which are not the same as the ones the resource supports), and it passes neither the name nor the documentation, which come out null. To publish a complete document you have to call the route creation function by hand.&lt;/p>
&lt;p>&lt;strong>In TypeScript it exists and does not mount on its own.&lt;/strong> The function that builds the document emits &lt;strong>only five fields&lt;/strong>: resource, authorisation servers, supported scopes, name and documentation. &lt;strong>It does not emit the supported bearer methods&lt;/strong>, nor the key set URL, nor anything about DPoP. And serving it is explicit: either you call the response function from your own handler, or you mount the metadata router. There is no point in the SDK that does it on its own.&lt;/p>
&lt;h3 id="audience-validation">Audience validation&lt;/h3>
&lt;p>This is the one to fix on day one.&lt;/p>
&lt;p>&lt;strong>In Python it comes disabled.&lt;/strong> The token verifier is a single-method protocol, that is to say a hole the implementer fills. The access token&amp;rsquo;s resource field is the indicator you set yourself. And the comparison only happens if you ask for it: there is a function that normalises as a URL and ignores the trailing slash, but the resource server URL reaches it empty unless the validation flag is enabled.&lt;/p>
&lt;p>The detail to read twice is in the configuration: &lt;strong>if the resource URL is set and the flag is not, the SDK raises a deprecation warning and behaves as if the flag were false&lt;/strong>. The docstring says version 3 will set the default to true. Until then, a Python server configured with authorisation accepts tokens issued for another resource, unless the implementer enables the flag or validates the audience inside their own verifier.&lt;/p>
&lt;p>&lt;strong>In TypeScript it is not validated at all.&lt;/strong> Bearer token verification does exactly three things: strip the prefix, check the required scopes, and require that the expiry is present and not past. A search for audience or key set across the server and middleware packages returns no validation. It is all left in the hands of whichever verifier you plug in.&lt;/p>
&lt;p>&lt;strong>Neither of the two ships a signature verifier with public keys.&lt;/strong> In Python the only example is introspection against the authorisation server, and its audience check sits behind a flag that also comes out false. In TypeScript the interface is empty. That is: the component that validates the token, which is the one holding up the most important normative requirement of the specification, is your own code in both cases.&lt;/p>
&lt;h3 id="the-authentication-challenge">The authentication challenge&lt;/h3>
&lt;p>&lt;strong>Python&lt;/strong> builds the challenge with the error and its description, and adds the metadata URL only if it is configured. It returns 401 with invalid token and 403 with insufficient scope, both from the same function, so both carry the metadata URL. &lt;strong>What it never emits is the scope parameter&lt;/strong>, which revision 2026-07-28 asks to be included, and which is exactly what the client needs in order to know what to ask for.&lt;/p>
&lt;p>&lt;strong>TypeScript does emit the scope&lt;/strong> when there are required scopes, in addition to the metadata URL, and it correctly maps invalid token to 401 and insufficient scope to 403.&lt;/p>
&lt;p>That is, each SDK gets a different half right. The Python one validates better and warns worse; the TypeScript one warns better and does not validate.&lt;/p>
&lt;h3 id="scopes-which-are-not-per-tool">Scopes, which are not per tool&lt;/h3>
&lt;p>In both SDKs scopes are &lt;strong>a static list at the transport mounting level&lt;/strong>, not per tool. There is no per-tool scope declaration and no step-up elevation help on the server side. Emitting a 403 with the specific scope that call requires, which is what the specification describes, is your own code.&lt;/p>
&lt;p>That is the origin of almost all the fine-grained authorisation you have to build, and it is why there is a Part 6.&lt;/p>
&lt;h3 id="which-revision-each-one-announces">Which revision each one announces&lt;/h3>
&lt;p>&lt;strong>Python is already on 2026-07-28.&lt;/strong> The revision appears in the known versions and in the list of modern versions, described as the ones that use the stateless envelope per request. The server discovery method is registered with a default handler, and the method and name headers are validated against the body.&lt;/p>
&lt;p>&lt;strong>TypeScript has it, but in a separate list.&lt;/strong> The latest protocol version constant is still 2025-11-25, because that list is only the one for the initial handshake. The modern era lives in a separate module, with a comment explaining the reason: keeping them deliberately separate so that adding a revision there never leaks a modern version string into a 2025-era handshake.&lt;/p>
&lt;p>And it is worth recalling the mismatch that already came up &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway post&lt;/a>: LiteLLM 1.102.0 still announces 2025-06-18.&lt;/p>
&lt;h2 id="part-3-building-the-protected-resource">Part 3. Building the protected resource&lt;/h2>
&lt;p>With the above, the list of what you have to write is short and concrete.&lt;/p>
&lt;p>&lt;strong>1. A token verifier that validates the signature and the audience.&lt;/strong> Against the realm&amp;rsquo;s key set, checking issuer, expiry and that the audience contains the MCP server&amp;rsquo;s canonical URL and &lt;strong>only&lt;/strong> things that concern it. Here the side effect of the Keycloak workaround comes in: if a client asks for two scopes from two different resources, out comes a token with two audiences, because each mapper contributes its own to the array. There is no client policy condition or executor in 26.7 that limits the number of audiences per token. The practical defence is the resource&amp;rsquo;s: reject tokens whose audience includes other resources, instead of merely checking that its own is present.&lt;/p>
&lt;p>&lt;strong>2. The complete metadata document&lt;/strong>, with all the authorisation servers you accept, the real catalogue of scopes the resource understands, and the name and documentation filled in. In Python, mounting the route by hand instead of leaving the automatic one. In TypeScript, mounting it, full stop.&lt;/p>
&lt;p>&lt;strong>3. The authentication challenge with the scope.&lt;/strong> In Python you have to add it, because the SDK does not emit it. And you have to take scope hierarchies into account, which is a new requirement of the revision.&lt;/p>
&lt;p>&lt;strong>4. The decision between local validation and introspection.&lt;/strong> Validating the signature locally costs no network, but &lt;strong>revocation does not take effect until the token expires&lt;/strong>: there are no revocation lists for signed tokens. Introspection costs a round trip per request and only confidential clients can invoke it. The reasonable combination is a short token life and introspection on the operations that change state.&lt;/p>
&lt;p>&lt;strong>5. Binding state to the user.&lt;/strong> The new revision is explicit about this, because with protocol sessions gone, state is carried in handlers that travel as an ordinary tool argument. Servers &lt;strong>must&lt;/strong> verify every incoming request and &lt;strong>must not&lt;/strong> treat possession of a handler as authentication; and they &lt;strong>should&lt;/strong> bind the handler to the user on the server side, for example storing the state under a key that combines the user identifier derived from the verified token with the handler, and reject the handler if someone else presents it. It is a change in the way of working compared with the sessions of before.&lt;/p>
&lt;h2 id="part-4-the-hop-from-the-gateway-to-the-mcp-server">Part 4. The hop from the gateway to the MCP server&lt;/h2>
&lt;p>The real shape in an inference platform is not client against MCP server: it is client against gateway, and gateway against MCP server. That second hop is where it is decided whether the architecture is correct.&lt;/p>
&lt;p>The specification leaves no room. The MCP server &lt;strong>must not&lt;/strong> accept or relay tokens that were not issued for it. And if it makes requests to upstream APIs, it may act as an OAuth client of theirs, but &lt;strong>the token it uses there is a different token, issued by the upstream authorisation server&lt;/strong>, and it must not forward the one it received.&lt;/p>
&lt;p>The standard mechanism for that is token exchange, and here you need to know three Keycloak details.&lt;/p>
&lt;p>&lt;strong>First, the permission model changed.&lt;/strong> The old version required fine-grained administration permissions and an explicit exchange authorisation on the target client. The standard version, supported since 26.2, does not require them: it is enough for the requesting client to be confidential and to have the corresponding switch enabled. In exchange there is a surprising condition: &lt;strong>the subject token has to carry the requesting client in its audience&lt;/strong>, unless it exchanges its own token. That is, the gateway needs to appear in the audience of the user token, which is achieved with an audience mapper in a default scope of the gateway. Only then can it narrow the audience to that of the specific MCP server.&lt;/p>
&lt;p>&lt;strong>Second, the audience is a client identifier, not a resource URL.&lt;/strong> The parameter filters audiences, that is to say it narrows them, which is what you want. But it takes the identifier of a client registered in the realm. The practical consequence is that &lt;strong>the MCP server has to be registered as a client, and its identifier should be its canonical URL&lt;/strong>, so that the resulting audience matches what the resource validates. It is a naming trick, and it has to be documented for whoever comes after. The documentation itself acknowledges it: token exchange does not yet support the resource parameter.&lt;/p>
&lt;p>&lt;strong>Third, the subject token type is limited.&lt;/strong> The standard version only accepts access tokens as subject.&lt;/p>
&lt;p>There is also a new experimental feature in 26.7 that adds a parameterised scope type for validating whether the requesting user is authorised to act on behalf of another. Interesting for delegation, but experimental.&lt;/p>
&lt;h3 id="what-the-gateway-does-today">What the gateway does today&lt;/h3>
&lt;p>In LiteLLM 1.102.0 the authentication modes towards MCP servers number twelve, with a default value of none. The ones that matter for this discussion are four.&lt;/p>
&lt;p>&lt;code>oauth2_token_exchange&lt;/code> implements the standard exchange. It sends the correct grant type, the subject token and its type, and &lt;strong>the audience, never the resource&lt;/strong>. The omission is deliberate according to the code itself: fabricating a target risks an invalid target error. It caches the resulting token under a key that combines the subject token with the whole configuration, and with a lifetime equal to the token&amp;rsquo;s minus one minute. And it &lt;strong>blocks, never degrades&lt;/strong>: with no incoming token it returns 401, which the edge turns into a challenge with the resource metadata URL; a rejection from the identity provider returns 401; a gateway configuration error returns 500; a transport failure, 503.&lt;/p>
&lt;p>&lt;code>true_passthrough&lt;/code> and &lt;code>oauth_delegate&lt;/code> forward the client&amp;rsquo;s authorisation header as is. That is token passthrough, by the name the specification gives it, and its legitimate use is narrow: when the token the client presents was already issued for the target MCP server and the gateway is a mere transport. Outside that case, it is non-compliant.&lt;/p>
&lt;p>&lt;code>oauth2_id_jag&lt;/code> is the surprise, and it deserves a part of its own.&lt;/p>
&lt;h2 id="part-5-the-enterprise-extension-which-changes-the-picture">Part 5. The enterprise extension, which changes the picture&lt;/h2>
&lt;p>In June 2026 the MCP project published an authorisation extension that solves a different problem from the one the standard flow solves, and which in an organisation with an identity provider of its own is the real problem.&lt;/p>
&lt;p>The normal flow is per user: each employee authorises each client against each MCP server. It works for consumer applications and it does not work in a company, because onboarding a person requires authorising dozens of services one by one and offboarding requires revoking them one by one.&lt;/p>
&lt;p>&lt;strong>The &lt;code>io.modelcontextprotocol/enterprise-managed-authorization&lt;/code> extension inverts that.&lt;/strong> It is in the stable specifications directory of the extensions repository, it comes from SEP-990, and the flow is this:&lt;/p>
&lt;ol>
&lt;li>The MCP client authenticates the user against the corporate identity provider through the normal flow, and &lt;strong>stores the identity assertion&lt;/strong>, which may be an OpenID identity token or a SAML assertion.&lt;/li>
&lt;li>When the server indicates that enterprise-managed authorisation is needed, the client &lt;strong>exchanges that assertion at the corporate provider for an authorisation grant based on an identity assertion&lt;/strong>. The provider evaluates the organisation&amp;rsquo;s policy there: group membership, roles, conditional access.&lt;/li>
&lt;li>The client presents that grant to the MCP authorisation server and obtains an access token.&lt;/li>
&lt;li>The sentence that defines the extension, verbatim: &lt;strong>the user is not redirected to the authorisation endpoint of the MCP authorisation server&lt;/strong>.&lt;/li>
&lt;/ol>
&lt;p>The MCP authorisation server validates the signature against the corporate provider&amp;rsquo;s key set, plus audience, issuer and expiry, and uses the subject as the user&amp;rsquo;s stable identifier, with the email as a fallback for linking earlier accounts.&lt;/p>
&lt;p>What that buys is exactly what a compliance file asks for: policy in a single place, an auditable decision at the identity provider, and &lt;strong>centralised revocation that takes effect across all clients at once&lt;/strong>. The employee who loses access stops receiving grants, without touching any server.&lt;/p>
&lt;p>The underlying standard is a draft from the IETF&amp;rsquo;s OAuth working group, under the name of JWT identity assertion authorisation grant, revision 04 of May 2026, signed by people from Okta, Ping Identity and an independent author. It profiles identity chaining across trust domains by combining token exchange with the JWT profile.&lt;/p>
&lt;h3 id="and-here-comes-the-problem">And here comes the problem&lt;/h3>
&lt;p>&lt;strong>Keycloak implements it only halfway.&lt;/strong> It has its own documentation page, behind a feature flag. And three limitations you need in front of you before designing anything:&lt;/p>
&lt;ul>
&lt;li>It only acts as receiver. It accepts assertions issued by an external provider and issues local tokens. Native support for acting as issuer, the documentation says, is not yet fully implemented. In the extension&amp;rsquo;s flow, the issuer is the corporate provider: if that provider is Keycloak, the missing piece is exactly the one you need.&lt;/li>
&lt;li>It is based on draft 01 while the working group is on 04.&lt;/li>
&lt;li>The official documentation says not to use it in production.&lt;/li>
&lt;/ul>
&lt;p>The asymmetry is striking and worth pointing out: Keycloak does not support a standard published eight years ago such as resource indicators, and does partially support a draft from a few months back.&lt;/p>
&lt;h3 id="what-is-already-in-the-gateway-undocumented">What is already in the gateway, undocumented&lt;/h3>
&lt;p>In LiteLLM 1.102.0, the &lt;code>oauth2_id_jag&lt;/code> mode runs the two stages of the flow. In the first it asks the corporate provider for a token exchange with the requested token type specific to this grant, sending the user&amp;rsquo;s identity token as subject and, optionally, audience, &lt;strong>resource&lt;/strong> and scope. In the second it presents the resulting assertion to the resource&amp;rsquo;s authorisation server with the JWT bearer grant type. In both the gateway authenticates as a client with a JWT signed by private key.&lt;/p>
&lt;p>Two operational warnings that come out of the code and are nowhere else:&lt;/p>
&lt;ul>
&lt;li>The subject comes from the caller&amp;rsquo;s identity token, or from the one captured at login. And only the generic OIDC provider captures assertions: with Google, Microsoft or SAML there is none, and &lt;strong>all users fail&lt;/strong>. The code raises a warning when loading the configuration.&lt;/li>
&lt;li>A badly configured server refuses with a 500 instead of falling back to the static credential. It is the right decision, and it is worth knowing before it happens.&lt;/li>
&lt;/ul>
&lt;p>And the main warning: &lt;strong>there is no documentation&lt;/strong>. Not a single file in the repository that mentions this mode. Anyone using it is reading code, like this post.&lt;/p>
&lt;h2 id="part-6-per-tool-authorisation">Part 6. Per-tool authorisation&lt;/h2>
&lt;p>None of the previous pieces answers the question the business ends up asking: whether this person may call this tool with these arguments. The identity provider gives roles and scopes, and with that you do not model a graph of relations, as was already argued &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">in the previous post&lt;/a>.&lt;/p>
&lt;p>What is published today, with sources:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>OpenFGA&lt;/strong> is the only one with a dedicated product guide, updated on 9 September 2026. The pattern is a tool type with an invocation relation, checked on each call, and a listing query so the client only sees the tools it may invoke. It is a pattern and a modelling language, not a reference implementation.&lt;/li>
&lt;li>&lt;strong>Pomerium&lt;/strong> ships a per-tool policy criterion, with exact, prefix, suffix and list matching.&lt;/li>
&lt;li>&lt;strong>Kong&lt;/strong> offers it as a product, with per-tool control lists.&lt;/li>
&lt;li>&lt;strong>Traefik Hub&lt;/strong> goes as far as parameter-level restrictions, which is the finest published grain.&lt;/li>
&lt;li>&lt;strong>Envoy&lt;/strong> has an MCP filter that extracts protocol attributes for fine-grained access control, declared to be in active development and with no OAuth of its own: the hook is external authorisation towards a policy engine.&lt;/li>
&lt;/ul>
&lt;p>The lowest-friction option in a platform that already has a service mesh is external authorisation against a policy engine. The most expressive one, the relations model.&lt;/p>
&lt;h2 id="part-7-what-still-has-no-standard">Part 7. What still has no standard&lt;/h2>
&lt;p>Two gaps the specification does not cover and that have to be closed from outside.&lt;/p>
&lt;p>&lt;strong>Tool definition change.&lt;/strong> Neither revision 2026-07-28 nor the SDKs pin a hash of the description or of the input schema. The best practices page covers confused deputy, token passthrough, server-side request forgery in discovery, state handler hijacking, server confusion and validation of the authorisation URL scheme, but &lt;strong>there is no integrity or pinning requirement&lt;/strong>. The tool that does it is the Invariant Labs scanner, under the Apache 2.0 licence, which pins hashes to detect definition change after approval and also ships a proxy mode with guardrails. It already came up &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway post&lt;/a> and it remains the only answer.&lt;/p>
&lt;p>&lt;strong>The public guidance that does get into this&lt;/strong> is the NSA&amp;rsquo;s on MCP security design considerations, from May 2026, done with Carnegie Mellon&amp;rsquo;s software engineering institute. Two of its observations are directly actionable: that authorisation in MCP is optional and that the specification imposes no requirement on token lifecycle management, so expiry and rotation are left in the organisation&amp;rsquo;s hands; and the warning against dynamic tool discovery with no origin verification and no authorisation checks, which is the most direct acknowledgement of the risk in a state guidance document.&lt;/p>
&lt;h2 id="part-8-who-covers-what">Part 8. Who covers what&lt;/h2>
&lt;p>With all of the above, the decision table for the layer you put in front of the MCP server:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Publishes resource metadata&lt;/th>
&lt;th>Validates audience&lt;/th>
&lt;th>Token exchange&lt;/th>
&lt;th>Per-tool authorisation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Python SDK, automatic route&lt;/td>
&lt;td>Yes, degraded&lt;/td>
&lt;td>&lt;strong>Not by default&lt;/strong>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TypeScript SDK&lt;/td>
&lt;td>Does not mount on its own&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Traefik Hub (commercial)&lt;/td>
&lt;td>Yes, automatic&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, down to parameter&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kong, OAuth plugin for MCP&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, per-tool lists&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pomerium&lt;/td>
&lt;td>Partial&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, per-tool criterion&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>agentgateway (Solo.io)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes, by JWT policy&lt;/td>
&lt;td>Yes, including the assertion grant&lt;/td>
&lt;td>Yes, with expressions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>mcp-context-forge (IBM)&lt;/td>
&lt;td>Not in release notes&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, since 1.0.6&lt;/td>
&lt;td>Yes, per-role control&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Envoy, MCP filter&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Via external authorisation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM 1.102.0&lt;/td>
&lt;td>Yes, and authorisation server metadata too&lt;/td>
&lt;td>&lt;strong>Not on the incoming JWT&lt;/strong>&lt;/td>
&lt;td>Yes, and the assertion grant&lt;/td>
&lt;td>Yes, per-key and per-team permissions&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two warnings about that table. The Kong plugin requires a minimum version of 3.12, is in technical preview and needs a licence for its AI edition; it also introduces a breaking change in 3.13, which starts treating all traffic as MCP in order to close a possible authentication bypass. And on oauth2-proxy I found no primary documentation either for or against protected resource mode: I am not claiming it does not have it, I am claiming it is not documented where I could look.&lt;/p>
&lt;h2 id="reference-configuration">Reference configuration&lt;/h2>
&lt;p>&lt;strong>Keycloak: the per-resource scope, optional, with its audience.&lt;/strong> That it is optional and not default is the key piece, because it is what makes the client have to ask for it explicitly and makes it work as a substitute for the resource indicator:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kcadm.sh create client-scopes -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>mcp:inventario -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;attributes.&amp;#34;include.in.token.scope&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create &lt;span class="s2">&amp;#34;client-scopes/&amp;lt;id&amp;gt;/protocol-mappers/models&amp;#34;&lt;/span> -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>aud-mcp-inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocolMapper&lt;/span>&lt;span class="o">=&lt;/span>oidc-audience-mapper &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;included.custom.audience&amp;#34;=https://mcp.ejemplo.es/inventario&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;access.token.claim&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the MCP server registered as a client &lt;strong>with its canonical URL as identifier&lt;/strong>, so that token exchange produces the right audience:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kcadm.sh create clients -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">clientId&lt;/span>&lt;span class="o">=&lt;/span>https://mcp.ejemplo.es/inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">enabled&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> -s &lt;span class="nv">publicClient&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">false&lt;/span> -s &lt;span class="nv">consentRequired&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>The gateway, with token exchange instead of token passthrough:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">mcp_servers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">inventario&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">url&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://mcp.ejemplo.es/inventario&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">transport&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;http&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">auth_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;oauth2_token_exchange&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">client_id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;litellm-gateway&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">client_secret&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/GW_SECRET&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">token_exchange_endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://sso.ejemplo.es/realms/plataforma/protocol/openid-connect/token&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">audience&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://mcp.ejemplo.es/inventario&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">subject_token_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;urn:ietf:params:oauth:token-type:access_token&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">token_exchange_profile&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;rfc8693&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>The resource verifier, which is what nobody hands you ready-made.&lt;/strong> The essential bit is the last check, the one that rejects other resources&amp;rsquo; audiences instead of settling for finding its own:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">RECURSO&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;https://mcp.ejemplo.es/inventario&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">verificar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">AccessToken&lt;/span> &lt;span class="o">|&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">claims&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">jwt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decode&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">jwks&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">algorithms&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;RS256&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">audience&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># validates that I am there&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">issuer&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ISSUER&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">aud&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">claims&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;aud&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">aud&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">aud&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">aud&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="n">aud&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># and that nobody else is: the scopes workaround allows&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># tokens with two audiences, and that reopens the confused deputy&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nb">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">aud&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">CLIENTE_GATEWAY&lt;/span>&lt;span class="p">}:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">AccessToken&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">client_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">claims&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;azp&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">scopes&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">claims&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;scope&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">split&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">expires_at&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">claims&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;exp&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">resource&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And when mounting the server, the flag that does not come set:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">auth&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AuthSettings&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">issuer_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ISSUER&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">resource_server_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">validate_token_resource&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># without this there is only a warning&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">required_scopes&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;mcp:inventario&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Enable audience validation in the Python SDK, or write it into the verifier if it is TypeScript. Without that, the central normative requirement is not met.&lt;/li>
&lt;li>Reject tokens with other resources&amp;rsquo; audiences, not just check that your own is present.&lt;/li>
&lt;li>Mount the metadata document by hand, with all the authorisation servers and the real catalogue of scopes.&lt;/li>
&lt;li>Add the scope parameter to the challenge, in Python, and account for scope hierarchies.&lt;/li>
&lt;li>Register each MCP server as a Keycloak client with its canonical URL as identifier, so that token exchange produces the right audience.&lt;/li>
&lt;li>Replace any token passthrough mode with token exchange, except the narrow case where the token was already issued for the target.&lt;/li>
&lt;li>Check that the gateway appears in the audience of the user token, or the exchange will fail.&lt;/li>
&lt;li>Bind state handlers to the user on the server side, and never treat them as authentication.&lt;/li>
&lt;li>Decide between local validation and introspection knowing that the first delays revocation until expiry, and shorten the token life accordingly.&lt;/li>
&lt;li>Pin a hash of the description and the input schema from outside, because the specification does not cover it.&lt;/li>
&lt;li>Put a policy engine in for per-tool authorisation, and do not try to model it with roles.&lt;/li>
&lt;li>If you are considering the enterprise extension, check first who issues the grant: Keycloak today only knows how to receive it, in experimental and against an earlier draft.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>The Python SDK mounts the metadata document on its own, and that is why it looks solved.&lt;/strong> What it publishes carries a single authorisation server and no name and no documentation.&lt;/p>
&lt;p>&lt;strong>The audience validation deprecation warning does not enable anything.&lt;/strong> It warns and carries on behaving as disabled.&lt;/p>
&lt;p>&lt;strong>The TypeScript SDK does not validate the audience at all.&lt;/strong> It is not configuration, it is that there is no code for it.&lt;/p>
&lt;p>&lt;strong>Python&amp;rsquo;s challenge does not carry the scope&lt;/strong>, so a client that receives a 403 does not know what to ask for.&lt;/p>
&lt;p>&lt;strong>The scopes workaround allows two audiences in the same token.&lt;/strong> Each mapper contributes its own, and there is no client policy that limits it. The defence is in the resource.&lt;/p>
&lt;p>&lt;strong>The token exchange audience is a client identifier, not a URL.&lt;/strong> If the MCP server is not registered with its URL as identifier, the resulting audience will not match what the resource validates.&lt;/p>
&lt;p>&lt;strong>Token exchange requires the gateway to be in the audience of the user token.&lt;/strong> It is the condition that wastes the most time the first time round.&lt;/p>
&lt;p>&lt;strong>Dynamic client registration is deprecated&lt;/strong> as of this revision, and the substitute, client identifier metadata documents, is experimental in Keycloak and has an open bug with documents carrying unknown fields, which blocks login with real clients.&lt;/p>
&lt;p>&lt;strong>LiteLLM&amp;rsquo;s identity assertion grant depends on the generic OIDC provider.&lt;/strong> With Google, Microsoft or SAML no assertion is captured and all users fail.&lt;/p>
&lt;p>&lt;strong>Step-up elevation is no longer the server&amp;rsquo;s responsibility.&lt;/strong> Scope accumulation moved to the client in this revision.&lt;/p>
&lt;p>&lt;strong>The authentication elevation signal goes in a 401, not a 403.&lt;/strong> Insufficient scope is 403; asking for a higher authentication level is a different thing and a different code.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The summary of this post fits in one sentence: the identity provider is the cheap half of the problem, and the expensive half is the protected resource, which no SDK hands you ready-made and which in the two official ones comes with audience validation disabled or absent.&lt;/p>
&lt;p>From that comes an order of work. First the token verifier, with the audience validated in both directions, because without it everything else is decoration. Second, the complete metadata document, mounted by hand. Third, replacing any token passthrough with exchange, with the MCP server registered with its URL as identifier. Fourth, a policy engine for what roles do not model.&lt;/p>
&lt;p>And a scheduling decision worth taking with the information in front of you. The enterprise-managed authorisation extension is the right route for an organisation with a provider of its own, it is stable and it has servers in production behind it. What is not ready is Keycloak as issuer. Anyone who wants that flow today has two honest options: delegate the mechanics to a gateway that implements it, or wait. Putting it into production with a feature the official documentation marks as experimental and expressly advises against is not one of them.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a>: the identity piece from the inside, and the gap this post closes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows up: giving it authentication with Keycloak&lt;/a>: the basic setup, predating this revision of the specification.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the second front door, its permissions, its cost and its lack of audit logging.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>: the audience validation that does not happen on the gateway side either.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">The model&amp;rsquo;s chain of trust (4 of 4)&lt;/a>: workload identity with attestation, the other plane.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside and its observability&lt;/a>: the protocol and its primitives.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents&lt;/a>: the network isolation everything above presupposes.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Especificación MCP, revisión 2026-07-28: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization">autorización&lt;/a>, &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration">registro de clientes&lt;/a> y &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices">buenas prácticas de seguridad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization">Extensión de autorización gestionada por la empresa&lt;/a> y su &lt;a href="https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization.mdx">especificación estable&lt;/a>, del SEP-990. Anuncio de 18 de junio de 2026.&lt;/li>
&lt;li>&lt;a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/">draft-ietf-oauth-identity-assertion-authz-grant&lt;/a>, revisión 04 de 21 de mayo de 2026, grupo de trabajo OAuth del IETF.&lt;/li>
&lt;li>Código de los SDK oficiales de MCP: &lt;code>python-sdk&lt;/code> y &lt;code>typescript-sdk&lt;/code> 2.0.0-alfa, clonados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/securing-apps/mcp-authz-server">Integración de Keycloak con Model Context Protocol&lt;/a>, &lt;a href="https://www.keycloak.org/securing-apps/token-exchange">intercambio de tokens&lt;/a> y &lt;a href="https://www.keycloak.org/securing-apps/identity-assertion-jwt-authorization-grant">concesión de autorización por aserción de identidad&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>Incidencias de Keycloak &lt;a href="https://github.com/keycloak/keycloak/issues/14355">14355&lt;/a>, &lt;a href="https://github.com/keycloak/keycloak/issues/47117">47117&lt;/a> y &lt;a href="https://github.com/keycloak/keycloak/issues/51039">51039&lt;/a>, y petición de cambios &lt;a href="https://github.com/keycloak/keycloak/pull/35711">35711&lt;/a>.&lt;/li>
&lt;li>Código de LiteLLM 1.102.0, commit &lt;code>9071ca50&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://media.defense.gov/2026/Jun/02/2003943289/-1/-1/0/CSI_MCP_SECURITY.PDF">NSA y SEI, Model Context Protocol: Security Design Considerations&lt;/a>, mayo de 2026.&lt;/li>
&lt;li>&lt;a href="https://openfga.dev/docs/use-cases/mcp-server-authorization">OpenFGA, autorización de servidores MCP&lt;/a>, actualizado el 9 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://invariantlabs-ai.github.io/docs/mcp-scan/">mcp-scan de Invariant Labs&lt;/a>, Apache 2.0.&lt;/li>
&lt;li>Documentación de &lt;a href="https://docs.solo.io/agentgateway/2.3.x/mcp/auth/setup/">agentgateway&lt;/a>, &lt;a href="https://doc.traefik.io/traefik-hub/mcp-gateway/mcp">Traefik Hub&lt;/a>, &lt;a href="https://developer.konghq.com/plugins/ai-mcp-oauth2/">Kong&lt;/a> y &lt;a href="https://www.pomerium.com/docs/capabilities/mcp">Pomerium&lt;/a>, consultadas el 12 de septiembre de 2026.&lt;/li>
&lt;/ul></description></item><item><title>Keycloak in an AI platform: where it fits, what it solves and the two standards MCP requires and it does not implement</title><link>https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/</link><pubDate>Sat, 12 Sep 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/</guid><description>&lt;blockquote>
&lt;p>This post opens the identity block of the operations track. &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The previous one&lt;/a> covered the gateway side: the JWT that does not validate audience, the user who never reaches the trace. This one covers the piece on the other side. Verified against Keycloak 26.7.3, revision 2026-07-28 of the MCP specification and the text of Royal Decree 311/2022 in the BOE.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Keycloak 26.7.3 was released on 31 August 2026&lt;/strong> and is still incubating at the CNCF, which it entered in April 2023. The cadence is four minor releases a year and support in practice covers a single branch: the twenty security bugs fixed in 26.7.3 were not backported to the 26.4, 26.5 or 26.6 branches at the time of release.&lt;/p>
&lt;p>&lt;strong>The Quarkus-based distribution separates build and runtime configuration.&lt;/strong> What is set in the build phase is frozen into the image, and changing it at startup forces a rebuild. In containers, the pattern is to build into the image and start with the optimised flag.&lt;/p>
&lt;p>&lt;strong>Storage is relational and only relational.&lt;/strong> The alternative engine that was in development was withdrawn in version 25.0. Since 26.0 sessions are persisted to the database by default, which changes the load profile: the dominant cost in the official benchmarks is database CPU.&lt;/p>
&lt;p>&lt;strong>Organisations solves multi-tenancy inside a realm&lt;/strong>, fully supported since 26.0. It groups members, associates email domains and links identity providers per organisation. It is the alternative to replicating realms per customer.&lt;/p>
&lt;p>&lt;strong>The measured bottleneck is password hashing.&lt;/strong> Since 25.0 the default algorithm outside FIPS environments is Argon2, with around 7 MB of memory per operation. The official sizing guide gives one vCPU for every 15 password logins per second, against 120 per second for client credentials and for refreshes.&lt;/p>
&lt;p>&lt;strong>Keycloak does not implement two standards the MCP specification marks as mandatory.&lt;/strong> Protected resource metadata belongs to the MCP server, not the authorisation server, so there is nothing to hold against it there. Resource indicators do belong to it and are &lt;strong>not supported&lt;/strong>. Keycloak&amp;rsquo;s own documentation declares partial conformance with the last three MCP revisions for that reason. The practical substitute is scopes plus an audience mapper.&lt;/p>
&lt;p>&lt;strong>There is a recent critical bug worth looking at today.&lt;/strong> &lt;code>CVE-2026-18963&lt;/code>, scored 9.1, allowed resetting any user&amp;rsquo;s password without authenticating, administrators included. Fixed in 26.7.2, released on 19 August 2026.&lt;/p>
&lt;h2 id="you-are-here-the-cross-cutting-layer">You are here: the cross-cutting layer&lt;/h2>
&lt;p>Identity is not a stage of the pipeline, it is a layer that crosses all of them. It shows up in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">stack hardening&lt;/a> as one of the seven layers of defence, in &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">the model&amp;rsquo;s chain of trust&lt;/a> as workload identity, in &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">the gateway&lt;/a> as token validation, and in &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">the post about authenticating MCP servers&lt;/a> as authorisation server.&lt;/p>
&lt;p>What was missing was the post about the piece itself, and above all the decision of where to put it and what to expect from it.&lt;/p>
&lt;h2 id="the-analogy-the-civil-registry-and-building-permits">The analogy: the civil registry and building permits&lt;/h2>
&lt;p>A small town hall has a population register, which says who lives there, and it has permits, which say who can do what. They are two different things and it is worth not confusing them, because the register is good for the whole town while a permit is good for one specific job on one specific street.&lt;/p>
&lt;p>Keycloak is the register. It knows who each person is, identifies them reliably, knows which groups they belong to and issues a credential document that expires. What it does not do is decide whether this person can modify this particular file: that is the permit, and it lives in each service.&lt;/p>
&lt;p>Confusing the two produces the most expensive design mistake in platforms of this kind, which consists of putting into the token everything that will need to be decided later. The register document grows, gets handed out everywhere, and when a permission changes you have to wait for everyone&amp;rsquo;s document to expire.&lt;/p>
&lt;p>There is a third element the analogy captures well. A credential document issued for presenting at the tax office should not be good for entering the municipal depot. That is the token&amp;rsquo;s audience, and it is the central subject of the second half of this post.&lt;/p>
&lt;h2 id="part-1-what-keycloak-is-today">Part 1. What Keycloak is today&lt;/h2>
&lt;h3 id="project-status">Project status&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Item&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Latest stable version&lt;/td>
&lt;td>26.7.3, released on 31 August 2026&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Previous ones&lt;/td>
&lt;td>26.7.2 (19 August), 26.7.0 (9 July), 26.6.0 (8 April)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cadence&lt;/td>
&lt;td>four minor releases a year&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CNCF&lt;/td>
&lt;td>incubating since 10 April 2023, not graduated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Support&lt;/td>
&lt;td>the current branch; earlier ones do not receive fixes reliably&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>That last point deserves underlining because it conditions the upgrade policy. The analysis of 26.7.3 shows that its twenty security fixes were not backported to earlier branches at release time. Operating Keycloak means keeping up to date with the current branch, not staying on a &amp;ldquo;stable&amp;rdquo; version for a year.&lt;/p>
&lt;p>Since 26.0 there is a backward compatibility guarantee across minor versions for features marked as fully supported, and breaking changes are opt-in.&lt;/p>
&lt;h3 id="the-internal-architecture-in-five-pieces">The internal architecture in five pieces&lt;/h3>
&lt;p>&lt;strong>Two startup modes.&lt;/strong> The development one enables HTTP, does not resolve the hostname strictly and disables the theme cache. The production one requires a hostname and TLS, or it will not start. Confusing them in production is the first antipattern on the final list.&lt;/p>
&lt;p>&lt;strong>The build phase.&lt;/strong> The build command materialises the optimisations into the image. Build options, such as the database engine, the enabled features or metrics, are frozen. Runtime ones, such as the database password or the hostname, are applied at startup. Changing a build option at startup forces a rebuild with the corresponding latency cost. The correct pattern in containers is to build into the image and start with the optimised flag.&lt;/p>
&lt;p>&lt;strong>Extensions via SPI.&lt;/strong> Authenticators, protocol mappers, user storage, event listeners, hashing algorithms and secret vaults. They are packaged as JARs and resolved in the build phase.&lt;/p>
&lt;p>&lt;strong>Infinispan cache.&lt;/strong> Embedded by default, with database-based discovery as the recommended mechanism. The discovery alternatives via Kubernetes DNS, TCP and UDP are deprecated. There are local caches, with default capacities of 10,000 entries in several of them, and replicated caches for sessions, client sessions, offline sessions, authentication sessions, login failures and action tokens. Remote mode, with an external Infinispan, is needed for first-generation multi-site deployment.&lt;/p>
&lt;p>&lt;strong>Relational storage.&lt;/strong> JPA over a database. The alternative engine was removed in version 25.0. The databases supported today include PostgreSQL from 14 to 18, MariaDB and MySQL in their extended support versions, SQL Server 2019 and 2022, Oracle 19c and 23.x, and the managed variants Aurora PostgreSQL and Azure SQL.&lt;/p>
&lt;h3 id="the-minimum-conceptual-model">The minimum conceptual model&lt;/h3>
&lt;p>Before touching anything you need to be clear on eight concepts, and the order in which they relate:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Realm&lt;/strong>: the isolation boundary. Its own users, its own signing keys, its own policies. Two realms share nothing.&lt;/li>
&lt;li>&lt;strong>Client&lt;/strong>: an application that asks for tokens. Public if it cannot keep a secret, such as a browser application; confidential if it can, such as a service; and bearer-only if it merely validates tokens without requesting them.&lt;/li>
&lt;li>&lt;strong>Client scope&lt;/strong>: a reusable bundle of scopes and mappers, assigned to clients as default or as optional. It is the unit with which you bound what a token carries.&lt;/li>
&lt;li>&lt;strong>Protocol mapper&lt;/strong>: what projects an attribute, a role or an audience into the token. The audience mapper is the one that solves the central problem of this post.&lt;/li>
&lt;li>&lt;strong>Role&lt;/strong>, realm or client, and &lt;strong>composite role&lt;/strong>, which aggregates others.&lt;/li>
&lt;li>&lt;strong>Group&lt;/strong>, hierarchical, with inherited roles and attributes.&lt;/li>
&lt;li>&lt;strong>Identity provider&lt;/strong>: federation towards another external OIDC or SAML. &lt;strong>User federation&lt;/strong>: LDAP, Active Directory and Kerberos, with synchronisation and attribute mapping.&lt;/li>
&lt;li>&lt;strong>Authentication flow&lt;/strong>: a sequence of executions with required, alternative, conditional or disabled requirements, and subflows. It is where you build a second factor or a check of your own.&lt;/li>
&lt;/ul>
&lt;p>And one feature that changes architectures: &lt;strong>organisations&lt;/strong>, fully supported since 26.0. It solves multi-tenancy inside a single realm, grouping members, associating email domains and linking identity providers per organisation, so that provider discovery is done by the user&amp;rsquo;s email domain. The classic alternative, one realm per customer, multiplies administration and shares no configuration.&lt;/p>
&lt;h2 id="part-2-who-can-authenticate-against-it">Part 2. Who can authenticate against it&lt;/h2>
&lt;p>This is the table you need before drawing anything. The right-hand column is usually the one that decides.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Protocol&lt;/th>
&lt;th>Cost&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Kubernetes API&lt;/td>
&lt;td>native OIDC, or the structured authentication configuration file&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM&lt;/td>
&lt;td>OIDC and JWT, plus UI SSO&lt;/td>
&lt;td>&lt;strong>JWT and SSO above five users: Enterprise&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>vLLM&lt;/td>
&lt;td>static key only&lt;/td>
&lt;td>No OIDC&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse&lt;/td>
&lt;td>generic OIDC and Keycloak provider&lt;/td>
&lt;td>SSO free; per-project access control: Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Grafana&lt;/td>
&lt;td>generic OAuth &lt;strong>yes in the open edition&lt;/strong>&lt;/td>
&lt;td>Team synchronisation: Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Backstage&lt;/td>
&lt;td>generic OIDC and community-maintained Keycloak provider&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Open WebUI&lt;/td>
&lt;td>OIDC, with role management by claim&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LibreChat&lt;/td>
&lt;td>generic OIDC, with Keycloak documented&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Argo CD&lt;/td>
&lt;td>native OIDC or Dex, with groups into its RBAC file&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MinIO&lt;/td>
&lt;td>OIDC with role assumption via web identity&lt;/td>
&lt;td>See the note below&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two clarifications that change decisions.&lt;/p>
&lt;p>&lt;strong>Kubernetes.&lt;/strong> The modern mechanism is structured authentication configuration, which allows several JWT issuers plus validations and mappings with CEL expressions. The corresponding proposal, KEP-3331, went alpha in 1.29, beta in 1.30 and is &lt;strong>stable since 1.34&lt;/strong>. It is the route to use today, and it replaces the loose API server flags.&lt;/p>
&lt;p>&lt;strong>vLLM has no identity.&lt;/strong> All there is is a static key via environment variable. This is not a defect to fix, it is an architectural fact: the engine sits behind the gateway and its access control is network-based, not identity-based. Any design that exposes vLLM directly to users is giving up per-person traceability.&lt;/p>
&lt;p>About MinIO a note of honesty is in order: the public documentation now redirects to the commercial edition, so the detail of how it maps policies from a claim is verified there and not in the open edition. Anyone depending on that mechanism should confirm it against their own version before designing on top of it.&lt;/p>
&lt;h2 id="part-3-humans-and-machines">Part 3. Humans and machines&lt;/h2>
&lt;p>They are two different problems and it is worth solving them separately.&lt;/p>
&lt;h3 id="people">People&lt;/h3>
&lt;p>Authorization code with PKCE, with the S256 method. The MCP specification requires it when the client is technically capable, and it goes further: it mandates &lt;strong>rejecting the flow&lt;/strong> if the authorisation server metadata does not publish the supported challenge methods.&lt;/p>
&lt;h3 id="workloads-with-no-person-behind-them">Workloads with no person behind them&lt;/h3>
&lt;p>Here the obvious option is client credentials with a Keycloak service account, authenticated with a secret or, better, with a JWT signed by private key.&lt;/p>
&lt;p>But there is a less known and better option for workloads inside the cluster: &lt;strong>federated client authentication&lt;/strong>, introduced in January 2026 under the banner of doing away with secrets. It lets a client authenticate with a token issued by an external provider, and one of those providers is Kubernetes. The announcement placed full support for the Kubernetes service account variant and for OIDC in version 26.6; the SPIFFE variant, by contrast, expressly says it will stay in preview until the SPIFFE client authentication standard is finalised at the IETF.&lt;/p>
&lt;p>That means a pod can authenticate against Keycloak with the token the kubelet already projects into it, with no additional secret to rotate. It is worth confirming the exact status and the flag name against the release notes of whichever version you have deployed, because the feature is recent and has changed status between 26.5 and 26.6. It is preferable to token exchange, whose standard version is supported but is internal-to-internal only, while the old one, which covered external-to-internal and impersonation, is deprecated and in preview.&lt;/p>
&lt;h3 id="keycloak-against-spiffe">Keycloak against SPIFFE&lt;/h3>
&lt;p>The question always comes up and the answer is that they solve different things with a small overlap.&lt;/p>
&lt;p>SPIRE attests the node and the process, and issues short-lived identity documents with no prior secret. Keycloak does not attest workloads: it trusts a credential someone had to place there, except in the federated authentication case just mentioned, where the one attesting is Kubernetes. In exchange, Keycloak federates people, issues tokens with business data and governs consent, things SPIFFE does not do.&lt;/p>
&lt;p>The practical rule: SPIFFE for service-to-service identity inside the perimeter, Keycloak for anything that has a person behind it or crosses the perimeter. The SPIFFE detail is in &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">the close of the chain of trust series&lt;/a>.&lt;/p>
&lt;h2 id="part-4-mcp-and-the-two-missing-standards">Part 4. MCP, and the two missing standards&lt;/h2>
&lt;p>This part has the most consequences and is the worst documented outside the primary sources.&lt;/p>
&lt;h3 id="what-the-specification-requires">What the specification requires&lt;/h3>
&lt;p>From revision 2026-07-28, with the normative words as they appear:&lt;/p>
&lt;ul>
&lt;li>The MCP server &lt;strong>must&lt;/strong> implement RFC 9728 protected resource metadata, and the client &lt;strong>must&lt;/strong> use it to discover the authorisation server.&lt;/li>
&lt;li>The client &lt;strong>must&lt;/strong> implement RFC 8707 resource indicators, including the &lt;code>resource&lt;/code> parameter &lt;strong>in both the authorisation request and the token request&lt;/strong>, and &lt;strong>must&lt;/strong> send it regardless of whether the authorisation server supports it.&lt;/li>
&lt;li>The MCP server &lt;strong>must&lt;/strong> validate that the access token was issued specifically for it as the audience, and &lt;strong>must not&lt;/strong> accept or relay any other token.&lt;/li>
&lt;li>Authorisation servers and clients &lt;strong>should&lt;/strong> support client identifier metadata documents. RFC 7591 dynamic registration becomes optional and is declared deprecated, retained for compatibility.&lt;/li>
&lt;/ul>
&lt;p>The revision also adds the issuer in the authorisation response, from RFC 9207, and a stateless model with state handlers where it is expressly stated that holding a state handler is not equivalent to being authenticated.&lt;/p>
&lt;h3 id="what-keycloak-supports">What Keycloak supports&lt;/h3>
&lt;p>From its own MCP integration guide:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Standard&lt;/th>
&lt;th>Keycloak&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>OAuth 2.1 and authorisation server metadata&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RFC 9207, issuer in the response&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RFC 7591, dynamic registration&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Client identifier metadata document&lt;/td>
&lt;td>Supported, &lt;strong>experimental&lt;/strong>, after enabling the feature&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RFC 8707, resource indicators&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Not supported&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RFC 9728, protected resource metadata&lt;/strong>&lt;/td>
&lt;td>Out of scope, it belongs to the MCP server&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The conformance declared by the project itself is &amp;ldquo;partially supported, without resource indicators&amp;rdquo; for the last three MCP revisions.&lt;/p>
&lt;p>Two practical conclusions:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The protected resource metadata document is published by the MCP server or a proxy in front of it&lt;/strong>, never by Keycloak. That is correct and there is nothing to fix, but you have to remember to implement it, because the client needs it to discover where to go.&lt;/li>
&lt;li>&lt;strong>Without resource indicators, the audience is bounded with scopes.&lt;/strong> The official route is to use the &lt;code>scope&lt;/code> parameter instead of &lt;code>resource&lt;/code>, and to hang from that client scope an audience mapper with the MCP server URL as the included custom audience.&lt;/li>
&lt;/ol>
&lt;p>It is a workaround, it works, and it has to be documented in the design because anyone coming after will look for the standard parameter and will not find it.&lt;/p>
&lt;h2 id="part-5-the-confused-deputy">Part 5. The confused deputy&lt;/h2>
&lt;p>With the above on the table, the classic mistake becomes obvious on its own. A token with a generic audience turns any compromised service into a master key: whoever obtains the token a user presented to the dashboard can present it to the gateway, and from there to the MCP server.&lt;/p>
&lt;p>The MCP specification is explicit in three sentences worth quoting verbatim in any architecture document:&lt;/p>
&lt;ul>
&lt;li>MCP servers &lt;strong>must not&lt;/strong> accept tokens that were not explicitly issued for them.&lt;/li>
&lt;li>If the MCP server calls upstream APIs, it &lt;strong>must not&lt;/strong> forward the token it received from the client.&lt;/li>
&lt;li>MCP proxies that use static client identifiers &lt;strong>must&lt;/strong> obtain user consent for each dynamically registered client before forwarding to third-party authorisation servers.&lt;/li>
&lt;/ul>
&lt;p>Translated into the specific figure this track has been dealing with: &lt;strong>the gateway must not forward the user&amp;rsquo;s JWT to third-party MCP servers&lt;/strong>. It has to obtain a different token with the right audience, or act as an OAuth client in its own right. By default, as already documented &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway post&lt;/a>, the behaviour is to use the server&amp;rsquo;s own credential, which is the correct one; there are two modes that forward the caller&amp;rsquo;s token and both have a name in the specification.&lt;/p>
&lt;h2 id="part-6-two-hours-of-task-against-five-minutes-of-token">Part 6. Two hours of task against five minutes of token&lt;/h2>
&lt;p>The specification pushes towards short-lived tokens and mandatory refresh token rotation in public clients. A two-hour agentic task comfortably exceeds the life of an access token and may also exceed session idle time if nobody refreshes.&lt;/p>
&lt;p>Three real options, with their cost:&lt;/p>
&lt;p>&lt;strong>Offline tokens.&lt;/strong> They survive session expiry, and expire through their own idle timeout or through a maximum if that limit is enabled. The risk is obvious: a near-permanent credential, hard to inventory. And the MCP specification adds that MCP servers &lt;strong>should not&lt;/strong> include that scope either in the authentication header or in the supported scopes of their metadata.&lt;/p>
&lt;p>&lt;strong>Automatic refresh in the client.&lt;/strong> It is the right answer when there is a client that can implement it, with refresh token rotation and secure storage. For an agent with no person behind it, it is better to renew via client credentials.&lt;/p>
&lt;p>&lt;strong>Long-lived virtual keys in the gateway.&lt;/strong> It works, and it is what most people end up doing. The price has to be written down in the risk analysis: identity ends up decoupled from the provider, so deactivating someone in Keycloak does not invalidate their key, and traceability back to the real person depends on the discipline with which the keys were created. The levers that compensate are expiry, automatic rotation and a mandatory alias, covered &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">in the virtual keys post&lt;/a>.&lt;/p>
&lt;p>A note of honesty about the default values. The numbers going around (five minutes of access token, thirty of session idle time, ten hours of maximum) do not appear published in the administration guide and I did not locate them in the code. Treat them as a console observation and confirm them in your own realm before quoting them in a document.&lt;/p>
&lt;h2 id="part-7-sizing-and-operating">Part 7. Sizing and operating&lt;/h2>
&lt;h3 id="on-kubernetes">On Kubernetes&lt;/h3>
&lt;p>The operator exposes three custom resources: the server, the realm import and the OIDC client. Probes and metrics live on &lt;strong>management port 9000&lt;/strong>, with the health and readiness paths, and metrics in Prometheus format provided the corresponding option was enabled in the build phase. That port is not exposed publicly.&lt;/p>
&lt;p>For high availability there are three shapes. A single cluster spread across zones, with no additional infrastructure. First-generation multi-cluster, with an external load balancer and an external Infinispan at each site. And second-generation multi-cluster, introduced in 26.7.0 and still in preview, which connects clusters &lt;strong>without external Infinispan&lt;/strong>, using the synchronously replicated database as the source of truth, with less cache and more database load.&lt;/p>
&lt;h3 id="the-official-sizing-figures">The official sizing figures&lt;/h3>
&lt;p>From the CPU and memory sizing concepts guide, which is still published:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>1 vCPU for every 15 password logins per second&lt;/strong>, tested up to 300 per second.&lt;/li>
&lt;li>&lt;strong>1 vCPU for every 120 client credential grants per second&lt;/strong>, tested up to 2,000.&lt;/li>
&lt;li>&lt;strong>1 vCPU for every 120 token refresh requests per second&lt;/strong>, tested up to 435.&lt;/li>
&lt;li>Leave a &lt;strong>150 % CPU headroom&lt;/strong> for spikes, startup and failover.&lt;/li>
&lt;li>&lt;strong>1,250 MB of memory per pod&lt;/strong> with realm data and 10,000 cached sessions. Keycloak allocates 70 % of the limit to the heap and around 300 MB outside it, so the limit comes from subtracting those 300 MB from the expected usage and dividing by 0.7.&lt;/li>
&lt;/ul>
&lt;p>The stated assumptions behind those figures include Argon2 with five iterations, sessions in the database and the default cache of 10,000 entries.&lt;/p>
&lt;p>The difference between 15 and 120 is the whole story of Keycloak&amp;rsquo;s performance: &lt;strong>the cost is in password hashing&lt;/strong>. Since 25.0, the default algorithm outside FIPS environments is Argon2, with around 7 MB of memory per operation. Before it was PBKDF2, whose iterations were multiplied by ten in version 24. In FIPS mode PBKDF2 is still used.&lt;/p>
&lt;p>From that comes an architectural consequence for an AI platform: &lt;strong>machine workloads, which go through client credentials, cost eight times less than a human login&lt;/strong>. Sizing by number of services is cheap; sizing by morning login peaks is the expensive part.&lt;/p>
&lt;h3 id="the-benchmark">The benchmark&lt;/h3>
&lt;p>From the 26.4 performance report, published on 1 October 2025: up to 2,000 logins per second and 10,000 refreshes per second with three pods, 74 vCPU in total and 8 GB per pod, over an Aurora PostgreSQL database, with almost linear vertical scaling.&lt;/p>
&lt;p>Two useful findings from that report. With 20 ms of round-trip time between sites, the 99th percentile rises from 51 to 130 ms, and the project &lt;strong>advises against deploying across different regions&lt;/strong>. And raising the cache from 10,000 to 200,000 entries brought the database CPU peak down from 77.77 % to 63.77 %, in exchange for somewhat longer garbage collection pauses.&lt;/p>
&lt;h3 id="security">Security&lt;/h3>
&lt;p>The urgent matter, if the deployment is below 26.7.2:&lt;/p>
&lt;p>&lt;strong>&lt;code>CVE-2026-18963&lt;/code>, score 9.1, critical.&lt;/strong> A bypass of the email action token in the credential reset flow allowed resetting any user&amp;rsquo;s password with no prior authentication, administrators included. Fixed in 26.7.2, released on 19 August 2026. The temporary mitigation, if you cannot upgrade, is to disable password recovery in all realms. No known exploitation was recorded as of the end of August.&lt;/p>
&lt;p>In 26.7.3 there are another nineteen, among them the missing hostname verification in the TLS certificate towards LDAP with a score of 8.8, two tenant restriction bypasses in token exchange with Microsoft and Google brokers, and two access control bypasses in organisations.&lt;/p>
&lt;p>Official hardening comes down to four decisions:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Strict hostname&lt;/strong>, which is the default value since the second generation of that configuration. The server does not infer the URL from headers, which prevents poisoning email links and redirects.&lt;/li>
&lt;li>&lt;strong>Administration console on a different hostname&lt;/strong>, and network-restricted.&lt;/li>
&lt;li>&lt;strong>Proxy headers declared explicitly&lt;/strong>, trusting only a controlled reverse proxy.&lt;/li>
&lt;li>&lt;strong>Separate management port, not exposed.&lt;/strong>&lt;/li>
&lt;/ol>
&lt;h2 id="part-8-the-mapping-to-the-ens">Part 8. The mapping to the ENS&lt;/h2>
&lt;p>The codes and titles below are verified against the official text of Royal Decree 311/2022 published in the BOE, Annex II.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Code&lt;/th>
&lt;th>Literal title&lt;/th>
&lt;th>What a central IdP contributes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>op.acc.1&lt;/td>
&lt;td>Identificación&lt;/td>
&lt;td>A unique identifier per entity, user or process that gains access&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.2&lt;/td>
&lt;td>Requisitos de acceso&lt;/td>
&lt;td>Permissions are granted by role and group, not by loose credential&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.3&lt;/td>
&lt;td>Segregación de funciones y tareas&lt;/td>
&lt;td>It supports it with roles, but the control is organisational&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.4&lt;/td>
&lt;td>Proceso de gestión de derechos de acceso&lt;/td>
&lt;td>Least privilege and a specific remote access policy&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.5&lt;/td>
&lt;td>Mecanismo de autenticación (usuarios externos)&lt;/td>
&lt;td>Flows and factors configurable per flow&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.6&lt;/td>
&lt;td>Mecanismo de autenticación (usuarios de la organización)&lt;/td>
&lt;td>Reinforcements required already at the low level&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.exp.8&lt;/td>
&lt;td>Registro de la actividad&lt;/td>
&lt;td>It contributes part of the events, not all of them&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.exp.10&lt;/td>
&lt;td>Protección de claves criptográficas&lt;/td>
&lt;td>Management and rotation of the realm signing keys&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two nuances an auditor is going to look for.&lt;/p>
&lt;p>The first, about &lt;strong>op.acc.1&lt;/strong>: the text requires every entity that accesses the system, whether user or process, to have a unique identifier. That includes agents. A virtual key shared between several agents does not comply, and that is exactly what usually happens when JWT authentication falls outside the budget.&lt;/p>
&lt;p>The second, about &lt;strong>op.exp.8&lt;/strong>: the control requires recording the identifier of the user or entity associated with the event, the date and time, what information it is performed on, the type and the result. Keycloak contributes the authentication events. The usage events are contributed by the gateway and the MCP servers, and there the two already documented gaps reappear: &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">adding and removing MCP servers generates no audit log&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">the trace user is set by the client&lt;/a>.&lt;/p>
&lt;p>The correspondence between control and product in this table is my own interpretation and must be validated by the certification body. The codes and titles are not: those are literal from the BOE.&lt;/p>
&lt;h2 id="part-9-the-configuration-to-leave-written-down">Part 9. The configuration to leave written down&lt;/h2>
&lt;p>Three concrete pieces, so as not to stay at the level of concepts.&lt;/p>
&lt;p>&lt;strong>Hardened startup&lt;/strong>, as container arguments:&lt;/p>
&lt;pre tabindex="0">&lt;code>kc.sh start --optimized \
--hostname=https://sso.ejemplo.es \
--hostname-admin=https://sso-admin.interna.ejemplo.es \
--proxy-headers=xforwarded \
--health-enabled=true \
--metrics-enabled=true \
--http-management-port=9000 \
--cache=ispn --cache-stack=jdbc-ping \
--db=postgres
&lt;/code>&lt;/pre>&lt;p>The metrics, health, cache and database options are build options: they go into the image, and passing them here forces a rebuild if they do not match.&lt;/p>
&lt;p>&lt;strong>The client scope with audience&lt;/strong>, which is the workaround forced by the lack of resource indicators. Created with the administration tool:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># one scope per protected resource&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create client-scopes -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>mcp-inventario -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;attributes.&amp;#34;include.in.token.scope&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># the mapper that sets the token audience&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create &lt;span class="s2">&amp;#34;client-scopes/&amp;lt;id&amp;gt;/protocol-mappers/models&amp;#34;&lt;/span> -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>aud-mcp-inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocolMapper&lt;/span>&lt;span class="o">=&lt;/span>oidc-audience-mapper &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;included.custom.audience&amp;#34;=https://mcp.ejemplo.es/inventario&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;access.token.claim&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The client asks for that scope and receives a token whose audience is only that MCP server. The MCP server validates the audience and rejects any other token, as the specification requires.&lt;/p>
&lt;p>&lt;strong>Realm import via GitOps&lt;/strong>, with the operator&amp;rsquo;s custom resource, so that the configuration does not live only in the console:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">k8s.keycloak.org/v2beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">KeycloakRealmImport&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">plataforma&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">identidad&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">keycloakCRName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sso&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">realm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">realm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">plataforma&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># without this, the recovery email is attack surface&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resetPasswordAllowed&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bruteForceProtected&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sslRequired&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">all&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="part-10-what-keycloak-does-not-solve">Part 10. What Keycloak does not solve&lt;/h2>
&lt;p>&lt;strong>Fine-grained authorisation over resources.&lt;/strong> The question &amp;ldquo;can this person see this particular trace&amp;rdquo; is a relation between objects, not a role. That problem is solved by Zanzibar-style engines, with OpenFGA and its object, relation and user tuples, or a policy language such as Cedar. Keycloak gives roles and scopes, and with those you do not model a graph of relations.&lt;/p>
&lt;p>&lt;strong>Machine secrets.&lt;/strong> Storage credentials, provider keys, database passwords. That is a secrets manager, covered in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">stack hardening&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Cryptographic workload identity with attestation.&lt;/strong> That is SPIFFE and SPIRE. In Keycloak it exists as an identity provider in preview, which is a different thing.&lt;/p>
&lt;p>&lt;strong>Authorisation inside the model.&lt;/strong> Which MCP tool an agent may invoke, and with what arguments. The specification offers scopes, the insufficient scope error and step-up elevation, but the per-tool and per-argument decision is taken by the MCP server or a policy engine, not by the identity provider.&lt;/p>
&lt;h2 id="antipatterns">Antipatterns&lt;/h2>
&lt;ol>
&lt;li>Forwarding the user&amp;rsquo;s token from the gateway to third-party MCP servers. Explicitly forbidden by the specification.&lt;/li>
&lt;li>Static client identifier with dynamic registration and no per-client consent, which is the exact recipe for the confused deputy.&lt;/li>
&lt;li>General scopes, of the full-access kind, and publishing the entire catalogue of supported scopes in the metadata.&lt;/li>
&lt;li>Treating a state handler as authentication, which the new revision expressly forbids.&lt;/li>
&lt;li>Following OAuth discovery towards any URL, which opens the door to requests towards internal addresses. It also applies to the authorisation server when it downloads a client metadata document.&lt;/li>
&lt;li>Trust headers badly exposed in chat front ends. The Open WebUI documentation warns that an incorrect configuration allows authenticating as any user.&lt;/li>
&lt;li>Starting in development mode in production, with the hostname not resolved strictly.&lt;/li>
&lt;li>A single shared static key as the only control between the gateway and the engine.&lt;/li>
&lt;/ol>
&lt;h2 id="startup-checklist">Startup checklist&lt;/h2>
&lt;ol>
&lt;li>Check the version. Below 26.7.2 there is a critical password reset bug.&lt;/li>
&lt;li>Build the image with the build phase done and start with the optimised flag.&lt;/li>
&lt;li>Strict hostname, administration console on another hostname, separate management port and not exposed.&lt;/li>
&lt;li>Decide between organisations and one realm per tenant before creating the second tenant.&lt;/li>
&lt;li>One client scope per resource, with an audience mapper, because resource indicators are not supported.&lt;/li>
&lt;li>In the gateway, define the two audience and issuer variables. Without them none of that is validated.&lt;/li>
&lt;li>For workloads inside the cluster, look at the Kubernetes identity provider before handing out client secrets.&lt;/li>
&lt;li>Size by login peaks, not by number of services: the difference is eightfold.&lt;/li>
&lt;li>Export the Keycloak events to the same place the gateway&amp;rsquo;s go, because the activity logging control needs them together.&lt;/li>
&lt;li>Write into the design what the system does with a two-hour task, before someone solves the problem with an offline token.&lt;/li>
&lt;/ol>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>Keycloak is a mature piece that solves one bounded problem well: knowing who the caller is, and saying so verifiably in a document that expires. In a sovereign inference platform that is worth a lot, because the alternative is an inventory of static keys spread across ten services that no audit can walk through.&lt;/p>
&lt;p>What is not advisable is asking more of it. Fine-grained authorisation lives in each service, workload identity with attestation lives in SPIFFE, and the decision of which tool an agent may call lives in the MCP server.&lt;/p>
&lt;p>And there is one concrete gap worth noting down, because it does not close on its own: the MCP specification marks two standards as mandatory, and of the two that would fall to an authorisation server, resource indicators are not implemented. The workaround with scopes and an audience mapper works, but it is a workaround, and whoever comes after will look for the standard parameter. Leaving it written in the design saves an afternoon.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>: how the gateway validates the tokens this piece issues, and the two variables you have to define.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows up: giving it authentication with Keycloak&lt;/a>: the concrete setup for MCP servers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the second front door, its permissions and its lack of audit logging.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">The model&amp;rsquo;s chain of trust (4 of 4)&lt;/a>: SPIFFE, SPIRE and workload identity with attestation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign LLM stack&lt;/a>: where identity fits among the seven layers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits&lt;/a>: the alternative when JWT falls outside the budget.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS, ISO 42001 and the EU AI Act&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">Completing Keycloak for MCP&lt;/a>: the follow-up: the protected resource side you have to build, token exchange and the enterprise authorisation extension.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.keycloak.org/2026/08/keycloak-2673-released">Keycloak 26.7.3&lt;/a>, 31 de agosto de 2026, y &lt;a href="https://www.keycloak.org/downloads">descargas&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/server/configuration">Configuración del servidor&lt;/a>, &lt;a href="https://www.keycloak.org/server/caching">caché&lt;/a>, &lt;a href="https://www.keycloak.org/server/db">bases de datos&lt;/a> y &lt;a href="https://www.keycloak.org/server/hostname">nombre de host&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/securing-apps/mcp-authz-server">Integración con Model Context Protocol&lt;/a>, &lt;a href="https://www.keycloak.org/securing-apps/token-exchange">intercambio de tokens&lt;/a> y &lt;a href="https://www.keycloak.org/2026/01/federated-client-authentication">autenticación federada de clientes&lt;/a>, 27 de enero de 2026.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/high-availability/multi-cluster/concepts-memory-and-cpu-sizing">Dimensionado de CPU y memoria&lt;/a> e &lt;a href="https://www.keycloak.org/high-availability/introduction">introducción a alta disponibilidad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/2025/10/keycloak-benchmark">Bancos de pruebas de rendimiento de Keycloak 26.4&lt;/a>, 1 de octubre de 2025.&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization">Especificación de MCP, revisión 2026-07-28, autorización&lt;/a> y &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices">buenas prácticas de seguridad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/kubernetes/enhancements/tree/master/keps/sig-auth/3331-structured-authentication-configuration">KEP-3331, configuración estructurada de autenticación&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.boe.es/diario_boe/xml.php?id=BOE-A-2022-7191">Real Decreto 311/2022, Anexo II&lt;/a>.&lt;/li>
&lt;li>Registro de &lt;code>CVE-2026-18963&lt;/code> en la base de datos de Red Hat y notas de la versión 26.7.3 en GitHub.&lt;/li>
&lt;li>&lt;a href="https://www.cncf.io/projects/keycloak/">CNCF, proyecto Keycloak&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>LiteLLM's MCP gateway: the second front door, the tool catalogue nobody bills, and why filtering it can make selection worse</title><link>https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/</link><pubDate>Fri, 11 Sep 2026 01:45:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/</guid><description>&lt;blockquote>
&lt;p>Sixth post in the operational track on the control layer. The previous five treat the gateway as the piece the tokens pass through: &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the pair with Langfuse&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">day 2&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">humans and agents&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">prefix routing&lt;/a>. This one is about the other door. Verified against LiteLLM 1.102.0, commit of 10 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>The MCP gateway is not a module, it is a complete second surface.&lt;/strong> A native MCP endpoint with four route spellings, a proxy mode with three fixed tools, four REST routes, some thirty-five administration routes and a long set of OAuth discovery endpoints. It still lives under &lt;code>_experimental&lt;/code> and it is nearly 20,000 lines.&lt;/p>
&lt;p>&lt;strong>I correct what I said in the previous post: access is NOT open by default.&lt;/strong> In 1.102.0, a key with no declared permissions and no team resolves zero servers. What does open things up is a per-server flag, &lt;code>allow_all_keys&lt;/code>, and that flag &lt;strong>overrides the key&amp;rsquo;s explicit scope&lt;/strong> unless you enable a setting that is off by default.&lt;/p>
&lt;p>&lt;strong>Permissions are enforced on execution, not just on the listing.&lt;/strong> Calling a tool that did not appear in &lt;code>tools/list&lt;/code> returns 403. There are three chained checks before the call, including one on the allowed arguments. The same predicate governs listing and calling, by declared design.&lt;/p>
&lt;p>&lt;strong>The hierarchy is built out of intersections, with two escapes and a fail-open.&lt;/strong> The key&amp;rsquo;s access groups are additive on top of the key and team ceiling. The organisation ceiling replaces rather than intersects when there is nothing below it. And on an indeterminate error while resolving tool permissions, the code returns &amp;ldquo;unrestricted&amp;rdquo;. It is a deliberate decision and it has to be declared in the risk analysis.&lt;/p>
&lt;p>&lt;strong>Per-tool cost is worth zero and that is why its metric disappears.&lt;/strong> There are exactly two Prometheus metrics for MCP, and the spend one is only incremented if the cost is greater than zero. With the default configuration, that series never exists. There is no listing metric, no latency metric, no error metric, no server health metric.&lt;/p>
&lt;p>&lt;strong>The cost that does matter is not attributed to MCP anywhere.&lt;/strong> It is the tokens of the tool definitions, resent on every turn inside the &lt;code>prompt_tokens&lt;/code> of the model call. Five typical MCP servers add up to 58 tools and some 55,000 tokens. The tool&amp;rsquo;s spend row, with its zero cost, has nothing to do with that.&lt;/p>
&lt;p>&lt;strong>And the gateway&amp;rsquo;s semantic filter does not act on the MCP door.&lt;/strong> It only applies when it is LiteLLM calling the model. A client connected to &lt;code>/mcp&lt;/code> receives the whole catalogue, filter enabled or not. And even if it did act, filtering per request rewrites the cached prefix of the prompt, which is the opposite of what Anthropic and OpenAI do with their own tool searches.&lt;/p>
&lt;p>&lt;strong>The good observability ships switched off.&lt;/strong> Without &lt;code>LITELLM_OTEL_V2&lt;/code>, a tool call comes out as a generic span with the model set to &lt;code>MCP: name&lt;/code>. With it enabled there is a dedicated span and correct attributes, and the arguments and the result stay hidden unless you opt in. And in neither case is trace context propagated towards the upstream MCP server: the trace is cut at the gateway.&lt;/p>
&lt;p>&lt;strong>Tool arguments are written, and they are written in the clear.&lt;/strong> They go in full into the &lt;code>metadata&lt;/code> column of the spend table, without passing through the switch that governs prompt storage nor through the redaction function, which only knows about &lt;code>messages&lt;/code> and &lt;code>response&lt;/code>. Whoever enabled redaction for GDPR probably believes they are covered.&lt;/p>
&lt;p>&lt;strong>The 1.83 branch concentrated four serious vulnerabilities, and one of them belongs to the MCP gateway itself.&lt;/strong> The endpoint for testing a connection before saving accepted the stdio transport&amp;rsquo;s command, args and environment in the request body. A low-privilege key got command execution on the proxy host.&lt;/p>
&lt;p>&lt;strong>And the specification moved underneath.&lt;/strong> The revision in force since July 2026 removed protocol sessions, the initialisation handshake and the session header. LiteLLM 1.102.0 advertises the June 2025 revision and its version enumeration does not even contemplate the two later ones.&lt;/p>
&lt;h2 id="you-are-here-the-door-nobody-chose">You are here: the door nobody chose&lt;/h2>
&lt;p>The gateway was chosen for the reasons in &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">the switchboard post&lt;/a>: licence, fit inside the cluster, maturity. What was evaluated was the inference traffic.&lt;/p>
&lt;p>Later, somebody connects an agent, the agent needs tools, and the same process that is already authenticated and deployed turns out to speak MCP too. The decision to turn it into the tool door is almost never taken: it is inherited. And that door has neither the same controls, nor the same observability, nor the same cost model.&lt;/p>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows&lt;/a> covered the identity of your own MCP servers. &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside&lt;/a> covered the protocol and its instrumentation. This post covers the piece in between.&lt;/p>
&lt;h2 id="the-analogy-the-switchboard-that-also-hands-out-keys">The analogy: the switchboard that also hands out keys&lt;/h2>
&lt;p>An office building&amp;rsquo;s telephone switchboard. Its original job is to route calls: it decides who can call outside, how much each department spends, and it leaves a record of every conference call.&lt;/p>
&lt;p>Over time, the switchboard operator is also given the key cabinet. They are already at reception, they already know who everybody is, it looks like the natural place. And from that moment there are two services at the same desk with very different rules.&lt;/p>
&lt;p>Calls are priced by the minute and show up on the bill. Keys have no tariff, so on the department&amp;rsquo;s bill they come out at zero, and somebody might conclude that the key cabinet costs nothing. It does cost: it costs the operator&amp;rsquo;s time, it costs having to read the whole key catalogue every time somebody asks what is available, and it costs that nobody checks that the label on a key still says what it said the day its loan was authorised.&lt;/p>
&lt;p>The analogy ends at the interesting spot. When somebody takes a key, the record says which key it was and what they said they wanted it for. That record is simultaneously the evidence of the action and a piece of data that perhaps should not be stored in the clear.&lt;/p>
&lt;h2 id="the-real-routes">The real routes&lt;/h2>
&lt;p>The first thing that surprises you when you look at the code is how many spellings get you to the same place.&lt;/p>
&lt;h3 id="the-native-endpoint">The native endpoint&lt;/h3>
&lt;p>The MCP sub-application is mounted at &lt;code>/mcp&lt;/code> and inside it has four mounts: the root as a wildcard, &lt;code>/mcp&lt;/code>, &lt;code>/{name}/mcp&lt;/code> and &lt;code>/sse&lt;/code>.&lt;/p>
&lt;p>And there is also a hand-declared route for bare &lt;code>/mcp&lt;/code> on the main application, with a comment that explains why: the mount cannot match its own bare prefix, and the resulting 307 redirect breaks MCP clients sitting behind a proxy that terminates TLS. It is the kind of detail you discover in production on a Friday.&lt;/p>
&lt;p>&lt;code>/mcp/sse&lt;/code> holds a surprise. It does not use the classic SSE transport: it uses the same HTTP session manager in stateless mode. The &lt;code>SseServerTransport&lt;/code> object is constructed and is not referenced on any other line of the file. It is dead code, and the &lt;code>/mcp/sse/messages&lt;/code> endpoint that this object advertises does not exist as a working route.&lt;/p>
&lt;p>The &lt;code>/{name}/mcp&lt;/code> route resolves in a documented order: server alias, comma-separated list with a maximum of 16 elements, tool set, access group. If nothing matches, 404.&lt;/p>
&lt;p>And &lt;code>/mcp/proxy&lt;/code> exposes a fixed surface of three tools. It is the progressive disclosure pattern, and it will come up again further down because it is the most important mitigation in this post.&lt;/p>
&lt;h3 id="the-rest-routes">The REST routes&lt;/h3>
&lt;p>Four, under the &lt;code>/mcp-rest&lt;/code> prefix:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>Route&lt;/th>
&lt;th>Authentication&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>GET&lt;/td>
&lt;td>&lt;code>/mcp-rest/tools/list&lt;/code>&lt;/td>
&lt;td>Virtual key&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>POST&lt;/td>
&lt;td>&lt;code>/mcp-rest/tools/call&lt;/code>&lt;/td>
&lt;td>Virtual key&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>POST&lt;/td>
&lt;td>&lt;code>/mcp-rest/test/connection&lt;/code>&lt;/td>
&lt;td>Key plus administrator role&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>POST&lt;/td>
&lt;td>&lt;code>/mcp-rest/test/tools/list&lt;/code>&lt;/td>
&lt;td>Key plus administrator role&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The variants without &lt;code>-rest&lt;/code> that appear in the internal types have no router registering them. The &lt;code>/mcp/*&lt;/code> wildcard absorbs them as JSON-RPC.&lt;/p>
&lt;p>The last two are the stars of the security section.&lt;/p>
&lt;h3 id="administration-and-discovery">Administration and discovery&lt;/h3>
&lt;p>Some thirty-five routes under &lt;code>/v1/mcp&lt;/code>, almost all of them with virtual key authentication. Three details worth knowing:&lt;/p>
&lt;p>&lt;code>GET /v1/mcp/registry.json&lt;/code> &lt;strong>carries no authentication dependency&lt;/strong>. It returns 404 if the public registry is not enabled, which is the real protection.&lt;/p>
&lt;p>&lt;code>POST /v1/mcp/server&lt;/code> requires the proxy administrator role. &lt;code>POST /v1/mcp/server/register&lt;/code> does not: it is the proposal path, which requires a key with a team, explicitly forbids the stdio transport and leaves the entry pending review.&lt;/p>
&lt;p>And on top of that there is a long set of OAuth &lt;code>.well-known&lt;/code> endpoints, with dynamic client registration, authorisation, token, revocation and introspection, plus their per-server variants.&lt;/p>
&lt;h3 id="configuration-and-transport">Configuration and transport&lt;/h3>
&lt;p>The &lt;code>mcp_servers&lt;/code> schema in &lt;code>config.yaml&lt;/code> has fifty-odd fields, all of them optional. A warning for anyone coming from earlier versions: &lt;strong>&lt;code>spec_version&lt;/code> no longer exists&lt;/strong> in 1.102.0.&lt;/p>
&lt;p>The default transport when omitted is &lt;code>http&lt;/code>, that is, streamable HTTP. The other two values are &lt;code>sse&lt;/code> and &lt;code>stdio&lt;/code>. The string &lt;code>streamable_http&lt;/code> is not valid in the configuration file, although it is accepted as a synonym when importing connector files.&lt;/p>
&lt;p>Tool prefixing uses the format &lt;code>{server}{separator}{tool}&lt;/code> with &lt;code>-&lt;/code> as the default separator, configurable through an environment variable read at import time. The prefix chosen is the alias, or the name, or the identifier, in that order. There is an optional short mode that derives three characters from the hash of the server identifier and resolves collisions by rehashing.&lt;/p>
&lt;h2 id="access-control-and-the-correction-to-the-previous-post">Access control, and the correction to the previous post&lt;/h2>
&lt;p>In &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the humans and agents post&lt;/a> I closed the list of traps with this one: &amp;ldquo;access to MCP servers is open by default if no level defines a list&amp;rdquo;. Verified in 1.102.0, that is false.&lt;/p>
&lt;p>The function that resolves the allowed servers does the opposite. If the team declares nothing, it inherits from the key; if the key declares nothing either, the base set is empty and the scope is marked as unbounded with zero servers. There is no branch that expands to &amp;ldquo;the whole registry&amp;rdquo; other than the administration role one.&lt;/p>
&lt;p>What does open things up, and this is the important part, is something else.&lt;/p>
&lt;h3 id="allow_all_keys">&lt;code>allow_all_keys&lt;/code>&lt;/h3>
&lt;p>It is a per-server flag, in configuration, in the API and in the database, defaulting to false. When set to true, that server joins the set of open servers and any key can see it.&lt;/p>
&lt;p>The detail to know: &lt;strong>by default it overrides the key&amp;rsquo;s explicit scope&lt;/strong>. A key that declares access only to server A will also see server B if B has the flag. The key&amp;rsquo;s scope is only respected if you enable &lt;code>general_settings.mcp_allow_all_keys_respects_mcp_scope&lt;/code>, which is off by default.&lt;/p>
&lt;p>There is a second opening case: anonymous subjects, with no user identifier, role or key, additionally receive the servers configured with authentication delegation towards the upstream and the pass-through ones. It is consistent with the design of those two modes, and it is a surface you have to know about.&lt;/p>
&lt;h3 id="require_key_mcp_access_defined">&lt;code>require_key_mcp_access_defined&lt;/code>&lt;/h3>
&lt;p>It lives in &lt;code>general_settings&lt;/code>, defaults to false, and needs no licence. What it inverts is &lt;strong>only&lt;/strong> the empty-key inheritance towards the team: with the flag on, a key with no list of its own gets an empty set instead of inheriting the team&amp;rsquo;s.&lt;/p>
&lt;p>It does not affect keys that do declare servers, which still intersect with the team. And it does not close off the access group grants, which are additive and are the escape route you have to audit separately.&lt;/p>
&lt;p>There is a sibling, &lt;code>require_end_user_mcp_access_defined&lt;/code>, with the same shape and applied to the end user.&lt;/p>
&lt;h3 id="the-hierarchy">The hierarchy&lt;/h3>
&lt;p>The function&amp;rsquo;s docstring is normative and says that all the rules are intersections. In detail:&lt;/p>
&lt;ul>
&lt;li>Key and team: intersection when both declare. Inheritance in one direction when one is empty.&lt;/li>
&lt;li>Key access groups: &lt;strong>additive union&lt;/strong> on top of the previous result.&lt;/li>
&lt;li>End user: intersection if it declares anything.&lt;/li>
&lt;li>Agent: intersection.&lt;/li>
&lt;li>Internal user: ceiling that only narrows.&lt;/li>
&lt;li>Organisation: ceiling that intersects if there are restrictions below, and that &lt;strong>replaces&lt;/strong> by becoming the ceiling when there are none.&lt;/li>
&lt;/ul>
&lt;p>For subjects with no key, admitted by SSO or by the gateway, the model changes to a per-source union instead of an intersection.&lt;/p>
&lt;h3 id="the-fail-open">The fail-open&lt;/h3>
&lt;p>This is the point you have to take to the risk analysis and not leave in a technical note.&lt;/p>
&lt;p>On an indeterminate failure while resolving the tool permissions of a key or a JWT, the code returns &amp;ldquo;unrestricted&amp;rdquo;, that is, all tools allowed. The organisation ceiling is skipped in the same situation. For subjects with no key the behaviour is the opposite, fail-closed.&lt;/p>
&lt;p>There is a well-resolved intermediate case: when a permission is named but cannot be read, a specific error is raised and it is denied in both cases.&lt;/p>
&lt;p>The doctrine is documented in the class docstring, which is appreciated. And it remains a behaviour that, in a system under the ENS, you have to declare in writing instead of discovering it during an audit.&lt;/p>
&lt;h3 id="what-is-done-well">What is done well&lt;/h3>
&lt;p>Permissions are enforced on the call, not just on the listing. The execution path chains three checks before contacting the server: allowed or forbidden tools, per key and team tool permission, and validation of the arguments against the allowed ones. All three return 403.&lt;/p>
&lt;p>Listing and calling share the same predicate, and the docstring declares it as an explicit invariant. A client that calls a tool directly when it did not appear in its listing receives 403, it does not execute. The same over REST and over the responses API route.&lt;/p>
&lt;p>The &lt;code>x-mcp-servers&lt;/code> and &lt;code>x-mcp-access-groups&lt;/code> headers only narrow, and they fail closed if they do not resolve.&lt;/p>
&lt;p>Header forwarding towards the upstream is an explicit per-server allowlist, with a centralised decision about when to strip the caller&amp;rsquo;s &lt;code>Authorization&lt;/code>, and a protection against cross-forwarding: in a multi-server listing, the global header is withheld if more than one server would consume it. That is RFC 9700 applied.&lt;/p>
&lt;h2 id="the-bill-that-never-shows-up">The bill that never shows up&lt;/h2>
&lt;p>Here is, in my opinion, the most important thing in this post, and it has nothing to do with security.&lt;/p>
&lt;p>Every tool the gateway aggregates takes up space in the model&amp;rsquo;s context. Name, description and parameter schema travel in the request&amp;rsquo;s &lt;code>tools&lt;/code> block, &lt;strong>on every turn&lt;/strong>, and they are paid for as input tokens of the real model.&lt;/p>
&lt;p>The figures published by Anthropic in November 2025 are the best public reference there is. Five connected MCP servers add up to 58 tools and some 55,000 tokens. The breakdown: GitHub, 35 tools and some 26,000 tokens. Slack, 11 tools and some 21,000. Sentry, five and some 3,000. Grafana, five and some 3,000. Splunk, two and some 2,000.&lt;/p>
&lt;p>With tool search enabled, that set drops from some 72,000 to some 8,700 tokens, and the usable context goes from 122,800 to 191,300.&lt;/p>
&lt;p>The second cost of a large catalogue is not economic and is not billed: it is that the model chooses worse. It has its own section further down, because of the two it is the one that decides whether the agent is any good.&lt;/p>
&lt;h3 id="how-to-measure-it-with-what-is-already-there">How to measure it with what is already there&lt;/h3>
&lt;p>The gateway stores enough data, even if it does not cross-reference it for you.&lt;/p>
&lt;p>In the listing&amp;rsquo;s spend row, with call type &lt;code>list_mcp_tools&lt;/code>, it enriches the metadata with &lt;code>allowed_server_count&lt;/code>, &lt;code>tool_count_total&lt;/code>, &lt;code>per_server_tool_counts&lt;/code> and &lt;code>per_server_list_outcomes&lt;/code>.&lt;/p>
&lt;p>In the model call rows, the &lt;code>proxy_server_request&lt;/code> column keeps the request body with the &lt;code>tools&lt;/code> block inside it.&lt;/p>
&lt;p>The practical method: pair the listing rows with the LLM rows of the same session by &lt;code>session_id&lt;/code>, read the total number of tools from the first, and pass the &lt;code>tools&lt;/code> array of the second through &lt;code>litellm.token_counter(model=..., tools=...)&lt;/code>, which accepts that argument. Multiply by the number of turns, because the block is resent on all of them.&lt;/p>
&lt;p>For the relative cost, a quicker comparison: mean &lt;code>prompt_tokens&lt;/code> of keys with MCP enabled against those without, same model and same period.&lt;/p>
&lt;h2 id="tool-selection">Tool selection&lt;/h2>
&lt;p>The previous section treats the catalogue as a bill. This one treats it as what determines whether the agent gets it right. This is ground where the talk runs well ahead of the measurement, so it is worth separating what has been measured from what gets repeated.&lt;/p>
&lt;h3 id="how-many-tools-a-model-can-take">How many tools a model can take&lt;/h3>
&lt;p>Anthropic&amp;rsquo;s official figure is that Claude&amp;rsquo;s ability to pick the right tool degrades when going from 30 to 50 available tools. The same documentation gives the inverse criterion, which is the more useful one: the full catalogue with nothing else is the right option with fewer than ten tools, when all of them are used on every request, or when the definitions add up to fewer than a hundred tokens.&lt;/p>
&lt;p>There is also an administrative ceiling before the semantic one. VS Code with Copilot cuts off at &lt;strong>128 tools per request&lt;/strong> and returns an error when you exceed it.&lt;/p>
&lt;p>The full curve gets cited far more than it has been measured. The soundest primary data I found is from May 2025: varying the number of candidates from 1 to 11,100 over a registry of more than 4,400 servers, below some 30 tools the success rate is above 90 %, between 31 and 70 intermittent failures appear, and past the hundred mark the degradation becomes dominated by retrieval. It is a qualitative heat map, not a numeric table.&lt;/p>
&lt;p>The figure that circulates most (78 % with ten tools, 40 % with a hundred, 13.6 % with seven hundred) comes from a conference talk with no paper, no repository and no published methodology. Do not take it to a client presentation.&lt;/p>
&lt;p>And there is a warning that affects everything else. An audit of 496 tasks from four tool calling benchmarks found &lt;strong>18.5 % misalignment between the label and reality&lt;/strong>. In the BFCL v4 subset, 80 % of the failures assigned to the agent came from fragile state comparisons. In one of the benchmarks with an automatic judge, 23 identical re-runs gave a range from 57.9 % to 76.8 %, almost 19 points of spread, enough to reorder the ranking. Any absolute figure in this section deserves a generous margin of error.&lt;/p>
&lt;h3 id="how-it-fails">How it fails&lt;/h3>
&lt;p>The work with the cleanest taxonomy evaluates 36 servers, 220 tools and 1,000 tasks against twenty frontier models, with an average of 15.2 tools exposed per task of which 4.1 are relevant.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Failure mode&lt;/th>
&lt;th>Share&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Not using a tool when one was needed&lt;/td>
&lt;td>10.5 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Choosing the wrong tool&lt;/td>
&lt;td>9.0 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Malformed parameters&lt;/td>
&lt;td>6.9 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Not recovering from an error&lt;/td>
&lt;td>10.3 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cognitive failures, not tool failures&lt;/td>
&lt;td>63.3 %&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The interesting part is in the per-model breakdown: &lt;strong>the dominant failure mode changes with the model, not with the size of the catalogue&lt;/strong>. In one of them, 40.1 % of the failures are simply not calling any tool. In others the malformed parameter dominates. In another, the wrong tool. There is no canonical failure to attack.&lt;/p>
&lt;p>On tools that do the same thing, which is the real case of a gateway with fifteen servers, there is a good measurement. A study with ten groups of five functionally equivalent tools and a thousand query pairs puts the selection bias between 0.3 and 0.4, that is, &lt;strong>you would have to redistribute between 30 and 40 % of the probability mass for equivalent tools to be chosen equally&lt;/strong>. The same work shows that a biased continued training takes the selection of a specific endpoint from 0.6 % to 12.8 %.&lt;/p>
&lt;p>That study contributes another figure that reorders priorities: shuffling a tool&amp;rsquo;s &lt;strong>description&lt;/strong> moves the selection distribution substantially, whereas &lt;strong>changing only the name has minimal and inconsistent effects&lt;/strong>. Applied to the gateway&amp;rsquo;s &lt;code>server-tool&lt;/code> prefixing: it costs tokens and probably does not change which tool the model goes to. There is no study isolating the effect of name spacing, and it is a real gap in the literature.&lt;/p>
&lt;p>If the description is what carries weight, the state of the estate is the problem. An audit of 856 tools from 103 MCP servers found undeclared limitations in 89.8 %, missing usage guidance in 89.3 % and opaque parameters in 84.3 %. &lt;strong>Only 2.9 % were free of problems across all their components.&lt;/strong>&lt;/p>
&lt;h3 id="the-optimum-is-not-a-number">The optimum is not a number&lt;/h3>
&lt;p>The May 2026 work I cited above proposes a chance-corrected metric and uses it to size the list adaptively. Over 370 functions it reaches 90.3 % coverage while seeing an average of 7.4 tools, against 90.8 % seeing a fixed 50.&lt;/p>
&lt;p>But the same paper publishes its own counterexample, and it is honest to cite it: over another benchmark of 3,251 tools, &lt;strong>the fixed K of 5 wins in aggregate&lt;/strong>, 64.7 % against 61.9 %. The adaptive advantage concentrates in the hard queries, where the right tool sits between sixth and twentieth by similarity: there it finds 16.7 % of the cases where the fixed K finds 0 %.&lt;/p>
&lt;p>The March 2026 work over 121 tools puts the plateau at K=3, with 97.1 % that does not improve going up to K=5. With the caveat that it measures retrieval against labels, not model accuracy.&lt;/p>
&lt;p>And a July 2026 work gives the formulation that seems to me the right one: ranking by score is inconsistent with optimal acquisition when costs are heterogeneous, so &lt;strong>the optimal K is a function of cost, not a constant&lt;/strong>. Trimming from 7 to 4.4 exposed tools while keeping success is possible; fixing a universal number is not.&lt;/p>
&lt;h3 id="filtering-can-make-the-result-worse">Filtering can make the result worse&lt;/h3>
&lt;p>This is the part that appears in no product blog. A June 2026 benchmark with three registry sizes (25, 100 and 250 tools), six filtering methods, seven models and 26,460 runs:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Strategy&lt;/th>
&lt;th>Success&lt;/th>
&lt;th>Visible tools&lt;/th>
&lt;th>Tokens&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Expose the whole catalogue&lt;/td>
&lt;td>32.1 %&lt;/td>
&lt;td>125.00&lt;/td>
&lt;td>56,062&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keyword, top-5&lt;/td>
&lt;td>22.1 %&lt;/td>
&lt;td>4.80&lt;/td>
&lt;td>3,200&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keyword, top-10&lt;/td>
&lt;td>22.4 %&lt;/td>
&lt;td>9.54&lt;/td>
&lt;td>5,356&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>State-aware&lt;/td>
&lt;td>24.0 %&lt;/td>
&lt;td>25.95&lt;/td>
&lt;td>13,522&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Full causal path&lt;/td>
&lt;td>24.0 %&lt;/td>
&lt;td>26.24&lt;/td>
&lt;td>13,697&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Four of the five filtering strategies perform worse than not filtering.&lt;/strong> The fifth, which does win comfortably, exposes 0.99 tools per step and requires hand-written precondition and effect contracts for every tool, plus a search of the causal path to the goal state. It is a planner with the goal given, not a retrieval filter, and it is not comparable with anything a gateway offers.&lt;/p>
&lt;p>There is more evidence in the same direction. A work over a corpus of 43,000 tools measured that the agent&amp;rsquo;s completion rate drops when using retrieved sets against hand-annotated sets: the filter introduces its own recall ceiling. And top-K breaks the tasks that need several tools: on a benchmark of 16,464 endpoints, a fine-tuned retriever reaches a Recall@3 of 68.6 % but only 39.7 % of the queries have their &lt;strong>complete&lt;/strong> tool set within the first three. Almost 29 points of gap between &amp;ldquo;the one I needed is there&amp;rdquo; and &amp;ldquo;all the ones I needed are there&amp;rdquo;.&lt;/p>
&lt;p>Filtering is not free on security either. A March 2026 work attacks the retrieval layer by injecting adversarial tools that saturate the top-k: with injection rates of 1.2 % it manages to dominate the top-k between 91 % and 97 % of the time. It is an attack surface that exposing the whole catalogue does not have.&lt;/p>
&lt;p>The most elegant counterpoint comes from Anthropic&amp;rsquo;s own numbers. With the same catalogue of more than fifty tools, the gain from tool search drops from 25 points on one model to 8.6 on the next, because the unfiltered baseline rises from 49 % to 79.5 %. &lt;strong>The margin left by filtering shrank to a third in one generation of models.&lt;/strong> Any architecture decision taken here has a short shelf life.&lt;/p>
&lt;h3 id="the-prompt-cache-trap">The prompt cache trap&lt;/h3>
&lt;p>Here is what makes a homemade top-K filter a bad idea even if it worked.&lt;/p>
&lt;p>Tool definitions do not go just anywhere in the prompt: they go at the beginning. Anthropic documents the order of the cacheable prefix as &lt;code>tools&lt;/code>, then &lt;code>system&lt;/code>, then &lt;code>messages&lt;/code>, with a hierarchy where each level rests on the previous one. And the invalidation rule is explicit: &lt;strong>modifying tool definitions, be it names, descriptions or parameters, invalidates the entire cache&lt;/strong>, and a change at one level invalidates that level and all subsequent ones.&lt;/p>
&lt;p>OpenAI says the same and adds a detail: the prefix must remain unchanged, and &lt;strong>order counts&lt;/strong>. The same set of tools served in a different order is a cache miss.&lt;/p>
&lt;p>With vLLM serving an open model there is no tool block of its own. The &lt;code>tools&lt;/code> parameter is serialised by the chat template inside the first system message, behind the system prompt text. For automatic prefix caching the effect is identical, because it is still near the absolute start of the prompt.&lt;/p>
&lt;p>The consequence is the one that matters. A filter that returns a different set of tools on the next turn does not just invalidate the tool block: it invalidates &lt;strong>everything behind it&lt;/strong>, which is the system prompt and the whole conversation, which is exactly the part that grows with the turns. At list prices, a prefix token goes from costing 0.1 times when read from cache to 1.25 times when written. An agent with sixty thousand tokens of stable prefix goes from some six thousand equivalent tokens per turn to some seventy-five thousand, plus the prefill that gets paid again in latency.&lt;/p>
&lt;p>The two big providers have separately arrived at the same solution, and it is the opposite of filtering in the prefix. Anthropic excludes deferred tools from the prefix and, when the model discovers one through search, injects it inline inside the conversation: &amp;ldquo;the prefix stays intact, so prompt caching is preserved&amp;rdquo;. OpenAI: &amp;ldquo;all tools are loaded at the end of the model&amp;rsquo;s context window (&amp;hellip;) this allows the cache to be preserved from one request to the next&amp;rdquo;. Anthropic&amp;rsquo;s operational recommendation is the stable core pattern: leave the three to five most used tools undeferred.&lt;/p>
&lt;p>The closest academic work, from January 2026, states the problem without measuring it and recommends exactly that, keeping a fixed set of reusable general-purpose functions and implementing dynamic capability through code generation.&lt;/p>
&lt;p>The July 2026 MCP specification picks up both halves of the matter. Servers &amp;ldquo;&lt;strong>should&lt;/strong> return the tools from &lt;code>tools/list&lt;/code> in a deterministic order to allow caching in the client and improve the hit rate of the model&amp;rsquo;s prompt cache&amp;rdquo;. And it adds mandatory &lt;code>ttlMs&lt;/code> and &lt;code>cacheScope&lt;/code> in listing results, so that the client can freeze the catalogue for a known window instead of re-querying it and propagating any variation to the prompt.&lt;/p>
&lt;p>Nobody has publicly measured the cost in euros of this effect. The only attempt I found is a self-published audit of ten thousand turns that puts it at 2.4 % to 3.5 % of cache misses, with non-reproducible methodology. It is a gap in the literature, and the order of magnitude in the previous paragraph comes from applying list prices, not from a measurement.&lt;/p>
&lt;h3 id="and-now-litellm">And now, LiteLLM&lt;/h3>
&lt;p>With all of the above in mind, the gateway&amp;rsquo;s levers read differently.&lt;/p>
&lt;p>&lt;strong>The semantic filter does not act on the MCP endpoint.&lt;/strong> It is registered as a generic LiteLLM callback, and its only dispatcher is the proxy&amp;rsquo;s precall hook, which additionally discards anything that is not &lt;code>completion&lt;/code>, &lt;code>acompletion&lt;/code> or &lt;code>aresponses&lt;/code>. The &lt;code>tools/list&lt;/code> path does not invoke that hook at any point; there is a comment in the code that admits it in passing, when explaining why the listing&amp;rsquo;s JWT signature is done separately. An external MCP client connected to &lt;code>/mcp&lt;/code> receives the whole catalogue, filter enabled or not.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Lever&lt;/th>
&lt;th>Serves external MCP clients&lt;/th>
&lt;th>Breaks the prompt cache&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Per-server &lt;code>allowed_tools&lt;/code>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, it is static&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Name and description overrides&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, it is static&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Selection by route or header&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, if it is stable per agent&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/mcp/proxy&lt;/code> and virtual tools&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, the surface is fixed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Semantic filter&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>Yes, it rewrites the block per request&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>When it does act, the filter has details you need to know before enabling it. It embeds only each tool&amp;rsquo;s description, with the name as a fallback if it is missing; the parameter schema does not go in. Of the query it embeds &lt;strong>only the last user message&lt;/strong>: on turn twelve of an agent conversation, that might be &amp;ldquo;yes, go ahead&amp;rdquo;. The tool embeddings are cached and indexed at start-up; the query&amp;rsquo;s is computed on every request, with a synchronous call inside a coroutine.&lt;/p>
&lt;p>Its failure modes are almost all open, which is the reasonable thing: if the embedding model does not respond, if nothing passes the threshold, if the names do not match the catalogue, it returns all the tools. The exception is context overflow, which fails closed with a 400, and which if it happens while building the index during start-up is memoised and makes &lt;strong>all&lt;/strong> subsequent requests fail until you restart. The defaults are &lt;code>text-embedding-3-small&lt;/code>, &lt;code>top_k&lt;/code> of 10 and a threshold of 0.3, under &lt;code>litellm_settings.mcp_semantic_tool_filter&lt;/code>.&lt;/p>
&lt;p>&lt;strong>&lt;code>/mcp/proxy&lt;/code> does serve external clients&lt;/strong>, and that is where its real value lies. It replaces the catalogue with three tools with opaque identifiers. How &lt;code>search_tools&lt;/code> searches deserves a line, because it is not what you would assume: by default &lt;strong>it is neither a regular expression nor a semantic match, it is a keyword counter&lt;/strong>, how many tokens of the query appear as a substring in the concatenated name and description. It only moves to embeddings if you configure a model in &lt;code>mcp_tool_search&lt;/code>. The result cap is hard-coded at five in this mode, and the client cannot ask for more. In exchange, &lt;code>call_tool&lt;/code> goes through the same permission checks as a normal call plus a validation of the argument schema that the normal route does not do at that point.&lt;/p>
&lt;p>There is a related variant: with &lt;code>mcp_tool_search_enabled&lt;/code> in the key&amp;rsquo;s permissions, the catalogue is replaced by four virtual tools, and there the search one does expose &lt;code>top_k&lt;/code> to the client.&lt;/p>
&lt;p>&lt;strong>The catalogue order is not deterministic, and that breaks the client&amp;rsquo;s cache without anybody filtering anything.&lt;/strong> The list of allowed servers comes from iterating a Python set of strings, so with the random hash seed the order differs between processes, between uvicorn workers and between restarts. There is no sorting anywhere in the listing path, and there is no tool cache: every &lt;code>tools/list&lt;/code> queries the upstreams live, so the order within each server also depends on what the upstream returns that time. The gateway does not offer today the guarantee that the July specification recommends. The fix, sorting the catalogue before serving it, fits on one line.&lt;/p>
&lt;p>Nor is there any pagination towards the client. The catalogue is returned whole, always.&lt;/p>
&lt;p>&lt;strong>And the lever almost nobody uses is the static one.&lt;/strong> Every MCP server accepts &lt;code>tool_name_to_display_name&lt;/code> and &lt;code>tool_name_to_description&lt;/code>, and those overrides &lt;strong>really do replace what is sent to the client&lt;/strong> in &lt;code>tools/list&lt;/code>, not just what the administrator sees in the interface. The &lt;code>tools/call&lt;/code> routing translates from the new name to the real one before any permission check. With that you can trim a four-hundred-token description written by a third party, rename an ambiguous tool so it does not compete with a similar one, and do it without touching the prompt cache, because it is static and holds for all turns.&lt;/p>
&lt;p>It is also the rug pull mitigation left pending from the security section: if the description the model sees is the one the operator wrote, whatever the upstream server changes stops reaching the context. Two limits. They are configured through the database or the management API, &lt;strong>not through &lt;code>config.yaml&lt;/code>&lt;/strong>. And the reverse name translation is by exact equality and is not disambiguated between servers, so two servers with the same display name route unstably, made worse by the non-deterministic order of the previous paragraph.&lt;/p>
&lt;p>Alongside that, per-server &lt;code>allowed_tools&lt;/code> remains the blunt and effective instrument: it filters the listing the client sees, reduces tokens, and is static.&lt;/p>
&lt;h3 id="the-ladder-in-order">The ladder, in order&lt;/h3>
&lt;ol>
&lt;li>&lt;strong>Trim.&lt;/strong> Per-server &lt;code>allowed_tools&lt;/code>, plus a per-key allowlist. Cheap, static, does not break the cache and reduces the permission surface at the same time.&lt;/li>
&lt;li>&lt;strong>Rewrite the descriptions&lt;/strong> with the overrides. That is where the evidence is, because the description carries weight and the name almost none, and along the way it pins the contract against the rug pull.&lt;/li>
&lt;li>&lt;strong>Split by route.&lt;/strong> Each agent to &lt;code>/{server}/mcp&lt;/code> or to a tool set, never to the aggregated endpoint. Stable per agent, so the cache survives.&lt;/li>
&lt;li>&lt;strong>Sort the catalogue&lt;/strong> before serving it if you control the deployment, while the gateway does not do it.&lt;/li>
&lt;li>&lt;strong>&lt;code>/mcp/proxy&lt;/code>&lt;/strong> when the catalogue goes past thirty or fifty tools. It is progressive disclosure and it works with external clients, which is more than you can say for the semantic filter.&lt;/li>
&lt;li>&lt;strong>The semantic filter&lt;/strong>, only when the one calling the model is LiteLLM. With a generous &lt;code>top_k&lt;/code>, knowing that it embeds only the last user message and that context overflow is a closed and sticky failure.&lt;/li>
&lt;li>&lt;strong>What not to do&lt;/strong>: a home-grown top-K filter that rewrites the &lt;code>tools&lt;/code> array on every turn. It costs the whole prefix and, according to four of the five measured strategies, probably makes selection worse.&lt;/li>
&lt;/ol>
&lt;h2 id="the-declared-cost-which-is-worth-zero">The declared cost, which is worth zero&lt;/h2>
&lt;p>The calculation has four precedence steps: cost set by a post-call hook, per-tool cost in &lt;code>tool_name_to_cost_per_query&lt;/code>, server default cost, and &lt;strong>0.0&lt;/strong>.&lt;/p>
&lt;p>Without configuring &lt;code>mcp_server_cost_info&lt;/code>, all MCP traffic is accounted at zero and consumes neither key nor team budget.&lt;/p>
&lt;p>There are exactly two Prometheus metrics for MCP:&lt;/p>
&lt;ul>
&lt;li>&lt;code>litellm_mcp_tool_calls_total&lt;/code>&lt;/li>
&lt;li>&lt;code>litellm_mcp_tool_call_spend_metric&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>Both with the same eight labels: tool name, server name, key hash, key alias, team, team alias, user and end user.&lt;/p>
&lt;p>And the second is only incremented if the cost is greater than zero. With the default configuration, &lt;strong>that time series never appears in Prometheus&lt;/strong>. It is not that it is worth zero: it is that it does not exist.&lt;/p>
&lt;p>Nor is there a &lt;code>tools/list&lt;/code> metric, or MCP latency, or MCP errors, or upstream server health. For that you are left with the generic request metrics.&lt;/p>
&lt;p>In the reports, &lt;code>/spend/calculate&lt;/code> has no MCP branch: it accepts a model and messages, or a completion response, and it always calls the LLM cost calculation. The per-team report does include MCP calls, grouped by the &lt;code>model&lt;/code> column, which for these rows is worth &lt;code>MCP: tool_name&lt;/code>. That is: every tool appears as if it were a model, with zero input and output tokens. Only the sessions endpoint separates the two call types explicitly.&lt;/p>
&lt;h2 id="observability-and-the-trace-that-gets-cut">Observability, and the trace that gets cut&lt;/h2>
&lt;p>There are two OpenTelemetry stacks in LiteLLM and which one runs depends on an environment variable.&lt;/p>
&lt;p>Without &lt;code>LITELLM_OTEL_V2=true&lt;/code>, the old logger runs, and it has no MCP handling: the only appearance of MCP in the whole file is the metadata key in a list. The tool call comes out as a generic &lt;code>litellm_request&lt;/code> span with the model set to &lt;code>MCP: name&lt;/code>.&lt;/p>
&lt;p>With v2 enabled there is a dedicated span, with a dedicated role, client kind and a parent in the proxy&amp;rsquo;s request span. The span name is &lt;code>tools/call tool-name&lt;/code>. The attributes are correct and follow the conventions:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Attribute&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>gen_ai.operation.name&lt;/code>&lt;/td>
&lt;td>&lt;code>execute_tool&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.method.name&lt;/code>&lt;/td>
&lt;td>&lt;code>tools/call&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.session.id&lt;/code>&lt;/td>
&lt;td>MCP session identifier&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.name&lt;/code>&lt;/td>
&lt;td>Tool name&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.call.arguments&lt;/code>&lt;/td>
&lt;td>Only with content capture enabled&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.call.result&lt;/code>&lt;/td>
&lt;td>Only with content capture enabled&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>server.address&lt;/code>, &lt;code>server.port&lt;/code>&lt;/td>
&lt;td>From the upstream URL, redacted&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm.cost.total&lt;/code>&lt;/td>
&lt;td>The cost that is probably worth zero&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Content capture is off by default. Without it you see neither the arguments nor the result in the trace.&lt;/p>
&lt;p>A note for anyone working with semantic conventions: &lt;code>mcp.tool.name&lt;/code> &lt;strong>does not exist&lt;/strong>. The MCP registry has exactly four attributes (&lt;code>mcp.method.name&lt;/code>, &lt;code>mcp.session.id&lt;/code>, &lt;code>mcp.protocol.version&lt;/code> and &lt;code>mcp.resource.uri&lt;/code>), all four in development, and the tool name goes in &lt;code>gen_ai.tool.name&lt;/code>. LiteLLM gets it right. And the GenAI conventions were extracted into their own repository which as of today &lt;strong>has no release at all&lt;/strong>, so there is no version number to cite.&lt;/p>
&lt;p>In Langfuse, the vendor mapper returns an empty dictionary for any span that is not an LLM call. An MCP call appears as a raw span with its attributes, with no native input, output or model fields.&lt;/p>
&lt;h3 id="the-trace-is-cut-at-the-gateway">The trace is cut at the gateway&lt;/h3>
&lt;p>Incoming context is read. The &lt;code>traceparent&lt;/code> the client puts in &lt;code>params._meta&lt;/code>, following the specification&amp;rsquo;s propagation proposal, is extracted and turned into a &lt;strong>link&lt;/strong> on the span, never into its parent. The reason is documented in the code: parenting to the remote trace would leave the span hanging off a trace whose root never reaches the backend. The client&amp;rsquo;s baggage is discarded on purpose to avoid forging identity attributes.&lt;/p>
&lt;p>Outgoing context is not propagated. There is no propagator injection call anywhere in the tree. The headers that go out towards the upstream MCP server are built with authentication and the authorised extra headers, and nothing else.&lt;/p>
&lt;p>The operational consequence: there is no end-to-end trace. If the upstream MCP server is instrumented, its spans live in another trace and there is no way to correlate them automatically.&lt;/p>
&lt;h2 id="auditing-and-the-conflict-with-privacy">Auditing and the conflict with privacy&lt;/h2>
&lt;p>A tool call leaves a normal spend row, with call type &lt;code>call_mcp_tool&lt;/code>, spend equal to the cost per query, model &lt;code>MCP: name&lt;/code>, zero tokens in the three token columns, a dedicated &lt;code>mcp_namespaced_tool_name&lt;/code> column, and the usual key, team, user and session fields.&lt;/p>
&lt;p>And in the &lt;code>metadata&lt;/code> column, in JSON, the complete structure of the call: name, &lt;strong>arguments&lt;/strong>, result, server, namespaced name, MCP session identifier, authentication mode and server resource with the URL redacted.&lt;/p>
&lt;h3 id="the-problem">The problem&lt;/h3>
&lt;p>That structure is written into the metadata column &lt;strong>without passing through the privacy switch&lt;/strong>. The assignment is direct and is not conditioned by the function that governs whether prompts and responses are stored, which does control the &lt;code>messages&lt;/code> and &lt;code>response&lt;/code> columns.&lt;/p>
&lt;p>And the redaction function does not cover it either: it only touches &lt;code>messages&lt;/code> and &lt;code>response&lt;/code>. A search for MCP in the redaction file returns nothing.&lt;/p>
&lt;p>The practical result: an operator who enables &lt;code>turn_off_message_logging&lt;/code> for GDPR keeps sending the full arguments of every tool to every logging destination they have configured. To the SIEM, to Langfuse, to S3.&lt;/p>
&lt;p>If an argument carries a national ID number, an address, a case number or a medical record, it has been exported.&lt;/p>
&lt;p>There is an aggravating factor that does not depend on LiteLLM. The revision of the specification in force introduces an extension that allows mirroring tool arguments into HTTP headers visible to load balancers, proxies and web application firewalls. The specification recommends not marking sensitive parameters that way, and it is a recommendation, and the upstream server decides.&lt;/p>
&lt;h3 id="the-fit-with-the-ens">The fit with the ENS&lt;/h3>
&lt;p>The conflict is real and has a familiar shape: activity logging (&lt;code>op.exp.8&lt;/code>) requires the call to be traced and non-repudiable; protection of personal data (&lt;code>mp.info.1&lt;/code>) requires that log not to become a secondary repository of personal data with a different purpose. The tool argument is simultaneously the evidence of the action and the data. Redacting it destroys non-repudiation; storing it in the clear creates the repository.&lt;/p>
&lt;p>The defensible way out you can build today: a hash of the argument in the audit log, the argument in the clear with encryption (&lt;code>mp.info.3&lt;/code>), separate retention and its own access control. The hash sustains non-repudiation and the clear text is only opened under procedure.&lt;/p>
&lt;p>There are two other measures this material touches directly. Change management (&lt;code>op.exp.5&lt;/code>) because registering an MCP server and, above all, changing the description of an already approved tool are changes to the security configuration. And maintenance and updates (&lt;code>op.exp.4&lt;/code>) because, as you can see in the next section, an old version of the gateway is a non-compliance with a name and a number.&lt;/p>
&lt;p>Two honesty warnings. The codes follow the numbering of Annex II of RD 311/2022 and you have to check them against the text in force before citing them in a compliance document. And on NIS2: as of the date of this post the directive &lt;strong>is still not transposed in Spain&lt;/strong>, with the draft bill on cybersecurity coordination and governance going through the process and a reasoned opinion from the Commission over the delay. What is enforceable today in the public sector and its suppliers is the ENS.&lt;/p>
&lt;h3 id="what-is-not-logged">What is not logged&lt;/h3>
&lt;p>An administrative gap: registering, modifying and deleting MCP servers &lt;strong>generates no audit log&lt;/strong>. There are three &lt;code>TODO&lt;/code> comments in the management code that say so, one of them with an &lt;code>if is_audit_logging_enabled(): pass&lt;/code>. In the same deletion block there are two more TODOs about not cleaning up orphaned permissions on keys and teams.&lt;/p>
&lt;h2 id="security">Security&lt;/h2>
&lt;h3 id="the-vulnerability-you-need-to-know-about">The vulnerability you need to know about&lt;/h3>
&lt;p>&lt;code>CVE-2026-42271&lt;/code>, published on 8 May 2026. Affects LiteLLM from 1.74.2 up to versions prior to 1.83.7. CVSS 3.1 of 8.8.&lt;/p>
&lt;p>The &lt;code>POST /mcp-rest/test/connection&lt;/code> and &lt;code>POST /mcp-rest/test/tools/list&lt;/code> endpoints, which exist to preview an MCP server before saving it, accepted the stdio transport&amp;rsquo;s &lt;code>command&lt;/code>, &lt;code>args&lt;/code> and &lt;code>env&lt;/code> in the request body. A low-privilege key executed arbitrary commands on the proxy host. It also affects Red Hat OpenShift AI on several branches.&lt;/p>
&lt;p>It is the story of this post condensed into one line: the gateway you put in to contain MCP was the hole.&lt;/p>
&lt;p>It does not come alone. The 1.83 branch concentrates three more serious ones:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>CVE&lt;/th>
&lt;th>Component&lt;/th>
&lt;th>CVSS&lt;/th>
&lt;th>Fixed in&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>CVE-2026-42208&lt;/code>&lt;/td>
&lt;td>SQL injection in key validation, &lt;strong>with no prior authentication&lt;/strong>&lt;/td>
&lt;td>9.8&lt;/td>
&lt;td>1.83.7&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>CVE-2026-35030&lt;/code>&lt;/td>
&lt;td>OIDC userinfo cache keyed on &lt;code>token[:20]&lt;/code>, collision and impersonation&lt;/td>
&lt;td>9.1&lt;/td>
&lt;td>1.83.0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>CVE-2026-40217&lt;/code>&lt;/td>
&lt;td>Remote execution through bytecode rewriting in the guardrails test endpoint&lt;/td>
&lt;td>8.8&lt;/td>
&lt;td>no version recorded&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Plus two privilege escalations fixed in 1.83.10 and 1.83.14.&lt;/p>
&lt;p>The operational conclusion is short: &lt;strong>a deployment below 1.83.14 is exposed to remote execution or to an unauthenticated SQL injection&lt;/strong>. The current branch is 1.102.0.&lt;/p>
&lt;p>And a note for context: the whole MCP ecosystem has the same pattern. The unauthenticated remote execution in MCP Inspector, the command injection in &lt;code>mcp-remote&lt;/code> when connecting to an untrusted server, DNS rebinding disabled by default in the Python and TypeScript SDKs until late 2025, the directory escapes in the reference servers. Connecting to an untrusted MCP server compromises the client, not just the other way round.&lt;/p>
&lt;h3 id="the-rug-pull-nobody-covers">The rug pull nobody covers&lt;/h3>
&lt;p>Documented since April 2025: a server can change a tool&amp;rsquo;s description &lt;strong>after&lt;/strong> the client has approved it. The description is what the model reads to decide when and how to use it, so changing it is changing the behaviour without asking for permission again.&lt;/p>
&lt;p>The specification in force does not address it. It defines list change notification and cache freshness fields, which are recency mechanisms, not integrity ones. The closest normative warning is that clients must treat tool annotations as untrusted unless they come from trusted servers.&lt;/p>
&lt;p>LiteLLM does not cover it either. The only &amp;ldquo;pinning&amp;rdquo; in the code is of the configuration&amp;rsquo;s server identifier, so that it does not change when you edit the YAML. There is no hash or comparison of the description or the input schema between successive listings. A search for related terms in the MCP module: nothing functional.&lt;/p>
&lt;p>The control exists outside the gateway. &lt;code>mcp-scan&lt;/code>, from the same researchers who documented the attack, implements description hash pinning precisely to detect this.&lt;/p>
&lt;p>In ENS terms it is an unauthorised change to the security configuration that today goes undetected. And the home-made fix fits in a cron job: store the hash of the description and schema of every approved tool, compare against &lt;code>tools/list&lt;/code> periodically, alert on the difference.&lt;/p>
&lt;h3 id="three-more-holes-in-the-code">Three more holes in the code&lt;/h3>
&lt;p>&lt;strong>No URL validation towards the upstream.&lt;/strong> A new server&amp;rsquo;s &lt;code>url&lt;/code> field has no validator. The SSRF protection utilities exist in the tree and are used in OAuth discovery and in downloading OpenAPI specifications, but &lt;strong>not&lt;/strong> in the egress of &lt;code>tools/call&lt;/code>. A server pointing at a private range or at the cloud metadata IP is accepted as is.&lt;/p>
&lt;p>&lt;strong>The stdio transport is local execution on the proxy host.&lt;/strong> There is a command allowlist: &lt;code>npx&lt;/code>, &lt;code>uvx&lt;/code>, &lt;code>python&lt;/code>, &lt;code>python3&lt;/code>, &lt;code>node&lt;/code>, &lt;code>docker&lt;/code>, &lt;code>deno&lt;/code>, extendable through an environment variable. The code&amp;rsquo;s own comment admits the residual risk: the permitted runtimes can execute code through arguments. With &lt;code>docker&lt;/code> and &lt;code>npx&lt;/code> in there, it is de facto arbitrary execution for any proxy administrator.&lt;/p>
&lt;p>&lt;strong>Two server modes skip the gateway&amp;rsquo;s authentication.&lt;/strong> Pass-through and authentication delegation to the upstream completely omit virtual key validation. They are well hardened (fail-closed if the target is mixed or unresolvable, strict comparison against truthy values, explicit blocking of the client credentials flow so as not to lend out the proxy&amp;rsquo;s service account), and the result is still that those servers have no gateway access control.&lt;/p>
&lt;h3 id="the-limit-that-truncates-in-silence">The limit that truncates in silence&lt;/h3>
&lt;p>The &lt;code>tools/list&lt;/code> pagination stops and returns what it has accumulated in three cases: repeated cursor, one thousand pages, or expiry of a global deadline. The docstring declares it: a slow or faulty upstream produces a partial catalogue instead of an error.&lt;/p>
&lt;p>The first two log a warning. The third, the deadline, exits &lt;strong>without logging anything at all&lt;/strong>. A slow server makes part of its tools disappear from the catalogue with no trace left behind.&lt;/p>
&lt;h3 id="correction-on-the-slot-leak">Correction on the slot leak&lt;/h3>
&lt;p>In the previous post I took issue 34534 at face value, according to which every MCP tool call acquires a &lt;code>max_parallel_requests&lt;/code> slot and does not release it. Tracing the 1.102.0 code, that diagnosis no longer holds: the release path exists, on both success and failure, and the limiter is registered as a litellm callback and not just as a proxy hook.&lt;/p>
&lt;p>With one honest caveat: I found no regression test covering the full cycle for MCP, and the definitive confirmation is a real proxy with &lt;code>max_parallel_requests: 2&lt;/code> and five MCP calls in a row. If anybody reproduces it today, I would like to know.&lt;/p>
&lt;h2 id="the-specification-moved-underneath">The specification moved underneath&lt;/h2>
&lt;p>The MCP revision in force is &lt;code>2026-07-28&lt;/code>, published on 28 July 2026, and in between there was another one, &lt;code>2025-11-25&lt;/code>. The changes in the latest one are not cosmetic:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Protocol sessions and the session header are removed.&lt;/strong> Cross-request state becomes an explicit identifier in the tool arguments.&lt;/li>
&lt;li>&lt;strong>The initialisation handshake is removed.&lt;/strong> Every request carries version and capabilities in &lt;code>_meta&lt;/code>. MCP becomes stateless.&lt;/li>
&lt;li>&lt;code>server/discover&lt;/code> appears as a mandatory call.&lt;/li>
&lt;li>Elicitation changes shape: out go server-initiated requests, in comes a multi-round pattern where the server returns an &amp;ldquo;input required&amp;rdquo; result and the client retries with the answers.&lt;/li>
&lt;li>&lt;code>ping&lt;/code>, &lt;code>logging/setLevel&lt;/code> and the roots change notification are removed. SSE stream resumption is removed.&lt;/li>
&lt;li>Standard HTTP headers are mandatory on POST, which lets a gateway or a web application firewall decide without parsing the JSON.&lt;/li>
&lt;li>On authorisation: mandatory issuer validation in the client, and &lt;strong>dynamic client registration is deprecated&lt;/strong> in favour of client identifier metadata documents.&lt;/li>
&lt;li>Roots, sampling and logging are deprecated, with a minimum window of twelve months.&lt;/li>
&lt;/ul>
&lt;p>LiteLLM 1.102.0 advertises &lt;code>2025-06-18&lt;/code>. Its version enumeration has three values and none of them is later than that date. The literal is sent in the &lt;code>initialize&lt;/code> against the upstream.&lt;/p>
&lt;p>One nuance that is not entirely bad: the version the gateway advertises to &lt;strong>its&lt;/strong> clients is not hard-coded in LiteLLM, the SDK negotiates it. And there is partial implementation of the intermediate revision, because the elicitation and sampling handlers reference it in their docstrings. That is: partial implementation of one revision while advertising an earlier one.&lt;/p>
&lt;p>In practical interoperability it does not hurt yet, because the client ecosystem is still mostly on the 2025 revisions. It hurts in planning: July&amp;rsquo;s stateless redesign invalidates the gateway&amp;rsquo;s session model, and that is work ahead.&lt;/p>
&lt;p>Two ecosystem notes for anyone choosing. There are clients that speak remote streamable HTTP natively (Claude and Claude Code, ChatGPT with OAuth and dynamic registration, VS Code with Copilot, Cursor, Zed, Kiro) and others that still demand stdio or a local bridge with &lt;code>mcp-remote&lt;/code>, among them Windsurf, Cline and Continue.&lt;/p>
&lt;h2 id="tool-execution-from-the-proxy-itself">Tool execution from the proxy itself&lt;/h2>
&lt;p>Besides serving MCP outwards, the proxy can consume it itself. It is enabled with an &lt;code>mcp&lt;/code> block in the body, with &lt;code>server_url&lt;/code> equal to the &lt;code>litellm_proxy&lt;/code> sentinel, and it works both on &lt;code>/v1/responses&lt;/code> and on &lt;code>/chat/completions&lt;/code>.&lt;/p>
&lt;p>The flow: it separates the gateway&amp;rsquo;s tools, lists them, filters by allowed, deduplicates, translates them into OpenAI function format and concatenates them. It calls the model. If there are tool calls and auto-execution is permitted, it runs them against the MCP servers. It builds the follow-up and makes &lt;strong>one&lt;/strong> second call.&lt;/p>
&lt;p>Two limits you need to know before designing on top of this.&lt;/p>
&lt;p>&lt;strong>The loop is a single hop.&lt;/strong> There is no multi-step iteration. The calls from the first response are executed and the follow-up is returned. A plan that chains tools requires the client to call again.&lt;/p>
&lt;p>&lt;strong>Auto-execution fails closed and as a block.&lt;/strong> It requires all MCP references to have approval set to &amp;ldquo;never&amp;rdquo;; a single one requiring approval disables auto-execution for the whole request.&lt;/p>
&lt;p>And a third one, which is the one that bites: &lt;strong>the multi-turn state of &lt;code>/v1/responses&lt;/code> is rehydrated from the spend table&lt;/strong>. The query is literal, it looks up the session of the previous &lt;code>request_id&lt;/code> and retrieves all the rows of that session ordered by time.&lt;/p>
&lt;p>With &lt;code>disable_spend_logs: true&lt;/code> nothing is written, the query comes out empty and the history is lost &lt;strong>in silence&lt;/strong>, with no 4xx error. The code itself acknowledges it: it does not expect retries because deployments that do not write spend logs have nothing to wait for. It is a coupling between a performance option and a conversation feature that nobody expects to find.&lt;/p>
&lt;h2 id="health-and-day-to-day-operation">Health and day-to-day operation&lt;/h2>
&lt;p>Health checks exist, they are on demand and they are not periodic. &lt;code>GET /v1/mcp/server/health&lt;/code> accepts repeated identifiers and returns per-server status. The implementation opens a client and runs an empty operation inside the session, with a ten-second timeout.&lt;/p>
&lt;p>It skips the check entirely if the server requires per-user authentication, and those always return unknown status.&lt;/p>
&lt;p>The surprising part: the status, last check and error columns have existed in the database since a May 2025 migration, and the check function &lt;strong>returns an object without writing to the database&lt;/strong>. Status is recalculated on every request. There is no periodic MCP health job.&lt;/p>
&lt;p>When an upstream server is down, the behaviour is to degrade and not block. The listing fans out in parallel and each failing server contributes an empty list plus a result classified into six categories. Those results reach the client in the response&amp;rsquo;s &lt;code>_meta&lt;/code> under a vendor key, without filtering the upstream&amp;rsquo;s text or URL. The reason for the design is in the code: an empty contribution with no signal makes a broken upstream indistinguishable from a healthy server with no tools.&lt;/p>
&lt;p>There is no cache of the previous list. A server that is down disappears from that turn&amp;rsquo;s tools.&lt;/p>
&lt;p>On registering and deregistering without restarting, there are two worlds. Database servers can be created, edited and deleted through the API, and every operation reloads the registry with an atomic swap. Propagation between replicas goes through Redis publication on the configuration change channel, if there is Redis, and through polling every 30 seconds if there is not.&lt;/p>
&lt;p>Servers declared in &lt;code>config.yaml&lt;/code> are loaded once, at start-up. There is no re-read. Changing them requires a restart.&lt;/p>
&lt;p>And there is a precedence trap between the two worlds, with two different warnings in the code. A database row with the same identifier as a configuration entry &lt;strong>hides the configuration one entirely&lt;/strong>. And the other way round, a configuration identifier that matches the name or alias of a database server captures its permissions, because identifiers are resolved before names. Both cases emit a log warning, once.&lt;/p>
&lt;h2 id="a-hardened-configyaml">A hardened &lt;code>config.yaml&lt;/code>&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Access is not open by default, but this closes the empty-key&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># inheritance towards the team, which is the one that surprises people.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">require_key_mcp_access_defined&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">require_end_user_mcp_access_defined&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Without this, allow_all_keys overrides the key&amp;#39;s explicit scope.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mcp_allow_all_keys_respects_mcp_scope&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Does not cover MCP arguments. See the auditing section.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">turn_off_message_logging&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">callbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;langfuse_otel&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">mcp_servers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">inventario&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">url&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https://mcp-inventario.interna.svc/mcp&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">transport&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">auth_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bearer_token&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">auth_value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/MCP_INVENTARIO_TOKEN&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Explicit allowlist. Also enforced on tools/call.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allowed_tools&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">buscar_articulo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">consultar_stock&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Never true on a server with data.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allow_all_keys&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">access_groups&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;operaciones&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">20&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_concurrent_requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mcp_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">description&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Inventario de almacen, solo lectura&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Without this, MCP spend is zero and its metric does not exist.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mcp_server_cost_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">default_cost_per_query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0005&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tool_name_to_cost_per_query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">consultar_stock&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0002&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And in the process environment:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Without this there is no MCP span, only a generic litellm_request.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_OTEL_V2&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Full block hashes, not truncated, if correlating&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># with engine events.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_MCP_CLIENT_TIMEOUT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">45&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_MCP_TOOL_LISTING_TIMEOUT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">20&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For clients, the rule is not to use the aggregated endpoint. Each agent points at &lt;code>/{server}/mcp&lt;/code> or at a tool set, or at the three-tool proxy mode if the catalogue is large.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Check the version. Below 1.83.14 there is remote execution and an unauthenticated SQL injection with an assigned CVE.&lt;/li>
&lt;li>Inventory which servers have &lt;code>allow_all_keys&lt;/code> and enable the setting that makes it respect the key&amp;rsquo;s scope.&lt;/li>
&lt;li>Audit the access groups separately, because they are additive on top of the key and team ceiling.&lt;/li>
&lt;li>Declare in writing the fail-open behaviour on tool permission resolution errors.&lt;/li>
&lt;li>Measure the real cost: number of tools per server from the listing row, and &lt;code>tools&lt;/code> block tokens from the stored request body.&lt;/li>
&lt;li>Choose a catalogue strategy before connecting the third server: three-tool proxy mode, semantic filter, or selection by route. Not the aggregated endpoint.&lt;/li>
&lt;li>Declare &lt;code>mcp_server_cost_info&lt;/code> even with a token value, or the spend metric will not exist.&lt;/li>
&lt;li>Enable the OTel v2 logger, and decide deliberately whether to enable content capture.&lt;/li>
&lt;li>Assume the trace is cut at the gateway and plan correlation with the upstream by other means.&lt;/li>
&lt;li>Treat the spend table&amp;rsquo;s metadata column as a personal data repository: encryption, its own retention and separate access control.&lt;/li>
&lt;li>Set up the rug pull check, because nobody does it: hash of the description and schema of every approved tool, periodic comparison against the listing.&lt;/li>
&lt;li>Restrict egress towards MCP servers at the network level, because the gateway does not validate the destination URL.&lt;/li>
&lt;li>Do not enable the stdio transport unless you need it, and if you do, review what is on the command allowlist.&lt;/li>
&lt;li>If you use &lt;code>/v1/responses&lt;/code> with multi-turn state, do not enable &lt;code>disable_spend_logs&lt;/code>.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>MCP access is not open by default in 1.102.0.&lt;/strong> I got that wrong in the previous post. What opens things up is per-server &lt;code>allow_all_keys&lt;/code>.&lt;/li>
&lt;li>&lt;strong>&lt;code>allow_all_keys&lt;/code> overrides the key&amp;rsquo;s explicit scope&lt;/strong> unless you enable a setting that ships disabled.&lt;/li>
&lt;li>&lt;strong>&lt;code>require_key_mcp_access_defined&lt;/code> only closes key-to-team inheritance.&lt;/strong> Access group grants remain additive.&lt;/li>
&lt;li>&lt;strong>On an indeterminate failure resolving tool permissions, the code fails open&lt;/strong> for keys and JWTs.&lt;/li>
&lt;li>&lt;strong>The default cost of an MCP call is 0.0&lt;/strong>, and the spend metric is only emitted if the cost is greater than zero, so the series does not exist.&lt;/li>
&lt;li>&lt;strong>MCP calls appear in the per-team report as if they were models&lt;/strong>, named &lt;code>MCP: tool&lt;/code> and with zero tokens.&lt;/li>
&lt;li>&lt;strong>The OTel logger with MCP support is off by default.&lt;/strong> Without the environment variable there is no MCP span.&lt;/li>
&lt;li>&lt;strong>Trace context is not propagated towards the upstream MCP server.&lt;/strong> There is no end-to-end trace.&lt;/li>
&lt;li>&lt;strong>&lt;code>mcp.tool.name&lt;/code> does not exist&lt;/strong> in the semantic conventions. The name goes in &lt;code>gen_ai.tool.name&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Message redaction does not cover tool arguments.&lt;/strong> They are written in the clear into the spend table.&lt;/li>
&lt;li>&lt;strong>Registering, modifying and deleting MCP servers generates no audit log.&lt;/strong> There are three TODOs in the code.&lt;/li>
&lt;li>&lt;strong>The semantic tool filter is not applied on &lt;code>tools/list&lt;/code>.&lt;/strong> Only on &lt;code>/chat/completions&lt;/code> and &lt;code>/v1/responses&lt;/code>.&lt;/li>
&lt;li>&lt;strong>The catalogue order is not deterministic&lt;/strong>, because it comes from iterating a set of strings. It breaks the client&amp;rsquo;s prompt cache even though nobody filters anything.&lt;/li>
&lt;li>&lt;strong>&lt;code>search_tools&lt;/code> in proxy mode counts keywords by default&lt;/strong>, it uses neither regular expressions nor embeddings, and its cap of five results is hard-coded.&lt;/li>
&lt;li>&lt;strong>Description overrides really do replace what the client sees&lt;/strong>, and they are the only catalogue lever that does not break the cache. They are only configurable through the database or the API.&lt;/li>
&lt;li>&lt;strong>&lt;code>spec_version&lt;/code> no longer exists&lt;/strong> in the configuration schema.&lt;/li>
&lt;li>&lt;strong>&lt;code>/mcp/sse&lt;/code> does not use the SSE transport&lt;/strong>, and the messages endpoint its object advertises is dead code.&lt;/li>
&lt;li>&lt;strong>Listing pagination truncates in silence when the deadline expires&lt;/strong>, without leaving a single log line.&lt;/li>
&lt;li>&lt;strong>A database row hides a configuration entry with the same identifier&lt;/strong>, and the other way round a configuration identifier can capture the permissions of a database server.&lt;/li>
&lt;li>&lt;strong>Servers declared in &lt;code>config.yaml&lt;/code> require a restart.&lt;/strong> Only the database ones reload hot.&lt;/li>
&lt;li>&lt;strong>The health check does not write its result to the database&lt;/strong> even though the columns exist, and there is no periodic job.&lt;/li>
&lt;li>&lt;strong>The gateway does not validate the upstream MCP server&amp;rsquo;s URL.&lt;/strong> The anti-SSRF utilities exist and are not used on that path.&lt;/li>
&lt;li>&lt;strong>&lt;code>docker&lt;/code> and &lt;code>npx&lt;/code> are on the stdio command allowlist.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The &lt;code>/v1/responses&lt;/code> tool loop is a single hop&lt;/strong>, and &lt;code>disable_spend_logs: true&lt;/code> breaks its multi-turn behaviour with no error.&lt;/li>
&lt;li>&lt;strong>LiteLLM advertises the June 2025 revision of the protocol&lt;/strong>, two behind the one in force.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>There is a thread running through the six posts in this track that is more visible here than anywhere else. The gateway is chosen for one job and ends up doing five, and each new job arrives with its own defaults, which are almost never the ones you would have chosen.&lt;/p>
&lt;p>In the case of MCP the asymmetry is twofold. On one side, the part that looks dangerous is better built than you would assume: access control is enforced on execution and not just on the listing, the hierarchy is built out of intersections, header forwarding is an allowlist, and default access is closed, contrary to what I myself wrote two days ago. On the other, the part nobody looks at is worse: tool arguments come out in the clear bypassing the privacy switch, spend is worth zero and its metric never gets to exist, the trace is cut, and a change to the description of an approved tool goes undetected by anybody in the whole chain, neither the specification nor the gateway.&lt;/p>
&lt;p>The architecture decision that comes out of this is that the MCP door has to be treated as what it is, an interconnection of systems with third parties, and not as one more function of the LLM proxy. That means its own inventory, formal registration, change control over every tool&amp;rsquo;s description, network isolation towards the upstream and a log designed knowing that it is going to contain personal data.&lt;/p>
&lt;p>And it means accepting that the cost you really pay for connecting fifteen MCP servers is in no column of the spend table. It is spread across the &lt;code>prompt_tokens&lt;/code> of every model call, on every turn, and across the accuracy you lose when the catalogue is too large for the model to choose well. That number can be computed with what the gateway already stores. The first step is wanting to compute it.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a> — the other face of the same problem, when the agent is a traffic class and not an identity.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">Prefix routing&lt;/a> — the sibling post, where the correction is about what the gateway does not do.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits&lt;/a> — the identity hierarchy these permissions rest on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — the trace path that MCP spans reach, or do not reach.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: authentication with Keycloak&lt;/a> — the identity of your own MCP servers, upstream of this gateway.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside and its deep observability&lt;/a> — the protocol, the six primitives and the semantic conventions.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents&lt;/a> — the network isolation this post assumes is necessary and does not explain.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls for the ENS, 42001 and the AI Act&lt;/a> — the compliance framework that activity logging and change management fit into.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Secret hardening in a sovereign LLM stack&lt;/a> — the upstream credentials the gateway holds.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">The second cost vector of AI agents&lt;/a> — what a tool loop that half fails costs you.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — the other face of the tool catalogue, the one paid in proxy CPU when the token counter serialises it on every request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a> — the seams with the identity provider and with the trace backend.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a> — the two standards the MCP specification requires and the authorisation server does not implement.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">Completing Keycloak for MCP&lt;/a> — the resource side: why the official SDKs do not validate the audience and what replaces token exchange.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>MCP Gateway&lt;/em>: &lt;a href="https://docs.litellm.ai/docs/mcp">https://docs.litellm.ai/docs/mcp&lt;/a>.&lt;/li>
&lt;li>LiteLLM, código: &lt;code>litellm/proxy/_experimental/mcp_server/server.py&lt;/code>, &lt;code>mcp_server_manager.py&lt;/code>, &lt;code>rest_endpoints.py&lt;/code>, &lt;code>auth/user_api_key_auth_mcp.py&lt;/code>, &lt;code>cost_calculator.py&lt;/code>, &lt;code>faults/list_outcomes.py&lt;/code>, &lt;code>litellm/proxy/management_endpoints/mcp_management_endpoints.py&lt;/code>, &lt;code>litellm/proxy/spend_tracking/spend_tracking_utils.py&lt;/code>, &lt;code>litellm/litellm_core_utils/redact_messages.py&lt;/code>, &lt;code>litellm/integrations/otel/&lt;/code>, &lt;code>litellm/responses/litellm_completion_transformation/session_handler.py&lt;/code>: &lt;a href="https://github.com/BerriAI/litellm">https://github.com/BerriAI/litellm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 34534, fuga de slot de concurrencia en llamadas MCP: &lt;a href="https://github.com/BerriAI/litellm/issues/34534">https://github.com/BerriAI/litellm/issues/34534&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2026-42271&lt;/code>, ejecución de comandos por los endpoints de prueba de MCP: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-42271">https://nvd.nist.gov/vuln/detail/CVE-2026-42271&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2026-42208&lt;/code>, inyección SQL sin autenticación en la validación de clave: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-42208">https://nvd.nist.gov/vuln/detail/CVE-2026-42208&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2026-35030&lt;/code>, colisión de clave de caché de userinfo OIDC: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-35030">https://nvd.nist.gov/vuln/detail/CVE-2026-35030&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2025-49596&lt;/code>, ejecución remota en MCP Inspector: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-49596">https://nvd.nist.gov/vuln/detail/CVE-2025-49596&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2025-6514&lt;/code>, inyección de comandos en &lt;code>mcp-remote&lt;/code>: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-6514">https://nvd.nist.gov/vuln/detail/CVE-2025-6514&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2025-66416&lt;/code>, protección de DNS rebinding desactivada por defecto en el SDK de Python: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-66416">https://nvd.nist.gov/vuln/detail/CVE-2025-66416&lt;/a>.&lt;/li>
&lt;li>Invariant Labs, &lt;em>MCP Security Notification: Tool Poisoning Attacks&lt;/em> (tool poisoning, rug pull, shadowing): &lt;a href="https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks">https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks&lt;/a>.&lt;/li>
&lt;li>Invariant Labs, &lt;em>Introducing mcp-scan&lt;/em> (fijado de herramientas por hash): &lt;a href="https://invariantlabs.ai/blog/introducing-mcp-scan">https://invariantlabs.ai/blog/introducing-mcp-scan&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, changelog de la revisión 2026-07-28: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog">https://modelcontextprotocol.io/specification/2026-07-28/changelog&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, changelog de la revisión 2025-11-25: &lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/changelog">https://modelcontextprotocol.io/specification/2025-11-25/changelog&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, &lt;em>Security Best Practices&lt;/em> (confused deputy, indicadores de recurso, validación de audiencia): &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices">https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices&lt;/a>.&lt;/li>
&lt;li>NSA, &lt;em>Model Context Protocol: Security Design Considerations for AI-Driven Automation&lt;/em>, mayo de 2026: &lt;a href="https://media.defense.gov/2026/Jun/02/2003943289/-1/-1/0/CSI_MCP_SECURITY.PDF">https://media.defense.gov/2026/Jun/02/2003943289/-1/-1/0/CSI_MCP_SECURITY.PDF&lt;/a>.&lt;/li>
&lt;li>CISA y agencias aliadas, &lt;em>Careful Adoption of Agentic AI Services&lt;/em>, mayo de 2026: &lt;a href="https://www.cisa.gov/resources-tools/resources/careful-adoption-agentic-ai-services">https://www.cisa.gov/resources-tools/resources/careful-adoption-agentic-ai-services&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Advanced tool use&lt;/em> (coste en tokens de las definiciones, búsqueda de herramientas, precisión de selección): &lt;a href="https://www.anthropic.com/engineering/advanced-tool-use">https://www.anthropic.com/engineering/advanced-tool-use&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Code execution with MCP&lt;/em>: &lt;a href="https://www.anthropic.com/engineering/code-execution-with-mcp">https://www.anthropic.com/engineering/code-execution-with-mcp&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Prompt caching&lt;/em> (jerarquía &lt;code>tools&lt;/code> → &lt;code>system&lt;/code> → &lt;code>messages&lt;/code> e invalidación): &lt;a href="https://platform.claude.com/docs/en/build-with-claude/prompt-caching">https://platform.claude.com/docs/en/build-with-claude/prompt-caching&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Tool search tool&lt;/em> (herramientas diferidas fuera del prefijo, umbral de 30 a 50 herramientas): &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool">https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool&lt;/a>.&lt;/li>
&lt;li>OpenAI, &lt;em>Prompt caching&lt;/em> (el orden de las herramientas cuenta): &lt;a href="https://developers.openai.com/api/docs/guides/prompt-caching">https://developers.openai.com/api/docs/guides/prompt-caching&lt;/a>.&lt;/li>
&lt;li>OpenAI, &lt;em>Tool search&lt;/em> (las herramientas descubiertas se cargan al final del contexto): &lt;a href="https://developers.openai.com/api/docs/guides/tools-tool-search">https://developers.openai.com/api/docs/guides/tools-tool-search&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Automatic Prefix Caching&lt;/em>: &lt;a href="https://docs.vllm.ai/en/latest/features/automatic_prefix_caching.html">https://docs.vllm.ai/en/latest/features/automatic_prefix_caching.html&lt;/a>.&lt;/li>
&lt;li>Microsoft, &lt;em>Use tools in chat&lt;/em> (límite de 128 herramientas por petición en VS Code): &lt;a href="https://code.visualstudio.com/docs/copilot/agents/agent-tools">https://code.visualstudio.com/docs/copilot/agents/agent-tools&lt;/a>.&lt;/li>
&lt;li>&lt;em>RAG-MCP&lt;/em>, prueba de estrés de 1 a 11.100 servidores candidatos, mayo de 2025: &lt;a href="https://arxiv.org/abs/2505.03275">https://arxiv.org/abs/2505.03275&lt;/a>.&lt;/li>
&lt;li>&lt;em>Benchmarking the Benchmarks&lt;/em>, desalineación del 18,5 % entre etiqueta y realidad, junio de 2026: &lt;a href="https://arxiv.org/html/2607.02577v1">https://arxiv.org/html/2607.02577v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>MCP-Atlas&lt;/em>, taxonomía de fallos sobre 220 herramientas y veinte modelos, febrero de 2026: &lt;a href="https://arxiv.org/html/2602.00933v3">https://arxiv.org/html/2602.00933v3&lt;/a>.&lt;/li>
&lt;li>&lt;em>BiasBusters&lt;/em>, sesgo de selección entre herramientas equivalentes y peso de la descripción frente al nombre: &lt;a href="https://arxiv.org/html/2510.00307">https://arxiv.org/html/2510.00307&lt;/a>.&lt;/li>
&lt;li>&lt;em>MCP Tool Descriptions Are Smelly&lt;/em>, auditoría de 856 herramientas de 103 servidores, febrero de 2026: &lt;a href="https://arxiv.org/html/2602.14878v1">https://arxiv.org/html/2602.14878v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>ToolMenuBench&lt;/em>, seis estrategias de filtrado y 26.460 ejecuciones, junio de 2026: &lt;a href="https://arxiv.org/html/2606.15508">https://arxiv.org/html/2606.15508&lt;/a>.&lt;/li>
&lt;li>&lt;em>Retrieval Models Aren&amp;rsquo;t Tool-Savvy&lt;/em> (ToolRet), corpus de 43.000 herramientas, marzo de 2025: &lt;a href="https://arxiv.org/abs/2503.01763">https://arxiv.org/abs/2503.01763&lt;/a>.&lt;/li>
&lt;li>&lt;em>Tools Are Not Islands&lt;/em>, brecha entre Recall@3 y conjunto completo, julio de 2026: &lt;a href="https://arxiv.org/html/2607.25718">https://arxiv.org/html/2607.25718&lt;/a>.&lt;/li>
&lt;li>&lt;em>ToolFlood&lt;/em>, saturación adversaria de la capa de recuperación, marzo de 2026: &lt;a href="https://arxiv.org/html/2603.13950">https://arxiv.org/html/2603.13950&lt;/a>.&lt;/li>
&lt;li>&lt;em>Scores Are Not Decisions&lt;/em>, el K óptimo como función del coste, julio de 2026: &lt;a href="https://arxiv.org/html/2607.27083v1">https://arxiv.org/html/2607.27083v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>Don&amp;rsquo;t Break the Cache&lt;/em>, caché de prompts en tareas agénticas de horizonte largo, enero de 2026: &lt;a href="https://arxiv.org/html/2601.06007v2">https://arxiv.org/html/2601.06007v2&lt;/a>.&lt;/li>
&lt;li>&lt;em>How Many Tools Should an LLM Agent See? A Chance-Corrected Answer&lt;/em>, mayo de 2026: &lt;a href="https://arxiv.org/html/2605.24660v1">https://arxiv.org/html/2605.24660v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>Semantic Tool Discovery for LLMs&lt;/em>, marzo de 2026: &lt;a href="https://arxiv.org/abs/2603.20313">https://arxiv.org/abs/2603.20313&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry, convenciones semánticas de GenAI y MCP: &lt;a href="https://github.com/open-telemetry/semantic-conventions-genai">https://github.com/open-telemetry/semantic-conventions-genai&lt;/a>.&lt;/li>
&lt;li>Boletín Oficial del Estado, Real Decreto 311/2022, Esquema Nacional de Seguridad: &lt;a href="https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191">https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191&lt;/a>.&lt;/li>
&lt;li>IBM ContextForge, gateway y registro MCP: &lt;a href="https://github.com/IBM/mcp-context-forge">https://github.com/IBM/mcp-context-forge&lt;/a>.&lt;/li>
&lt;li>Docker MCP Gateway: &lt;a href="https://github.com/docker/mcp-gateway">https://github.com/docker/mcp-gateway&lt;/a>.&lt;/li>
&lt;li>agentgateway: &lt;a href="https://github.com/agentgateway/agentgateway">https://github.com/agentgateway/agentgateway&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Model chain of trust (4/4): who serves the model and which machine you trust</title><link>https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/</link><pubDate>Sun, 26 Jul 2026 17:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/</guid><description>&lt;p>The three previous articles closed questions about the artefact: what contract the served model exposes, where its bytes come from and why you trust them. What remains is the one almost nobody asks until an auditor asks it: nothing proves that the process currently returning tokens on port 8000 is the inference engine and not something that slipped into the cluster and inherited its token. And a second one, worse: whether the machine it runs on is trustworthy, and against whom.&lt;/p>
&lt;p>These are different problems with different projects. &lt;strong>Workload identity&lt;/strong> is solved by SPIFFE/SPIRE, a CNCF graduated project since 2022. &lt;strong>Environment isolation&lt;/strong> is covered by Kata Containers and Confidential Containers, the latter promoted to CNCF &lt;em>incubating&lt;/em> on 22 July 2026, four days before this article was published. The first is cheap and almost always worth it. The second is expensive, more than the vendor&amp;rsquo;s press release suggests, and is only justified against a specific threat model.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>User identity ≠ workload identity.&lt;/strong> OIDC says which person is behind a request; it does not say which process serves it. &lt;code>ServiceAccount&lt;/code> tokens, static keys and gateway API keys are &lt;em>bearer&lt;/em> credentials: whoever holds them, is them.&lt;/li>
&lt;li>&lt;strong>SPIFFE&lt;/strong> defines a URI identifier (&lt;code>spiffe://domain/path&lt;/code>) and a verifiable document (&lt;strong>SVID&lt;/strong>, X.509 or JWT) delivered by a Workload API that does not require the workload to hold any prior secret. SPIRE implements it with node and workload attestation; defaults: &lt;code>default_x509_svid_ttl&lt;/code> 1 h, &lt;code>default_jwt_svid_ttl&lt;/code> 5 min, &lt;code>ca_ttl&lt;/code> 24 h.&lt;/li>
&lt;li>Identity is only worth something if something enforces it: Istio/Envoy via SDS, Cilium with mutual authentication (with serious reservations) or an &lt;code>ext_authz&lt;/code> with OPA in front of the gateway.&lt;/li>
&lt;li>&lt;strong>Three rungs of isolation&lt;/strong>: container with a shared kernel → Kata Containers (its own kernel and VM; v3.32.0 from June 2026) → Confidential Containers (hardware TEE, hypervisor outside the trust base, remote attestation with Trustee per RFC 9334).&lt;/li>
&lt;li>&lt;strong>The overhead of confidential mode runs from ~0 % to ~28 %&lt;/strong> depending on what you measure: GPU-only in CC with large batches sits around 4-8 %, while the full stack (a CVM with Intel TDX plus an H100 in CC), measured independently in 2026, gives +21.8 % to +27.8 % on TTFT and −17.7 % to −21.1 % on throughput. Honest heuristic: reserve 15-25 % extra capacity.&lt;/li>
&lt;li>&lt;strong>MIG and vGPU are forbidden in CC mode&lt;/strong> according to NVIDIA&amp;rsquo;s reference architecture, and every GPU on a node goes in the same mode.&lt;/li>
&lt;li>&lt;strong>Closing criterion&lt;/strong>: the order is contract → digest → signature verified at admission → workload identity → TEE.&lt;/li>
&lt;/ul>
&lt;h2 id="the-analogy-the-biosafety-laboratory">The analogy: the biosafety laboratory&lt;/h2>
&lt;p>A laboratory handling pathogens solves two problems much like ours, separately.&lt;/p>
&lt;p>The first is who gets in. The company badge is not enough: at the airlock there is a specific credential, short-lived and automatically renewed (if lost, it expires on its own within an hour), issued only after independently checking that the applicant is where they say they are and is who they say they are. That credential is the &lt;strong>SVID&lt;/strong>; the prior check is &lt;strong>attestation&lt;/strong>.&lt;/p>
&lt;p>The second is which room you work in, and here there are containment levels. On the open bench the air is shared and anything aerosolised affects everyone: the normal container, with a kernel shared by all the neighbours. One rung up, the biological safety cabinet, with a physical barrier and its own airflow: Kata Containers. At the very top, the maximum containment laboratory, where maintenance staff change filters without ever seeing the sample: the TEE, where the operator maintains the machine but cannot read its memory. And before opening the airlock somebody verifies that the integrity of the suit and of the room is as expected: remote attestation with conditional secret release.&lt;/p>
&lt;p>The uncomfortable lesson: the maximum containment level is a cost decision before it is one more degree of security. Nobody builds a BSL-4 to grow yeast. The useful question is who it isolates from, not how much.&lt;/p>
&lt;h2 id="part-a--workload-identity">Part A — Workload identity&lt;/h2>
&lt;h3 id="six-services-talking-to-each-other-and-none-of-them-knows-who-the-other-is">Six services talking to each other and none of them knows who the other is&lt;/h3>
&lt;p>In a serious inference cluster, like the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven layers&lt;/a> one, the gateway talks to the serving engine, the engine queries the vector database, the trace collector receives &lt;em>spans&lt;/em> from everyone and the agents with MCP invoke tools that in turn call the gateway. Many east-west connections and, in most deployments, none of them authenticated.&lt;/p>
&lt;p>User identity says which person is behind the request and is resolved with OIDC, as when &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">putting Keycloak in front of MCP&lt;/a>. Workload identity says which process issues it: there is no human on the other side, no browser, no consent, and the process is born and dies in seconds. Its three usual substitutes fail for different reasons:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Mechanism&lt;/th>
&lt;th>Where it fails&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Projected &lt;code>ServiceAccount&lt;/code> token&lt;/td>
&lt;td>SA granularity, not workload granularity: two pods with the same SA are indistinguishable. It does not cross the cluster boundary and does not federate. It is a &lt;em>bearer token&lt;/em> on the filesystem&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Static key&lt;/td>
&lt;td>It does not rotate, it gets shared over Slack, it does not distinguish issuer from bearer; once leaked, it is valid until somebody notices&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Gateway API key&lt;/td>
&lt;td>It authenticates the &lt;em>client&lt;/em>, not the process. LiteLLM&amp;rsquo;s virtual keys are for quota and budget, not for proving identity&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>There is no cryptographic link between credential and process: copy the credential, copy the identity. In a multi-tenant cluster like the &lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">H100 cluster&lt;/a> one, real isolation then depends on network topology and not on identity.&lt;/p>
&lt;h3 id="spiffe-the-standard">SPIFFE: the standard&lt;/h3>
&lt;p>&lt;a href="https://spiffe.io/docs/latest/spiffe-about/spiffe-concepts/">SPIFFE&lt;/a> and its implementation &lt;a href="https://github.com/spiffe/spire">SPIRE&lt;/a> graduated together in the CNCF on 20 September 2022. As of July 2026 they are settled infrastructure, not a bet.&lt;/p>
&lt;p>&lt;strong>The SPIFFE ID&lt;/strong> is a URI of the form &lt;code>spiffe://&amp;lt;trust-domain&amp;gt;/&amp;lt;workload-identifier&amp;gt;&lt;/code>, for example &lt;code>spiffe://inferencia.example/ns/inferencia/sa/vllm&lt;/code>: a name, not a credential. &lt;strong>The trust domain&lt;/strong> is the root of trust (organisation, environment or site), and the guidance recommends separating workloads from different sites or security environments into different domains.&lt;/p>
&lt;p>The SVID is the credential that proves that ID. The &lt;strong>X509-SVID&lt;/strong> carries the SPIFFE ID in the URI-type SAN, not in the &lt;code>CN&lt;/code>, which the specification discourages as a source of identity, and it is the preferred format. The &lt;strong>JWT-SVID&lt;/strong> exists for when there are L7 proxies terminating TLS in the middle, with the replay risk the documentation warns about:&lt;/p>
&lt;pre tabindex="0">&lt;code>Certificate:
Issuer: C=ES, O=SPIFFE
Validity
Not Before: Jul 26 08:00:00 2026 GMT
Not After : Jul 26 09:00:00 2026 GMT
Subject: C=ES, O=SPIRE, CN=vllm.inferencia
X509v3 extensions:
X509v3 Key Usage: critical
Digital Signature, Key Encipherment
X509v3 Extended Key Usage:
TLS Web Server Authentication, TLS Web Client Authentication
X509v3 Basic Constraints: critical
CA:FALSE
X509v3 Subject Alternative Name:
URI:spiffe://inferencia.example/ns/inferencia/sa/vllm
&lt;/code>&lt;/pre>&lt;p>One hour of validity, and what authenticates is the SAN URI: the &lt;code>CN&lt;/code> is decorative.&lt;/p>
&lt;p>&lt;strong>The Workload API&lt;/strong> is the elegant piece: a local UNIX socket from which the workload obtains its SVID, its private key and the trust bundle, without presenting any secret. The documentation is explicit: &amp;ldquo;the Workload API does not require that a calling workload have any knowledge of its own identity, or possess any authentication token&amp;rdquo;. That solves the cold start of all identity cryptography: the secret you would need in order to obtain the first secret.&lt;/p>
&lt;h3 id="spire-how-that-document-is-issued">SPIRE: how that document is issued&lt;/h3>
&lt;p>SPIRE has a server (the authority that signs) and one agent per node (which exposes the Workload API), and it chains two checks.&lt;/p>
&lt;p>&lt;strong>Node attestation&lt;/strong> verifies that the agent runs where it says it does. In Kubernetes the reference attestor is &lt;code>k8s_psat&lt;/code>, which validates a projected token with an audience against the API server; on bare metal there are TPM-based attestors. A note of honesty: SPIRE v1.15.1, from 28 May 2026, is a security patch over an incorrect PKCS7 validation in the &lt;code>azure_imds&lt;/code> attestor that allowed attested documents to be forged and a virtual machine to be impersonated.&lt;/p>
&lt;p>&lt;strong>Workload attestation&lt;/strong> answers the opening question. The &lt;a href="https://github.com/spiffe/spire/blob/main/doc/plugin_agent_workloadattestor_k8s.md">&lt;code>k8s&lt;/code> attestor&lt;/a> receives the PID of the process that opens the socket, infers the pod from its cgroup membership and queries the kubelet for the metadata. From there come the &lt;strong>selectors&lt;/strong>: namespace, service account, pod name and UID, labels, owner, node and, above all, container and image by tag or by digest. From SPIRE v1.15.0 (19 May 2026) Sigstore support stopped being experimental, enabling selectors by signature verification status, certificate subject and issuer, and transparency log. That closes the series&amp;rsquo; circle: you can require that the engine&amp;rsquo;s SVID go only to the container whose image carries a valid cosign signature from the expected issuer, which is what was verified in &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-signature-provenance-aibom-why-you-trust-those-bytes/">article 3/4&lt;/a>. Provenance goes from being a one-off admission check to a precondition of identity at runtime.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">spire-server entry create &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -parentID spiffe://inferencia.example/spire/agent/k8s_psat/prod/nodo-gpu-a &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -spiffeID spiffe://inferencia.example/ns/inferencia/sa/vllm &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -selector k8s:ns:inferencia &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -selector k8s:sa:vllm &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -selector k8s:container-name:vllm &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -selector k8s:container-image:vllm/vllm-openai@sha256:aa11bb22cc33 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -x509SVIDTTL &lt;span class="m">3600&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -federatesWith spiffe://sede-b.example
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The selectors are &lt;strong>conjunctive&lt;/strong>: a pod in another namespace, with another SA or with another image does not obtain that SVID even if it shares the node.&lt;/p>
&lt;p>The default TTLs have three consequences. Rotation is continuous, not a one-off event: code that loads the certificate once at startup will break exactly one hour later. Server downtime has a clock: twenty minutes are invisible, two hours bring down the cluster&amp;rsquo;s authenticated communications, and the &lt;a href="https://spiffe.io/docs/latest/planning/scaling_spire/">scaling guide&lt;/a> admits that &amp;ldquo;a single SPIRE Server instance represents a single point of failure&amp;rdquo;, with the datastore as the bottleneck and indicative figures running from two replicas of 1 CPU and 1 GB for 10 agents to eight of 16 CPU and 16 GB for 5,000 agents and 10,000 workloads. And tag-based selectors are fragile: the runtime may report one tag or another depending on the node and the moment, so the correct selector is the digest, as in &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">article 2/4&lt;/a>.&lt;/p>
&lt;h3 id="federation-between-trust-domains">Federation between trust domains&lt;/h3>
&lt;p>Two sites with independent clusters, or an external partner exposing a &lt;em>reranker&lt;/em>, should not share an authority. &lt;a href="https://spiffe.io/docs/latest/spiffe-specs/spiffe_federation/">SPIFFE federation&lt;/a> makes each domain publish a &lt;strong>bundle endpoint&lt;/strong> with its public trust material and the other poll it periodically: the &lt;code>https_web&lt;/code> profile authenticates it with web PKI and &lt;code>https_spiffe&lt;/code> with an X509-SVID from the domain itself, enabling automatic rotation and revocation of the root. The relationship is one-directional, bundles from different domains must never be merged and refresh is by polling with a default suggestion of five minutes. In practice, site A&amp;rsquo;s gateway accepts requests from site B&amp;rsquo;s engine without sharing a CA or an identity database, and cutting the relationship means deleting a bundle, not revoking certificates.&lt;/p>
&lt;h3 id="from-identity-to-effective-authorisation">From identity to effective authorisation&lt;/h3>
&lt;p>An SVID blocks nothing by itself; somebody has to compare the presented ID against a policy.&lt;/p>
&lt;p>&lt;strong>Istio + SPIRE&lt;/strong> is the best-worn integration: Istio detects a UNIX socket implementing Envoy&amp;rsquo;s SDS API and the proxy obtains its identities from there instead of from &lt;code>istiod&lt;/code>, mounted with the &lt;strong>SPIFFE CSI Driver&lt;/strong> (recommended over &lt;code>hostMount&lt;/code>). Two conditions break deployments: SPIRE&amp;rsquo;s trust domain and Istio&amp;rsquo;s must match exactly, and SPIRE only issues to previously registered workloads, including Istio&amp;rsquo;s own components. With that, policy is written in terms of identity and not IP:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">security.istio.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">AuthorizationPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-solo-desde-gateway&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inferencia&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ALLOW&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rules&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">from&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">source&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">principals&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;inferencia.example/ns/inferencia/sa/gateway&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">to&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">operation&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">methods&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;POST&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">paths&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;/v1/chat/completions&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;/v1/completions&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is a layer on top of the &lt;code>NetworkPolicy&lt;/code> from &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">layered hardening&lt;/a>: there we closed communication pairs by topology; here we also require who calls and on what path.&lt;/p>
&lt;p>&lt;strong>Cilium&lt;/strong> offers mutual authentication backed by SPIRE, tempting if you already use eBPF for the &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">data network&lt;/a>. It deserves criticism: it is still marked &lt;strong>beta&lt;/strong> in the stable documentation, and The New Stack published a reasoned critique arguing that the design does not preserve mTLS properties over the life of the connection (it uses the &lt;em>handshake&lt;/em> only to authenticate and discards the session keys) and that the identity model rests on eventually consistent per-node IP-identity caches, which can allow traffic that policy should deny.&lt;/p>
&lt;p>&lt;strong>The inference gateway and LiteLLM&lt;/strong> do not speak SPIFFE natively as of this article, and saying so is better than suggesting an integration that does not exist. The pattern that does work is the one the project itself documents: an Envoy in front that terminates mTLS with an X509-SVID or validates a JWT-SVID and delegates to OPA via &lt;code>ext_authz&lt;/code>. The gateway carries on with its own job (quota, budget, routing), as we saw when &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">choosing an OSS gateway&lt;/a> and in the &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">L7 router&lt;/a>; workload identity is resolved one hop earlier.&lt;/p>
&lt;h3 id="ai-agents-and-mcp-the-missing-piece">AI agents and MCP: the missing piece&lt;/h3>
&lt;p>An agent invoking tools over MCP is a shared secret with legs: it is handed a static credential, the MCP server cannot know whether the caller is the legitimate agent or any process holding the same string, and the blast radius is the entire set of exposed tools. The &lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">threat model for agent isolation&lt;/a> warned that a granted permission is a usable permission; with bearer credentials it is also transferable.&lt;/p>
&lt;p>SPIFFE provides what is missing: the agent presents a short-lived SVID obtained through attestation and the MCP server authorises against the SPIFFE ID. In 2026 this stopped being theory: there is work at the IETF on dynamic OAuth client registration based on trusted SPIFFE issuers, and in June 2026 Google published a SPIFFE-based &lt;em>Agent Identity&lt;/em> in its IAM. None of it is fully standardised.&lt;/p>
&lt;p>Two honest caveats. SPIFFE answers &amp;ldquo;who&amp;rdquo;, not &amp;ldquo;why&amp;rdquo;: to know whether the action is the one the user asked for you need traceability (&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP with OTel&lt;/a>) and runtime detection with &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon&lt;/a>. And both identities must be composed: the agent proves its own with an SVID and propagates the delegating user&amp;rsquo;s in the token, authorising on the pair. Collapsing them into one is how you end up with agents able to do, in the system&amp;rsquo;s name, things no user could.&lt;/p>
&lt;h2 id="part-b--isolation-of-the-execution-environment">Part B — Isolation of the execution environment&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Three rungs of isolation: container with a shared kernel, virtual machine sandbox with Kata Containers, and trusted execution environment with Confidential Containers, showing which components stay inside the trusted computing base in each case">
&lt;text x="410" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">What stays INSIDE the trusted computing base (TCB)&lt;/text>
&lt;rect x="20" y="44" width="240" height="200" rx="10" fill="none" stroke="currentColor" stroke-width="1.6"/>
&lt;text x="140" y="68" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="currentColor">1. Container (runc)&lt;/text>
&lt;text x="140" y="92" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">namespaces + cgroups + seccomp&lt;/text>
&lt;line x1="40" y1="106" x2="240" y2="106" stroke="currentColor" stroke-width="1"/>
&lt;text x="140" y="126" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">host kernel (shared)&lt;/text>
&lt;text x="140" y="146" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">container runtime&lt;/text>
&lt;text x="140" y="166" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">hypervisor / firmware&lt;/text>
&lt;text x="140" y="186" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">platform operator&lt;/text>
&lt;text x="140" y="216" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="currentColor">startup: ~1-6 s&lt;/text>
&lt;text x="140" y="234" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="currentColor">cost: zero&lt;/text>
&lt;rect x="290" y="44" width="240" height="200" rx="10" fill="none" stroke="currentColor" stroke-width="1.6"/>
&lt;text x="410" y="68" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="currentColor">2. Kata Containers&lt;/text>
&lt;text x="410" y="92" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">own kernel + lightweight VM&lt;/text>
&lt;line x1="310" y1="106" x2="510" y2="106" stroke="currentColor" stroke-width="1"/>
&lt;text x="410" y="126" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">host kernel: OUT&lt;/text>
&lt;text x="410" y="146" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">container runtime: OUT&lt;/text>
&lt;text x="410" y="166" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">hypervisor / firmware: in&lt;/text>
&lt;text x="410" y="186" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">platform operator: in&lt;/text>
&lt;text x="410" y="216" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="currentColor">startup: +1-2 s over runc&lt;/text>
&lt;text x="410" y="234" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="currentColor">cost: VM memory&lt;/text>
&lt;rect x="560" y="44" width="240" height="200" rx="10" fill="none" stroke="currentColor" stroke-width="2.2"/>
&lt;text x="680" y="68" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="currentColor">3. Confidential Containers&lt;/text>
&lt;text x="680" y="92" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">TEE (SEV-SNP / TDX) + GPU in CC&lt;/text>
&lt;line x1="580" y1="106" x2="780" y2="106" stroke="currentColor" stroke-width="1"/>
&lt;text x="680" y="126" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">host kernel: OUT&lt;/text>
&lt;text x="680" y="146" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">hypervisor: OUT&lt;/text>
&lt;text x="680" y="166" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">platform operator: OUT&lt;/text>
&lt;text x="680" y="186" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">CPU/GPU and their firmware: in&lt;/text>
&lt;text x="680" y="216" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="currentColor">startup: +10 s or more&lt;/text>
&lt;text x="680" y="234" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="currentColor">cost: 0-25 % of performance&lt;/text>
&lt;text x="410" y="272" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor">Each rung takes components out of the TCB. What leaves the TCB can no longer read your memory.&lt;/text>
&lt;text x="410" y="290" text-anchor="middle" font-family="sans-serif" font-size="11" font-style="italic" fill="currentColor">The right question is not "how much does it isolate", but "from whom".&lt;/text>
&lt;/svg>
&lt;/div>
### Kata Containers: the virtual machine sandbox
&lt;p>Kata replaces &lt;code>runc&lt;/code> with a runtime that boots a lightweight virtual machine per pod, with its own kernel and a &lt;code>kata-agent&lt;/code> inside; for Kubernetes it is transparent via &lt;code>RuntimeClass&lt;/code>. As of July 2026 the stable branch is 3.32.x (3.32.0 is from June 2026, with Rust 1.94, Go 1.25.11, QEMU 11.0.1, guest kernel 6.18.35 and containerd 2.3), and in April 2026 the 4.0.0 preview was published, making the Rust runtime (&lt;code>runtime-rs&lt;/code>) the default and leaving the Go one deprecated until 5.0.0, because of memory footprint.&lt;/p>
&lt;p>For GPUs the route is &lt;strong>VFIO passthrough&lt;/strong>. The &lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/deploy-kata-containers.html">NVIDIA GPU Operator&lt;/a> automates it with &lt;code>sandboxWorkloads.enabled=true&lt;/code> and &lt;code>sandboxWorkloads.mode=kata&lt;/code>, installing the VFIO Manager, the Sandbox Device Plugin, the Confidential Computing Manager and the Kata Manager. It requires virtualisation and ACS in the BIOS, IOMMU, kata-deploy 3.29.0 or later, containerd (not CRI-O), the &lt;code>KubeletPodResourcesGet&lt;/code> &lt;em>feature gate&lt;/em> (on by default from Kubernetes 1.34) and removing the NVIDIA drivers from the host: the GPU is handed over whole to the VM and the guest manages it.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Pod&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-kata&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inferencia&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runtimeClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kata-qemu-nvidia-gpu&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai@sha256:aa11bb22cc33&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/pgpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two measurable costs. Memory: the VM reserves its own and you have to declare it as &lt;code>overhead&lt;/code> in the &lt;code>RuntimeClass&lt;/code> so the scheduler does not overcommit the node. And startup: according to the SESAME'24 study on serverless confidential containers, cold start goes from around 6 s with &lt;code>runc&lt;/code> to around 7 s with Kata, and warm start from 1 s to 2 s. Acceptable for an engine that takes minutes to load weights; unacceptable for ephemeral functions.&lt;/p>
&lt;h3 id="confidential-containers-the-tee">Confidential Containers: the TEE&lt;/h3>
&lt;p>&lt;a href="https://confidentialcontainers.org/">Confidential Containers&lt;/a> (CoCo) takes the hypervisor and the platform operator out of the trust base. The CNCF promoted it to &lt;em>incubating&lt;/em> on 22 July 2026, with more than 150 active contributors, 26 repositories and over 1,200 merged &lt;em>pull requests&lt;/em>, and with Microsoft Azure, Intel, AMD, IBM, NVIDIA, Alibaba and Red Hat behind it. A serious project, but &lt;em>incubating&lt;/em> means it is not graduated and that its integration surface changes between versions.&lt;/p>
&lt;p>The components are the CoCo pods (unmodified containers run in a TEE via Kata) and &lt;strong>Trustee&lt;/strong>: &lt;strong>KBS&lt;/strong> (&lt;em>Key Broker Service&lt;/em>, which releases secrets conditionally), &lt;strong>AS&lt;/strong> (&lt;em>Attestation Service&lt;/em>, which validates the hardware evidence) and &lt;strong>RVPS&lt;/strong> (&lt;em>Reference Value Provider Service&lt;/em>, which holds the reference values). Inside the guest, the Attestation Agent collects the evidence and the Confidential Data Hub consumes the secrets.&lt;/p>
&lt;p>The flow is a clean instance of the RATS architecture from RFC 9334: the agent inside the TEE is the &lt;em>attester&lt;/em>, the Attestation Service the &lt;em>verifier&lt;/em> and the Confidential Data Hub the &lt;em>relying party&lt;/em>. The evidence is a hardware-signed report with the boot measurements; the AS validates it against the RVPS values; and only if it matches does the KBS release the key that decrypts the image layers or the weights. Without valid attestation there is no key, and without a key there is no model: that is what stops the operator extracting it.&lt;/p>
&lt;p>A mapping warning: the &lt;code>confidential-containers/operator&lt;/code> repository was archived in February 2026 and deployment moved to the Helm charts and the &lt;code>trustee-operator&lt;/code>. As of July 2026 the latest chart release is v0.21.0, aligned with kata-deploy 3.31.0, and Trustee v0.20.0, with TLS 1.3 and post-quantum cryptography, external plugins for the KBS and multi-GPU support via Intel Trust Authority and NVIDIA NVSwitch.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">confidentialcontainers.org/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TrusteeConfig&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">trusteeconfig&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">operators&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">profileType&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Restrictive&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpsSpec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tlsSecretName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">trustee-tls-cert&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>Permissive&lt;/code> profile exists for development and should not leave it: it is the mode in which attestation does not block.&lt;/p>
&lt;h3 id="the-hardware-cpu-and-gpu">The hardware: CPU and GPU&lt;/h3>
&lt;p>On the CPU side the two relevant TEEs are &lt;strong>AMD SEV-SNP&lt;/strong> and &lt;strong>Intel TDX&lt;/strong>; NVIDIA&amp;rsquo;s reference architecture sets EPYC Milan/Genoa and Xeon Emerald/Granite Rapids as the baseline. Host support is no longer the hard part.&lt;/p>
&lt;p>On the GPU side, the &lt;a href="https://cacm.acm.org/practice/creating-the-first-confidential-gpus/">technical description of the design of the first confidential GPUs&lt;/a> explains what CC mode on an H100 buys you. Memory is split into a &lt;strong>Compute Protected Region&lt;/strong> that hardware firewalls stop both the CPU over PCIe and other GPUs over NVLink from accessing. Everything crossing the CPU-GPU boundary passes through &lt;strong>bounce buffers&lt;/strong> encrypted with AES-GCM-256, and command buffers and CUDA kernels are encrypted and signed before crossing the bus. There is a chain of trust from GPU boot with a signed attestation report, and only NVIDIA-signed firmware runs in CC mode, validated against NRAS or locally in isolated environments. And the performance counters are disabled in hardware as a side-channel mitigation: that is the telemetry much of &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">observability with DCGM&lt;/a> depends on.&lt;/p>
&lt;p>What it does not protect: the package&amp;rsquo;s HBM memory is not encrypted (it is considered resistant to interposers, which is a claim about physical difficulty and not a cryptographic guarantee); nothing against denial of service, because the operator who cannot read you can still switch you off; nothing against timing or access-pattern side channels; and nothing against a bug in your own code inside the enclave or prompt injection, which remains the job of &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails&lt;/a>.&lt;/p>
&lt;p>On Blackwell, NVIDIA announces (2 July 2026) NVLink encryption that extends confidential computing to up to 8 GPUs, non-existent on Hopper and a condition for serving a large model with tensor parallelism. The reference architecture lists H100, H200, RTX Pro 6000 Blackwell Server Edition and B200 in single-GPU passthrough, and H100/H200 in PPCIe mode and B200 for multi-GPU.&lt;/p>
&lt;h3 id="the-overhead-numbers-with-source-and-with-judgement">The overhead numbers, with source and with judgement&lt;/h3>
&lt;p>Here it pays to distrust everybody, papers included. The published spread runs from 0 % to 28 % because the studies do not measure the same thing.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Source (date)&lt;/th>
&lt;th>What it measures&lt;/th>
&lt;th>Result&lt;/th>
&lt;th>Nature&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>NVIDIA, Blackwell blog (Jul. 2026)&lt;/td>
&lt;td>B200 with CC enabled&lt;/td>
&lt;td>−1.0 % to −7.5 % throughput; per-token latency under 8 %; &amp;ldquo;up to 98 % of native performance&amp;rdquo;&lt;/td>
&lt;td>&lt;strong>Vendor claim&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Corvex, HGX B200 with TDX (2026)&lt;/td>
&lt;td>&amp;ldquo;Verified&amp;rdquo; deployment, encrypted NVSwitch&lt;/td>
&lt;td>&amp;ldquo;Near-native performance&amp;rdquo;, with no figures of its own&lt;/td>
&lt;td>&lt;strong>Commercial claim&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>arXiv 2409.03992 (2024)&lt;/td>
&lt;td>H100 GPU only in CC, Llama-3.1 8B/70B&lt;/td>
&lt;td>−0.36 % to 6.85 % throughput; TTFT up to +19 %; overhead → 0 as the model grows&lt;/td>
&lt;td>Independent, partial&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>arXiv 2509.18886 (Sep. 2025)&lt;/td>
&lt;td>CPU TEE (TDX/SGX) and GPU TEE (H100), Llama2 7B/13B/70B&lt;/td>
&lt;td>CPU TEE: under 10 % throughput and 20 % latency. GPU TEE: 4-8 %&lt;/td>
&lt;td>Independent&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>arXiv 2607.19353 (May 2026)&lt;/td>
&lt;td>&lt;strong>Full stack&lt;/strong>: H100 in CC inside a CVM with TDX, Mistral-7B and Qwen3-30B-A3B under load&lt;/td>
&lt;td>TTFT +21.8 % and +27.8 %; throughput −17.7 % and −21.1 %; closed-loop, 11.5-20.2 %&lt;/td>
&lt;td>Independent, &lt;strong>complete&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The reconciliation: the penalty is in the data path, not in the GPU&amp;rsquo;s compute. NVIDIA itself quantifies the bottleneck: the effective bandwidth of the CPU-GPU interconnect in CC mode is limited by the CPU&amp;rsquo;s encryption throughput, &amp;ldquo;around 4 GB/s&amp;rdquo;. From there, three rules. The higher the compute/I-O ratio, the lower the overhead: a 70B with large batches and long sequences amortises the toll almost entirely, and a 7B with small batches and short prompts pays it in full. Adding the CPU TEE brings its own cost, and that is where most of the difference between 4-8 % and 20 % lies. And model loading and cold start are the worst cases, with tens of gigabytes crossing an encrypted bus at 4 GB/s, which makes everything discussed in &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">from disk to HBM&lt;/a> and in &lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">speeding up cold start&lt;/a> more important.&lt;/p>
&lt;p>The recommendation from the authors of the 2026 study, reserving between 15 % and 25 % extra capacity, is the figure I would take to a &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a> exercise, and not the vendor&amp;rsquo;s &amp;ldquo;98 % of native performance&amp;rdquo;. Both can be true at once; only one is prudent for sizing.&lt;/p>
&lt;h3 id="when-each-rung-is-worth-it">When each rung is worth it&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Scenario&lt;/th>
&lt;th>Kata&lt;/th>
&lt;th>CoCo (TEE)&lt;/th>
&lt;th>Reason&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Tenants that do not trust each other&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>Depends&lt;/td>
&lt;td>Kata removes the shared kernel, a real escape vector&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unaudited third-party code or models&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>Optional&lt;/td>
&lt;td>A shared kernel is too much surface for someone else&amp;rsquo;s code&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model intellectual property against the &lt;strong>infrastructure operator&lt;/strong>&lt;/td>
&lt;td>Not enough&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>The canonical case: third-party hosting, cloud, a partner providing the iron&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Regulated third-party data on infrastructure you do not control&lt;/td>
&lt;td>Not enough&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>See &lt;a href="https://blog.lo0.es/en/posts/infrastructure-defence-ai-compliance/">defence&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/infrastructure-healthcare-ai-compliance/">healthcare&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sovereign on-premise, one tenant, trusted operator&lt;/td>
&lt;td>Useful&lt;/td>
&lt;td>&lt;strong>Expensive over-engineering&lt;/strong>&lt;/td>
&lt;td>It protects you from an adversary you do not have&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Low latency with small batches&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;strong>Bad idea&lt;/strong>&lt;/td>
&lt;td>The worst point on the bus-encryption cost/benefit curve&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The nuance that saves the most money: the two axes are independent. Kata without a TEE is cheap and delivers most of the isolation between tenants; CoCo only adds something if your threat model includes the operator. If you build a sovereign factory with your own iron and your own staff and you answer &amp;ldquo;no&amp;rdquo; to that question, &lt;strong>the TEE is a cost with no return&lt;/strong>.&lt;/p>
&lt;h3 id="operational-traps">Operational traps&lt;/h3>
&lt;p>&lt;strong>MIG and vGPU against confidential mode.&lt;/strong> NVIDIA&amp;rsquo;s GA 1.0.0 reference architecture is blunt: MIG and vGPU are forbidden in CC mode, and mixed-mode nodes are not supported; every GPU on a host goes in CC or none does. The forums show the typical contradiction of a moving area: in February 2026 a moderator states that MIG+CC is not supported and in April a user cites the MIG page suggesting that Hopper and Blackwell already allow it. As of this article the reference architecture overrides the product page: if your multi-tenancy rested on &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">partitioning the GPU with MIG&lt;/a>, enabling CC sends you back to &amp;ldquo;one whole GPU per tenant&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>Startup grows non-linearly.&lt;/strong> SESAME'24 breaks down where: SEV memory provisioning adds around one second per 2 GB of guest (SEV pins every page in advance, and an inference VM allocates a lot), the OVMF firmware about three seconds and layer decryption another three to five. Scaling from 0 to 16 instances goes from 16 s with &lt;code>runc&lt;/code> to 190 s: irrelevant for a stable &lt;code>Deployment&lt;/code>, a design change for aggressive autoscaling with &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">KEDA&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Version coupling is brutal.&lt;/strong> Kata, kata-deploy, GPU Operator, containerd, QEMU with its own patches, guest kernel, NVIDIA driver and Trustee are a matrix that has to be treated as a unit: the reference architecture fixes Kubernetes 1.32 or later, Kata 3.29, GPU Operator 26.3.1 or later, containerd 2.2.2 or later and QEMU 10.1. Add it to the weight the &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">stack&amp;rsquo;s operators&lt;/a> already carry.&lt;/p>
&lt;p>&lt;strong>Attestation fails after a firmware update, and that is normal.&lt;/strong> The RVPS reference values describe a specific boot state. When you patch the SEV-SNP firmware, the microcode or the GPU firmware, the TCB version changes, the evidence stops matching and the KBS does not release keys, so the pods do not start. Since these updates usually respond to a security bulletin, the typical scenario is &amp;ldquo;we applied the critical patch on a Friday and on Saturday inference would not start&amp;rdquo;. The correct procedure reverses the order: update the reference values first with an overlap window, and patch the iron afterwards.&lt;/p>
&lt;p>&lt;strong>The attestation service is a single point of failure by design.&lt;/strong> If the KBS does not answer, no new confidential pod gets its key; those already started survive, but any rescheduling or scaling does not. The same dependency as the SPIRE server and the same treatment: real high availability, its own alert and rehearsed degradation.&lt;/p>
&lt;h2 id="closing-the-series-the-complete-chain">Closing the series: the complete chain&lt;/h2>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/">Article 1/4&lt;/a> set the contract: what the endpoint exposes and what control plane governs it reproducibly. In &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">2/4&lt;/a> the subject was the provenance of the bytes: which registry they come from, by immutable digest and not by a moving tag. &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-signature-provenance-aibom-why-you-trust-those-bytes/">3/4&lt;/a> added the cryptographic proof of what they are, with signatures, build attestations and an AIBOM verified at admission. This 4/4 closes with who runs them and where: an SVID that proves the serving process&amp;rsquo;s identity and an isolation rung chosen for the adversary you actually have. If one of the four fails, the other three are worth less than they look: an impeccably signed model served by an unidentified process on a machine a third party can dump is still a problem.&lt;/p>
&lt;p>With a limited budget, the order follows cost per unit of risk removed. First, digests everywhere: almost free, and it removes a whole family of substitution attacks. Second, signature verified at admission, low cost and the first thing an auditor asks to see. Third, a declarative contract and control plane, which make everything else auditable. Fourth, workload identity with SPIFFE/SPIRE: expensive to operate, but it is the only thing that turns east-west traffic into something authorisable, and it becomes mandatory as soon as agents with MCP arrive. And last, the TEE, only if the infrastructure operator is in your threat model; Kata without a TEE can jump ahead to fourth place if there are tenants that do not trust each other.&lt;/p>
&lt;h3 id="mapping-the-series-to-ens-isoiec-42001-and-the-eu-ai-act">Mapping the series to ENS, ISO/IEC 42001 and the EU AI Act&lt;/h3>
&lt;p>The detail is in &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">technical controls ENS × ISO 42001 × EU AI Act&lt;/a>; this table is the summary by link.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Link&lt;/th>
&lt;th>ENS measure (RD 311/2022)&lt;/th>
&lt;th>ISO/IEC 42001 (Annex A)&lt;/th>
&lt;th>EU AI Act&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1/4 Contract and control plane&lt;/td>
&lt;td>&lt;code>op.exp.2&lt;/code>; &lt;code>op.mon.1&lt;/code>&lt;/td>
&lt;td>A.6 life cycle&lt;/td>
&lt;td>Art. 12 (record-keeping); Art. 13 (transparency)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2/4 Registry and distribution by digest&lt;/td>
&lt;td>&lt;code>op.ext.3&lt;/code>&lt;/td>
&lt;td>A.10 third parties&lt;/td>
&lt;td>Art. 11 and Annex IV (technical documentation)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3/4 Signature, attestations and AIBOM&lt;/td>
&lt;td>&lt;code>op.exp.6&lt;/code>; &lt;code>op.ext.3&lt;/code>&lt;/td>
&lt;td>A.6.2 and A.7 data and traceability&lt;/td>
&lt;td>Art. 15(5), data and model poisoning&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4/4-A Workload identity&lt;/td>
&lt;td>&lt;code>op.acc.1/2/5&lt;/code>; &lt;code>op.exp.11&lt;/code>; &lt;code>mp.com.2-3&lt;/code>&lt;/td>
&lt;td>A.9 use of the AI system&lt;/td>
&lt;td>Art. 15(5), unauthorised third parties&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4/4-B Isolation and TEE&lt;/td>
&lt;td>&lt;code>mp.info.3&lt;/code>; &lt;code>op.exp.2&lt;/code>; &lt;code>mp.com.4&lt;/code>&lt;/td>
&lt;td>A.6 life cycle controls&lt;/td>
&lt;td>Art. 15(4) robustness; Art. 15(5)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Art. 15(5) carries the most weight: it requires high-risk systems to be &amp;ldquo;resilient against attempts by unauthorised third parties to alter their use, outputs or performance&amp;rdquo;, and it enumerates data and model poisoning and confidentiality attacks. Links 3 and 4 are its technical implementation. For the management framework, see &lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001 as an AIMS&lt;/a> and the &lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">AI Act mapping&lt;/a>.&lt;/p>
&lt;h2 id="for-an-inference-factory">For an inference factory&lt;/h2>
&lt;p>&lt;strong>Start workload identity with the gateway↔engine pair and with the agents, not with the whole cluster.&lt;/strong> Registering all forty workloads on day one is the most reliable way to abandon the project: register two entries, set up the Istio policy that requires the gateway&amp;rsquo;s principal and live with it for a couple of weeks to learn what breaks when the certificate rotates. Then the agents with MCP, where static credentials are the entire threat model.&lt;/p>
&lt;p>&lt;strong>Separate the Kata decision from the TEE decision, and take them in that order.&lt;/strong> Kata is a &lt;code>RuntimeClass&lt;/code> change with a second of startup and some memory. CoCo is a redesign: you lose MIG and the GPU&amp;rsquo;s performance counters, you gain ten seconds or more of startup, you pay 15-25 % of capacity and you add two dependencies whose failure prevents workloads from starting. Evaluate the second with a written threat model, not with a slide.&lt;/p>
&lt;p>&lt;strong>If you go to CoCo, rehearse patch day before you need it.&lt;/strong> The failure that will take your inference down will not be an attack, it will be a firmware update that misaligns the RVPS reference values. Write the runbook (update the RVPS with overlap, patch, retire the old value), rehearse it on one node and monitor the attestation success rate as a first-class metric, just as for SPIRE: alert on attestation failures before they turn into 401s at the gateway.&lt;/p>
&lt;p>That closes the series: from the contract to the artefact, from the artefact to its proof, and from the proof to the process and the machine. What lies below is no longer a chain of trust but daily operation: seeing what each process does with &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon&lt;/a> and measuring whether what is served is still good with &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals&lt;/a>. Trust is established once; vigilance is continuous.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/">Model chain of trust (1/4): KServe and the Open Inference Protocol&lt;/a> — the API contract that opens the series.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">Model chain of trust (2/4): registry and distribution with OCI and ORAS&lt;/a> — why the digest is everything.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-signature-provenance-aibom-why-you-trust-those-bytes/">Model chain of trust (3/4): signature, provenance and AIBOM&lt;/a> — the proof that Sigstore selectors turn into a precondition of identity.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">MCP grows up: authentication with Keycloak&lt;/a> — the user identity to compose with the workload one.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">Isolating AI agents: from workstation to cluster&lt;/a> — the threat model that SPIFFE gives a non-transferable credential.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon: runtime security&lt;/a> — the vigilance that starts where the chain of trust ends.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/infrastructure-defence-ai-compliance/">Infrastructure and regulatory compliance in defence&lt;/a> — where the TEE stops being over-engineering.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">H100 cluster: multi-tenant platform&lt;/a> — where MIG and confidential mode collide head-on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a> — the other identity plane, the one for people, and where it overlaps with SPIFFE.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">Completing Keycloak for MCP&lt;/a> — the authorisation of the tools an agent invokes, which neither SPIFFE nor the IdP solves.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>CNCF, &lt;em>SPIFFE and SPIRE Projects Graduate from CNCF Incubator&lt;/em> (20 sep. 2022) — &lt;a href="https://www.cncf.io/announcements/2022/09/20/spiffe-and-spire-projects-graduate-from-cloud-native-computing-foundation-incubator/">https://www.cncf.io/announcements/2022/09/20/spiffe-and-spire-projects-graduate-from-cloud-native-computing-foundation-incubator/&lt;/a>&lt;/li>
&lt;li>SPIFFE, &lt;em>SPIFFE Concepts&lt;/em> (SPIFFE ID, trust domain, SVID, Workload API) — &lt;a href="https://spiffe.io/docs/latest/spiffe-about/spiffe-concepts/">https://spiffe.io/docs/latest/spiffe-about/spiffe-concepts/&lt;/a>&lt;/li>
&lt;li>SPIFFE, &lt;em>SPIFFE Federation specification&lt;/em> — &lt;a href="https://spiffe.io/docs/latest/spiffe-specs/spiffe_federation/">https://spiffe.io/docs/latest/spiffe-specs/spiffe_federation/&lt;/a>&lt;/li>
&lt;li>SPIFFE, &lt;em>Scaling SPIRE&lt;/em> (dimensionamiento y punto único de fallo) — &lt;a href="https://spiffe.io/docs/latest/planning/scaling_spire/">https://spiffe.io/docs/latest/planning/scaling_spire/&lt;/a>&lt;/li>
&lt;li>spiffe/spire, &lt;em>Releases&lt;/em> (v1.15.0 de 19 may. 2026; v1.15.1 de 28 may. 2026, parche de &lt;code>azure_imds&lt;/code>) — &lt;a href="https://github.com/spiffe/spire/releases">https://github.com/spiffe/spire/releases&lt;/a>&lt;/li>
&lt;li>spiffe/spire, &lt;em>Kubernetes Workload Attestor plugin&lt;/em> (selectores, kubelet, cgroups, Sigstore) — &lt;a href="https://github.com/spiffe/spire/blob/main/doc/plugin_agent_workloadattestor_k8s.md">https://github.com/spiffe/spire/blob/main/doc/plugin_agent_workloadattestor_k8s.md&lt;/a>&lt;/li>
&lt;li>spiffe/spire, &lt;em>SPIRE Server configuration reference&lt;/em> (&lt;code>default_x509_svid_ttl&lt;/code>, &lt;code>default_jwt_svid_ttl&lt;/code>, &lt;code>ca_ttl&lt;/code>) — &lt;a href="https://github.com/spiffe/spire/blob/main/doc/spire_server.md">https://github.com/spiffe/spire/blob/main/doc/spire_server.md&lt;/a>&lt;/li>
&lt;li>Istio, &lt;em>SPIRE integration&lt;/em> (Envoy SDS, SPIFFE CSI Driver, trust domain) — &lt;a href="https://istio.io/latest/docs/ops/integrations/spire/">https://istio.io/latest/docs/ops/integrations/spire/&lt;/a>&lt;/li>
&lt;li>SPIFFE, &lt;em>OPA Authorization with Envoy and JWT-SVIDs&lt;/em> — &lt;a href="https://spiffe.io/docs/latest/microservices/envoy-jwt-opa/readme/">https://spiffe.io/docs/latest/microservices/envoy-jwt-opa/readme/&lt;/a>&lt;/li>
&lt;li>The New Stack, &lt;em>How Cilium&amp;rsquo;s Mutual Authentication Can Compromise Security&lt;/em> — &lt;a href="https://thenewstack.io/how-ciliums-mutual-authentication-can-compromise-security/">https://thenewstack.io/how-ciliums-mutual-authentication-can-compromise-security/&lt;/a>&lt;/li>
&lt;li>Riptides, &lt;em>Bringing SPIFFE to OAuth for MCP&lt;/em> — &lt;a href="https://riptides.io/blog/bringing-spiffe-to-oauth-for-mcp-secure-identity-for-agentic-workloads/">https://riptides.io/blog/bringing-spiffe-to-oauth-for-mcp-secure-identity-for-agentic-workloads/&lt;/a>&lt;/li>
&lt;li>CNCF, &lt;em>Confidential Containers becomes a CNCF incubating project&lt;/em> (22 jul. 2026) — &lt;a href="https://www.cncf.io/blog/2026/07/22/confidential-containers-becomes-a-cncf-incubating-project/">https://www.cncf.io/blog/2026/07/22/confidential-containers-becomes-a-cncf-incubating-project/&lt;/a>&lt;/li>
&lt;li>Confidential Containers, &lt;em>Attestation with Trustee&lt;/em> (KBS, AS, RVPS, CDH) — &lt;a href="https://confidentialcontainers.org/docs/attestation/">https://confidentialcontainers.org/docs/attestation/&lt;/a>&lt;/li>
&lt;li>Confidential Containers, &lt;em>Deploy Trustee in Kubernetes&lt;/em> (11 feb. 2026) — &lt;a href="https://confidentialcontainers.org/blog/2026/02/11/deploy-trustee-in-kubernetes/">https://confidentialcontainers.org/blog/2026/02/11/deploy-trustee-in-kubernetes/&lt;/a>&lt;/li>
&lt;li>confidential-containers/trustee, &lt;em>Releases&lt;/em> (v0.20.0: TLS 1.3, PQC, multi-GPU ITA, NVSwitch) — &lt;a href="https://github.com/confidential-containers/trustee/releases">https://github.com/confidential-containers/trustee/releases&lt;/a>&lt;/li>
&lt;li>IETF, &lt;em>RFC 9334 — Remote ATtestation procedureS (RATS) Architecture&lt;/em> — &lt;a href="https://www.rfc-editor.org/info/rfc9334/">https://www.rfc-editor.org/info/rfc9334/&lt;/a>&lt;/li>
&lt;li>Kata Containers, &lt;em>Kata Containers 4.0.0 Preview&lt;/em> (28 abr. 2026, &lt;code>runtime-rs&lt;/code> por defecto) — &lt;a href="https://katacontainers.io/blog/release-4-0-0-preview/">https://katacontainers.io/blog/release-4-0-0-preview/&lt;/a>&lt;/li>
&lt;li>kata-containers, &lt;em>Release 3.32.0&lt;/em> — &lt;a href="https://github.com/kata-containers/kata-containers/releases/tag/3.32.0">https://github.com/kata-containers/kata-containers/releases/tag/3.32.0&lt;/a>&lt;/li>
&lt;li>NVIDIA, &lt;em>Deploy with Kata Containers — GPU Operator&lt;/em> — &lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/deploy-kata-containers.html">https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/deploy-kata-containers.html&lt;/a>&lt;/li>
&lt;li>NVIDIA, &lt;em>Deploying Proprietary Models Securely with Confidential Computing on Self-Hosted Kubernetes&lt;/em> (GA 1.0.0; MIG y vGPU prohibidos en CC) — &lt;a href="https://docs.nvidia.com/enterprise-reference-architectures/deploying-proprietary-models-confidential-compute-self-hosted-kubernetes/latest/reference-implementations.html">https://docs.nvidia.com/enterprise-reference-architectures/deploying-proprietary-models-confidential-compute-self-hosted-kubernetes/latest/reference-implementations.html&lt;/a>&lt;/li>
&lt;li>CACM, &lt;em>Creating the First Confidential GPUs&lt;/em> (CPR, bounce buffers, contadores deshabilitados, HBM no cifrada) — &lt;a href="https://cacm.acm.org/practice/creating-the-first-confidential-gpus/">https://cacm.acm.org/practice/creating-the-first-confidential-gpus/&lt;/a>&lt;/li>
&lt;li>NVIDIA, &lt;em>Confidential Computing on H100 GPUs for Secure and Trustworthy AI&lt;/em> (límite de ~4 GB/s, NRAS) — &lt;a href="https://developer.nvidia.com/blog/confidential-computing-on-h100-gpus-for-secure-and-trustworthy-ai/">https://developer.nvidia.com/blog/confidential-computing-on-h100-gpus-for-secure-and-trustworthy-ai/&lt;/a>&lt;/li>
&lt;li>NVIDIA, &lt;em>Hardware-Rooted AI Security That Won&amp;rsquo;t Slow You Down&lt;/em> (2 jul. 2026; Blackwell, NVLink cifrado hasta 8 GPU) — &lt;a href="https://developer.nvidia.com/blog/hardware-rooted-ai-security-that-wont-slow-you-down">https://developer.nvidia.com/blog/hardware-rooted-ai-security-that-wont-slow-you-down&lt;/a>&lt;/li>
&lt;li>Corvex, &lt;em>Confidential Computing Meets NVIDIA HGX B200&lt;/em> (claim comercial) — &lt;a href="https://www.corvex.ai/blog/confidential-computing-meets-nvidia-hgxtm-b200-secure-ai-without-the-performance-trade-off">https://www.corvex.ai/blog/confidential-computing-meets-nvidia-hgxtm-b200-secure-ai-without-the-performance-trade-off&lt;/a>&lt;/li>
&lt;li>arXiv 2409.03992, &lt;em>Confidential Computing on NVIDIA Hopper GPUs: A Performance Benchmark Study&lt;/em> — &lt;a href="https://arxiv.org/html/2409.03992v1">https://arxiv.org/html/2409.03992v1&lt;/a>&lt;/li>
&lt;li>arXiv 2509.18886, &lt;em>Confidential LLM Inference: Performance and Cost Across CPU and GPU TEEs&lt;/em> (23 sep. 2025) — &lt;a href="https://arxiv.org/abs/2509.18886">https://arxiv.org/abs/2509.18886&lt;/a>&lt;/li>
&lt;li>arXiv 2607.19353, &lt;em>Benchmarking Confidential GPU Inference on NVIDIA H100 under Intel TDX&lt;/em> (may. 2026; heurística del 15-25 %) — &lt;a href="https://arxiv.org/html/2607.19353">https://arxiv.org/html/2607.19353&lt;/a>&lt;/li>
&lt;li>Segarra et al., &lt;em>Serverless Confidential Containers: Challenges and Opportunities&lt;/em> (SESAME'24) — &lt;a href="https://carlossegarra.com/assets/papers/sesame24-serverlesscoco.pdf">https://carlossegarra.com/assets/papers/sesame24-serverlesscoco.pdf&lt;/a>&lt;/li>
&lt;li>EU Artificial Intelligence Act, &lt;em>Article 15: Accuracy, Robustness and Cybersecurity&lt;/em> — &lt;a href="https://artificialintelligenceact.eu/article/15/">https://artificialintelligenceact.eu/article/15/&lt;/a>&lt;/li>
&lt;li>EU Artificial Intelligence Act, &lt;em>Article 12: Record-Keeping&lt;/em> — &lt;a href="https://artificialintelligenceact.eu/article/12/">https://artificialintelligenceact.eu/article/12/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Storage in the AI era (3/4): security</title><link>https://blog.lo0.es/en/posts/storage-ai-era-security/</link><pubDate>Mon, 22 Jun 2026 10:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/storage-ai-era-security/</guid><description>&lt;p>Data is the most valuable and most durable asset of an AI system. Models get retrained; weights get replaced; but training datasets, the customer data feeding RAG and the weights of a frontier model represent years of investment and, often, regulated information. In the &lt;a href="https://blog.lo0.es/en/posts/ai-era-storage-performance/">two previous articles&lt;/a> we discussed how to move that data fast. This third one is about how to protect it: encryption, integrity, access control, sovereignty and the threat that forces us to act today even though it looks distant, quantum computing.&lt;/p>
&lt;h2 id="encryption-at-rest-sed-aes-xts-and-the-transition-to-fips-140-3">Encryption at rest: SED, AES-XTS and the transition to FIPS 140-3&lt;/h2>
&lt;p>The first line of defence for any persistent data is encryption at rest. In enterprise storage the dominant pattern is the &lt;strong>SED&lt;/strong> (&lt;em>Self-Encrypting Drive&lt;/em>), a drive that implements the Trusted Computing Group&amp;rsquo;s Opal, Ruby and Enterprise standards and integrates a &lt;strong>256-bit AES-XTS&lt;/strong> encryption engine in the controller. The operational advantage is twofold: they encrypt at interface speed, with no perceptible performance penalty and without stealing CPU cycles, and they enable instant cryptographic erase.&lt;/p>
&lt;p>The mechanism behind that erase is the two-tier key model of TCG Opal 2.0. Data is actually encrypted against a data encryption key (DEK) that never leaves the drive; that DEK, in turn, is protected by an authentication key derived from the user&amp;rsquo;s passphrase. Destroying or regenerating the DEK instantly renders terabytes of data unreadable without having to overwrite them: the &lt;em>cryptographic erase&lt;/em>. For an organisation decommissioning 245 TB drives, this property is not a luxury, it is the only practical way to guarantee erasure.&lt;/p>
&lt;p>SED hardware encryption and software encryption with AES-NI are not mutually exclusive, but they do have different profiles. Hardware acceleration keeps a measurable edge, on the order of 2 times on 4K random reads and writes compared with pure software, although the gap narrows on modern CPUs with AES-NI and ARMv8. The rule of thumb: SED for the baseline at no performance cost, and software encryption on top when you need key domain separation or end-to-end encryption.&lt;/p>
&lt;p>The Achilles heel of encryption is never the algorithm, it is key management. Impeccable AES-256 protects nothing if the key is badly held. That is why serious storage encryption relies on an external key manager that speaks &lt;strong>KMIP&lt;/strong> (&lt;em>Key Management Interoperability Protocol&lt;/em>) to the arrays, separating custody of the key from the medium that encrypts. Keys are rotated periodically, backed up with their own availability guarantees (losing the key means losing the data as definitively as a fire would) and their access is audited. In regulated environments those keys live in certified hardware security modules (HSM). For an architect, the design decision is not &amp;ldquo;do we encrypt?&amp;rdquo;, the answer is always yes, but &amp;ldquo;who holds the keys and how are they governed?&amp;rdquo;, a question that, as we will see, sits at the heart of data sovereignty.&lt;/p>
&lt;p>The point no architect should ignore in 2026 is the &lt;strong>transition to FIPS 140-3&lt;/strong>. The CMVP stopped accepting new FIPS 140-2 validations in April 2022, and on &lt;strong>21 September 2026&lt;/strong> it will move all remaining 140-2 certificates to the &amp;ldquo;historical&amp;rdquo; list. From that date, US federal agencies must not include historical modules in new procurements; existing systems can keep operating, but the compliance clock is running. And there is a logistical side effect: validation times have grown from 367 to 542 days, 42 % more, which strains vendor roadmaps. Anyone planning storage purchases on a multi-year horizon should verify 140-3 certification, not 140-2, for every cryptographic module. The reference framework for the overall design remains NIST SP 800-209, which covers encryption, isolation, authentication and authorisation for block, file and object storage.&lt;/p>
&lt;h2 id="encryption-in-transit-the-weak-point-of-ai-fabrics">Encryption in transit: the weak point of AI fabrics&lt;/h2>
&lt;p>Data is also exposed in motion, and here the high-performance fabrics of AI introduce specific tensions. For NVMe-oF over TCP there is in-band authentication, and for stronger protection the NVMe working group proposes IPsec over RoCEv2. Modern NICs (ConnectX-6 Dx, BlueField-2) secure NVMe-oF connections with hardware-accelerated IPsec and TLS, so encryption in transit does not throttle bandwidth.&lt;/p>
&lt;p>The problem is InfiniBand. IPsec is not implemented over InfiniBand, since it is not an IP protocol, which leaves that fabric, ubiquitous in AI clusters, without one of the usual confidentiality tools. Nor does IPsec isolate well the RDMA connections of different users sharing the same interface. Academic research has documented attacks that exploit RDMA flaws in NVMe-oF storage applications. The conclusion for an architect is uncomfortable but important: in-transit security on an AI fabric is not inherited from the good habits of IP networks, and it demands explicit design of segmentation and isolation.&lt;/p>
&lt;h2 id="the-threat-arriving-from-the-future-post-quantum-cryptography">The threat arriving from the future: post-quantum cryptography&lt;/h2>
&lt;p>No storage security topic has changed its urgency as much as post-quantum cryptography (PQC), and the reason is an attack pattern that does not need a quantum computer to begin: &lt;strong>&amp;quot;&lt;em>harvest now, decrypt later&lt;/em>&amp;quot;&lt;/strong> (HNDL). Well-funded state actors are collecting encrypted data today with the intention of decrypting it once they have quantum capability. For any data whose confidentiality must last a decade, such as medical records, industrial secrets or proprietary training datasets, the threat is present, not future.&lt;/p>
&lt;p>In August 2024 NIST finalised the first three PQC standards, and by mid-2026 they are the foundation on which the entire migration is built:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Standard&lt;/th>
&lt;th>Algorithm&lt;/th>
&lt;th>Function&lt;/th>
&lt;th>Origin&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>FIPS 203&lt;/td>
&lt;td>ML-KEM&lt;/td>
&lt;td>Key encapsulation&lt;/td>
&lt;td>CRYSTALS-Kyber&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FIPS 204&lt;/td>
&lt;td>ML-DSA&lt;/td>
&lt;td>Digital signature (default)&lt;/td>
&lt;td>CRYSTALS-Dilithium&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FIPS 205&lt;/td>
&lt;td>SLH-DSA&lt;/td>
&lt;td>Backup signature (hash-based)&lt;/td>
&lt;td>SPHINCS+&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>In March 2025 NIST additionally selected HQC, a code-based encapsulation mechanism, as a backup for ML-KEM with different mathematical assumptions. The NSA, for its part, set a demanding timetable in CNSA 2.0: by 2030 all software and firmware deployed in national security systems must use CNSA 2.0 signatures, with full enforcement expected around 2031-2033 and complete quantum resistance in 2035.&lt;/p>
&lt;p>The pressure has intensified from the quantum hardware side. Between May 2025 and early 2026, several papers cut the estimates of qubits needed to break RSA-2048 from around 20 million to under a million, and even to figures on the order of 100,000 with new architectures. &amp;ldquo;Q-Day&amp;rdquo; around 2030 is increasingly cited by analysts, NIST and the NSA. It is not a certainty, but the safety margin has narrowed enough to make inaction imprudent.&lt;/p>
&lt;p>The storage industry is already moving. NetApp announced PQC for data at rest in 2025, compliant with the NIST algorithms and integrated both at rest and in flight. Western Digital began integrating NIST-approved PQC into its Ultrastar drives in 2026, one of the first deployments in production infrastructure. Cohesity, Commvault and Quantum also have products along these lines. The dominant pattern in 2026 is not wholesale replacement but &lt;strong>hybrid approaches&lt;/strong> that combine classical and post-quantum cryptography: they protect against a failure of the new algorithms and allow a gradual migration. For an architect, today&amp;rsquo;s concrete action is the cryptographic inventory, or &lt;em>crypto-agility&lt;/em>: knowing which algorithms protect which data and planning their rotation.&lt;/p>
&lt;h2 id="security-specific-to-ai-data-integrity-and-poisoning">Security specific to AI data: integrity and poisoning&lt;/h2>
&lt;p>Beyond classical encryption, AI brings a threat class of its own that falls directly on the data layer: &lt;strong>data poisoning&lt;/strong>. An adversary manipulates training or fine-tuning data to corrupt what the model learns. Its distinctive trait is stealth: a poisoned model behaves normally for long periods before exhibiting the malicious behaviour, which makes it very hard to detect after the fact. The OWASP Top 10 for LLM applications of 2025 formally recognises &amp;ldquo;data and model poisoning&amp;rdquo; as an integrity attack category, with especially high risk when external sources are ingested.&lt;/p>
&lt;p>The defence has moved towards provenance and lineage. Organisations are adopting rigorous documentation of every data source, a &amp;ldquo;digital chain of custody&amp;rdquo;, and the concept of the &lt;strong>ML-BOM&lt;/strong> (&lt;em>Machine Learning Bill of Materials&lt;/em>), analogous to the software SBOM. Frameworks such as the FINOS AI governance framework explicitly catalogue data poisoning among their risks. For storage, this means the system must be able to guarantee the integrity and immutability of training data and to record its lineage verifiably. Integrity stops being a desirable property and becomes a security control.&lt;/p>
&lt;h2 id="model-theft-when-the-asset-to-protect-is-the-weights">Model theft: when the asset to protect is the weights&lt;/h2>
&lt;p>There is one asset the traditional storage security conversation did not contemplate: model weights. Training a frontier model costs tens or hundreds of millions, and the result, a file of a few TB, concentrates all that value. Weight theft (&lt;em>model exfiltration&lt;/em>) is consequently a first-order threat, and its attack surface is precisely the storage layer where checkpoints and final models live.&lt;/p>
&lt;p>Protection combines several of the layers already described, applied with a specific focus. Encryption at rest with strict key control stops an attacker who reaches the drives from walking away with a usable model. Least-privilege access control limits who can read the weight directories, and auditing logs every access so anomalous exfiltration can be spotted; a process that suddenly reads 4 TB from a model directory is a signal. And confidential computing, which we will see further down, protects the weights even while they are being loaded into the GPU for inference. The lesson for the architect is that the trained model deserves, at minimum, the same defences as customer data, because its loss can be irreversible and its value greater.&lt;/p>
&lt;h2 id="immutability-and-ransomware-the-second-phase-of-the-attack">Immutability and ransomware: the second phase of the attack&lt;/h2>
&lt;p>Ransomware remains the highest economic impact threat against storage, and its recent evolution points straight at backups. The reference defence is &lt;strong>WORM immutability&lt;/strong> (&lt;em>Write-Once-Read-Many&lt;/em>). In the object world, S3 Object Lock prevents modifying or deleting data even for an attacker with administrative credentials, neutralising the second phase of the attack, the encryption of the victim&amp;rsquo;s data. It has two modes: &lt;em>Governance&lt;/em>, which can be overridden with specific permissions, and &lt;em>Compliance&lt;/em>, which nobody can override, not even the root account. Object Lock creates a logical air gap that, for many use cases, makes tapes and physical air gaps redundant.&lt;/p>
&lt;p>The numbers justify the investment. Ransom payments reached a record USD 1.1 billion in 2023; the average total cost of a ransomware attack was around USD 5.13 million in 2024, with an average recovery cost, excluding the ransom, of 1.53 million in 2025. Two out of three organisations suffered ransomware in the past year according to Sophos.&lt;/p>
&lt;p>For an inference factory, ransomware has an angle of its own: the most valuable target is not only customer data but the &lt;strong>model repository&lt;/strong>. Encrypting or deleting the weights the factory serves leaves it inoperative immediately, with a direct business impact, the service goes down, on top of the recovery cost. The defence is the same as for any critical asset, applied as a priority: immutable WORM copies of the model repository and of the vector indexes, so that a clean version of each model can always be restored even if the hot copy is compromised. A model that took months and millions to produce, or whose weights cannot be regenerated, deserves the strictest immutability protection available.&lt;/p>
&lt;p>The storage-side response has been to integrate detection into the array itself. Dell PowerProtect Cyber Recovery offers an isolated, immutable vault with an automated air gap, and its CyberSense component uses AI and full content analysis, not just metadata, to detect ransomware corruption, with very high accuracy figures announced by the vendor. NetApp integrates Autonomous Ransomware Protection into the storage, creating immutable snapshots in real time by analysing data patterns at the storage layer. It is wise to treat the accuracy figures (99 % and above) as vendor claims rather than independent benchmarks, but the underlying trend is real: &lt;strong>the storage array has stopped being a passive component and become an actor in the defence&lt;/strong>.&lt;/p>
&lt;p>A useful framework for prioritising these investments is the annualised loss expectancy, which relates the impact of an incident to its frequency:&lt;/p>
$$\text{ALE} = \text{SLE} \times \text{ARO}$$
&lt;p>where \( \text{SLE} \) is the expected loss per incident and \( \text{ARO} \) the expected annual frequency. Immutability and fast recovery act by reducing \( \text{SLE} \): even if the attack happens, the loss and the recovery time are bounded.&lt;/p>
&lt;h2 id="access-control-and-the-cost-of-a-breach">Access control and the cost of a breach&lt;/h2>
&lt;p>Encryption protects the data; access control decides who touches it. IBM&amp;rsquo;s &lt;em>Cost of a Data Breach 2025&lt;/em> report supplies the reference figure: the global average cost of a breach fell to USD 4.44 million, 9 % less and the first drop in five years, driven by faster containment thanks to AI-powered defences, with the average time to identify and contain falling to 241 days, the lowest in nine years. The United States, by contrast, set a record of 10.22 million. The largest cost component is still detection and escalation.&lt;/p>
&lt;p>Mature architectures combine &lt;strong>Zero Trust&lt;/strong> with RBAC as the base, ABAC attributes for fine grain and microsegmentation to prevent lateral movement inside the network. In an AI environment, where training data moves between data lake, pipeline and GPU cluster, microsegmentation and continuous contextual authorisation stop being optional.&lt;/p>
&lt;h2 id="data-sovereignty-the-regulatory-dimension">Data sovereignty: the regulatory dimension&lt;/h2>
&lt;p>For a European company, AI storage security is inseparable from data sovereignty. The &lt;strong>EU Data Act&lt;/strong>, applicable since 12 September 2025, mandates data portability and interoperability to eliminate vendor lock-in, and in its Chapter VII requires cloud providers operating in the EU to put in place technical, legal and organisational measures to prevent non-EU government access to non-personal data stored in the EU where such access would be unlawful under European law. This creates a documented tension with the US CLOUD Act, which empowers US authorities to claim data from providers under their jurisdiction regardless of where it is stored.&lt;/p>
&lt;p>The technical answer gaining traction in 2026 is &lt;strong>client-side encryption with full key ownership&lt;/strong>: whoever controls the keys controls the data, regardless of where it physically resides. &amp;ldquo;Whoever allows plaintext in the cloud cedes control&amp;rdquo; has become the motto of real sovereignty. For a European AI architect, data residency and key ownership are design decisions as important as performance.&lt;/p>
&lt;p>The &lt;strong>EU AI Act&lt;/strong>, furthermore, applies in phases that touch data governance: prohibited practices are enforceable since February 2025, obligations for general-purpose models since August 2025, and sanctioning powers since August 2026, although the provisional &lt;em>Digital Omnibus&lt;/em> agreement of May 2026 postponed the deadline for Annex III high-risk systems to December 2027, a point worth re-checking given how recent it is.&lt;/p>
&lt;h2 id="confidential-computing-protecting-data-in-use">Confidential computing: protecting data in use&lt;/h2>
&lt;p>Encryption at rest and in transit leaves a gap: data in use, decrypted in memory while it is processed. &lt;strong>Confidential computing&lt;/strong> closes it with trusted execution environments (TEE). On the CPU, Intel TDX and AMD SEV-SNP launch confidential virtual machines with encrypted memory. The novelty for AI is on the GPU.&lt;/p>
&lt;p>NVIDIA introduced confidential computing on the H100 (Hopper) with a CC mode and a root of trust in silicon: the CPU&amp;rsquo;s confidential VM exchanges encrypted data with an enclave in the GPU, extending the chain of trust from CPU to GPU. The &lt;strong>Blackwell&lt;/strong> architecture (B200, GB200) has made the qualitative leap: it is the industry&amp;rsquo;s first GPU with TEE-I/O capability, with inline protection over NVLink and NVSwitch that removes the PCIe I/O bottlenecks of earlier generations. And what matters most for performance: thanks to AI-specific encryption engines and hardware-accelerated encrypted HBM access, the HGX B200 keeps its roughly 2 times advantage in training and 2.5 times in inference over the HGX H200 &lt;strong>even with confidential computing fully enabled&lt;/strong>. This enables confidential training, inference and federated learning without the prohibitive penalty that previously made encrypting data in use unworkable, protecting both sensitive data and the model weights themselves from theft.&lt;/p>
&lt;h3 id="security-in-a-multi-tenant-inference-factory">Security in a multi-tenant inference factory&lt;/h3>
&lt;p>Confidential computing takes on special meaning in an inference factory, above all if it serves several customers or teams on the same infrastructure. Here the attack surface is not only the disk at rest but the data in use of each request: the prompts, the context documents, the responses and, crucially, the &lt;strong>KV cache&lt;/strong>. And the KV cache introduces a risk specific to inference that is worth naming: if a cache layer is shared between tenants to save compute, reusing prefixes as we saw in the performance article, badly isolated reuse can leak one customer&amp;rsquo;s content to another. The design rule is that prefix caching should only be shared within a single trust domain; between different tenants, KV cache isolation, whether in memory, in CXL or on NVMe, is a security control, not an optional optimisation.&lt;/p>
&lt;p>The same principle extends to the factory&amp;rsquo;s other planes. The model repository must apply per-tenant access control so that nobody loads or extracts someone else&amp;rsquo;s model. The RAG vector database must segment indexes per customer, because an embedding retrieved from the wrong corpus is both a quality failure and a data leak. And telemetry, which records prompts and responses, is one of the most sensitive repositories in the whole installation, subject to the same encryption, retention and residency requirements as customer data. A well-designed inference factory treats each of these planes as an isolation domain, not as a resource shared for convenience.&lt;/p>
&lt;p>An important technical nuance of confidential computing is &lt;strong>attestation&lt;/strong>. A secure enclave is worthless if you cannot prove the data is really executing inside it and not in a compromised environment pretending to be one. Attestation is the cryptographic mechanism by which the hardware, the root of trust in the silicon of the GPU or the CPU, issues a verifiable proof of its state and identity before keys or data are entrusted to it. In a confidential AI architecture, the chain of trust extends from the CPU&amp;rsquo;s confidential VM to the GPU enclave, and every link is attested. For the architect, the operational question is who verifies those attestations and where the keys that are only released after a valid attestation are held: that is where confidential computing connects back to the key management from the start of the article.&lt;/p>
&lt;h2 id="a-checklist-for-the-architect">A checklist for the architect&lt;/h2>
&lt;p>Gathering all of the above into actionable practice helps avoid leaving layers uncovered. At rest: SED with AES-XTS-256 as the baseline, external key management via KMIP with HSM, and verification of FIPS 140-3 certification on every module, anticipating September 2026. In transit: TLS 1.3 and IPsec wherever possible, with particular attention to isolation on InfiniBand fabrics, where the IP tools do not apply. Against the quantum threat: cryptographic inventory, crypto-agility and adoption of hybrid classical-PQC schemes for long-lived data, without waiting for Q-Day. Against ransomware: WORM immutability with S3 Object Lock in Compliance mode for critical copies, detection built into the array and tested recovery. On integrity: verifiable lineage and ML-BOM for training data, with reinforced protection of model weights. In the inference factory, additionally: isolation of the KV cache and of prefix caching between tenants, per-customer segmentation of the model repository and of the RAG vector indexes, and treatment of prompt and response telemetry as sensitive data. On governance: Zero Trust access control, microsegmentation and auditing. And on sovereignty: key ownership and data residency aligned with the EU Data Act and the EU AI Act. None of these layers substitutes for another; real security emerges from their combination.&lt;/p>
&lt;h2 id="takeaways">Takeaways&lt;/h2>
&lt;p>AI storage security is a layered system that has to be designed all at once: encryption at rest with SED and migration to FIPS 140-3; encryption in transit with particular care on InfiniBand fabrics; crypto-agility and hybrid approaches to get ahead of quantum risk and harvest now, decrypt later; data integrity and lineage against poisoning; WORM immutability against ransomware; Zero Trust access control; data sovereignty through key ownership; and confidential computing to protect data and models in use. The good news is that the array is no longer passive: it defends. The bad news is that the most serious threat, the quantum one, demands action before it becomes visible.&lt;/p>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/storage-ai-era-availability/">last article in the series&lt;/a> closes with the property without which none of the previous ones matter: availability.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ai-era-storage-state-of-the-art/">Storage in the AI era (1/4): the state of the art&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ai-era-storage-performance/">Storage in the AI era (2/4): performance&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/storage-ai-era-availability/">Storage in the AI era (4/4): availability&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>NIST, &lt;em>FIPS 140-3 Transition Effort&lt;/em> — &lt;a href="https://csrc.nist.gov/projects/fips-140-3-transition-effort">https://csrc.nist.gov/projects/fips-140-3-transition-effort&lt;/a>&lt;/li>
&lt;li>SafeLogic, &lt;em>What Happens on September 21, 2026&lt;/em> — &lt;a href="https://www.safelogic.com/blog/what-happens-on-september-21-2026">https://www.safelogic.com/blog/what-happens-on-september-21-2026&lt;/a>&lt;/li>
&lt;li>NIST, &lt;em>Security Guidelines for Storage Infrastructure (SP 800-209)&lt;/em> — &lt;a href="https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-209.pdf">https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-209.pdf&lt;/a>&lt;/li>
&lt;li>NIST, &lt;em>NIST Releases First 3 Finalized Post-Quantum Encryption Standards&lt;/em> — &lt;a href="https://www.nist.gov/news-events/news/2024/08/nist-releases-first-3-finalized-post-quantum-encryption-standards">https://www.nist.gov/news-events/news/2024/08/nist-releases-first-3-finalized-post-quantum-encryption-standards&lt;/a>&lt;/li>
&lt;li>PostQuantum.com, &lt;em>NSA Unveils CNSA 2.0&lt;/em> — &lt;a href="https://postquantum.com/quantum-policy/nsa-cnsa-2-0-pqc/">https://postquantum.com/quantum-policy/nsa-cnsa-2-0-pqc/&lt;/a>&lt;/li>
&lt;li>Blocks &amp;amp; Files, &lt;em>NetApp boosts storage security with post-quantum encryption&lt;/em> — &lt;a href="https://www.blocksandfiles.com/security/2025/04/29/netapp-boosts-storage-security-with-post-quantum-encryption/1610355">https://www.blocksandfiles.com/security/2025/04/29/netapp-boosts-storage-security-with-post-quantum-encryption/1610355&lt;/a>&lt;/li>
&lt;li>The Quantum Insider, &lt;em>Western Digital Adds Post-Quantum Cryptography to Hard Drives&lt;/em> — &lt;a href="https://thequantuminsider.com/2026/05/19/western-digital-adds-post-quantum-cryptography-to-hard-drives/">https://thequantuminsider.com/2026/05/19/western-digital-adds-post-quantum-cryptography-to-hard-drives/&lt;/a>&lt;/li>
&lt;li>FINOS AI Governance Framework, &lt;em>Data Poisoning (RI-9)&lt;/em> — &lt;a href="https://air-governance-framework.finos.org/risks/ri-9_data-poisoning.html">https://air-governance-framework.finos.org/risks/ri-9_data-poisoning.html&lt;/a>&lt;/li>
&lt;li>Object First, &lt;em>S3 Object Lock for Ransomware Protection&lt;/em> — &lt;a href="https://objectfirst.com/guides/immutability/s3-object-lock-for-ransomware-protection/">https://objectfirst.com/guides/immutability/s3-object-lock-for-ransomware-protection/&lt;/a>&lt;/li>
&lt;li>StorageReview, &lt;em>Dell enhances PowerProtect portfolio for cyber resilience&lt;/em> — &lt;a href="https://www.storagereview.com/news/dell-technologies-enhances-powerprotect-portfolio-for-improved-cyber-resilience">https://www.storagereview.com/news/dell-technologies-enhances-powerprotect-portfolio-for-improved-cyber-resilience&lt;/a>&lt;/li>
&lt;li>NetApp, &lt;em>Autonomous Ransomware Protection&lt;/em> — &lt;a href="https://www.netapp.com/cyber-resilience/autonomous-ransomware-protection/">https://www.netapp.com/cyber-resilience/autonomous-ransomware-protection/&lt;/a>&lt;/li>
&lt;li>IBM, &lt;em>Cost of a Data Breach Report 2025&lt;/em> — &lt;a href="https://www.ibm.com/reports/data-breach">https://www.ibm.com/reports/data-breach&lt;/a>&lt;/li>
&lt;li>European Commission, &lt;em>Data Act&lt;/em> — &lt;a href="https://digital-strategy.ec.europa.eu/en/policies/data-act">https://digital-strategy.ec.europa.eu/en/policies/data-act&lt;/a>&lt;/li>
&lt;li>artificialintelligenceact.eu, &lt;em>Implementation Timeline&lt;/em> — &lt;a href="https://artificialintelligenceact.eu/implementation-timeline/">https://artificialintelligenceact.eu/implementation-timeline/&lt;/a>&lt;/li>
&lt;li>NVIDIA, &lt;em>Confidential Computing on H100 GPUs&lt;/em> — &lt;a href="https://developer.nvidia.com/blog/confidential-computing-on-h100-gpus-for-secure-and-trustworthy-ai/">https://developer.nvidia.com/blog/confidential-computing-on-h100-gpus-for-secure-and-trustworthy-ai/&lt;/a>&lt;/li>
&lt;li>Corvex, &lt;em>Confidential Computing Meets NVIDIA HGX B200&lt;/em> — &lt;a href="https://www.corvex.ai/blog/confidential-computing-meets-nvidia-hgxtm-b200-secure-ai-without-the-performance-trade-off">https://www.corvex.ai/blog/confidential-computing-meets-nvidia-hgxtm-b200-secure-ai-without-the-performance-trade-off&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>How we use our infrastructure to meet defence standards with AI</title><link>https://blog.lo0.es/en/posts/infrastructure-defence-ai-compliance/</link><pubDate>Thu, 18 Jun 2026 11:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/infrastructure-defence-ai-compliance/</guid><description>&lt;p>In defence, the challenge of putting AI into operation is not the model: it is security and compliance. The rules that apply, the allied interoperability standards, the data-centric security rules, the accreditation frameworks and the principles of responsible use, impose concrete requirements on access control, classification, confidentiality, traceability, supply chain and sovereignty. A demo on a laptop connected to the internet ignores all of that; an operable platform has to satisfy it, and has to be able to prove it to an accreditation body.&lt;/p>
&lt;p>This article is not a tour of technologies. It is the opposite: we take the requirements imposed by the standards of the sector and, for each one, we show how our infrastructure is used to meet it. The infrastructure is the same real platform we use in other sectors, two RKE2 clusters (Kubernetes v1.35) with Cilium as the network, GitOps with Flux, Keycloak as the identity server, inference on our own GPUs and, in pre-production, scheduling with Volcano, adapted to an environment with stricter security requirements. Every component is here because it solves a regulatory requirement, not because it looks good on a diagram. And, like all infrastructure, it is measured by efficiency and cost, which is what makes compliance sustainable.&lt;/p>
&lt;p>It is worth starting with an idea many people are unaware of: &lt;strong>standards for this already exist in the sector&lt;/strong>, and nothing has to be invented. There are allied interoperability standards, there is a defence-specific DevSecOps reference model on Kubernetes, there is a security framework (Zero Trust) and a supplier accreditation scheme, and there is a body of principles for the responsible use of military AI. Let us first lay out that map, and then walk the requirements one by one.&lt;/p>
&lt;h2 id="the-map-of-standards-in-the-sector">The map of standards in the sector&lt;/h2>
&lt;p>Four families of standards intersect in a defence AI platform, and understanding what each one covers avoids reinventing wheels.&lt;/p>
&lt;p>&lt;strong>Allied interoperability.&lt;/strong> In a coalition, systems have to understand each other. &lt;strong>Federated Mission Networking (FMN)&lt;/strong> defines how mission networks are federated across nations through successive &amp;ldquo;spirals&amp;rdquo;; the &lt;strong>MIP (Multilateral Interoperability Programme)&lt;/strong> and its JC3IEDM data model standardise command and control; Link-16 and formats such as NVG (NATO Vector Graphics) cover tactical and situational exchange. They are, in defence, the equivalent of what FHIR and DICOM are in healthcare: the common language without which AI is an isolated system that cannot be integrated into the operation.&lt;/p>
&lt;p>&lt;strong>Data-centric security: STANAG 4774 and 4778.&lt;/strong> This is the most distinctive piece of the sector. &lt;strong>STANAG 4774&lt;/strong> defines the syntax of a &lt;strong>confidentiality label&lt;/strong> (policy, classification, security categories), and &lt;strong>STANAG 4778&lt;/strong> defines the &lt;strong>binding mechanism&lt;/strong> that ties that label to the data throughout its whole life cycle and between the parties that share it. Instead of protecting only the perimeter or the system, the information object itself is protected: the classification travels with the data and the access decision is taken against the label. Both STANAGs are in the process of ratification by the nations and are moving from recommended practice to a requirement for mission-critical software.&lt;/p>
&lt;p>&lt;strong>Defence DevSecOps on Kubernetes.&lt;/strong> To build and deploy defence software there is a very concrete reference model: the &lt;strong>DoD Enterprise DevSecOps Reference Design&lt;/strong> on Kubernetes, embodied in platforms such as &lt;strong>Platform One&lt;/strong> and its &lt;strong>Big Bang&lt;/strong> product line (declarative continuous delivery of hardened packages on a Kubernetes cluster), supported by &lt;strong>Iron Bank&lt;/strong>, the repository of &lt;strong>hardened container images compliant with the DISA STIGs&lt;/strong>, continuously scanned. The associated philosophy is &lt;strong>cATO (Continuous Authority To Operate)&lt;/strong>: accreditation stops being a one-off milestone and becomes a state maintained through continuous monitoring. For defence, it is the equivalent of the Kubernetes-native standard that MONAI represents in medical imaging: an agreed way to package and operate software with guarantees about the substrate we already use.&lt;/p>
&lt;p>&lt;strong>Security and accreditation frameworks.&lt;/strong> The &lt;strong>DoD Zero Trust Reference Architecture&lt;/strong> (v2.0) sets the guiding principle, do not trust by default, always verify, with adoption targets towards 2027. Supplier accreditation is governed by &lt;strong>CMMC 2.0&lt;/strong> (whose Phase 2, with mandatory third-party certification for level 2, arrives in November 2026) over the 110 controls of &lt;strong>NIST SP 800-171&lt;/strong>. In Spain and the EU, the &lt;strong>Esquema Nacional de Seguridad&lt;/strong> (the Spanish National Security Framework) at high category and the &lt;strong>CCN-STIC&lt;/strong> guides of the Centro Criptológico Nacional (the Spanish National Cryptologic Centre) play an equivalent role for systems handling sensitive or classified information.&lt;/p>
&lt;p>&lt;strong>Responsible AI in defence.&lt;/strong> NATO adopted six &lt;strong>Principles of Responsible Use (PRU)&lt;/strong> for AI in defence: lawfulness, responsibility and accountability, explainability and traceability, reliability, governability and bias mitigation, reinforced by its AI Strategy revised in 2024 and by the &lt;strong>DARB (Data and AI Review Board)&lt;/strong>. An important legal nuance: the EU &lt;strong>AI Act expressly excludes&lt;/strong> (art. 2.3) AI systems intended exclusively for military, defence or national security purposes. That is, in defence the responsible AI framework is not the AI Act, but NATO&amp;rsquo;s PRU, national rules and, as a management system, ISO/IEC 42001.&lt;/p>
&lt;p>With this map on the table, let us walk the requirements.&lt;/p>
&lt;h2 id="requirement-coalition-interoperability-fmn-mipjc3iedm">Requirement: coalition interoperability (FMN, MIP/JC3IEDM)&lt;/h2>
&lt;p>In an allied operation, a system that does not integrate is useless however good it is. The requirement, derived from FMN, from the command and control data model (MIP/JC3IEDM) and from the tactical formats (Link-16, NVG), is that AI must consume and produce information in the common formats of the coalition, not in a dialect of its own.&lt;/p>
&lt;p>How we meet it: just as in other sectors we put an interoperability layer at the boundary, here the platform integrates through the agreed &lt;em>gateways&lt;/em> and formats, so that AI never talks directly to the mission systems but consumes and delivers information already normalised to the coalition standard. This keeps AI as a component that couples into the federated architecture, respecting its profiles and its data governance, instead of a silo that forces bespoke integrations at every deployment. Interoperability is also a precondition for traceability and labelling: only if the data enters and leaves in a known format can its confidentiality label be attached and preserved.&lt;/p>
&lt;h2 id="requirement-do-not-trust-by-default-zero-trust-and-verified-access-dod-zt-ra-nist-800-171">Requirement: do not trust by default, Zero Trust and verified access (DoD ZT RA, NIST 800-171)&lt;/h2>
&lt;p>The guiding principle of modern defence security is Zero Trust: no entity, user, service or node, is trusted because of its position in the network; every access is authenticated, authorised and continuously verified. NIST 800-171 details the access and identification controls that underpin it.&lt;/p>
&lt;p>How we meet it: a single identity server, &lt;strong>Keycloak&lt;/strong>, governs authorisation with OAuth 2.1 and OpenID Connect, issuing short-lived tokens and binding them to their recipient with audience validation (&lt;code>aud&lt;/code>), so that a credential issued for one service is useless against another. Between components handling sensitive information we require &lt;strong>mutual TLS (mTLS)&lt;/strong>: it is not only the user or the service that is authenticated, but the workload itself taking part in the exchange. And the network operates in &lt;strong>default-deny&lt;/strong> (next section), so that connectivity is not a right conferred by being inside the cluster, but an explicit permission. Workloads use &lt;em>service accounts&lt;/em> with no permissions by default, and administrator access to the Kubernetes API is federated against Keycloak over OIDC with scoped roles: technical access too is authenticated, limited and audited. Zero Trust is not a product you buy; it is a property that emerges from strong identity, least privilege and continuous verification, and that is how the platform is built. Verification, moreover, does not stop at sign-in: tokens are short-lived and are renewed, policies are re-evaluated on every request, and the posture of each workload, signed image, applied security policy, determines whether it can run and communicate. A component that stops complying with its policy stops being able to operate; it is granted no grace period for having been inside.&lt;/p>
&lt;h2 id="requirement-data-centric-security-and-labelling-stanag-47744778">Requirement: data-centric security and labelling (STANAG 4774/4778)&lt;/h2>
&lt;p>Unlike other sectors, in defence the classification travels with the data. The requirement, derived from STANAG 4774/4778, is that every information object carries its confidentiality label and that access decisions are taken against that label, not against the system hosting it.&lt;/p>
&lt;p>How we meet it: authorisation decisions are not limited to roles, but incorporate &lt;strong>attribute-based access control (ABAC)&lt;/strong> evaluating the classification label of the data against the clearance of the requester. The policy is expressed as code and evaluated at a central decision point (a policy engine of the OPA/Open Policy Agent kind) that the application layer and the &lt;em>gateways&lt;/em> consult before serving a resource. The label, in accordance with STANAG 4774, travels with the object, and the binding under STANAG 4778 is preserved across the platform, in the object metadata in storage and in the annotations that travel with the request. For an AI agent, this means that its token gives it access to a classification level, and that data above that level is simply not handed to it, regardless of where it is stored.&lt;/p>
&lt;p>This approach embodies the classic no-read-up rule: an entity with a given clearance does not access information above its level, and the decision is taken on the object&amp;rsquo;s label, not on assumptions made by the system. When a workflow requires moving information to a lower level, a declassification or sanitisation, it is an explicit, audited step, with its own policy, never a side effect of a query. The label is preserved in the object metadata in storage and in the annotations accompanying the request, so that the STANAG 4778 binding is not lost when crossing the components of the platform.&lt;/p>
&lt;h2 id="requirement-segregation-by-mission-and-level-multitenancy-nist-800-171-ens">Requirement: segregation by mission and level (multitenancy, NIST 800-171, ENS)&lt;/h2>
&lt;p>Different missions, different classification levels and different compartments cannot share a common space without controls. The requirement: strong isolation, so that what belongs to one mission or level is not accessible from another.&lt;/p>
&lt;p>How we meet it: each mission or compartment lives in its own Kubernetes &lt;strong>namespace&lt;/strong>, with scoped RBAC, ResourceQuota and LimitRange, dedicated storage and databases per operator, not shared behind an application filter, and its own network segmentation. Identity is segregated by realm in Keycloak. The whole definition lives in Git and is reconciled by Flux (GitOps), so that segregation is declarative and auditable through change history. Where the classification level demands it, separation goes beyond the cluster: operation runs on physically distinct clusters or sites, and exchange between classification domains, when necessary, is done through controlled cross-domain solutions, not by opening a hole between namespaces. At the highest levels, each security domain can be an independent cluster with its own chain of trust, its own identities and its own governance, preventing a single control plane from spanning classifications that must never be mixed. The choice between a separate namespace, cluster or site is itself an accreditation decision: it is documented, justified according to the level and compartment, and reflected declaratively in the repository.&lt;/p>
&lt;h2 id="requirement-confidentiality-encryption-and-exfiltration-prevention-nist-800-171-ens-zero-trust">Requirement: confidentiality, encryption and exfiltration prevention (NIST 800-171, ENS, Zero Trust)&lt;/h2>
&lt;p>NIST 800-171 (system and communications protection families) and the ENS require confidentiality, encryption of communications and prevention of information leakage. In defence, exfiltration is not an abstract risk: it is the adversary&amp;rsquo;s objective.&lt;/p>
&lt;p>How we meet it: the network is managed by &lt;strong>Cilium&lt;/strong> (eBPF) with a &lt;strong>default-deny&lt;/strong> model. A pod has no connectivity beyond what a policy grants it explicitly, flow by flow, which turns exfiltration into something blocked in the kernel. A component can only talk to strictly necessary destinations:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">CiliumNetworkPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inferencia-egress-minimo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">mision-alfa&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpointSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">serving-inferencia&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">egressDeny&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">toEntities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">world] &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># no egress outside the cluster&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">egress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">toEndpoints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gateway-modelos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">toEndpoints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># internal DNS, without this it does not resolve&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">k8s:io.kubernetes.pod.namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kube-system&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">k8s-app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kube-dns&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">toPorts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;53&amp;#34;&lt;/span>&lt;span class="nt">, protocol&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">UDP }]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two nuances separate a correct policy from a leaky one: you have to explicitly allow DNS and filter by &lt;strong>endpoint identity&lt;/strong>, not by CIDR, because in an identity-based network filtering by IP ranges does not behave the way intuition suggests. On top of this we add flow observability with Hubble and runtime security with &lt;strong>Tetragon&lt;/strong> (eBPF), which watches process execution, file access and connections inside the container, making it possible to detect and block anomalous behaviour; an unexpected process in a sensitive workload is exactly the signal you want to see in real time. Encryption in transit is covered by cert-manager (TLS at the edges) and Cilium&amp;rsquo;s transparent node-to-node encryption. Added to this are the &lt;strong>Pod Security Standards&lt;/strong> in restricted profile (no privileges, no root) and disciplined management of secrets and keys, which do not live in plain manifests.&lt;/p>
&lt;h2 id="requirement-verifiable-software-supply-chain-iron-bankstig-sbom-cmmc">Requirement: verifiable software supply chain (Iron Bank/STIG, SBOM, CMMC)&lt;/h2>
&lt;p>The software supply chain is a first-order attack vector, and the sector treats it as such: images hardened in line with the STIGs, a component inventory (SBOM) and cryptographic verification of provenance. CMMC and NIST 800-171 require it in the supplier flow; Executive Order 14028 popularised the SBOM.&lt;/p>
&lt;p>How we meet it: we start from &lt;strong>hardened base images&lt;/strong> (in the spirit of Iron Bank and the DISA STIGs), we generate an SBOM (CycloneDX/SPDX) per artefact and we &lt;strong>sign images and SBOMs with Sigstore/Cosign&lt;/strong>, a graduated CNCF project, with keyless signing and a transparency log. In the cluster, an &lt;strong>admission control&lt;/strong> (Kyverno/policies) &lt;strong>rejects any image that is not signed&lt;/strong> by a trusted identity, so software whose provenance cannot be verified never runs. The rule is expressed as code and verifies the signature of every image before allowing it to run:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kyverno.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ClusterPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">exigir-imagenes-firmadas&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">validationFailureAction&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Enforce&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rules&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">verificar-firma-cosign&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">match&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">any&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kinds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">Pod]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">verifyImages&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">imageReferences&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;registro.interno/*&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">attestors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">entries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">keys&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">publicKeys&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|-&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> -----BEGIN PUBLIC KEY-----
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> ...trusted key...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> -----END PUBLIC KEY-----&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>An image without a valid signature is rejected at admission, before it runs. Since the whole deployment is GitOps, the chain, from the commit to the signed image to the policy that admits it, is traceable end to end. This satisfies the supply chain control of NIST 800-171 and fits the cATO model: verification is continuous, not a one-off stamp.&lt;/p>
&lt;h2 id="requirement-sovereignty-and-disconnected-or-air-gapped-operation-classified-networks-ddil">Requirement: sovereignty and disconnected or air-gapped operation (classified networks, DDIL)&lt;/h2>
&lt;p>Defence networks frequently operate isolated from the internet (air-gapped) or under DDIL conditions, degraded, intermittent or low-bandwidth connectivity at the tactical edge. And the data cannot leave to an external API under any circumstances. The requirement: the platform has to work without internet and without taking the data outside.&lt;/p>
&lt;p>How we meet it: inference is served locally, on our own GPUs, with a high-performance serving engine (vLLM) and &lt;strong>open-weight models&lt;/strong> that can be hosted entirely inside the perimeter. Everything the platform needs, container images, models, dependencies, is mirrored into internal registries and repositories, so that the GitOps deployment reconciles from internal sources and requires no internet access. A &lt;strong>model gateway&lt;/strong> centralises access and reverses the burden: the default behaviour is local inference, and any egress is an exception that, besides having to be enabled explicitly, would collide with the default-deny network policies. Sovereignty does not rest on a promise, but on several mutually reinforcing controls, gateway, network policy and isolation, so that a configuration slip does not translate into a leak.&lt;/p>
&lt;p>One point that cannot be overlooked in defence: no component should &amp;ldquo;phone home&amp;rdquo;. Many tools send telemetry, check licences or download updates against external servers by default, and in a classified network that is unacceptable. That is why default-deny is not only a measure against the adversary, but also against the software itself: if an image attempts an undeclared telemetry connection, the policy blocks it and Tetragon logs it. The absence of phone-home is verified, not trusted.&lt;/p>
&lt;h2 id="requirement-resilience-and-operation-at-the-tactical-edge-ddil">Requirement: resilience and operation at the tactical edge (DDIL)&lt;/h2>
&lt;p>Much defence operation happens under DDIL conditions, degraded, intermittent or low-bandwidth connectivity, and even in total isolation. The requirement: the platform must remain useful when the link to the rear falls, and reconcile when it returns.&lt;/p>
&lt;p>How we meet it: the declarative GitOps model fits disconnected operation naturally. An edge site keeps its own copy of the desired state and reconciles against local registries and repositories, so it operates without depending on a permanent connection; when the link returns, it synchronises. Inference is served locally with models already hosted on the node, with no external calls. Traces and audit events are stored locally and forwarded (store-and-forward) when there is a link, without losing the record. The high-availability control plane and the separation into sites allow controlled degradation instead of collapsing as a block. Resilience is not an add-on: it is a property of the declarative design and of sovereign inference.&lt;/p>
&lt;h2 id="requirement-robustness-and-protection-of-the-ai-system-itself-pru-reliability">Requirement: robustness and protection of the AI system itself (PRU reliability)&lt;/h2>
&lt;p>NATO&amp;rsquo;s &lt;strong>reliability&lt;/strong> principle requires AI systems to have explicit and well-tested capabilities, and to be secure against manipulation. This adds a requirement that other sectors treat with less urgency: protecting the model itself, its weights and its behaviour, as a sensitive asset.&lt;/p>
&lt;p>How we meet it: model weights are treated as sensitive data, hosted inside the perimeter and with access controlled by the same identity and network policies as everything else. The model gateway is the point where input and output safeguards are applied (validation, limits, filtering) and where a model can be constrained or disconnected. The signed supply chain (SBOM and Cosign) extends to model artefacts, so that only what has verifiable provenance is executed. And traceability feeds continuous evaluation: measuring the model&amp;rsquo;s behaviour over time is what makes it possible to detect degradation or manipulation before it affects the operation. Robustness is built with the same controls, identity, network, supply chain, traces, now applied to the model as well. Added to this are safeguards against adversarial inputs, prompt injection, manipulated data, at the gateway, which validates and constrains what goes into and out of the model. In defence, assuming the adversary will try to manipulate the AI is not pessimism: it is the design&amp;rsquo;s starting assumption.&lt;/p>
&lt;h2 id="requirement-reliable-execution-of-mission-workloads-and-efficiency-availability-sustainability">Requirement: reliable execution of mission workloads and efficiency (availability, sustainability)&lt;/h2>
&lt;p>Serving AI in-house is only sustainable if the GPU, a scarce and expensive resource, is used well, and certain mission workloads must run reliably and to completion. The default Kubernetes scheduler schedules pod by pod, which for distributed workloads produces half-started jobs that hold GPUs without making progress.&lt;/p>
&lt;p>How we meet it: GPU allocation is governed by &lt;strong>Volcano&lt;/strong> (a CNCF incubating project), today in pre-production, with &lt;strong>gang scheduling&lt;/strong> (&lt;code>PodGroup&lt;/code> with &lt;code>minAvailable&lt;/code>: the job starts complete or does not start), &lt;strong>per-mission queues&lt;/strong> with quota and priority, &lt;strong>fair-share (DRF)&lt;/strong> to share out fairly, &lt;strong>pre-emption&lt;/strong> so that an urgent workload can overtake a deferrable one, and &lt;strong>topology awareness&lt;/strong> (NVLink/PCIe) to reduce fragmentation. This raises the effective utilisation of the GPU fleet, which is what makes sovereign inference viable. On the state of the art: Kubernetes 1.34 took &lt;strong>DRA (Dynamic Resource Allocation)&lt;/strong> to general availability, accelerator allocation by attributes instead of by count, and Kueue covers job quota management; both combine with a gang scheduler. It is managed with metrics: effective versus allocated utilisation, fragmentation, throughput per GPU and GPU-hours per mission. At the edge, where the GPU is even scarcer, these same techniques, together with quantised and smaller models, are what make it possible to serve useful inference with the hardware available instead of depending on a datacenter that may not be reachable.&lt;/p>
&lt;h2 id="requirement-explainability-traceability-and-governability-nato-pru-nist-au">Requirement: explainability, traceability and governability (NATO PRU, NIST AU)&lt;/h2>
&lt;p>Three of NATO&amp;rsquo;s six Principles of Responsible Use bear directly on infrastructure: &lt;strong>explainability and traceability&lt;/strong>, &lt;strong>governability&lt;/strong> and &lt;strong>responsibility/accountability&lt;/strong>. Plus the audit (AU) controls of NIST. The requirement: being able to reconstruct every AI decision, keep human control and be accountable.&lt;/p>
&lt;p>How we meet it: every inference is recorded as an &lt;strong>auditable trace&lt;/strong> with Langfuse, input, output, model and version, cost, latency and mission, stored in our own components locked behind network policies that prevent it from leaving. That trace provides the traceability required by the PRU and by the NIST AU controls, and is the basis for human oversight, which is exercised at the &lt;strong>gateway&lt;/strong> (review, limits and, crucially, the possibility of intervention and disconnection demanded by the &lt;strong>governability&lt;/strong> principle: an AI system in defence must be able to be deactivated or constrained by the operator). An audit log is only worth anything as evidence if it is intact and ordered in time: that is why events are kept in append-only storage and clocks are kept synchronised by NTP. Instrumentation converges on the OpenTelemetry GenAI semantic conventions.&lt;/p>
&lt;p>Accountability also requires being able to attribute a decision: which model, which version, which input and which operator were involved. That is why each trace links the inference to the identity that requested it and to the exact version of the model artefact that answered, so that the chain &amp;ldquo;who asked what, with which system and with what result&amp;rdquo; can be reconstructed. Without that attribution, NATO&amp;rsquo;s responsibility and accountability principle remains a statement of intent.&lt;/p>
&lt;h2 id="requirement-continuous-accreditation-and-reproducible-operation-cato-cmmc-ens">Requirement: continuous accreditation and reproducible operation (cATO, CMMC, ENS)&lt;/h2>
&lt;p>The sector has replaced one-off accreditation with &lt;strong>cATO&lt;/strong>: an authorisation that is maintained as long as it is continuously demonstrated that the controls remain in place. The ENS and CMMC likewise require configuration management and traceability of changes.&lt;/p>
&lt;p>How we meet it: the entire platform state, deployments, network policies, quotas, RBAC, identity, admission policies, is described in Git and applied by Flux over GitOps. Every change is a revision with an author, a date and a reviewer; every environment is reproducible from its repository. Security policies are code (Kyverno/OPA) evaluated continuously, not a list someone reviews once a year, and compliance monitoring is permanent. This is what cATO looks like in practice: an audit stops being a snapshot and becomes a query against a state that is maintained and verified without interruption, with the ability to reconstruct exactly what was deployed on a given date. A practical benefit is that compliance evidence generates itself: the declarative state in Git, the admission records, the image signatures and the traces are, together, the body of evidence an accreditation needs, produced continuously instead of gathered by hand the week before the inspection.&lt;/p>
&lt;h2 id="the-piece-that-ties-it-all-together-isoiec-42001-and-natos-principles">The piece that ties it all together: ISO/IEC 42001 and NATO&amp;rsquo;s principles&lt;/h2>
&lt;p>Everything above consists of technical controls that satisfy concrete requirements. But a management system is needed to order, evaluate and improve them, with defined roles and responsibilities. In defence, that governance has two complementary references: NATO&amp;rsquo;s &lt;strong>Principles of Responsible Use&lt;/strong>, which set the ethical and operational &amp;ldquo;what&amp;rdquo;, and &lt;strong>ISO/IEC 42001&lt;/strong>, the certifiable standard for AI management systems, which supplies the &amp;ldquo;how&amp;rdquo; of continuous management. Given that the AI Act excludes defence, this combination, PRU plus ISO 42001, is the governance backbone for responsible, demonstrable military AI. It is, precisely, the content of the AI Management Systems course we will publish shortly.&lt;/p>
&lt;h2 id="a-view-of-the-sector-as-of-june-2026">A view of the sector as of June 2026&lt;/h2>
&lt;p>It is worth pinning down the snapshot of the moment, because several currents have converged and they condition any platform decision.&lt;/p>
&lt;p>&lt;strong>An &amp;ldquo;AI-first&amp;rdquo; agenda and AI on classified networks.&lt;/strong> In January 2026, the US Department of Defense set an AI agenda at &amp;ldquo;war speed&amp;rdquo;, and through the spring agreements were closed to deploy generative AI models &lt;strong>on classified networks&lt;/strong>. The signal is clear: military AI is moving from pilot to operation, which raises the demands on the infrastructure that supports it.&lt;/p>
&lt;p>&lt;strong>The substrate is hardened Kubernetes.&lt;/strong> Platform One, Big Bang and Iron Bank consolidate the pattern: Kubernetes clusters with STIG-compliant images, a signed supply chain and cATO. Defence AI is deployed on that same cloud-native substrate, not on bespoke systems.&lt;/p>
&lt;p>&lt;strong>Zero Trust with a deadline.&lt;/strong> Zero Trust adoption targets sit around 2027, which turns strong identity, microsegmentation and continuous verification into requirements with a calendar, not aspirations.&lt;/p>
&lt;p>&lt;strong>Supplier accreditation is tightening.&lt;/strong> CMMC Phase 2, in November 2026, ends self-attestation for anyone handling controlled information: third-party certification against the 110 NIST 800-171 controls will be required. The software supply chain, SBOM and Sigstore signing, moves from good practice to a contractual condition.&lt;/p>
&lt;p>&lt;strong>Data-centric security, operational at last.&lt;/strong> With STANAG 4774/4778 advancing through ratification, confidentiality labelling bound to the data becomes a real requirement for mission software, and not just a recommendation.&lt;/p>
&lt;p>&lt;strong>Defence-specific AI governance.&lt;/strong> NATO&amp;rsquo;s Principles of Responsible Use and the DARB are maturing as the reference framework, occupying the space the AI Act leaves free by excluding defence. And GPU economics, together with the maturity of open-weight models, pushes compute towards in-house infrastructure, precisely where classified data and disconnected operation demand it.&lt;/p>
&lt;p>&lt;strong>Europe pushes its own autonomy.&lt;/strong> In parallel, the EU is strengthening its defence industrial and technological base, through the European Defence Fund and the European Defence Agency, with growing emphasis on technological sovereignty. For a European platform, this reinforces the bet on in-house infrastructure, locally hostable models and minimal dependence on external services: exactly the direction it is built in.&lt;/p>
&lt;p>Where we stand in that picture: with the platform on Kubernetes, eBPF as the network and security substrate, a signed supply chain, GPU scheduling maturing in pre-production, sovereign and disconnectable inference by design, traceability in auditable format and the management framework (PRU plus ISO 42001) as the next step. We are not chasing fashion; we are on the line the sector has decided to advance along.&lt;/p>
&lt;h2 id="summary-requirement-by-requirement">Summary: requirement by requirement&lt;/h2>
&lt;p>Each rule or standard in the sector imposes a requirement, and each requirement is satisfied by a concrete piece of the infrastructure. Zero Trust and verified access, with Keycloak, mTLS and default-deny. The data-centric security of STANAG 4774/4778, with ABAC over classification labels. Segregation by mission and level, with multitenancy and, where appropriate, physical separation and cross-domain. Confidentiality and leak prevention, with Cilium in default-deny and Tetragon. A verifiable supply chain, with STIG-hardened images, SBOM and Sigstore signing plus admission control. Sovereignty and disconnected operation, with local inference over open models and internal registries. Reliable execution and efficiency, with Volcano. The explainability, traceability and governability of NATO&amp;rsquo;s PRU, with auditable traces and human control at the gateway. Continuous accreditation (cATO) and reproducible operation, with GitOps and policy as code. And the governance that ties it all together, with NATO&amp;rsquo;s PRU and ISO 42001.&lt;/p>
&lt;p>It is not a PowerPoint: it is infrastructure that operates, that complies and that, being infrastructure, is measured by efficiency and cost. That, the cost control of an AI platform, is what the next article is about.&lt;/p>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://standards.globalspec.com/std/10266868/stanag-4774">NATO STANAG 4774 — Confidentiality Metadata Label Syntax&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.archtis.com/achieving-nato-stanag-4774-and-4778-compliance/">NATO STANAG 4778 — Metadata Binding Mechanism&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.nato.int/cps/en/natohq/news_227234.htm">NATO — Principios de Uso Responsable de la IA y Estrategia revisada (2024)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://dodcio.defense.gov/Portals/0/Documents/Library/DoD%20Enterprise%20DevSecOps%20Reference%20Design%20-%20CNCF%20Kubernetes%20w-DD1910_cleared_20211022.pdf">DoD Enterprise DevSecOps Reference Design — CNCF Kubernetes&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/DoD-Platform-One/bigbang">DoD — Big Bang / Platform One&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://dodcio.defense.gov/Portals/0/Documents/Library/(U)ZT_RA_v2.0(U)_Sep22.pdf">DoD Zero Trust Reference Architecture v2.0&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://dodcio.defense.gov/Portals/0/Documents/CMMC/CMMC-AlignmentNIST-Standards.pdf">CMMC 2.0 y NIST SP 800-171 (cadena de suministro de defensa)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.sigstore.dev/">Sigstore / Cosign — firma de imágenes y SBOM (CNCF)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://volcano.sh/en/docs/">Volcano — scheduling batch (CNCF)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://cilium.io/">Cilium y Tetragon (eBPF)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.iso.org/standard/81230.html">ISO/IEC 42001 — Sistemas de gestión de IA&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>When MCP grows up: giving it authentication with Keycloak</title><link>https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/</link><pubDate>Thu, 18 Jun 2026 08:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/</guid><description>&lt;p>When a child is small, it is enough to make sure they do not hurt themselves. You put a gate on the stairs, you cover the sockets and little else. But the child grows. They start going out alone, handling money, making decisions that have consequences. And then raising them stops being &amp;ldquo;don&amp;rsquo;t let them fall&amp;rdquo; and turns into something far more demanding: teaching them to identify themselves, to ask permission, to know how far they can go and to account for what they do.&lt;/p>
&lt;p>That is exactly what has happened with our MCP servers.&lt;/p>
&lt;h2 id="mcp-is-no-longer-a-toy">MCP is no longer a toy&lt;/h2>
&lt;p>Not long ago, a &lt;a href="https://modelcontextprotocol.io/">Model Context Protocol&lt;/a> server was an experiment running on someone&amp;rsquo;s laptop so a model could read a couple of files or call an internal API. It did not matter who used it: it was us, locally, with nothing at stake.&lt;/p>
&lt;p>That is over. MCP servers have become infrastructure pieces that AI uses to touch real systems. And, like any child who grows up, they have developed new needs they did not have before:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Multitenancy&lt;/strong>: there is no longer a single user but many, and one user&amp;rsquo;s data cannot be visible to another.&lt;/li>
&lt;li>&lt;strong>Cost control&lt;/strong>: every model call consumes tokens, compute and, sometimes, paid APIs. You have to measure, attribute and set limits.&lt;/li>
&lt;li>&lt;strong>Authentication and authorisation&lt;/strong>: knowing who is on the other side, what permissions they hold and which specific server their credential is good for.&lt;/li>
&lt;/ul>
&lt;p>These are three separate fronts. Today we focus on the third, which is the one that opens the door to the other two: &lt;strong>authentication&lt;/strong>. Without knowing who is calling, there is no tenant to separate and no cost to attribute.&lt;/p>
&lt;h2 id="why-mcp-authentication-is-a-problem-with-a-name-of-its-own">Why MCP authentication is a problem with a name of its own&lt;/h2>
&lt;p>The good news is that nothing has to be invented here. The MCP specification did not pull a security mechanism of its own out of a hat: it builds on &lt;strong>OAuth&lt;/strong>, the same standard that has been behind half the internet&amp;rsquo;s &amp;ldquo;Sign in with…&amp;rdquo; for years. MCP defines how an MCP server acts as a &lt;em>protected resource&lt;/em> and delegates credential issuance to an &lt;em>authorisation server&lt;/em>.&lt;/p>
&lt;p>And that authorisation server can be &lt;a href="https://www.keycloak.org/">Keycloak&lt;/a>, the open source identity manager from the Cloud Native Computing Foundation that you probably already run for the rest of your applications. The idea is not to add one more identity silo just because we are now talking to models: reuse the one that already governs your users.&lt;/p>
&lt;p>The important nuance is that &lt;strong>MCP is not a single frozen standard&lt;/strong> but a living specification with several versions, and each one asks for different things. As of today four coexist:&lt;/p>
&lt;ul>
&lt;li>&lt;code>2025-11-25&lt;/code> (the latest)&lt;/li>
&lt;li>&lt;code>2025-06-18&lt;/code>&lt;/li>
&lt;li>&lt;code>2025-03-26&lt;/code>&lt;/li>
&lt;li>&lt;code>2024-11-05&lt;/code> (the initial one, which did not even contemplate authorisation)&lt;/li>
&lt;/ul>
&lt;p>Just as with raising a child what they need at 6 is not what they need at 16, what Keycloak has to satisfy depends on the MCP version you work with. Let us look at it.&lt;/p>
&lt;h2 id="what-mcp-demands-from-an-authorisation-server-and-what-keycloak-provides">What MCP demands from an authorisation server (and what Keycloak provides)&lt;/h2>
&lt;p>The specification lists a series of OAuth standards the authorisation server must support, with different levels of requirement (&lt;code>MUST&lt;/code>, &lt;code>SHOULD&lt;/code>, &lt;code>MAY&lt;/code>). This is the picture, cross-referenced with what Keycloak supports:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Standard&lt;/th>
&lt;th>2025-11-25&lt;/th>
&lt;th>2025-06-18&lt;/th>
&lt;th>2025-03-26&lt;/th>
&lt;th>Keycloak&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>OAuth 2.1 Authorization Framework (draft)&lt;/td>
&lt;td>MUST&lt;/td>
&lt;td>MUST&lt;/td>
&lt;td>MUST&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OAuth 2.0 Authorization Server Metadata (RFC 8414)&lt;/td>
&lt;td>MUST&lt;/td>
&lt;td>MUST&lt;/td>
&lt;td>MUST&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Resource Indicators for OAuth 2.0 (RFC 8707)&lt;/td>
&lt;td>MUST&lt;/td>
&lt;td>MUST&lt;/td>
&lt;td>—&lt;/td>
&lt;td>&lt;strong>Not supported&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dynamic Client Registration (RFC 7591)&lt;/td>
&lt;td>MAY&lt;/td>
&lt;td>SHOULD&lt;/td>
&lt;td>SHOULD&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OAuth Client ID Metadata Document (draft)&lt;/td>
&lt;td>SHOULD&lt;/td>
&lt;td>—&lt;/td>
&lt;td>—&lt;/td>
&lt;td>Supported (experimental)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>One clarification: MCP also adopts &lt;em>OAuth 2.0 Protected Resource Metadata&lt;/em> (RFC 9728), but that standard is the &lt;strong>MCP server&amp;rsquo;s&lt;/strong> business, not the authorisation server&amp;rsquo;s, so it does not appear in the Keycloak table.&lt;/p>
&lt;p>If we take as our conformance criterion that Keycloak &amp;ldquo;supports&amp;rdquo; a version when it meets all its &lt;code>MUST&lt;/code> and &lt;code>SHOULD&lt;/code> requirements, the result is this:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>MCP version&lt;/th>
&lt;th>Conformance&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>2025-03-26&lt;/code>&lt;/td>
&lt;td>&lt;strong>Supported&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>2025-06-18&lt;/code>&lt;/td>
&lt;td>Partial, without Resource Indicators (RFC 8707)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>2025-11-25&lt;/code>&lt;/td>
&lt;td>Partial, without Resource Indicators (RFC 8707)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The only piece Keycloak lacks today is &lt;strong>Resource Indicators for OAuth 2.0 (RFC 8707)&lt;/strong>, the &lt;code>resource&lt;/code> parameter that ties a token to a specific server. The Keycloak community already has support for it on the roadmap; in the meantime there is a perfectly valid workaround we look at below. Following the analogy, it is that subject the child still struggles with but can pass with a study trick until they fully mature.&lt;/p>
&lt;h2 id="down-to-work-setting-up-keycloak-for-your-mcp-version">Down to work: setting up Keycloak for your MCP version&lt;/h2>
&lt;h3 id="for-mcp-2025-03-26">For MCP 2025-03-26&lt;/h3>
&lt;p>Nothing special has to be configured. This version does not require the &lt;code>resource&lt;/code> parameter, so Keycloak complies out of the box. The child is still small and the gate on the stairs is enough.&lt;/p>
&lt;h3 id="for-mcp-2025-06-18-and-2025-11-25-tying-the-token-to-its-audience">For MCP 2025-06-18 and 2025-11-25: tying the token to its audience&lt;/h3>
&lt;p>Here is where it gets interesting. For security, these versions require an access token to be &lt;strong>bound to the audience&lt;/strong> it was issued for. In plain terms:&lt;/p>
&lt;ul>
&lt;li>The MCP client &lt;strong>must&lt;/strong> include the &lt;code>resource&lt;/code> parameter (from RFC 8707) in the authorisation and token requests, with the value that identifies the MCP server it is going to use.&lt;/li>
&lt;li>The MCP server &lt;strong>must&lt;/strong> validate that the tokens it receives were issued specifically for it.&lt;/li>
&lt;/ul>
&lt;p>This prevents a stolen or reused token from getting into a server other than the intended one. The problem, as we said, is that &lt;strong>Keycloak does not yet understand the &lt;code>resource&lt;/code> parameter&lt;/strong>.&lt;/p>
&lt;p>The solution until native RFC 8707 support arrives is to use OAuth&amp;rsquo;s &lt;strong>&lt;code>scope&lt;/code>&lt;/strong> parameter to achieve the same effect. Picture this situation:&lt;/p>
&lt;ul>
&lt;li>The MCP server is at &lt;code>https://example.com/mcp&lt;/code>.&lt;/li>
&lt;li>It supports three scopes: &lt;code>mcp:tools&lt;/code>, &lt;code>mcp:prompts&lt;/code> and &lt;code>mcp:resources&lt;/code>.&lt;/li>
&lt;li>The client asks for a token with &lt;code>resource = https://example.com/mcp&lt;/code> and a combination of those scopes.&lt;/li>
&lt;li>We want Keycloak to issue a token whose &lt;code>aud&lt;/code> (audience) is precisely &lt;code>https://example.com/mcp&lt;/code>.&lt;/li>
&lt;/ul>
&lt;p>To achieve it, we configure Keycloak like this, repeating the pattern for each scope:&lt;/p>
&lt;ul>
&lt;li>Create a &lt;strong>client scope&lt;/strong> &lt;code>mcp:tools&lt;/code> of type &lt;em>Optional&lt;/em> and add an &lt;strong>Audience mapper&lt;/strong> whose &lt;em>Included Custom Audience&lt;/em> is &lt;code>https://example.com/mcp&lt;/code>.&lt;/li>
&lt;li>Create a &lt;strong>client scope&lt;/strong> &lt;code>mcp:prompts&lt;/code> of type &lt;em>Optional&lt;/em> with its &lt;strong>Audience mapper&lt;/strong> pointing at &lt;code>https://example.com/mcp&lt;/code>.&lt;/li>
&lt;li>Create a &lt;strong>client scope&lt;/strong> &lt;code>mcp:resources&lt;/code> of type &lt;em>Optional&lt;/em> with its &lt;strong>Audience mapper&lt;/strong> pointing at &lt;code>https://example.com/mcp&lt;/code>.&lt;/li>
&lt;/ul>
&lt;p>The key is that the &lt;em>Included Custom Audience&lt;/em> of each client scope is &lt;strong>identical&lt;/strong> to the value of the request&amp;rsquo;s &lt;code>resource&lt;/code> parameter and to the MCP server URL. With this, if the client asks for a token with those three scopes, Keycloak issues something like:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;aud&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;https://example.com/mcp&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;scope&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;mcp:resources mcp:tools mcp:prompts&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The MCP server can now check the &lt;code>aud&lt;/code> and reject any token that was not meant for it. We have tied the credential to its recipient without waiting for RFC 8707.&lt;/p>
&lt;h3 id="if-you-use-mcp-inspector">If you use MCP Inspector&lt;/h3>
&lt;p>&lt;a href="https://github.com/modelcontextprotocol/inspector">MCP Inspector&lt;/a> is the official tool for debugging MCP servers, and it registers clients dynamically against Keycloak by running JavaScript from its backend. For it to work you have to adjust &lt;strong>CORS&lt;/strong> in the client registration &lt;em>anonymous access policies&lt;/em>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Allowed Client Scopes&lt;/strong>: include the scopes your MCP server supports.&lt;/li>
&lt;li>&lt;strong>Allowed Registration Web Origins&lt;/strong>: include the web origin of the MCP Inspector backend.&lt;/li>
&lt;li>&lt;strong>Trusted Hosts&lt;/strong>: include the host or IP of the machine sending the dynamic registration request, that is, where your browser runs.&lt;/li>
&lt;/ul>
&lt;h3 id="for-mcp-2025-11-25-client-registration-with-client-id-metadata-document">For MCP 2025-11-25: client registration with Client ID Metadata Document&lt;/h3>
&lt;p>The latest version adds a new chapter: how clients register. It contemplates three approaches, and you choose according to your scenario:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Client ID Metadata Documents&lt;/strong>: when client and server do not know each other beforehand (the most common case).&lt;/li>
&lt;li>&lt;strong>Pre-registration&lt;/strong>: when a prior relationship already exists.&lt;/li>
&lt;li>&lt;strong>Dynamic Client Registration&lt;/strong>: for backward compatibility or specific requirements.&lt;/li>
&lt;/ul>
&lt;p>Keycloak supports the first, &lt;strong>OAuth Client ID Metadata Document (CIMD)&lt;/strong>, although it is worth knowing that it is an &lt;strong>experimental feature&lt;/strong>: it may bring breaking changes in future versions. To enable it, start Keycloak with the flag:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">bin/kc.sh start --features&lt;span class="o">=&lt;/span>cimd
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The idea behind CIMD is that the &lt;code>client_id&lt;/code> stops being an opaque identifier and becomes &lt;strong>a URL&lt;/strong> pointing at a document with the client&amp;rsquo;s metadata. Keycloak downloads that document and processes the request with what it finds there. For it to do so, you have to create a &lt;em>client policy profile&lt;/em> and a &lt;em>policy&lt;/em> that trigger it.&lt;/p>
&lt;p>&lt;strong>Profile.&lt;/strong> In &lt;em>Realm Settings → Client Policies → Profiles&lt;/em>, create a profile (e.g. &lt;code>cimd-profile&lt;/code>), add the &lt;code>client-id-metadata-document&lt;/code> executor and configure it:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Allow http scheme&lt;/strong>: allows &lt;code>http&lt;/code> for the URLs (client_id, &lt;code>client_uri&lt;/code>, &lt;code>logo_uri&lt;/code>, &lt;code>jwks_uri&lt;/code>, etc.). Development only; in production &lt;strong>OFF&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Trusted domains&lt;/strong>: wildcard patterns of accepted domains (e.g. &lt;code>*.example.org&lt;/code>). If empty, all are denied.&lt;/li>
&lt;li>&lt;strong>Restrict same domain&lt;/strong>: if ON, it requires the &lt;code>client_id&lt;/code> URL, the &lt;code>redirect_uri&lt;/code> and the metadata URLs to all be under the same trusted domain.&lt;/li>
&lt;li>&lt;strong>Required properties&lt;/strong>: properties the metadata document must include.&lt;/li>
&lt;li>&lt;strong>Only Allow Confidential Client&lt;/strong>: if ON, only confidential clients are accepted (with &lt;code>jwks&lt;/code>/&lt;code>jwks_uri&lt;/code> and &lt;code>private_key_jwt&lt;/code> or &lt;code>tls_client_auth&lt;/code> authentication).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Policy.&lt;/strong> In &lt;em>Realm Settings → Client Policies → Policies&lt;/em>, create a policy (e.g. &lt;code>cimd-policy&lt;/code>), add the &lt;code>client-id-uri&lt;/code> condition and configure it:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>URI scheme&lt;/strong>: schemes to recognise in the &lt;code>client_id&lt;/code> (in production, &lt;code>https&lt;/code> only).&lt;/li>
&lt;li>&lt;strong>Trusted domains&lt;/strong>: domains accepted in the &lt;code>client_id&lt;/code> host. If filled in, the condition is only true when the host matches; if left empty, it is always false.&lt;/li>
&lt;/ul>
&lt;p>Then associate the &lt;code>cimd-profile&lt;/code> with this policy. From then on, when a request arrives with a &lt;code>client_id&lt;/code> that is an &lt;code>https&lt;/code> URL from a trusted domain, Keycloak downloads the metadata document and uses it to process the request.&lt;/p>
&lt;p>&lt;strong>Global executor settings.&lt;/strong> Some parameters are not in the console and are passed as SPI options at startup:&lt;/p>
&lt;ul>
&lt;li>&lt;code>min-cache-time&lt;/code>: minimum document cache time (300 s by default).&lt;/li>
&lt;li>&lt;code>max-cache-time&lt;/code>: maximum cache time (259200 s by default, 3 days).&lt;/li>
&lt;li>&lt;code>upper-limit-metadata-bytes&lt;/code>: maximum document size (5000 bytes by default).&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">bin/kc.sh start &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --spi-client-policy-executor--client-id-metadata-document--min-cache-time&lt;span class="o">=&lt;/span>&lt;span class="m">600&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --spi-client-policy-executor--client-id-metadata-document--max-cache-time&lt;span class="o">=&lt;/span>&lt;span class="m">86400&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --spi-client-policy-executor--client-id-metadata-document--upper-limit-metadata-bytes&lt;span class="o">=&lt;/span>&lt;span class="m">10000&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="a-real-case-vs-code-desktop-as-an-mcp-client">A real case: VS Code desktop as an MCP client&lt;/h3>
&lt;p>Visual Studio Code is an MCP client that uses CIMD. When it connects to an MCP server that requires authorisation, it sends a &lt;code>client_id&lt;/code> that is an &lt;code>https&lt;/code> URL hosted on &lt;code>vscode.dev&lt;/code> (e.g. &lt;code>https://vscode.dev/mcp-client&lt;/code>), and Keycloak downloads its metadata from there. The detail to bear in mind is that VS Code is a &lt;strong>public client&lt;/strong> using &lt;strong>PKCE&lt;/strong> (no client secret) and, for the OAuth callback, it starts a local server with a &lt;code>redirect_uri&lt;/code> of the form &lt;code>http://127.0.0.1:&amp;lt;port&amp;gt;/callback&lt;/code>. Since that redirect is not in the &lt;code>vscode.dev&lt;/code> domain, you have to leave &lt;strong>Restrict same domain OFF&lt;/strong>.&lt;/p>
&lt;p>Start with &lt;code>--features=cimd&lt;/code> and configure:&lt;/p>
&lt;p>&lt;strong>Profile &lt;code>vscode-cimd-profile&lt;/code>&lt;/strong> (&lt;code>client-id-metadata-document&lt;/code> executor):&lt;/p>
&lt;ul>
&lt;li>Allow http scheme: &lt;strong>OFF&lt;/strong>&lt;/li>
&lt;li>Trusted domains: &lt;code>vscode.dev&lt;/code>, &lt;code>127.0.0.1&lt;/code>&lt;/li>
&lt;li>Restrict same domain: &lt;strong>OFF&lt;/strong> (the redirect is a localhost, not &lt;code>vscode.dev&lt;/code>)&lt;/li>
&lt;li>Only Allow Confidential Client: &lt;strong>OFF&lt;/strong> (VS Code is public)&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Policy &lt;code>vscode-cimd-policy&lt;/code>&lt;/strong> (&lt;code>client-id-uri&lt;/code> condition):&lt;/p>
&lt;ul>
&lt;li>URI scheme: &lt;code>https&lt;/code>&lt;/li>
&lt;li>Trusted domains: &lt;code>vscode.dev&lt;/code>&lt;/li>
&lt;li>Associate the &lt;code>vscode-cimd-profile&lt;/code> profile.&lt;/li>
&lt;/ul>
&lt;p>With this, when VS Code launches the request, Keycloak recognises the &lt;code>client_id&lt;/code> as a &lt;code>vscode.dev&lt;/code> URL, downloads the Client ID Metadata Document and completes the OAuth flow with the callback on localhost.&lt;/p>
&lt;h2 id="the-education-continues">The education continues&lt;/h2>
&lt;p>We have taught our MCP to identify itself and to ask permission in a standard way, reusing the identity Keycloak already governs in the rest of the house. That is no small thing: with a token properly tied to its audience, everything else starts to become possible.&lt;/p>
&lt;p>But, as with any child who grows up, this does not end here. The other two subjects we mentioned at the start are still pending, &lt;strong>separating the tenants (multitenancy)&lt;/strong> and &lt;strong>controlling what it spends (costs)&lt;/strong>, and Keycloak still has some maturing to do in places, such as native Resource Indicators support. We will cover them in future posts. For now, our MCP already knows how to say who it is. And in security, that is the first day of school.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a> — the layer that sits in front of these servers when there are several: permissions, cost, audit and the state of the specification in September 2026.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a> — the piece from the inside and its position in the architecture, with the conformance status against MCP revision 2026-07-28.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">Completing Keycloak for MCP&lt;/a> — what this post&amp;rsquo;s setup is missing under revision 2026-07-28: protected resource metadata, audience validation and the end of dynamic registration.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.keycloak.org/securing-apps/mcp-authz-server">Keycloak — Integrating with Model Context Protocol (MCP)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization">Model Context Protocol — Authorization&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.rfc-editor.org/rfc/rfc8707">RFC 8707 — Resource Indicators for OAuth 2.0&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.rfc-editor.org/rfc/rfc8414">RFC 8414 — OAuth 2.0 Authorization Server Metadata&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.rfc-editor.org/rfc/rfc7591">RFC 7591 — OAuth 2.0 Dynamic Client Registration Protocol&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Runbook: caging the AI agent — bubblewrap on the client, Tetragon on the cluster</title><link>https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/</link><pubDate>Tue, 09 Jun 2026 17:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>Operational&lt;/strong> companion to &lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key&lt;/a>. That post explains the &lt;em>why&lt;/em> and the &lt;em>where&lt;/em>: the threat model, the five isolation families, which domain uses each. This one is the &lt;em>how&lt;/em>, with commands. If you have not read it, read it first: here I take for granted what the blast radius is, why &lt;code>bwrap&lt;/code> runs without root and what Tetragon watches. The procedure comes in two independent tracks, &lt;strong>client&lt;/strong> and &lt;strong>cluster&lt;/strong>, because, as the sibling post argues, the control extrapolates but the primitive gets rewritten.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Two reproducible procedures. &lt;strong>Client (workstation):&lt;/strong> install &lt;code>ai-jail&lt;/code> (it wraps &lt;code>bubblewrap&lt;/code>), generate the per-project &lt;code>.ai-jail&lt;/code>, audit with &lt;code>--dry-run&lt;/code>, fix the allowlists with &lt;code>--bootstrap&lt;/code>, use &lt;code>--lockdown&lt;/code> for anything you do not trust, and leave the agent without &lt;code>git push&lt;/code> permission. &lt;strong>Cluster (RKE2 with Cilium + Tetragon):&lt;/strong> put the pod baseline in place (unprivileged &lt;code>securityContext&lt;/code>, &lt;code>seccomp: RuntimeDefault&lt;/code>, default-deny &lt;code>NetworkPolicy&lt;/code>), put the untrusted agent&amp;rsquo;s pod in a microVM with &lt;code>runtimeClassName: kata&lt;/code>, and deploy the Tetragon &lt;code>TracingPolicy&lt;/code> files in &lt;strong>two phases&lt;/strong>: observe with &lt;code>action: Post&lt;/code> to raise the baseline, then promote to &lt;code>action: Sigkill&lt;/code> on &lt;code>tcp_connect&lt;/code> (egress) and &lt;code>security_file_open&lt;/code> (secret paths). The golden rule of the Tetragon phase: &lt;strong>adopt first, block later&lt;/strong>; never put a &lt;code>Sigkill&lt;/code> into production without having seen the events in observation mode first.&lt;/p>
&lt;h2 id="the-flow-of-the-two-tracks">The flow of the two tracks&lt;/h2>
&lt;div class="diagram" style="max-width:800px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 800 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Two operational tracks: client (install, configure, bootstrap, lockdown) and cluster (baseline, RuntimeClass, observe, enforce)">
&lt;defs>&lt;marker id="rm2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="400" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">CLIENT track — workstation&lt;/text>
&lt;rect x="20" y="36" width="150" height="46" rx="7" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="95" y="56" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#0d3a66">1 · Install&lt;/text>
&lt;text x="95" y="72" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#0d3a66">ai-jail + bwrap&lt;/text>
&lt;rect x="200" y="36" width="150" height="46" rx="7" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="275" y="56" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#0d3a66">2 · .ai-jail&lt;/text>
&lt;text x="275" y="72" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#0d3a66">--dry-run&lt;/text>
&lt;rect x="380" y="36" width="150" height="46" rx="7" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="455" y="56" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#0d3a66">3 · --bootstrap&lt;/text>
&lt;text x="455" y="72" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#0d3a66">allow/deny/ask&lt;/text>
&lt;rect x="560" y="36" width="150" height="46" rx="7" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="635" y="52" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#0d3a66">4 · lockdown&lt;/text>
&lt;text x="635" y="68" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#0d3a66">+ git with no push&lt;/text>
&lt;path d="M170,59 L198,59" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm2)"/>
&lt;path d="M350,59 L378,59" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm2)"/>
&lt;path d="M530,59 L558,59" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm2)"/>
&lt;line x1="20" y1="118" x2="780" y2="118" stroke="#ccc" stroke-width="1" stroke-dasharray="3 3"/>
&lt;text x="400" y="150" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">CLUSTER track — RKE2 + Cilium/Tetragon&lt;/text>
&lt;rect x="20" y="164" width="150" height="46" rx="7" fill="#e6d9f2" stroke="#5a2db0" stroke-width="1.4"/>
&lt;text x="95" y="184" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#42208a">1 · Baseline&lt;/text>
&lt;text x="95" y="200" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#42208a">secCtx+NetPol&lt;/text>
&lt;rect x="200" y="164" width="150" height="46" rx="7" fill="#e6d9f2" stroke="#5a2db0" stroke-width="1.4"/>
&lt;text x="275" y="184" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#42208a">2 · RuntimeClass&lt;/text>
&lt;text x="275" y="200" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#42208a">kata microVM&lt;/text>
&lt;rect x="380" y="164" width="150" height="46" rx="7" fill="#fde9d6" stroke="#a85a00" stroke-width="1.6"/>
&lt;text x="455" y="184" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#8a4a00">3 · Observe&lt;/text>
&lt;text x="455" y="200" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#8a4a00">Tetragon · Post&lt;/text>
&lt;rect x="560" y="164" width="150" height="46" rx="7" fill="#fbd4b8" stroke="#a85a00" stroke-width="1.8"/>
&lt;text x="635" y="184" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#8a4a00">4 · Enforce&lt;/text>
&lt;text x="635" y="200" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#8a4a00">Tetragon · Sigkill&lt;/text>
&lt;path d="M170,187 L198,187" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm2)"/>
&lt;path d="M350,187 L378,187" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm2)"/>
&lt;path d="M530,187 L558,187" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm2)"/>
&lt;text x="400" y="236" text-anchor="middle" font-family="sans-serif" font-size="10" font-style="italic" fill="#555">adopt first (observe), block later (enforce)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h1 id="track-a--client-the-developers-workstation">Track A — Client (the developer&amp;rsquo;s workstation)&lt;/h1>
&lt;h2 id="a0--install-ai-jail-and-bubblewrap">A0 — Install ai-jail and bubblewrap&lt;/h2>
&lt;p>&lt;code>ai-jail&lt;/code> wraps the sandbox; on Linux it needs &lt;code>bubblewrap&lt;/code> separately, on macOS it needs no extra dependency.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ai-jail (macOS and Linux)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">brew tap akitaonrails/tap &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> brew install ai-jail
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># or, with cargo:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">cargo install ai-jail
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># or, with mise:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">mise use -g ubi:akitaonrails/ai-jail
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># bubblewrap on Linux (pick your distro)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sudo pacman -S bubblewrap &lt;span class="c1"># Arch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sudo apt install bubblewrap &lt;span class="c1"># Debian / Ubuntu&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sudo dnf install bubblewrap &lt;span class="c1"># Fedora&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Check the binary is there and that &lt;code>bwrap&lt;/code> runs without root (it must not ask for &lt;code>sudo&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">ai-jail --version
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">bwrap --ro-bind / / --unshare-all &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;bwrap ok without root&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If &lt;code>bwrap&lt;/code> fails asking for privileges, your kernel has &lt;em>unprivileged user namespaces&lt;/em> disabled; enable them (&lt;code>sysctl kernel.unprivileged_userns_clone=1&lt;/code> on older Debian/Ubuntu) before continuing.&lt;/p>
&lt;h2 id="a1--the-per-project-ai-jail-file">A1 — The per-project .ai-jail file&lt;/h2>
&lt;p>On the first launch inside the project, &lt;code>ai-jail&lt;/code> creates a &lt;code>.ai-jail&lt;/code> (TOML) that is &lt;strong>committable to the repo&lt;/strong>: any colleague who clones inherits the same policy.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nb">cd&lt;/span> ~/Projects/mi-app
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ai-jail claude &lt;span class="c1"># creates .ai-jail and launches Claude Code inside the sandbox&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The generated file:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-toml" data-lang="toml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># .ai-jail — sandbox configuration (commit it to the repo)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">command&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;claude&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">rw_maps&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="p">[]&lt;/span> &lt;span class="c"># extra writable directories&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">ro_maps&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="p">[]&lt;/span> &lt;span class="c"># extra read-only directories&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Before trusting the sandbox, audit it.&lt;/strong> &lt;code>--dry-run --verbose&lt;/code> prints every mount point, every isolation flag and the full &lt;code>bwrap&lt;/code> command, without executing anything:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">ai-jail --dry-run --verbose claude
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Read the output and confirm three things: that &lt;code>$HOME&lt;/code> is mounted as a tmpfs (not the real one), that &lt;code>~/.ssh&lt;/code>, &lt;code>~/.aws&lt;/code> and &lt;code>~/.gnupg&lt;/code> &lt;strong>do not appear&lt;/strong> among the mounts, and that the only writable directory is the project one. If you need an extra directory:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">ai-jail --rw-map ~/Projects/shared-lib claude &lt;span class="c1"># extra, writable&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ai-jail --map /opt/datasets claude &lt;span class="c1"># extra, read-only&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Other agents, same binary:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">ai-jail codex
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ai-jail opencode
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ai-jail bash &lt;span class="c1"># bare shell to debug the sandbox&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ai-jail -- python script.py &lt;span class="c1"># any command&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="a2--permission-allowlists-with---bootstrap">A2 — Permission allowlists with &amp;ndash;bootstrap&lt;/h2>
&lt;p>&lt;code>--bootstrap&lt;/code> generates each agent&amp;rsquo;s permission configuration, with sensible allow/deny/ask, and takes a backup before overwriting:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">ai-jail --bootstrap
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What it produces, in summary:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Agent&lt;/th>
&lt;th>File&lt;/th>
&lt;th>Base policy&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Claude Code&lt;/td>
&lt;td>&lt;code>~/.claude/settings.json&lt;/code>&lt;/td>
&lt;td>&lt;strong>allow&lt;/strong>: &lt;code>git status/diff/log&lt;/code>, &lt;code>ls&lt;/code>, &lt;code>grep&lt;/code>, &lt;code>cargo&lt;/code>, &lt;code>npm&lt;/code>, &lt;code>python&lt;/code>, &lt;code>docker compose&lt;/code> · &lt;strong>ask&lt;/strong>: &lt;code>git push&lt;/code>, &lt;code>rm&lt;/code>, &lt;code>docker run&lt;/code> · &lt;strong>deny&lt;/strong>: &lt;code>rm -rf&lt;/code>, &lt;code>sudo&lt;/code>, &lt;code>chmod 777&lt;/code>, &lt;code>git push --force&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Codex&lt;/td>
&lt;td>&lt;code>~/.codex/config.toml&lt;/code>&lt;/td>
&lt;td>&lt;code>approval_policy = &amp;quot;on-request&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OpenCode&lt;/td>
&lt;td>&lt;code>~/.config/opencode/opencode.json&lt;/code>&lt;/td>
&lt;td>permissions for &lt;code>bash&lt;/code>, &lt;code>edit&lt;/code>, &lt;code>write&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The operational key: &lt;code>git push&lt;/code> is under &lt;strong>ask&lt;/strong>, not &lt;strong>allow&lt;/strong>, and &lt;code>git push --force&lt;/code> under &lt;strong>deny&lt;/strong>. The agent can commit, branch and rebase locally as much as it wants; none of that touches the remote. (If you use the Claude Code &lt;code>/sandbox&lt;/code>, also set &lt;code>&amp;quot;allowUnsandboxedCommands&amp;quot;: false&lt;/code> to close the &lt;code>dangerouslyDisableSandbox&lt;/code> &lt;em>escape hatch&lt;/em>, which is opt-out out of the box.)&lt;/p>
&lt;h2 id="a3--lockdown-for-anything-you-do-not-trust">A3 — Lockdown for anything you do not trust&lt;/h2>
&lt;p>To audit third-party code or run an agent over a project you do not know, &lt;code>--lockdown&lt;/code> goes further: project mounted &lt;strong>read-only&lt;/strong>, GPU/Docker/display disabled, &lt;code>--rw-map&lt;/code>/&lt;code>--map&lt;/code> ignored, &lt;code>$HOME&lt;/code> a pure tmpfs with no host dotfiles, network cut with &lt;code>--unshare-net&lt;/code> and the environment wiped with &lt;code>--clearenv&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">ai-jail --lockdown bash
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is the most restrictive sandbox possible short of a VM. Use it as your mental default for anything that is not your own code on your own machine.&lt;/p>
&lt;h2 id="a4--the-safety-net-git-with-no-push">A4 — The safety net: git with no push&lt;/h2>
&lt;p>It is not a flag, it is a property of the environment that changes the risk arithmetic. If the project is in git with a remote, and the agent does &lt;strong>not&lt;/strong> have &lt;code>push&lt;/code> permission, the worst case, that it corrupts every file in the project, is reverted with:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">git checkout . &lt;span class="c1"># back to the last commit&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># and if it touched .git (unlikely): delete the dir and re-clone&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The remote was never touched. &lt;strong>Sandbox for the filesystem + git for the code + manual push&lt;/strong> is already a reasonable level for daily use: &lt;code>ai-jail&lt;/code> protects your data and the system, git protects the code, and the decision to publish is still yours.&lt;/p>
&lt;hr>
&lt;h1 id="track-b--cluster-rke2-with-cilium--tetragon">Track B — Cluster (RKE2 with Cilium + Tetragon)&lt;/h1>
&lt;p>The untrusted agent, or the inference that executes generated code, runs as a pod. The same principle as on the client, different primitives. We assume a generic RKE2 cluster with Cilium as CNI and Tetragon already deployed (the eBPF agent &lt;code>DaemonSet&lt;/code> on every node).&lt;/p>
&lt;h2 id="b0--the-pod-baseline">B0 — The pod baseline&lt;/h2>
&lt;p>Before any eBPF, the standard kit. Unprivileged &lt;code>securityContext&lt;/code>, read-only root, default seccomp:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Pod&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ai-agent&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ai-agent&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">securityContext&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runAsNonRoot&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runAsUser&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10001&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">seccompProfile&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RuntimeDefault&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agent&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">registry.interno/ai-agent:pinned&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">securityContext&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allowPrivilegeEscalation&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readOnlyRootFilesystem&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">capabilities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">drop&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ALL&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">name: work, mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/work } &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># the only writable one&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">work&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">emptyDir&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the default egress cut, the cluster twin of &lt;code>--unshare-net&lt;/code>. A default-deny egress NetworkPolicy in the namespace, opening only DNS and the bare essentials:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">networking.k8s.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NetworkPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">default-deny-egress&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">podSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policyTypes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;Egress&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">egress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">to&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">namespaceSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">kubernetes.io/metadata.name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kube-system }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">protocol: UDP, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">53&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">protocol: TCP, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">53&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="b1--kata-runtimeclass-the-untrusted-pod-in-its-own-microvm">B1 — Kata RuntimeClass: the untrusted pod in its own microVM&lt;/h2>
&lt;p>For genuinely untrusted code, take it out of the shared kernel. With Kata deployed there is a &lt;code>RuntimeClass&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">node.k8s.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RuntimeClass&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kata&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">handler&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kata&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the pod asks for it with one line, &lt;code>runtimeClassName: kata&lt;/code>, running in its own microVM with a dedicated kernel instead of sharing the node&amp;rsquo;s:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runtimeClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kata &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># ← the pod runs in a microVM, not on the node&amp;#39;s kernel&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># ...the rest the same as B0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is the cluster twin of isolation by construction: a kernel exploit inside the pod does not reach the node.&lt;/p>
&lt;h2 id="b2--tetragon-observation-phase-post">B2 — Tetragon, observation phase (Post)&lt;/h2>
&lt;p>Now the layer that separates a platform with runtime visibility from one without. &lt;strong>Observe first, never kill from the outset.&lt;/strong> A &lt;code>TracingPolicyNamespaced&lt;/code>, scoped to the namespace and to the agent&amp;rsquo;s label, that reports (does not kill) three things: process executions, network connections and opens of sensitive paths. &lt;code>action: Post&lt;/code> only emits the event.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicyNamespaced&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agente-observa&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">podSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ai-agent&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kprobes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># --- outbound connections ---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;tcp_connect&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sock&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># --- opens of sensitive files ---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;security_file_open&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;file&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Prefix&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/var/run/secrets&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/work/.git/config&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>(Process executions need no kprobe: Tetragon emits &lt;code>process_exec&lt;/code>/&lt;code>process_exit&lt;/code> natively.) Deploy it and watch the events live from the node&amp;rsquo;s Tetragon pod:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kubectl apply -f agente-observa.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># readable events, filtered by namespace:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl &lt;span class="nb">exec&lt;/span> -n kube-system ds/tetragon -c tetragon -- &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> tetra getevents -o compact --namespace agentes
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Leave this running for a typical working day of the agent. Note &lt;strong>which destinations&lt;/strong> it really connects to (your internal registry, your HF mirror, your vLLM endpoint) and &lt;strong>which paths&lt;/strong> it opens. That is your baseline: the list of what is legitimate. Without this step, a &lt;code>Sigkill&lt;/code> kills good work and generates an availability incident, precisely what ENS asks you to avoid.&lt;/p>
&lt;h2 id="b3--tetragon-enforcement-phase-sigkill">B3 — Tetragon, enforcement phase (Sigkill)&lt;/h2>
&lt;p>With the baseline in hand, promote to blocking. Two rules. The first: &lt;strong>kill any connection whose destination is not on the allowlist&lt;/strong> — &lt;code>NotDAddr&lt;/code> inverts the match: it fires for everything that is &lt;em>not&lt;/em> those networks. The second: &lt;strong>kill any attempt to open a secrets path&lt;/strong>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicyNamespaced&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agente-enforce&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">podSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ai-agent&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kprobes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># --- egress: kill everything that is NOT on the allowlist ---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;tcp_connect&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sock&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;NotDAddr&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;127.0.0.1&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;10.0.0.0/8&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># internal cluster network&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;172.16.10.20&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># internal registry (example)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Sigkill&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># --- reading secrets: kill the process ---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;security_file_open&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;file&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Prefix&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/var/run/secrets/kubernetes.io/serviceaccount/token&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/work/.ssh&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Sigkill&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kubectl apply -f agente-enforce.yaml
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now the agent can do whatever it likes inside the pod, but &lt;strong>the instant&lt;/strong> it tries to connect to a disallowed destination or read the service account token, Tetragon kills it in the kernel, before the packet leaves or the &lt;code>read&lt;/code> returns bytes. It is the cluster twin of the &lt;code>curl&lt;/code> blocklist and the unmounted &lt;code>~/.ssh&lt;/code>, but applied at runtime and over &lt;em>any&lt;/em> binary, not just the ones you know about.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Operational warning.&lt;/strong> Enforcement with &lt;code>Sigkill&lt;/code> requires a recent kernel with eBPF support for the action (5.10+ is safe). Deploy &lt;code>agente-enforce&lt;/code> in a test namespace first, and keep &lt;code>agente-observa&lt;/code> active in parallel: if the block fires, the &lt;code>Post&lt;/code> event tells you exactly what triggered it. Adopt first, block later.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-client--cluster-equivalence-table">The client ↔ cluster equivalence table&lt;/h2>
&lt;p>The same vector, the two primitives. This is &amp;ldquo;extrapolating the technology&amp;rdquo; made explicit:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Threat vector&lt;/th>
&lt;th>Client (workstation)&lt;/th>
&lt;th>Cluster (RKE2)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>$HOME&lt;/code> / writable root&lt;/td>
&lt;td>&lt;code>$HOME&lt;/code> as ephemeral tmpfs (&lt;code>bwrap&lt;/code>)&lt;/td>
&lt;td>&lt;code>readOnlyRootFilesystem: true&lt;/code> + &lt;code>emptyDir&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Arbitrary egress&lt;/td>
&lt;td>&lt;code>curl&lt;/code>/&lt;code>wget&lt;/code> blocklist · &lt;code>--unshare-net&lt;/code>&lt;/td>
&lt;td>default-deny NetworkPolicy + Tetragon &lt;code>NotDAddr&lt;/code>→&lt;code>Sigkill&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reading secrets&lt;/td>
&lt;td>&lt;code>~/.ssh&lt;/code>/&lt;code>~/.aws&lt;/code>/&lt;code>~/.gnupg&lt;/code> not mounted&lt;/td>
&lt;td>secrets outside the pod + Tetragon &lt;code>security_file_open&lt;/code>→&lt;code>Sigkill&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kernel escape&lt;/td>
&lt;td>Landlock (2nd VFS barrier)&lt;/td>
&lt;td>&lt;code>runtimeClassName: kata&lt;/code> (microVM, own kernel)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>No escape hatch&lt;/td>
&lt;td>process inside &lt;code>bwrap&lt;/code>, no way out&lt;/td>
&lt;td>no &lt;code>privileged&lt;/code>, &lt;code>drop ALL&lt;/code>, &lt;code>allowPrivilegeEscalation:false&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Damage to the code&lt;/td>
&lt;td>git remote with no &lt;code>push&lt;/code> → &lt;code>git checkout .&lt;/code>&lt;/td>
&lt;td>GitOps + PR review, the agent does not apply to &lt;code>main&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Visibility&lt;/td>
&lt;td>&lt;code>--dry-run --verbose&lt;/code> (static, pre-run)&lt;/td>
&lt;td>Tetragon &lt;code>tetra getevents&lt;/code> (dynamic, at runtime)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="checklist-of-gotchas">Checklist of gotchas&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Do not put in a &lt;code>Sigkill&lt;/code> without passing through &lt;code>Post&lt;/code>.&lt;/strong> The observation baseline is not optional: it is what separates &amp;ldquo;blocking a C2&amp;rdquo; from &amp;ldquo;killing your own fine-tuning job&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>The &lt;code>.ai-jail&lt;/code> gets committed; secrets do not.&lt;/strong> The TOML is policy, not credentials. Check you are not putting paths holding sensitive data into &lt;code>rw_maps&lt;/code>.&lt;/li>
&lt;li>&lt;strong>&lt;code>readOnlyRootFilesystem&lt;/code> breaks apps that write to &lt;code>/tmp&lt;/code>.&lt;/strong> Mount an &lt;code>emptyDir&lt;/code> at &lt;code>/tmp&lt;/code> as well as the work one.&lt;/li>
&lt;li>&lt;strong>A NetworkPolicy with no DNS rule leaves the pod blind.&lt;/strong> Open port 53 to &lt;code>kube-system&lt;/code> or nothing resolves.&lt;/li>
&lt;li>&lt;strong>Kata is not free.&lt;/strong> It adds startup latency and not every workload with special devices (GPU passthrough) fits; reserve it for the untrusted, not for everything.&lt;/li>
&lt;li>&lt;strong>The Claude Code &lt;code>/sandbox&lt;/code> does not cover MCP or hooks&lt;/strong> unless you enable &lt;code>sandbox-runtime&lt;/code>. If your agent uses MCP servers, assume they run with full permissions until you do.&lt;/li>
&lt;li>&lt;strong>&lt;code>NotDAddr&lt;/code> with literal IPs ages badly.&lt;/strong> Document the allowlist and review it when the registry or the inference endpoint changes; consider stable internal CIDRs instead of loose IPs.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets in the sovereign LLM stack: defence in depth&lt;/a> — the hardening of the whole cluster beyond agent isolation.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents from workstation to cluster&lt;/a> — the landscape this runbook executes: threat model, the five isolation families and why client and cluster use different primitives.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">The kitchen door the maître never checked: Cilium eBPF and DRANET&lt;/a> — the Cilium eBPF layer on which Tetragon hooks its kprobes; the datapath you already have in the cluster.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS × ISO 42001 × EU AI Act&lt;/a> — Tetragon events as technical evidence for &lt;code>op.mon&lt;/code>/&lt;code>op.exp&lt;/code>; enforcement as a protection measure.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — mitigation on the content plane; this runbook, on the execution plane.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">Seven phases of deploying an on-premise LLM platform&lt;/a> — where runtime hardening fits in the deployment sequence (F4 identity/policies, F5 platform).&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>ai-jail (Fabio Akita), GPL-3.0: &lt;a href="https://github.com/akitaonrails/ai-jail">https://github.com/akitaonrails/ai-jail&lt;/a>&lt;/li>
&lt;li>bubblewrap: &lt;a href="https://github.com/containers/bubblewrap">https://github.com/containers/bubblewrap&lt;/a>&lt;/li>
&lt;li>Landlock LSM: &lt;a href="https://landlock.io">https://landlock.io&lt;/a>&lt;/li>
&lt;li>Tetragon — TracingPolicy: &lt;a href="https://tetragon.io/docs/concepts/tracing-policy/">https://tetragon.io/docs/concepts/tracing-policy/&lt;/a>&lt;/li>
&lt;li>Tetragon — enforcement (Sigkill/Override): &lt;a href="https://tetragon.io/docs/concepts/enforcement/">https://tetragon.io/docs/concepts/enforcement/&lt;/a>&lt;/li>
&lt;li>Kata Containers — Kubernetes RuntimeClass: &lt;a href="https://katacontainers.io">https://katacontainers.io&lt;/a>&lt;/li>
&lt;li>Kubernetes — Pod Security &amp;amp; seccomp: &lt;a href="https://kubernetes.io/docs/tutorials/security/seccomp/">https://kubernetes.io/docs/tutorials/security/seccomp/&lt;/a>&lt;/li>
&lt;li>Kubernetes — Network Policies: &lt;a href="https://kubernetes.io/docs/concepts/services-networking/network-policies/">https://kubernetes.io/docs/concepts/services-networking/network-policies/&lt;/a>&lt;/li>
&lt;li>Cilium: &lt;a href="https://cilium.io">https://cilium.io&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>The contractor with the master key: isolating AI agents from workstation to cluster</title><link>https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/</link><pubDate>Tue, 09 Jun 2026 16:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/</guid><description>&lt;blockquote>
&lt;p>First post of a pair on &lt;strong>AI agent isolation&lt;/strong>. This one fixes the &lt;em>what&lt;/em> and the &lt;em>where&lt;/em>: the complete map of isolation primitives and which domain each belongs to. The &lt;a href="https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/">sibling runbook&lt;/a> fixes the &lt;em>how&lt;/em>, with commands: &lt;code>ai-jail&lt;/code> and bubblewrap on the client, Tetragon &lt;code>TracingPolicy&lt;/code> and &lt;code>RuntimeClass&lt;/code> on the cluster. If you are only going to read one, this one gives you the mental model; the other one gives you the files to copy and paste.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An AI agent that runs code needs access to your filesystem and your tools: compiler, linter, &lt;code>grep&lt;/code>, &lt;code>make&lt;/code>, &lt;code>cargo&lt;/code>, &lt;code>npm&lt;/code>. That is the minimum to be useful. The problem is that alongside that access travels the ability to read &lt;code>~/.aws/credentials&lt;/code>, exfiltrate your SSH keys or fire an &lt;code>rm -rf&lt;/code> outside the project directory. And no malicious model is needed: a compromised dependency in an &lt;code>npm install&lt;/code> is enough, because the well-meaning agent and the poisoned &lt;em>post-install script&lt;/em> &lt;strong>run with the same permissions&lt;/strong>. The answer is not to trust the LLM&amp;rsquo;s good intentions; it is to &lt;strong>isolate in order to bound the blast radius&lt;/strong>. This post walks the five isolation families of 2026, from the process sandbox to the full VM, and sorts them into two columns: what applies on the &lt;strong>client&lt;/strong> (the developer&amp;rsquo;s workstation: bubblewrap, &lt;code>ai-jail&lt;/code>, sandbox-exec, Landlock, the native Claude Code and Codex sandboxes) and what applies on the &lt;strong>cluster&lt;/strong> (where the agent or the inference runs on Kubernetes: namespaces+seccomp, gVisor, Firecracker/Kata microVMs and &lt;strong>eBPF/Tetragon&lt;/strong> as the layer for live observation and enforcement). The thesis: the threat model is the same in both places; the tools are not. Policy extrapolates; the primitive gets rewritten.&lt;/p>
&lt;h2 id="the-analogy-the-contractor-with-the-master-key">The analogy: the contractor with the master key&lt;/h2>
&lt;p>You hire a builder for a renovation. He is competent and acts in good faith. But two things happen that you do not control. The first: he may misread the instruction and knock down the wrong partition wall. The second, worse: his toolbox may have been tampered with before he came through your door — somebody put something inside — and when he opens it in your living room, that something activates.&lt;/p>
&lt;p>Nobody sensible gives him the &lt;strong>master key&lt;/strong> to the whole building. You open the room he works in, you leave him the tools he needs, and you keep the study with the safe and the server room locked. If the renovation goes wrong, by error or by sabotage, the damage stays in that room.&lt;/p>
&lt;p>An AI agent is that contractor. The sandbox is the key policy: &lt;strong>you give it the project room and the tools, not the master key to the system.&lt;/strong> And here is the twist that justifies two posts: the builder works in two different buildings. One is &lt;strong>your flat&lt;/strong>, the developer&amp;rsquo;s workstation, with your credentials, your &lt;code>~/.ssh&lt;/code>, the browser&amp;rsquo;s password vault. The other is &lt;strong>the datacenter&lt;/strong>, the cluster where inference and autonomous agents serve customers, with data from several tenants at once. The key policy is identical in both: least privilege, bound the radius. But the lock on your flat&amp;rsquo;s door is not the same as the one at the datacenter. In the flat you fit a cylinder lock (&lt;code>bubblewrap&lt;/code>). At the datacenter you put a guard watching every door and a separate wing of the building (Tetragon + microVM). Same principle, different hardware. That is &lt;strong>extrapolating the technology&lt;/strong>, not copying it.&lt;/p>
&lt;h2 id="the-threat-model-what-a-runaway-agent-can-do">The threat model: what a runaway agent can do&lt;/h2>
&lt;p>Before choosing a lock it helps to enumerate the burglar. The attack surface of an agent that executes arbitrary bash breaks down into five concrete threats. Not all are defended by the same layer and, an uncomfortable fact that the security documentation itself acknowledges, &lt;strong>no single layer covers them all&lt;/strong>.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Threat&lt;/th>
&lt;th>What the agent does&lt;/th>
&lt;th>Minimum layer that cuts it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Filesystem out of scope&lt;/strong>&lt;/td>
&lt;td>Reads &lt;code>.env&lt;/code>, &lt;code>~/.ssh/id_rsa&lt;/code>, system secrets; modifies sources outside the project&lt;/td>
&lt;td>Process sandbox (path allowlist)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Arbitrary network egress&lt;/strong>&lt;/td>
&lt;td>Exfiltrates data, receives instructions from a remote C2, calls unauthorised APIs&lt;/td>
&lt;td>Network blocking / NetworkPolicy / microVM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Kernel syscall surface&lt;/strong>&lt;/td>
&lt;td>A kernel exploit from inside the container escalates to the host (shared kernel)&lt;/td>
&lt;td>gVisor or microVM (dedicated kernel)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Cross-tenant leakage&lt;/strong>&lt;/td>
&lt;td>One customer&amp;rsquo;s workload reads another&amp;rsquo;s data on a multi-tenant platform&lt;/td>
&lt;td>microVM (de facto standard)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Secret exfiltration&lt;/strong>&lt;/td>
&lt;td>Extracts tokens and environment variables via &lt;code>/proc&lt;/code> or the environment&lt;/td>
&lt;td>&lt;code>--clearenv&lt;/code> / tmpfs &lt;code>$HOME&lt;/code> / secrets outside the pod&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>There is a sixth threat that &lt;strong>no sandbox solves&lt;/strong>: &lt;em>prompt injection&lt;/em>. If an attacker manages to slip instructions into the agent&amp;rsquo;s context — a poisoned comment in the code, a malicious file the agent reads, an adversarial response from a tool — the agent will execute those instructions with whatever permissions the sandbox grants it. Isolation &lt;strong>shrinks the impact radius&lt;/strong> of a successful injection; it does not prevent the injection. That is why the sandbox is a layer, not the solution: on top go input validation, tool-call allowlists and output auditing. The sentence to internalise: &lt;em>isolation does not make the agent trustworthy; it bounds what an untrustworthy agent can break.&lt;/em>&lt;/p>
&lt;h2 id="two-domains-one-policy">Two domains, one policy&lt;/h2>
&lt;p>The builder works in two buildings. The distribution of tools reads better in two columns:&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 430" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Two-column map: client domain (workstation) and cluster domain, with their isolation layers and the shared policy bridge">
&lt;defs>&lt;marker id="am" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="26" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="700" fill="currentColor">Same least-privilege policy · different primitive&lt;/text>
&lt;!-- client column -->
&lt;rect x="24" y="46" width="360" height="350" rx="10" fill="#eef4fb" stroke="#1f5fa8" stroke-width="1.6"/>
&lt;text x="204" y="72" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="700" fill="#0d3a66">CLIENT · dev workstation&lt;/text>
&lt;text x="204" y="90" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#0d3a66">one process, one user, personal data&lt;/text>
&lt;rect x="48" y="106" width="312" height="50" rx="6" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.2"/>
&lt;text x="204" y="126" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#0d3a66">Process sandbox&lt;/text>
&lt;text x="204" y="143" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#0d3a66">bubblewrap (Linux) · sandbox-exec (macOS)&lt;/text>
&lt;rect x="48" y="166" width="312" height="42" rx="6" fill="#dff0ff" stroke="#1f5fa8" stroke-width="1.1"/>
&lt;text x="204" y="183" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#0d3a66">Landlock (LSM, 2nd VFS barrier)&lt;/text>
&lt;text x="204" y="199" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#0d3a66">defence in depth, kernel ≥5.13&lt;/text>
&lt;rect x="48" y="218" width="312" height="42" rx="6" fill="#eaf4ff" stroke="#1f5fa8" stroke-width="1.1"/>
&lt;text x="204" y="235" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#0d3a66">Dev container (optional)&lt;/text>
&lt;text x="204" y="251" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#0d3a66">reproducibility + reset, shared kernel&lt;/text>
&lt;text x="204" y="284" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#0d3a66">What wraps it:&lt;/text>
&lt;text x="204" y="304" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#0d3a66">ai-jail · Claude Code /sandbox&lt;/text>
&lt;text x="204" y="320" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#0d3a66">Codex --sandbox · Cursor /worktree&lt;/text>
&lt;text x="204" y="352" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#0d3a66">Safety net: git remote with no push&lt;/text>
&lt;text x="204" y="376" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="#555">startup ~0 ms · no daemon&lt;/text>
&lt;!-- cluster column -->
&lt;rect x="436" y="46" width="360" height="350" rx="10" fill="#f3eefb" stroke="#5a2db0" stroke-width="1.6"/>
&lt;text x="616" y="72" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="700" fill="#42208a">CLUSTER · inference / agents in prod&lt;/text>
&lt;text x="616" y="90" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#42208a">multi-pod, multi-tenant, RKE2&lt;/text>
&lt;rect x="460" y="106" width="312" height="50" rx="6" fill="#e6d9f2" stroke="#5a2db0" stroke-width="1.2"/>
&lt;text x="616" y="126" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#42208a">Pod baseline&lt;/text>
&lt;text x="616" y="143" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#42208a">namespaces + seccomp + cgroups + NetworkPolicy&lt;/text>
&lt;rect x="460" y="166" width="312" height="42" rx="6" fill="#ede1f7" stroke="#5a2db0" stroke-width="1.1"/>
&lt;text x="616" y="183" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#42208a">gVisor (runsc) — user-space kernel&lt;/text>
&lt;text x="616" y="199" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#42208a">cuts the syscall surface to the host&lt;/text>
&lt;rect x="460" y="218" width="312" height="42" rx="6" fill="#f0e6fb" stroke="#5a2db0" stroke-width="1.1"/>
&lt;text x="616" y="235" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#42208a">Firecracker / Kata microVM — own kernel&lt;/text>
&lt;text x="616" y="251" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#42208a">isolation by construction for untrusted code&lt;/text>
&lt;rect x="460" y="270" width="312" height="62" rx="6" fill="#fde9d6" stroke="#a85a00" stroke-width="1.6"/>
&lt;text x="616" y="290" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#8a4a00">eBPF / Tetragon (what we already have)&lt;/text>
&lt;text x="616" y="307" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#8a4a00">watches every exec · connect · open in the kernel&lt;/text>
&lt;text x="616" y="322" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#8a4a00">and kills (Sigkill) whatever strays from the script&lt;/text>
&lt;text x="616" y="356" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#42208a">Safety net: GitOps + PR review&lt;/text>
&lt;text x="616" y="376" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="#555">startup &amp;lt;1 s (microVM) · runtime layer always on&lt;/text>
&lt;!-- bridge -->
&lt;path d="M384,221 L436,221" stroke="#666" stroke-width="1.6" fill="none" marker-end="url(#am)"/>
&lt;path d="M436,241 L384,241" stroke="#666" stroke-width="1.6" fill="none" marker-end="url(#am)"/>
&lt;text x="410" y="415" text-anchor="middle" font-family="sans-serif" font-size="11" font-style="italic" fill="#555">extrapolate: each client control has its cluster analogue (equivalence table in the runbook)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-client-isolating-the-agent-on-the-workstation">The client: isolating the agent on the workstation&lt;/h2>
&lt;p>Here the agent is a coding assistant — Claude Code, Codex, OpenCode, Cursor — that a developer launches on their machine. One process, one user, and right next to it the juiciest assets that exist: &lt;code>~/.aws/credentials&lt;/code>, &lt;code>~/.ssh&lt;/code>, &lt;code>~/.gnupg&lt;/code>, the browser&amp;rsquo;s password store. The matching tier is the lightest one: the &lt;strong>process sandbox&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>bubblewrap (Linux) and sandbox-exec (macOS).&lt;/strong> &lt;code>bubblewrap&lt;/code> (&lt;code>bwrap&lt;/code>) is the same sandbox Flatpak uses to isolate each desktop app: a ~50 KB binary, ~4,000 lines of C, maintained by the GNOME team and, the key property, it runs &lt;strong>without root&lt;/strong> via &lt;code>CLONE_NEWUSER&lt;/code>, creating namespaces with no elevated privileges. It mounts &lt;code>$HOME&lt;/code> as an ephemeral tmpfs and only exposes the project directory, writable; the rest of the system becomes invisible. On macOS the equivalent is &lt;code>sandbox-exec&lt;/code> with SBPL profiles: a legacy Apple API, officially deprecated and with no public replacement, but working today. Parity between the two is not exact — on macOS the GPU (Metal) and the display (Cocoa) are system services and &lt;code>sandbox-exec&lt;/code> does not restrict them — but both protect what matters: access to the sensitive areas of the filesystem.&lt;/p>
&lt;p>&lt;strong>Landlock as a second barrier.&lt;/strong> &lt;code>bubblewrap&lt;/code> isolates via &lt;em>namespaces and mounts&lt;/em>; Landlock, a Linux Security Module available since kernel 5.13, restricts access at the &lt;strong>VFS&lt;/strong> level, independently of namespaces. It does not replace &lt;code>bwrap&lt;/code>: it complements it. It closes vectors that mount-based isolation does not cover on its own (escape routes via &lt;code>/proc&lt;/code>, symlink tricks inside allowed mounts) and acts as a safety net should the namespace machinery have a bug. It is defence in depth inside the client itself, and it degrades cleanly to a no-op on kernels that do not support it.&lt;/p>
&lt;p>&lt;strong>Dev containers, when reproducibility is needed.&lt;/strong> A dev container (&lt;code>devcontainer.json&lt;/code>, what Codespaces and Cursor use) is a Docker container with a configuration layer on top. It gives reasonable filesystem isolation and easy &lt;em>reset&lt;/em> (destroy and recreate), but it &lt;strong>shares the host kernel&lt;/strong>, the same limitation as any Docker, and it tends to be long-lived, accumulating state. For an agent running your own team&amp;rsquo;s code on your own machine it is a good repeatability story; it is not, on its own, the isolation layer for untrusted code.&lt;/p>
&lt;p>&lt;strong>What wraps all of this.&lt;/strong> A hand-written bash script works, but it does not scale to a team. The tools that package it:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>ai-jail&lt;/code>&lt;/strong> (Rust, GPL-3.0): wraps &lt;code>bwrap&lt;/code>/&lt;code>sandbox-exec&lt;/code> with per-project config in a &lt;code>.ai-jail&lt;/code> file (TOML, committable to the repo, so the whole team inherits the same policy), auto-detection of GPU/Docker/display, a &lt;code>--lockdown&lt;/code> mode (project read-only, network cut with &lt;code>--unshare-net&lt;/code>, &lt;code>--clearenv&lt;/code>), &lt;code>--dry-run&lt;/code> for auditing, and &lt;code>--bootstrap&lt;/code> to generate each agent&amp;rsquo;s permission allowlists. It is tool-agnostic: the same binary serves Claude, Codex, OpenCode or Crush. It also applies Landlock automatically on 5.13+ kernels as defence in depth.&lt;/li>
&lt;li>&lt;strong>The Claude Code &lt;code>/sandbox&lt;/code>&lt;/strong>: since October 2025 Claude Code ships its own sandbox that uses, exactly, &lt;code>bubblewrap&lt;/code> on Linux and &lt;code>sandbox-exec&lt;/code> on macOS. Its &lt;em>Sandboxed Bash&lt;/em> isolates shell commands, but &lt;strong>not&lt;/strong> the file tools, the MCP servers or the hooks, which run with the process&amp;rsquo;s full permissions unless you enable the beta &lt;code>sandbox-runtime&lt;/code> package, which wraps the whole process. There is one nuance worth knowing: if a command fails because of a restriction, the agent can retry with &lt;code>dangerouslyDisableSandbox&lt;/code> — it is &lt;em>opt-out&lt;/em>, not &lt;em>opt-in&lt;/em>.&lt;/li>
&lt;li>&lt;strong>Codex CLI&lt;/strong>: three modes via &lt;code>--sandbox&lt;/code> (&lt;code>read-only&lt;/code>, &lt;code>workspace-write&lt;/code>, &lt;code>danger-full-access&lt;/code>); the recommended default is &lt;code>workspace-write&lt;/code>. The philosophy is deliberate: Codex does not provide the isolation, it delegates it to the environment that wraps it. &lt;code>danger-full-access&lt;/code> only makes sense &lt;strong>inside&lt;/strong> a microVM.&lt;/li>
&lt;li>&lt;strong>Cursor&lt;/strong>: its cloud agents run in isolated VMs; &lt;code>/worktree&lt;/code> creates a single-use isolated worktree per task, and &lt;code>/best-of-n&lt;/code> launches several attempts in parallel in separate worktrees.&lt;/li>
&lt;/ul>
&lt;h2 id="the-cluster-isolating-the-agent-in-production">The cluster: isolating the agent in production&lt;/h2>
&lt;p>The second building is the datacenter. Here the &amp;ldquo;agent&amp;rdquo; can be an autonomous agent running with no human in front of it, or the inference service itself executing generated code, or a multi-tenant workload where one customer&amp;rsquo;s pod must not touch another&amp;rsquo;s data. The process is no longer one: they are pods in a Kubernetes cluster (RKE2/RKE3 on a typical sovereign platform). The primitives change in nature.&lt;/p>
&lt;p>&lt;strong>The pod baseline.&lt;/strong> First of all, the standard kit: Linux namespaces, &lt;code>seccomp&lt;/code> (&lt;code>RuntimeDefault&lt;/code>) to trim the syscall surface, cgroups for resource limits, an unprivileged &lt;code>securityContext&lt;/code> (&lt;code>runAsNonRoot&lt;/code>, &lt;code>readOnlyRootFilesystem&lt;/code>, dropping all capabilities) and &lt;strong>NetworkPolicy&lt;/strong> to cut egress. It is the cluster equivalent of the process sandbox allowlist. Necessary, but it shares the kernel with the host: insufficient for genuinely untrusted code.&lt;/p>
&lt;p>&lt;strong>gVisor (&lt;code>runsc&lt;/code>).&lt;/strong> Google&amp;rsquo;s user-space kernel: it intercepts the workload&amp;rsquo;s syscalls before they reach the host kernel and serves them inside a Linux kernel reimplemented in Go (the &lt;em>Sentry&lt;/em>). The surface exposed to host kernel vulnerabilities drops sharply, while keeping fast startup and a low footprint. It is the middle ground when the risk of a kernel escape is real but the overhead of a microVM is not acceptable.&lt;/p>
&lt;p>&lt;strong>Firecracker / Kata microVMs.&lt;/strong> The de facto standard for untrusted code in 2026. Firecracker (AWS&amp;rsquo;s VMM written in Rust, on top of KVM) gives each sandbox a &lt;strong>dedicated Linux kernel&lt;/strong>: a kernel exploit inside the microVM does not reach the host &lt;em>by construction&lt;/em>. It is what sits underneath Vercel Sandbox (GA January 2026) and E2B. On Kubernetes, &lt;strong>Kata Containers&lt;/strong> brings that model to a &lt;code>RuntimeClass&lt;/code>: you mark the untrusted agent&amp;rsquo;s pod with &lt;code>runtimeClassName: kata&lt;/code> and it runs in its own microVM instead of sharing the node&amp;rsquo;s kernel. For multi-tenant with generated code, this is the baseline, not the luxury.&lt;/p>
&lt;p>&lt;strong>eBPF / Tetragon: the layer we already have.&lt;/strong> Here is the piece that separates a platform with runtime observability from one that only trusts its configuration. The previous layers are &lt;em>static&lt;/em>: they define what the pod can do before it starts. &lt;strong>Tetragon&lt;/strong>, Cilium&amp;rsquo;s runtime security component, based on eBPF, is &lt;em>dynamic&lt;/em>: it observes, in the kernel and at minimal cost, &lt;strong>every&lt;/strong> process execution, &lt;strong>every&lt;/strong> network connection and &lt;strong>every&lt;/strong> file open in every pod, and it can act inline. It does not replace the sandbox; it watches it from inside the kernel. Where &lt;code>bubblewrap&lt;/code> on the client blocks &lt;code>curl&lt;/code> with a command blocklist, Tetragon on the cluster hooks &lt;code>tcp_connect&lt;/code> in the kernel and, if the destination is not allowed, &lt;strong>kills the process with &lt;code>Sigkill&lt;/code>&lt;/strong> before the packet leaves. Where the client hides &lt;code>~/.ssh&lt;/code> behind a tmpfs, Tetragon hooks &lt;code>security_file_open&lt;/code> and reports, or kills, any attempt to read a sensitive mounted path. It is the guard patrolling the corridors while the microVMs are the walls. And it is exactly the kind of control that materialises the ENS monitoring and traceability measures (&lt;code>op.mon&lt;/code>, &lt;code>op.exp&lt;/code>) without instrumenting the application: visibility lives in the kernel, not in the agent&amp;rsquo;s code.&lt;/p>
&lt;h2 id="the-landscape-table">The landscape table&lt;/h2>
&lt;p>The five families, their relative isolation strength, their startup cost and the domain where they live:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tier&lt;/th>
&lt;th>Primitive&lt;/th>
&lt;th style="text-align:center">Isolation&lt;/th>
&lt;th style="text-align:center">Startup&lt;/th>
&lt;th>Natural domain&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Process sandbox&lt;/td>
&lt;td>Seatbelt · bubblewrap&lt;/td>
&lt;td style="text-align:center">Baseline&lt;/td>
&lt;td style="text-align:center">~0 ms&lt;/td>
&lt;td>&lt;strong>Client&lt;/strong> (Claude Code default)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dev container&lt;/td>
&lt;td>Docker + seccomp&lt;/td>
&lt;td style="text-align:center">Moderate&lt;/td>
&lt;td style="text-align:center">seconds&lt;/td>
&lt;td>Client / cluster (repeatability)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>User-space kernel&lt;/td>
&lt;td>gVisor (&lt;code>runsc&lt;/code>)&lt;/td>
&lt;td style="text-align:center">Strong&lt;/td>
&lt;td style="text-align:center">ms&lt;/td>
&lt;td>&lt;strong>Cluster&lt;/strong> (medium multi-tenant)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>microVM&lt;/td>
&lt;td>Firecracker · Kata&lt;/td>
&lt;td style="text-align:center">Strongest (practical)&lt;/td>
&lt;td style="text-align:center">&amp;lt;1 s&lt;/td>
&lt;td>&lt;strong>Cluster&lt;/strong> (untrusted code)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Full VM&lt;/td>
&lt;td>KVM · EC2&lt;/td>
&lt;td style="text-align:center">Maximum&lt;/td>
&lt;td style="text-align:center">30 s+&lt;/td>
&lt;td>Cluster (external boundary, compliance)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;em>Runtime enforcement&lt;/em>&lt;/td>
&lt;td>&lt;strong>eBPF · Tetragon&lt;/strong>&lt;/td>
&lt;td style="text-align:center">&lt;em>Cross-cutting&lt;/em>&lt;/td>
&lt;td style="text-align:center">&lt;em>always on&lt;/em>&lt;/td>
&lt;td>&lt;strong>Cluster&lt;/strong> (watches+kills on top of any tier)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Tetragon gets its own row on purpose: it is not a rung on the ladder, it is a &lt;strong>cross-cutting layer&lt;/strong> that operates &lt;em>on top of&lt;/em> any of the others. It stacks with all of them.&lt;/p>
&lt;p>A numerical note on why the &amp;ldquo;startup&amp;rdquo; column decides as much as the &amp;ldquo;isolation&amp;rdquo; column. A full VM wins on raw isolation but takes tens of seconds to provision; for an agent that needs a fresh environment &lt;em>per request or per session&lt;/em>, that cost is prohibitive. A Firecracker microVM starts in &lt;strong>under 1 second&lt;/strong> and a process sandbox in &lt;strong>~0 ms&lt;/strong>. That is why the dominant pattern in 2026 is not &amp;ldquo;the most isolated VM&amp;rdquo;, but &lt;strong>a full VM as the external boundary plus a microVM as the per-request execution unit inside&lt;/strong>, the architecture of Vercel, AWS Lambda and E2B. On the client the arithmetic is the opposite: the developer launches the agent dozens of times a day interactively, and a startup measured in seconds would break the flow; hence the process sandbox, with its microsecond overhead, is the correct default.&lt;/p>
&lt;h2 id="extrapolate-do-not-copy">Extrapolate, do not copy&lt;/h2>
&lt;p>The thesis of this pair of posts fits in one sentence: &lt;strong>the threat model is invariant across domains; the primitive that implements it is not.&lt;/strong> The client and the cluster defend exactly the same five vectors — filesystem, network, kernel, multi-tenant, secrets — but with toolboxes that do not overlap. Every control has its twin on the other side:&lt;/p>
&lt;ul>
&lt;li>&lt;code>$HOME&lt;/code> as an ephemeral tmpfs (client) ↔ &lt;code>readOnlyRootFilesystem&lt;/code> + &lt;code>emptyDir&lt;/code> (cluster).&lt;/li>
&lt;li>A &lt;code>curl&lt;/code>/&lt;code>wget&lt;/code> blocklist in &lt;code>bwrap&lt;/code> (client) ↔ a &lt;code>TracingPolicy&lt;/code> on &lt;code>tcp_connect&lt;/code> in Tetragon plus NetworkPolicy (cluster).&lt;/li>
&lt;li>&lt;code>--unshare-net&lt;/code> in lockdown (client) ↔ default-deny NetworkPolicy (cluster).&lt;/li>
&lt;li>No &lt;em>escape hatch&lt;/em>, the process lives inside &lt;code>bwrap&lt;/code> (client) ↔ no &lt;code>privileged&lt;/code>, no &lt;code>hostPath&lt;/code>, &lt;code>RuntimeClass&lt;/code> kata (cluster).&lt;/li>
&lt;li>&lt;code>~/.ssh&lt;/code> and &lt;code>~/.aws&lt;/code> never mounted (client) ↔ secrets outside the pod plus Tetragon watching &lt;code>security_file_open&lt;/code> (cluster).&lt;/li>
&lt;/ul>
&lt;p>The sibling runbook turns each of these equivalences into concrete files. What matters here is the method: when someone shows you an agent sandbox, be it the Claude Code &lt;code>/sandbox&lt;/code> on a laptop or a microVM on a PaaS, the useful question is not &amp;ldquo;which tool does it use?&amp;rdquo;, but &amp;ldquo;which of the five vectors does it close, and which does it leave open?&amp;rdquo;. The tool gets replaced; the threat map stays.&lt;/p>
&lt;h2 id="what-no-sandbox-solves">What no sandbox solves&lt;/h2>
&lt;p>Three limits that Anthropic&amp;rsquo;s own documentation states, and which are worth keeping in view so as not to sell smoke:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Egress remains a risk&lt;/strong> in any sandbox that allows outbound connections. If the agent can open a connection, it can exfiltrate. That is why the client lockdown &lt;em>cuts&lt;/em> the network and the cluster uses default-deny NetworkPolicy plus Tetragon: you do not trust &amp;ldquo;filtering well&amp;rdquo;, you trust &amp;ldquo;not letting anything out&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Code modification remains possible&lt;/strong> in any sandbox with the project directory mounted writable. The remedy is not a sandbox-technical one, it is &lt;strong>git&lt;/strong>: with the remote untouched and no &lt;code>push&lt;/code> permission, the worst case is corrupting the local working copy — &lt;code>git checkout .&lt;/code> and start again. The damage never reaches the remote.&lt;/li>
&lt;li>&lt;strong>No sandbox prevents a compromised prompt from reaching the API.&lt;/strong> Isolation bounds the impact of an injection; it does not prevent it. The complementary defences — input validation, tool-call allowlists, output auditing — are mandatory &lt;em>alongside&lt;/em> isolation, not instead of it.&lt;/li>
&lt;/ol>
&lt;p>The operational conclusion: &lt;strong>isolation shrinks the blast radius; defence in depth is what closes the circle.&lt;/strong> A process sandbox for trusted code on a known machine is appropriate and practically free. For an agent acting on user prompts, executing generated code or running multi-tenant, the acceptable minimum in 2026 is a microVM, with Tetragon watching from above. Pick the tier that matches your real threat, check which vector it leaves open, and stack complementary controls on top.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets in the sovereign LLM stack: defence in depth&lt;/a> — the hardening of the stack that hosts those agents: network, secrets, runtime.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/">Runbook: caging the AI agent — bubblewrap on the client, Tetragon on the cluster&lt;/a> — the operational companion to this post: the &lt;code>.ai-jail&lt;/code> files, the permissions &lt;code>--bootstrap&lt;/code> and the Tetragon &lt;code>TracingPolicy&lt;/code> files to copy and paste. With commands.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">The kitchen door the maître never checked: network NUMA, Cilium eBPF and DRANET&lt;/a> — the Cilium eBPF datapath Tetragon leans on in the cluster; the same kernel layer, a different use.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — mitigation on the &lt;em>content&lt;/em> plane (what the model says and what is said to it); this post is mitigation on the &lt;em>execution&lt;/em> plane (what the agent&amp;rsquo;s process can do).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS × ISO 42001 × EU AI Act&lt;/a> — the compliance framework that runtime isolation materialises: Tetragon as technical evidence for &lt;code>op.mon&lt;/code>/&lt;code>op.exp&lt;/code>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">Catalogue of OSS tools for LLMOps&lt;/a> — where the runtime security layer fits in the complete open stack.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>bubblewrap: &lt;a href="https://github.com/containers/bubblewrap">https://github.com/containers/bubblewrap&lt;/a>&lt;/li>
&lt;li>Landlock LSM: &lt;a href="https://landlock.io">https://landlock.io&lt;/a> · &lt;a href="https://docs.rs/landlock">https://docs.rs/landlock&lt;/a>&lt;/li>
&lt;li>ai-jail (Fabio Akita): &lt;a href="https://github.com/akitaonrails/ai-jail">https://github.com/akitaonrails/ai-jail&lt;/a>&lt;/li>
&lt;li>gVisor: &lt;a href="https://gvisor.dev">https://gvisor.dev&lt;/a>&lt;/li>
&lt;li>Firecracker: &lt;a href="https://firecracker-microvm.github.io">https://firecracker-microvm.github.io&lt;/a>&lt;/li>
&lt;li>Kata Containers: &lt;a href="https://katacontainers.io">https://katacontainers.io&lt;/a>&lt;/li>
&lt;li>Tetragon (Cilium): &lt;a href="https://tetragon.io">https://tetragon.io&lt;/a>&lt;/li>
&lt;li>Vercel Sandbox — concepts: &lt;a href="https://vercel.com/docs/vercel-sandbox/concepts">https://vercel.com/docs/vercel-sandbox/concepts&lt;/a>&lt;/li>
&lt;li>E2B: &lt;a href="https://github.com/e2b-dev/E2B">https://github.com/e2b-dev/E2B&lt;/a>&lt;/li>
&lt;li>Claude Code sandboxing: &lt;a href="https://docs.claude.com/en/docs/claude-code">https://docs.claude.com/en/docs/claude-code&lt;/a>&lt;/li>
&lt;li>Codex CLI: &lt;a href="https://github.com/openai/codex">https://github.com/openai/codex&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Tetragon: Cilium's security cousin that sees every syscall in the kernel</title><link>https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/</link><pubDate>Tue, 19 May 2026 05:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;a href="https://tetragon.io/">Tetragon&lt;/a> is the runtime security and observability engine that the Cilium project published as a companion to the CNI. Its job is not to route packets, Cilium is already there for that, but to &lt;strong>observe what happens inside the node&amp;rsquo;s processes in real time&lt;/strong>: which binary runs in each pod, which files it opens, which syscalls it invokes, which capabilities it asks for, which network connections it establishes, which kernel modules get loaded. It does this by loading eBPF programs into the kernel&amp;rsquo;s hook points (kprobes, tracepoints, uprobes, LSM hooks) and filtering the relevant events &lt;strong>inside the kernel itself&lt;/strong> with a declarative language expressed as a CRD (&lt;code>TracingPolicy&lt;/code> and &lt;code>TracingPolicyNamespaced&lt;/code>). The result is a stream of events enriched with Kubernetes metadata (pod, namespace, labels) that costs less than 1% of CPU and, the thing that sets Tetragon apart from the competition, can &lt;strong>block actions inside the kernel&lt;/strong>, killing the process with &lt;code>SIGKILL&lt;/code> or overwriting a syscall&amp;rsquo;s return value, &lt;strong>before they finish executing&lt;/strong>, with no race conditions. Against Falco (which parses syscalls in userspace, 5-10% overhead, detection-only), Tetragon is &amp;ldquo;cheaper and with enforcement&amp;rdquo;; against the bare kernel, it is &amp;ldquo;a declarative layer your operations colleague can read&amp;rdquo;. This article is the extensive introduction you need to take it on seriously: architecture, all the hooks and selectors, the operating modes, a guide to use cases (exec auditing, sensitive file access, container escape, cryptomining, rootkit detection, network observability) and the traps you see in production.&lt;/p>
&lt;blockquote>
&lt;p>This article is part 2 of the eBPF series. Part 1, &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium: how the kernel learned to skip its own TCP/IP stack&lt;/a>, covered basic eBPF, the networking hooks (XDP, TC, sock_ops), how Cilium implements the datapath and the BGP Control Plane v2 CRDs. Here we take those same eBPF hooks and use them for something different: &lt;strong>observing and, if needed, stopping&lt;/strong> what the cluster&amp;rsquo;s processes do.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-auditd-on-steroids-in-ebpf">The analogy: auditd on steroids in eBPF&lt;/h2>
&lt;p>Anyone who has spent a few years administering Linux has used &lt;code>auditd&lt;/code>. It is the classic kernel subsystem for auditing syscalls: you configure a rule with &lt;code>auditctl&lt;/code> (for instance, &amp;ldquo;monitor any &lt;code>open&lt;/code> on &lt;code>/etc/shadow&lt;/code>&amp;rdquo;) and the kernel sends events to a userspace daemon that persists them. It works, but it has two limitations that weigh heavily on modern Kubernetes clusters:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>No Kubernetes context.&lt;/strong> auditd reports processes by PID and UID. Knowing which pod, which namespace, which image, which labels, the information that actually matters when responding to an incident, requires correlating afterwards with data from cri-o or containerd. It is operationally miserable.&lt;/li>
&lt;li>&lt;strong>No granular enforcement.&lt;/strong> auditd can generate events, but it cannot take the decision to kill the offending process before the syscall finishes. You leave that to a higher layer that reads the events, processes them and kills the process… if it gets there in time. A race by design.&lt;/li>
&lt;/ol>
&lt;p>Tetragon is &lt;strong>auditd on steroids&lt;/strong>: the same conceptual ideas, hooks on syscalls and events to userspace, but implemented with modern eBPF, with filtering inside the kernel so you do not pay the cost of waking the daemon for every irrelevant syscall, with Kubernetes metadata injected by an agent that knows the cluster, and with actions executed inside the kernel itself without waiting for userspace to decide. If the rule says &amp;ldquo;kill any process that opens &lt;code>/etc/shadow&lt;/code> from the &lt;code>prod&lt;/code> namespace&amp;rdquo;, the decision is taken in the kernel kprobe and &lt;code>SIGKILL&lt;/code> is delivered before the &lt;code>open&lt;/code> completes. There is no race; there is no window between detection and action.&lt;/p>
&lt;h2 id="what-tetragon-is-architecturally">What Tetragon is, architecturally&lt;/h2>
&lt;p>Tetragon is an agent deployed as a &lt;code>DaemonSet&lt;/code> (one pod per node) and a set of CRDs that define the policies to apply. The agent has four responsibilities:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Load eBPF programs&lt;/strong> into the hook points the active TracingPolicies demand.&lt;/li>
&lt;li>&lt;strong>Maintain a cache of Kubernetes metadata&lt;/strong> (pods, namespaces, labels) by reading the API server, so it can enrich every event with the right context.&lt;/li>
&lt;li>&lt;strong>Collect the events&lt;/strong> the eBPF programs emit (via ring buffers) and serialise them.&lt;/li>
&lt;li>&lt;strong>Export the events&lt;/strong> to configurable destinations: &lt;code>stdout&lt;/code> JSON (typical in sidecars or log-collection agents), gRPC streaming (to consume them from Hubble or another consumer), a file, or Fluentd/Loki/SIEM.&lt;/li>
&lt;/ol>
&lt;p>The eBPF programs are not written by the user. Tetragon generates the bytecode from the TracingPolicies: it reads the declarative policy, decides which hooks to attack, which arguments to read from the kernel, which filters to apply inline and which actions to execute. The user only writes &lt;strong>YAML&lt;/strong>.&lt;/p>
&lt;div class="diagram" style="max-width:720px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 720 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Tetragon architecture">
&lt;style>.title{font:600 13px sans-serif;fill:#222}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#555}.box{stroke:#444;stroke-width:1.4}.k{fill:#ffe9d6}.u{fill:#d6eaff}.p{fill:#d9f5d6}.api{fill:#e9d6f5}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#h)}&lt;/style>
&lt;defs>&lt;marker id="h" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="360" y="20" text-anchor="middle" class="title">Tetragon: control and data planes on a node&lt;/text>
&lt;rect x="40" y="50" width="200" height="70" rx="6" class="box k"/>
&lt;text x="140" y="70" text-anchor="middle" class="lbl">eBPF programs&lt;/text>
&lt;text x="140" y="90" text-anchor="middle" class="sm">kprobes, tracepoints,&lt;/text>
&lt;text x="140" y="105" text-anchor="middle" class="sm">uprobes, LSM&lt;/text>
&lt;rect x="40" y="160" width="200" height="60" rx="6" class="box u"/>
&lt;text x="140" y="183" text-anchor="middle" class="lbl">Tetragon agent&lt;/text>
&lt;text x="140" y="203" text-anchor="middle" class="sm">reads events from the ring buffer&lt;/text>
&lt;rect x="290" y="160" width="180" height="60" rx="6" class="box api"/>
&lt;text x="380" y="183" text-anchor="middle" class="lbl">Kubernetes API&lt;/text>
&lt;text x="380" y="203" text-anchor="middle" class="sm">pods, namespaces, labels&lt;/text>
&lt;rect x="510" y="50" width="180" height="70" rx="6" class="box p"/>
&lt;text x="600" y="70" text-anchor="middle" class="lbl">TracingPolicy CRDs&lt;/text>
&lt;text x="600" y="90" text-anchor="middle" class="sm">declarative YAML&lt;/text>
&lt;text x="600" y="105" text-anchor="middle" class="sm">cluster or namespaced&lt;/text>
&lt;rect x="510" y="160" width="180" height="60" rx="6" class="box u"/>
&lt;text x="600" y="183" text-anchor="middle" class="lbl">Exporters&lt;/text>
&lt;text x="600" y="203" text-anchor="middle" class="sm">stdout, gRPC, file, SIEM&lt;/text>
&lt;path class="arr" d="M510,80 L240,80"/>
&lt;text x="375" y="74" text-anchor="middle" class="sm">policies → bytecode&lt;/text>
&lt;path class="arr" d="M140,120 L140,160"/>
&lt;text x="160" y="143" text-anchor="middle" class="sm">events&lt;/text>
&lt;path class="arr" d="M290,190 L240,190"/>
&lt;text x="265" y="184" text-anchor="middle" class="sm">enrich&lt;/text>
&lt;path class="arr" d="M240,180 L510,180"/>
&lt;text x="375" y="174" text-anchor="middle" class="sm">enriched events&lt;/text>
&lt;text x="360" y="255" text-anchor="middle" class="sm">The arrows show data flow. TracingPolicies are compiled into eBPF programs;&lt;/text>
&lt;text x="360" y="270" text-anchor="middle" class="sm">events travel kernel → agent → exporter, decorated with K8s metadata along the way.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>This separation of declarative policy → generated eBPF bytecode is what makes Tetragon usable. Writing eBPF programs by hand is a specialist&amp;rsquo;s job; writing a &lt;code>TracingPolicy&lt;/code> is the job of an SRE with a good example in front of them.&lt;/p>
&lt;h2 id="the-two-crds-tracingpolicy-and-tracingpolicynamespaced">The two CRDs: TracingPolicy and TracingPolicyNamespaced&lt;/h2>
&lt;p>Tetragon exposes exactly two main CRDs:&lt;/p>
&lt;ul>
&lt;li>&lt;code>TracingPolicy&lt;/code> (cluster-scoped, &lt;code>cilium.io/v1alpha1&lt;/code>): applies to the whole cluster, every node, every pod. Suitable for platform policies (the whole cluster must be audited the same way): for example, &amp;ldquo;log every &lt;code>execve&lt;/code> in every pod&amp;rdquo; or &amp;ldquo;kill any process that tries to load a kernel module&amp;rdquo;.&lt;/li>
&lt;li>&lt;code>TracingPolicyNamespaced&lt;/code> (namespaced, same group and version): defined inside a namespace and applied only to the pods of that namespace. Suitable for policies with per-tenant autonomy: for example, &amp;ldquo;in the &lt;code>prod-payments&lt;/code> namespace, kill any outbound &lt;code>connect&lt;/code> to an IP outside the corporate range&amp;rdquo;.&lt;/li>
&lt;/ul>
&lt;p>Both CRDs have exactly the same internal structure. The difference is one of scope. The distinction was introduced precisely to allow multi-tenancy: the central security team defines cluster-wide &lt;code>TracingPolicy&lt;/code> objects and each tenant can add its own with &lt;code>TracingPolicyNamespaced&lt;/code> without needing cluster-admin permissions.&lt;/p>
&lt;h2 id="anatomy-of-a-tracingpolicy">Anatomy of a TracingPolicy&lt;/h2>
&lt;p>A policy is made up of:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Hook points&lt;/strong>: which kernel events to observe.&lt;/li>
&lt;li>&lt;strong>Arguments&lt;/strong>: which data to read when the hook fires.&lt;/li>
&lt;li>&lt;strong>Selectors&lt;/strong>: filters evaluated inside the kernel to discard irrelevant events and, optionally, execute actions when they match.&lt;/li>
&lt;/ol>
&lt;h3 id="supported-hook-points">Supported hook points&lt;/h3>
&lt;p>The official documentation lists five families of hook points:&lt;/p>
&lt;ul>
&lt;li>&lt;code>kprobes&lt;/code>: hook a kernel function. Syscalls are a particular case (when &lt;code>syscall: true&lt;/code>) because their ABI differs from that of internal functions. Typical examples: &lt;code>sys_open&lt;/code>, &lt;code>sys_openat&lt;/code>, &lt;code>sys_connect&lt;/code>, &lt;code>tcp_connect&lt;/code>, &lt;code>do_mount&lt;/code>, &lt;code>commit_creds&lt;/code>. It is the most versatile hook and the one used 80% of the time.&lt;/li>
&lt;li>&lt;code>tracepoints&lt;/code>: hook static tracepoints compiled into the kernel. More stable across kernel versions than kprobes (they do not depend on function names that can change). Examples: &lt;code>syscalls/sys_enter_openat&lt;/code>, &lt;code>sched/sched_process_exec&lt;/code>.&lt;/li>
&lt;li>&lt;code>uprobes&lt;/code>: hook functions in userspace libraries or binaries. They serve to observe runtime primitives such as libssl functions, libc, the Go runtime, the JVM.&lt;/li>
&lt;li>&lt;strong>USDT &lt;code>tracepoints&lt;/code>&lt;/strong> (User Statically Defined Tracepoints): static tracepoints defined in userspace binaries (like those MySQL, PostgreSQL and OpenJDK expose). Useful for application observability.&lt;/li>
&lt;li>&lt;code>lsmHooks&lt;/code> (LSM, Linux Security Module): hooks of the LSM subsystem, where SELinux/AppArmor plug in. They allow security policies very similar to traditional MAC but programmable with eBPF. Example: &lt;code>file_open&lt;/code>, &lt;code>inode_unlink&lt;/code>, &lt;code>socket_bind&lt;/code>.&lt;/li>
&lt;/ul>
&lt;h3 id="arguments">Arguments&lt;/h3>
&lt;p>Every hook can read the arguments of the function it is attached to. The supported types cover the primitives (&lt;code>int&lt;/code>, &lt;code>uint64&lt;/code>, &lt;code>bool&lt;/code>, &lt;code>string&lt;/code>, &lt;code>char_buf&lt;/code>) and higher abstractions (&lt;code>file&lt;/code>, &lt;code>path&lt;/code>, &lt;code>sock&lt;/code>, &lt;code>linux_binprm&lt;/code>, &lt;code>capability&lt;/code>, &lt;code>bpf_attr&lt;/code>, &lt;code>cred&lt;/code>). The high-level types are pointers to kernel structures that Tetragon knows how to parse; instead of having to read an offset, you write &lt;code>type: file&lt;/code> and Tetragon gives you the full path of the descriptor&amp;rsquo;s file.&lt;/p>
&lt;p>There is an important capability detail: on kernels ≥ 5.4, Tetragon can read &lt;strong>up to 327,360 bytes&lt;/strong> of an argument if the large buffers flag is enabled. That is the difference between being able to audit &lt;code>execve&lt;/code> with all of its long argv complete versus truncating them at 256 bytes and losing context.&lt;/p>
&lt;h3 id="selectors-filtering-in-the-kernel">Selectors: filtering in the kernel&lt;/h3>
&lt;p>Selectors are what make Tetragon cheap. Without them, every syscall on the node would fire an event that would travel kernel → ring buffer → agent → processed → filtered → discarded. With selectors, the filtering happens &lt;strong>inside the eBPF program itself, in the kernel&lt;/strong>, and only the events that matter reach userspace.&lt;/p>
&lt;p>The available selectors include:&lt;/p>
&lt;ul>
&lt;li>&lt;code>matchArgs&lt;/code>: filters by an argument&amp;rsquo;s value. Operators: &lt;code>Equal&lt;/code>, &lt;code>NotEqual&lt;/code>, &lt;code>Prefix&lt;/code>, &lt;code>Postfix&lt;/code>, &lt;code>GreaterThan&lt;/code>, &lt;code>LessThan&lt;/code>, &lt;code>Mask&lt;/code>, &lt;code>SPort&lt;/code> (source port), &lt;code>DPort&lt;/code> (dest port), &lt;code>Family&lt;/code> (AF_INET vs AF_INET6), &lt;code>State&lt;/code> (socket state).&lt;/li>
&lt;li>&lt;code>matchPIDs&lt;/code>: filters by PID; useful for targeted observation.&lt;/li>
&lt;li>&lt;code>matchBinaries&lt;/code>: filters by the binary executing the syscall (absolute path), with &lt;code>Operator: In&lt;/code>, &lt;code>NotIn&lt;/code>, &lt;code>Prefix&lt;/code>. Essential for avoiding noise from legitimate system processes.&lt;/li>
&lt;li>&lt;code>matchNamespaces&lt;/code>: filters by Linux namespace (Pid, Mnt, Net, Ipc, Cgroup, User). It allows policies specific to processes in containers versus the host.&lt;/li>
&lt;li>&lt;code>matchCapabilities&lt;/code>: filters by the process&amp;rsquo;s effective capabilities. Block actions requiring &lt;code>CAP_SYS_ADMIN&lt;/code> that run in pods that should not have them.&lt;/li>
&lt;li>&lt;code>matchNamespaceChanges&lt;/code>: detects namespace changes (typical of container escape).&lt;/li>
&lt;li>&lt;code>matchCapabilityChanges&lt;/code>: detects capability changes (privilege escalation).&lt;/li>
&lt;li>&lt;code>matchActions&lt;/code>: the actions executed when all the preceding matchers hit.&lt;/li>
&lt;/ul>
&lt;h3 id="actions-from-a-simple-post-to-sigkill">Actions: from a simple Post to Sigkill&lt;/h3>
&lt;p>When a selector matches, an &lt;code>action&lt;/code> is executed. Tetragon defines several:&lt;/p>
&lt;ul>
&lt;li>&lt;code>Post&lt;/code>: emits an event to userspace (the observability case). It supports &lt;code>rateLimit&lt;/code> to avoid flooding the agent if the condition fires a thousand times per second. The syntax accepts &lt;code>5&lt;/code> for 5 seconds, &lt;code>5m&lt;/code> for 5 minutes, &lt;code>1h&lt;/code> for 1 hour.&lt;/li>
&lt;li>&lt;code>Sigkill&lt;/code>: sends &lt;code>SIGKILL&lt;/code> to the offending process from inside the kernel, &lt;strong>before the syscall completes&lt;/strong>. This is the only thing that guarantees enforcement without a race.&lt;/li>
&lt;li>&lt;code>Override&lt;/code>: overwrites the syscall&amp;rsquo;s return value. Useful for making the process believe the syscall failed (&lt;code>Override -EPERM&lt;/code>) without killing it. A better experience for apps that can handle errors; worse for apps that assume success.&lt;/li>
&lt;li>&lt;code>Signal&lt;/code>: sends any arbitrary signal (not just &lt;code>SIGKILL&lt;/code>).&lt;/li>
&lt;li>&lt;code>NoPost&lt;/code>: does not emit an event, useful when combined with another selector that does emit and you only want the action without duplicated telemetry.&lt;/li>
&lt;li>&lt;strong>&lt;code>FollowFD&lt;/code> and &lt;code>UnfollowFD&lt;/code>&lt;/strong>: mark a file descriptor to follow its lifecycle and enrich subsequent events with the original path. Useful for auditing &amp;ldquo;which process read this file after opening it&amp;rdquo;.&lt;/li>
&lt;li>&lt;code>TrackSock&lt;/code> and &lt;code>UntrackSock&lt;/code>: the same for sockets.&lt;/li>
&lt;li>&lt;strong>&lt;code>GetUrl&lt;/code> and &lt;code>DnsLookup&lt;/code>&lt;/strong>: make HTTP requests or DNS resolutions from the kernel. Designed for integrations with external systems (security webhooks, IP reputation lookups).&lt;/li>
&lt;li>&lt;code>NotifyEnforcer&lt;/code> and &lt;code>CleanupEnforcerNotification&lt;/code>: communication with Tetragon&amp;rsquo;s enforcement subsystem for complex actions.&lt;/li>
&lt;/ul>
&lt;h2 id="modes-detection-vs-enforcement">Modes: detection vs enforcement&lt;/h2>
&lt;p>A policy can be declared in one of two explicit modes:&lt;/p>
&lt;ul>
&lt;li>&lt;code>enforce&lt;/code>: enforcement actions (&lt;code>Sigkill&lt;/code>, &lt;code>Override&lt;/code>, &lt;code>Signal&lt;/code>) are active. This is production.&lt;/li>
&lt;li>&lt;code>monitoring&lt;/code>: enforcement actions are ignored; only &lt;code>Post&lt;/code> events are emitted. This is the &amp;ldquo;let us see what would happen if this were switched on&amp;rdquo; mode, critical for testing policies without breaking applications.&lt;/li>
&lt;/ul>
&lt;p>Control is done with the &lt;code>spec.options[].name: policy-mode&lt;/code> field and &lt;code>value: monitoring&lt;/code> or &lt;code>enforce&lt;/code>. It is the best practice: start in &lt;code>monitoring&lt;/code>, collect events for days, tune the selectors until no false positives come out, and only then switch to &lt;code>enforce&lt;/code>.&lt;/p>
&lt;h2 id="full-example-blocking-writes-to-etcpasswd-in-the-prod-namespace">Full example: blocking writes to &lt;code>/etc/passwd&lt;/code> in the prod namespace&lt;/h2>
&lt;p>A realistic policy, commented line by line:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicyNamespaced&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">block-passwd-write&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prod&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kprobes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;fd_install&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># kernel function, not a syscall&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">int &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># file descriptor&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;file&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># struct file*&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Equal&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/etc/passwd&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Sigkill &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># kills the process&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rateLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1m&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># max once per minute&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">options&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">policy-mode&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">enforce &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># enforcement mode active&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>fd_install&lt;/code> runs every time a process obtains a new file descriptor; the second argument is the file&amp;rsquo;s &lt;code>file&lt;/code> struct. Tetragon knows how to resolve it to its absolute path. The &lt;code>matchArgs&lt;/code> compares that path with &lt;code>/etc/passwd&lt;/code>. If it matches, &lt;code>Sigkill&lt;/code> kills the process before the descriptor even becomes usable. &lt;code>rateLimit: 1m&lt;/code> stops the agent from saturating if a malicious application tries it in a loop.&lt;/p>
&lt;h2 id="common-use-cases">Common use cases&lt;/h2>
&lt;p>Now to real use. These are the six cases that show up in any serious Tetragon deployment in 2026.&lt;/p>
&lt;h3 id="1-execution-auditing-execve">1. Execution auditing (&lt;code>execve&lt;/code>)&lt;/h3>
&lt;p>The most basic use case and, even so, the most valuable. Which binaries are running in each pod? In a container that is supposed to run only &lt;code>nginx&lt;/code>, suddenly seeing an &lt;code>sh&lt;/code> or a &lt;code>wget&lt;/code> is almost always a red flag.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">audit-execve&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tracepoints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">subsystem&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sched&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">event&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sched_process_exec&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">linux_binprm &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># struct linux_binprm*&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># events only, no enforcement&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With no filters: every &lt;code>execve&lt;/code> in the cluster generates an event. With K8s metadata, the event includes pod, namespace, container, image, labels. You turn it into a stream of events towards your SIEM and set up rules: &amp;ldquo;alert if I see &lt;code>sh&lt;/code>, &lt;code>bash&lt;/code>, &lt;code>nc&lt;/code>, &lt;code>curl&lt;/code>, &lt;code>wget&lt;/code> or &lt;code>python&lt;/code> running in any pod of the &lt;code>prod-api&lt;/code> namespace&amp;rdquo;.&lt;/p>
&lt;p>An enforcement variant: instead of &lt;code>Post&lt;/code>, use &lt;code>matchBinaries&lt;/code> with &lt;code>Operator: NotIn&lt;/code> and a whitelist, plus &lt;code>Sigkill&lt;/code> if the binary is not on the list. A very rigid box, but effective in pods that are &amp;ldquo;single-binary&amp;rdquo; (like a Go microservice).&lt;/p>
&lt;h3 id="2-sensitive-file-access">2. Sensitive file access&lt;/h3>
&lt;p>Detecting (or blocking) reads and writes on critical files: &lt;code>/etc/shadow&lt;/code>, &lt;code>/etc/kubernetes/&lt;/code>, Secret mounts, &lt;code>/var/run/docker.sock&lt;/code>, &lt;code>/proc/*/cmdline&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sensitive-file-access&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kprobes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;security_file_open&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># LSM-ish via kprobe&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;file&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Prefix&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/etc/shadow&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/var/run/secrets/&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/var/run/docker.sock&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchBinaries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;NotIn&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/usr/bin/kubelet&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># legitimate kubelet access&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>matchBinaries: NotIn&lt;/code> is important: kubelet and other legitimate node agents access these paths constantly and would generate noise. We filter those out in the kernel.&lt;/p>
&lt;p>In enforcement: swap &lt;code>Post&lt;/code> for &lt;code>Override&lt;/code> with &lt;code>argError: -1&lt;/code> (&lt;code>EPERM&lt;/code>), so that the open fails but the offending process stays alive and produces the error so that tracing tools pick it up.&lt;/p>
&lt;h3 id="3-unauthorised-outbound-network-connections">3. Unauthorised outbound network connections&lt;/h3>
&lt;p>Detecting outbound connections to destinations outside the corporate range. Useful for spotting data exfiltration or malware command-and-control.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicyNamespaced&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">block-external-egress&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prod&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kprobes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;tcp_connect&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sock&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;NotDAddr&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># destination NOT in these CIDRs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;10.0.0.0/8&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;192.168.0.0/16&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;172.16.0.0/12&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Sigkill&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">options&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">policy-mode&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">enforce&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This kills any attempt at a TCP connection to an IP that is not in the corporate CIDRs, in the &lt;code>prod&lt;/code> namespace. Cilium already does this with NetworkPolicy, but Tetragon has two complementary advantages:&lt;/p>
&lt;ul>
&lt;li>It gives you the process that attempted the connection, not just &amp;ldquo;pod X tried to connect to Y&amp;rdquo;.&lt;/li>
&lt;li>It also works for exotic protocols where NetworkPolicy is less expressive.&lt;/li>
&lt;/ul>
&lt;h3 id="4-container-escape-detection">4. Container escape detection&lt;/h3>
&lt;p>Container escape is the operational nightmare: a process inside a container manages to break the isolation (via a kernel exploit, a badly set capability, a misconfigured &lt;code>hostPath&lt;/code> mount) and gain access to the host. Three typical signals:&lt;/p>
&lt;ul>
&lt;li>A namespace change on the process (it leaves the container&amp;rsquo;s &lt;code>pid&lt;/code> namespace).&lt;/li>
&lt;li>&lt;strong>&lt;code>setns&lt;/code> or &lt;code>unshare&lt;/code>&lt;/strong> in non-init processes.&lt;/li>
&lt;li>&lt;strong>Access to &lt;code>/proc/1/root&lt;/code> or &lt;code>/dev/&lt;/code>&lt;/strong> from a container.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">detect-container-escape&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kprobes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;__x64_sys_setns&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">int&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">int&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchNamespaces&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Pid&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NotIn&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;host_ns&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># only processes NOT in the host pid namespace&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Sigkill&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;__x64_sys_unshare&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">int&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchNamespaceChanges&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">unshare&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>__x64_sys_setns&lt;/code> targeting the host namespace from a process in a container is practically always malicious (legitimate containers do not need this at runtime).&lt;/p>
&lt;h3 id="5-cryptomining">5. Cryptomining&lt;/h3>
&lt;p>Mining processes have fairly recognisable profiles:&lt;/p>
&lt;ul>
&lt;li>Processes with names like &lt;code>xmrig&lt;/code>, &lt;code>minerd&lt;/code>, &lt;code>cgminer&lt;/code>, or legitimate processes such as &lt;code>python&lt;/code> running CPU-intensive scripts.&lt;/li>
&lt;li>Outbound connections to known mining pools (a public list of IPs and domains).&lt;/li>
&lt;li>Anomalous use of &lt;code>/dev/cpu_dma_latency&lt;/code> to avoid throttling.&lt;/li>
&lt;/ul>
&lt;p>A combined policy:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">detect-cryptomining&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tracepoints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">subsystem&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sched&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">event&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sched_process_exec&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">linux_binprm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Postfix&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/xmrig&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/minerd&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/cgminer&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Sigkill&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kprobes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;tcp_connect&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sock&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;DPort&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;3333&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;5555&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;7777&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;14444&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># common pool ports&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># log only&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The double policy: kill binaries with the classic names (belt) and log connections to pool ports (braces), so you also get an alert when someone renames &lt;code>xmrig&lt;/code> to &lt;code>nginx-helper&lt;/code> or uses exotic ports.&lt;/p>
&lt;h3 id="6-detecting-rootkits-and-suspicious-kernel-modules">6. Detecting rootkits and suspicious kernel modules&lt;/h3>
&lt;p>Modern rootkits load kernel modules to patch functions (hide processes, hide network connections, hide files). Detecting them:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kernel-module-load&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kprobes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;do_init_module&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;security_kernel_read_file&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;file&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">int&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Equal&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;READING_MODULE&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In a &amp;ldquo;well configured&amp;rdquo; Kubernetes cluster no new kernel modules get loaded at runtime; any event here is highly suspicious. Combine with enforcement on machines where modules should be fixed: &lt;code>Sigkill&lt;/code> for whoever tries to load one.&lt;/p>
&lt;h3 id="bonus-detecting-third-party-modification-of-ebpf-maps">Bonus: detecting third-party modification of eBPF maps&lt;/h3>
&lt;p>As a 2025-2026 trend: loading malicious eBPF programs to hide presence. Tetragon can observe the bpf syscall:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">audit-bpf-syscalls&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kprobes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;__x64_sys_bpf&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">int &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># bpf cmd&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bpf_attr&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchBinaries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;NotIn&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/usr/bin/cilium-agent&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/usr/bin/tetragon&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/usr/bin/bpftool&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Any process that is &lt;strong>not&lt;/strong> one of the legitimate agents loading eBPF programs: you want to know about it.&lt;/p>
&lt;h2 id="comparison-with-falco">Comparison with Falco&lt;/h2>
&lt;p>&lt;a href="https://falco.org/">Falco&lt;/a> is the closest competitor: it is also runtime security for Kubernetes, also originally based on eBPF (and before that on kernel modules), also with declarative policies. Three years ago they were functionally similar. In 2026 the divergence is clear:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dimension&lt;/th>
&lt;th>Tetragon&lt;/th>
&lt;th>Falco&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Philosophy&lt;/td>
&lt;td>Cilium-native, integrated&lt;/td>
&lt;td>Standalone, generic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Filtering&lt;/td>
&lt;td>In the kernel (eBPF)&lt;/td>
&lt;td>Parsing in userspace&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Typical overhead&lt;/td>
&lt;td>&lt;strong>&amp;lt;1% CPU&lt;/strong>&lt;/td>
&lt;td>5-10% CPU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Enforcement&lt;/td>
&lt;td>&lt;strong>Yes, in-kernel (Sigkill, Override)&lt;/strong>&lt;/td>
&lt;td>Not native (depends on plugins)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Race conditions&lt;/td>
&lt;td>No (action atomic with the syscall)&lt;/td>
&lt;td>Yes, in enforcement via plugins&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>False positive rate&lt;/td>
&lt;td>Low (K8s context in the kernel)&lt;/td>
&lt;td>Higher (parsing afterwards)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Detection latency&lt;/td>
&lt;td>5-26 ms&lt;/td>
&lt;td>~10 ms (more constant)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ecosystem maturity&lt;/td>
&lt;td>Young, growing&lt;/td>
&lt;td>Mature, plenty of material&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Community&lt;/td>
&lt;td>Cilium / CNCF Incubating&lt;/td>
&lt;td>CNCF Graduated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Integrations&lt;/td>
&lt;td>Hubble native&lt;/td>
&lt;td>Falcosidekick, many&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CRDs per policy&lt;/td>
&lt;td>TracingPolicy / Namespaced&lt;/td>
&lt;td>No CRDs; rules in YAML&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>When to choose each one&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tetragon&lt;/strong> if you already use Cilium, if you need enforcement in the kernel (not detection-only), if overhead matters to you (workloads with many syscalls), and if you value the Hubble integration. Container escape and cryptomining detection is where its advantage over Falco shows up most.&lt;/li>
&lt;li>&lt;strong>Falco&lt;/strong> if you want a tool independent of the CNI, if you need the catalogue of ready-made rules and the wide community, if your cluster is not Cilium, or if the integration with SIEMs and ready-made notifiers (Falcosidekick) saves you work.&lt;/li>
&lt;li>&lt;strong>Both&lt;/strong> if the organisation is large: Falco for breadth of detection, Tetragon for surgical enforcement on critical workloads. That is what you see most in companies that have spent years with Falco and add Tetragon for specific cases.&lt;/li>
&lt;/ul>
&lt;h2 id="hubble--tetragon-unified-observability">Hubble + Tetragon: unified observability&lt;/h2>
&lt;p>&lt;a href="https://docs.cilium.io/en/stable/observability/hubble/">Hubble&lt;/a> is Cilium&amp;rsquo;s traffic observability component: it shows L3-L7 flow logs with zero impact on latency. Tetragon exposes its events over gRPC with the same format and vocabulary as Hubble, which allows you to:&lt;/p>
&lt;ul>
&lt;li>See them in the same UI (Hubble UI shows Tetragon events as one more &amp;ldquo;layer&amp;rdquo;).&lt;/li>
&lt;li>Correlate network events (Hubble) with process events (Tetragon) on the same timeline.&lt;/li>
&lt;li>Export them together to Loki/Tempo/SIEM as a single stream.&lt;/li>
&lt;/ul>
&lt;p>The key synergy: Hubble tells you &amp;ldquo;this pod made a TCP connection to 1.2.3.4:80&amp;rdquo;. Tetragon tells you &amp;ldquo;this pod ran &lt;code>curl 1.2.3.4&lt;/code> from a &lt;code>bash&lt;/code> binary launched by &lt;code>pid 1234&lt;/code>&amp;rdquo;. Together they give you the full story.&lt;/p>
&lt;h2 id="deployment-and-operation">Deployment and operation&lt;/h2>
&lt;h3 id="helm">Helm&lt;/h3>
&lt;p>The canonical install with Helm:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">helm repo add cilium https://helm.cilium.io
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">helm install tetragon cilium/tetragon &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --namespace kube-system &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set tetragon.exportFilename&lt;span class="o">=&lt;/span>/var/log/tetragon/tetragon.log &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set tetragon.exportFileMaxSizeMB&lt;span class="o">=&lt;/span>&lt;span class="m">50&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set tetragon.exportFileRotationInterval&lt;span class="o">=&lt;/span>24h
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Tetragon deploys its &lt;code>DaemonSet&lt;/code>, its CRDs and a service for Hubble. By default, it exposes the events on the agent pod&amp;rsquo;s &lt;code>stdout&lt;/code> (any cluster log aggregator picks them up).&lt;/p>
&lt;h3 id="the-tetra-cli">The &lt;code>tetra&lt;/code> CLI&lt;/h3>
&lt;p>Tetragon ships a CLI called &lt;code>tetra&lt;/code> for interactive investigation:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># real-time stream of the node&amp;#39;s events&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">tetra getevents -o compact --pods &amp;lt;pod-name&amp;gt;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># structured JSON events to process with jq&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">tetra getevents -o json --since 5m --namespace prod
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># view loaded policies&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">tetra tracingpolicy list
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is the best tool for debugging policies in &lt;code>monitoring&lt;/code> before moving them to &lt;code>enforce&lt;/code>.&lt;/p>
&lt;h3 id="exporting-to-a-siem">Exporting to a SIEM&lt;/h3>
&lt;p>Three usual routes:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>stdout + log aggregator&lt;/strong>: the agent writes JSON to stdout, Fluent Bit/Vector picks it up and sends it to Splunk/Datadog/Elastic. Simple, works with any logging infrastructure.&lt;/li>
&lt;li>&lt;strong>gRPC streaming&lt;/strong>: for low-latency integrations. A gRPC consumer of your own or Hubble Relay.&lt;/li>
&lt;li>&lt;strong>File + rotation&lt;/strong>: for air-gapped environments or regulatory audits that require persistent logs with controlled rotation.&lt;/li>
&lt;/ul>
&lt;h3 id="performance">Performance&lt;/h3>
&lt;p>Published benchmarks consistently place Tetragon at &lt;strong>&amp;lt;1% of the node&amp;rsquo;s CPU&lt;/strong> under real workloads, compared with &lt;strong>5-10% for Falco&lt;/strong> under the same workloads. The reason is the architectural separation: Tetragon filters in the kernel and only carries the events that actually matter to userspace; Falco carries every syscall to userspace and filters there. On clusters with thousands of pods making hundreds of thousands of syscalls per second, the difference shows up on the bill.&lt;/p>
&lt;h2 id="common-operational-traps">Common operational traps&lt;/h2>
&lt;h3 id="permanent-monitoring">Permanent &lt;code>monitoring&lt;/code>&lt;/h3>
&lt;p>The biggest trap is &lt;strong>never reaching &lt;code>enforce&lt;/code>&lt;/strong>: starting well with policies in monitoring, collecting events, tuning selectors, and then never switching. The result: you have detection without prevention, exactly what Falco gave you without paying Tetragon&amp;rsquo;s complexity. If you are going to use Tetragon, plan the road to enforce for the critical policies.&lt;/p>
&lt;h3 id="selectors-that-are-too-lax">Selectors that are too lax&lt;/h3>
&lt;p>A policy with a single &lt;code>matchActions: Post&lt;/code> and no specific selectors generates events for &lt;strong>every&lt;/strong> syscall of the chosen hook. On a serious node that means &lt;strong>tens of thousands per second&lt;/strong>, which fill logs, saturate exporters and hide the signal in the noise. Always start with strict filters (&lt;code>matchBinaries&lt;/code>, &lt;code>matchNamespaces&lt;/code>, &lt;code>matchPIDs&lt;/code>) and open up once you know what you are looking for.&lt;/p>
&lt;h3 id="a-kernel-that-is-too-old">A kernel that is too old&lt;/h3>
&lt;p>Tetragon needs modern eBPF features. Kernels &amp;lt; 5.4 do not have the large buffer support (needed for &lt;code>execve&lt;/code> with complete argv). Kernels &amp;lt; 5.10 do not have many of the LSM hooks. &lt;strong>Kernel 5.15+ is the recommended minimum for production&lt;/strong> and 6.1+ to have every feature.&lt;/p>
&lt;h3 id="hooks-on-renamed-kernel-functions">Hooks on renamed kernel functions&lt;/h3>
&lt;p>kprobes are tied to kernel function names that &lt;strong>can change between versions&lt;/strong>. A policy that uses &lt;code>__x64_sys_setns&lt;/code> can fail silently on a kernel where the function is called &lt;code>__do_sys_setns&lt;/code>. Solutions: use static tracepoints where they are available (more stable), or keep alternative policies with several &lt;code>call&lt;/code> entries for compatibility.&lt;/p>
&lt;h3 id="sigkill-in-critical-namespaces">&lt;code>Sigkill&lt;/code> in critical namespaces&lt;/h3>
&lt;p>Applying &lt;code>Sigkill&lt;/code> to processes in &lt;code>kube-system&lt;/code> or &lt;code>cilium-system&lt;/code> can break the cluster. Enforcement policies must &lt;strong>explicitly exclude&lt;/strong> the platform namespaces with &lt;code>matchNamespaces&lt;/code> Operator: &lt;code>NotIn&lt;/code>, or limit the scope with &lt;code>TracingPolicyNamespaced&lt;/code> to make sure they do not act on systems they should not.&lt;/p>
&lt;h3 id="missing-ratelimit">Missing &lt;code>rateLimit&lt;/code>&lt;/h3>
&lt;p>A policy without a rateLimit on &lt;code>Post&lt;/code> can suffer a catastrophic fan-out if the condition is met millions of times in an instant (typical in attack loops or application bugs). The agent saturates, events are lost, logs overflow. &lt;strong>Always put a sensible &lt;code>rateLimit&lt;/code> on detection policies&lt;/strong>, especially on high-frequency hooks such as &lt;code>tcp_connect&lt;/code> or &lt;code>execve&lt;/code>.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>eBPF LSM hooks&lt;/strong> in depth: how they relate to SELinux/AppArmor and when Tetragon is the right tool versus classic MAC.&lt;/li>
&lt;li>&lt;strong>Hubble UI with a Tetragon overlay&lt;/strong>: configuring the UI to show process observability and network observability on the same timeline.&lt;/li>
&lt;li>&lt;strong>Integration with OPA/Kyverno&lt;/strong>: how Tetragon complements admission policy engines (Kyverno validates at admission; Tetragon validates at runtime).&lt;/li>
&lt;li>&lt;strong>Forensics with eBPF&lt;/strong>: combining Tetragon with tools such as Beyla or OpenTelemetry to trace the full chain of an incident from the initial connection to the final syscall.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>Official documentation (May 2026):&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tetragon.io/">Tetragon — official site&lt;/a> — entry point.&lt;/li>
&lt;li>&lt;a href="https://tetragon.io/docs/concepts/tracing-policy/">Tetragon docs — Tracing Policy&lt;/a> — conceptual reference.&lt;/li>
&lt;li>&lt;a href="https://tetragon.io/docs/concepts/tracing-policy/hooks/">Tetragon docs — Hook points&lt;/a> — kprobes, tracepoints, uprobes, LSM, USDT.&lt;/li>
&lt;li>&lt;a href="https://tetragon.io/docs/concepts/tracing-policy/selectors/">Tetragon docs — Selectors&lt;/a> — full filter reference.&lt;/li>
&lt;li>&lt;a href="https://tetragon.io/docs/concepts/tracing-policy/mode/">Tetragon docs — Enforcement Mode&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://tetragon.io/docs/concepts/tracing-policy/k8s-filtering/">Tetragon docs — Kubernetes Identity Aware Policies&lt;/a> — &lt;code>TracingPolicyNamespaced&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://github.com/cilium/tetragon">Tetragon GitHub&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Comparisons and analysis:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.scitepress.org/Papers/2025/142727/142727.pdf">Comparative Analysis of eBPF-Based Runtime Security Monitoring (SciTePress paper 2025)&lt;/a> — a benchmark with independent numbers.&lt;/li>
&lt;li>&lt;a href="https://www.armosec.io/blog/best-ebpf-security-solutions-runtime-protection/">Best eBPF Security Solutions for Kubernetes (ARMO, 2026)&lt;/a> — Falco vs Tetragon vs KubeArmor comparison.&lt;/li>
&lt;li>&lt;a href="https://medium.com/@mughal.asim/falco-vs-tetragon-a-runtime-security-showdown-for-kubernetes-a0e9fb9f30a0">Falco vs. Tetragon (Asim Mirza, Medium)&lt;/a> — analysis with use cases.&lt;/li>
&lt;li>&lt;a href="https://asecurityengineer.com/posts/deep-dive-into-tetragon/">Deep Dive into Tetragon (A Security Engineer)&lt;/a> — a walk through the inside of the agent.&lt;/li>
&lt;li>&lt;a href="https://medium.com/@mughal.asim/tetragon-series-part-2-enforcing-sensitive-file-access-with-a-namespaced-tracingpolicy-3c2f617ec912">Tetragon Series, Part 2: Enforcing Sensitive File Access (Medium)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Ecosystem:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://docs.cilium.io/en/stable/observability/hubble/">Cilium Hubble — network observability&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://falco.org/">Falco — official site&lt;/a> — the other big name in the field.&lt;/li>
&lt;li>&lt;a href="https://kubearmor.io/">KubeArmor&lt;/a> — the third option, with AppArmor + eBPF.&lt;/li>
&lt;/ul>
&lt;p>Cross-references:&lt;/p>
&lt;ul>
&lt;li>Part 1 of the series: &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium: how the kernel learned to skip its own TCP/IP stack&lt;/a> — the eBPF fundamentals we take as read here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kubernetes-cilium-bgp-services-without-ingress/">Kubernetes with Cilium BGP: services reachable without Ingress&lt;/a> — the starting point of the Cilium ecosystem on this blog.&lt;/li>
&lt;/ul></description></item></channel></rss>