Skip to content

Build1 publisher3 min readPublished

kagent 0.10 ships its RFC 8707 token scoping as an empty string you fill in yourself

The GA release registers KAGENT_STS_RESOURCE and KAGENT_STS_AUDIENCE with descriptions that name the RFC, then defaults both to empty, and whether that default is an exposure depends on what your own STS hands back.

The Engineer · Build desk

Illustration accompanying kagent 0.10 ships its RFC 8707 token scoping as an empty string you fill in yourself

What happened

  • kagent v0.10.0 reached GA on September 4, and its release list carries two environment variables that decide whether an agent's exchanged credential works at one backend or at all of them.
  • KAGENT_STS_RESOURCE and KAGENT_STS_AUDIENCE both default to empty, registered in go/core/pkg/env/kagent.go with a description that names RFC 8707 resource indicators outright.
  • The STS client adds one form field per list entry, so an empty list sends no resource field at all, which the author notes is why a misconfiguration here is silent.
  • In a three-container harness published with the writeup, leaving the variable unset produced a token with aud urn:kagent:unscoped, and both demo backends answered HTTP 401 on audience mismatch.
  • Setting the resource to https://mcp.internal/github got HTTP 200 at the GitHub backend and 401 at the payments backend, from the same agent, the same STS and the same subject identity.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • contradiction The writeup reasons that an unnamed target yields a token good at every backend trusting the STS, while its own unset run is refused at both, so which of those you inherit is a fact about your STS, not about kagent.
  • exposure Where the STS does fail open, the reachable set is chosen by whatever text the agent reads at call time, because the runtime is built to act on tool descriptions and prompts it did not author.
  • decision Filling the field in moves the agent-to-backend mapping into deployment environment, so somebody now has to decide, per runtime, which backend URIs a given agent is allowed to present a token at.
  • cost You cannot verify this from the agent's config, since an unset variable and an empty one emit identical requests; the check is a call that succeeds at the intended backend and 401s at the wrong one.

The startup path is three files long, and all three are readable in the tagged source. The runtime pulls both variables with os.Getenv at startup and runs each through splitCSV, because RFC 8693 allows resource and audience to repeat [5]. The STS client then walks each slice and calls data.Add once per entry [6]. When the slice is empty the loop does nothing: no field, not an empty field, which is correct, since an empty resource indicator would make the exchange request invalid [7]. An empty string is the only default that cannot be wrong for somebody else's topology, which is also why nobody reads it.

That part is code. The container output sits a step further away than it looks, because the agent in the harness is a stand-in for the kagent runtime, reading the same variables and mirroring the same comma-split and the same form fields, since running kagent proper needs a cluster and a real IdP [13]. What the three containers demonstrate is that an RFC 8707 exchange behaves the way RFC 8707 describes: name the resource, get an aud claim you can refuse against [11]. kagent's contribution is which strings reach the wire.

The gap worth arguing about is between the writeup's reasoning and the writeup's own output. Its argument is that an exchange naming no target has nothing to scope to, so what comes back is good at every backend that trusts that STS [9]. Its unset run fails at the door in both directions instead [15]. Both can be true of different deployments, and the difference lives entirely in components kagent does not ship. For the empty default to be an exposure rather than an outage, your STS has to mint something a backend will accept when the request names no resource, and your MCP servers have to be checking the aud claim at all. The demo servers do, one comparing the audience against https://mcp.internal/github and the other against its own URI [14]. A server that skips that comparison gains nothing from scoping either, since the 401 at the payments backend is the whole property being bought [16].

Mechanically, adoption is one variable, not two. The harness changed only KAGENT_STS_RESOURCE between runs, and the scoped run reports resource as the only parameter sent [18], so the audience list stayed empty and the token still came back bound to a single backend [16]. That tracks the division of labour in the RFCs: the resource indicator names a target that has a URI, and audience covers the targets that do not.

The framing that carries the risk is the confused deputy, with the agent in the deputy seat and, unlike the classic case, explicitly built to act on text it reads at runtime [10]. That is an argument about what an attacker can reach through the agent, and it is only as strong as your STS's fail-open behaviour. kagent 0.10 gives you the field [2]. The guarantee arrives when your STS and your backends agree about what the field means, which is two systems more than a release note can settle.

What to watch

  • Whether kagent's own runtime, on a cluster with a real IdP, reproduces the harness's aud behaviour, since the demo agent only mirrors the three source files.
  • Whether a later kagent release warns or refuses at startup on an empty resource indicator instead of silently omitting the form field.
  • Whether widely deployed MCP server implementations validate the aud claim, because scoping is worth nothing at a backend that ignores it.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories