Safeguard
AI Security•

Google Agent Mesh CVE-2026-102503 and CVE-2026-103091: Why Upgrading Isn't Enough

Two critical Agent Mesh (SAM) CVEs, fixed in 0.1.0-alpha.3 and alpha.9. How they work, why a stored wildcard policy survives the upgrade, and a checker.

Safeguard Security Team
Security Research
15 min read

Google published two critical CVEs for its open source Agent Mesh, formerly called SAM, on 19 October 2026. Both carry a CVSS 4.0 score of 9.3, and both let someone outside the mesh call the tools it protects. The code fixes shipped in August and September. One of the two is also a configuration problem, though, and the configuration lives in a database that an upgrade doesn't touch.

This post is for teams that run, or tried, Agent Mesh in front of MCP servers, A2A agents or model endpoints. It covers how each bug worked, a third advisory from the same week that has no CVE yet, why a fixed version can still be wide open, and a script that checks both your binaries and your stored policy.

The short answer

CVE-2026-102503 affects Agent Mesh (Go module github.com/google/sam) 0.1.0-alpha.1 and alpha.2 and is fixed in 0.1.0-alpha.3, released 6 August 2026: a second ingress path on each node skipped the Biscuit authorization check. CVE-2026-103091 affects 0.1.0-alpha.1 through alpha.8 and is fixed in 0.1.0-alpha.9, released 7 September 2026: anyone with a token from the configured OIDC issuer could enroll, and the shipped example policy gave every authenticated member every tool. Upgrading fixes the code, but a wildcard policy you already posted stays in the control plane's store, so export your policy and check it.

What Agent Mesh is

Agent Mesh is a private network for AI agents and the tools and models they call. The v0.1.0 release notes, published 16 October, describe three parts. A control plane handles enrollment and policy. Routers relay traffic and hold the service directory. A node sidecar puts an MCP server, an A2A agent or an OpenAI-compatible model on the mesh. Members hold a credential called a Biscuit, and every node checks the operator's policy, compiled to Datalog, before it lets a call through.

The project is young. The first alpha shipped on 26 June 2026 and the first stable release on 16 October. The repository moved from google/sam to google/agentmesh, and the Go module path changed from github.com/google/sam to github.com/google/agentmesh in v0.1.0-rc.10 on 14 October. Both CVE records name the old module path.

CVE-2026-102503: the ingress that skipped the check

A node exposed its registered services over two libp2p paths. The MCP protocol stream was wrapped in middleware that read the caller's Biscuit and ran it through the policy. The second path, an HTTP server on the /libp2p-http stream started by StartIngressServer, looked up the requested service by name and passed the request straight to its handler.

According to the advisory, the only check in front of that ingress was the connection gater, and it refused only banned or revoked peers. A TLS handshake proves a peer holds a key pair. It doesn't prove the peer enrolled. So any peer that could dial the node reached every registered service, including MCP servers run as local subprocesses over stdio, and URL-backed services the node would proxy to.

The advisory also notes that nodes bound libp2p to all interfaces by default. The fix landed in 0.1.0-alpha.3 as pull request 171, titled "Fix security gap in HTTP Datapath ingress and proxy token leak". The reporter is credited as Yanchon918s.

CVE-2026-103091: open enrollment plus a wildcard policy

This one is a chain of three reasonable-looking choices. Per the CVE record and the advisory:

  1. GET /info on the control plane needed no authentication and returned the OIDC issuer, client ID, audience and router addresses.
  2. POST /register accepted any valid token from that issuer and enrolled the caller, with no filter on email, domain or group claims and no ban check.
  3. The example policy bound the special member sam:system:authenticated, which matches every identity the issuer vouches for, to a role allowed to call every service on every target.

Put together, anyone who could get a token from the configured identity provider could become a full member of the mesh. The reporter, Eli Ainhorn of Noma Security, adds two details. Bans were keyed on a peer ID derived from a local key pair, so a new key pair meant a new identity. And a short-lived OIDC token produced a mesh session of 90 days. That 90 days is the OIDCSessionTTL constant in the source, and it is still 90 days in v0.1.1.

Version 0.1.0-alpha.9 moved to a default-closed policy, required an explicit binding for the role a node asks for at enrollment, and added ban checks. Its release notes list "3 security fixes and advisory published".

The third advisory, with no CVE yet

The same release fixed GHSA-hp3x-79wr-rx66, rated high by the project and credited to makino-yuto. On the control plane, GET /enroll/status took only a peer ID and, for an approved bootstrap enrollment, returned that peer's signed Biscuit. The peer ID it needed was published by the same unauthenticated /info endpoint, and the control plane then accepted the Biscuit as a credential on /policies and /routers/lease. The Helm chart approves enrollments automatically by default (autoApproveEnrollment: true, still the default in v0.1.1). As of 19 October no CVE ID is attached to it, and the CVE list updates published that day contain records for the other two only. Tools that key on CVE IDs won't show it.

Why the fixed version can still be open

Agent Mesh keeps policy in the control plane's database. You post it as JSON to /policies, edit it in the console, or seed it from a Helm job, and it stays until you change it. The alpha.9 fix changed what the project ships. It didn't change what you already stored.

Here's what changed in the shipped defaults between 0.1.0-alpha.8 and alpha.9, from the source at both tags:

Wherealpha.8alpha.9
Example manifest seed policyrole public-mesh with allowed_services and allowed_targets set to *, bound to sam:system:authenticatedexplicit demo-caller role for one test user, plus a comment warning never to ship the wildcard combination
Helm bootstrap.nodeServices["*"][], with comments saying empty means fail closed
Helm node role membersno setting; the chart bound the node role to group:data-scientist and group:usersbootstrap.nodeMembers: [], so no node enrolls until an operator grants it

If you seeded a mesh from the alpha.8 example and then upgraded, the wildcard binding is still in the store. That matters because of how the fixed enrollment check works. In both the alpha.9 and v0.1.1 source, registration succeeds when the role a caller asks for resolves for that caller's identity, and a role bound to the everyone-authenticated member resolves for every caller. The new check refuses roles nobody bound. It can't refuse a role your policy hands to everyone.

The v0.1.1 storage code even carries the binding forward: the migration for the sam-to-mesh rename includes a statement that rewrites sam:system:authenticated bindings to mesh:system:authenticated. That's the right call for a rename, because silently dropping bindings would break working meshes. It also means an open policy keeps working under its new name.

There's a second way to end up open on a fixed version. When alpha.9 refuses an enrollment because the requested role isn't bound, the error message suggests binding it to sam:system:authenticated to open enrollment to every authenticated identity. That's a legitimate choice for a public test mesh. On a private one, it rebuilds half of CVE-2026-103091 by hand.

Check your binaries and your policy

None of these three advisories was in OSV, the Go vulnerability database or GitHub's global advisory database when we checked on 19 October at 14:08 UTC. The GitHub advisories exist only on the repository, and none of the three links to a CVE ID. The two critical ones also carry a CVSS 3.1 score of 9.8, while the CVE records use CVSS 4.0 and score 9.3, so you may see either number depending on where you look. Dependency scanners that read those databases won't flag an old Agent Mesh build yet, so check it directly.

This script takes two kinds of input. For Go binaries it runs go version -m, which reads the build metadata embedded in the file without running it, and compares the module and version with the three fixes. For policy documents it flags any binding that gives every authenticated identity, or a bare wildcard member such as group:*, a role that may call every service or every target. Standard library only:

#!/usr/bin/env python3
"""Check an Agent Mesh (formerly SAM) deployment against CVE-2026-102503,
CVE-2026-103091 and GHSA-hp3x-79wr-rx66.

Pass policy documents (JSON from GET /admin/policy, or the JSON your seed job
or Helm values posted to /policies) and Go binaries (sam-node,
agentmesh-control-plane and so on). Binaries are read with `go version -m`,
which reads build metadata and does not run them.

Usage: python3 mesh_check.py policy.json /usr/local/bin/sam-node ...
"""
import json
import re
import subprocess
import sys

OPEN_MEMBERS = {"sam:system:authenticated", "mesh:system:authenticated"}
NODE_ROLES = {"sam:role:node", "mesh:role:node"}
OLD_MODULE = "github.com/google/sam"
NEW_MODULE = "github.com/google/agentmesh"

# (advisory, fixed in) for the old module path; every agentmesh release is later.
ADVISORIES = [
    ("CVE-2026-102503 (/libp2p-http ingress skips Biscuit checks)", "v0.1.0-alpha.3"),
    ("CVE-2026-103091 (open OIDC enrollment + wildcard default policy)", "v0.1.0-alpha.9"),
    ("GHSA-hp3x-79wr-rx66 (/enroll/status returns an approved Biscuit)", "v0.1.0-alpha.9"),
]


def vkey(v):
    """Sort key for vX.Y.Z[-alpha.N|-rc.N]; a final release sorts last."""
    m = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta|rc)\.(\d+))?", v)
    if not m:
        return None
    rank = {"alpha": 0, "beta": 1, "rc": 2, None: 3}[m.group(4)]
    return (int(m[1]), int(m[2]), int(m[3]), rank, int(m[5] or 0))


def is_wild(values):
    return any(v == "*" or v.endswith("://*") for v in values or [])


def check_policy(path, doc):
    out = []
    roles = {r.get("name"): r for r in doc.get("roles", [])}
    for b in doc.get("bindings", []):
        role, members = b.get("role"), b.get("members", [])
        r = roles.get(role, {})
        for m in members:
            bare = re.fullmatch(r"[a-z]+:\*", m) is not None
            if m in OPEN_MEMBERS or bare:
                who = "every authenticated identity" if not bare else f"bare wildcard {m!r}"
                if is_wild(r.get("allowed_services")) or is_wild(r.get("allowed_targets")):
                    out.append(f"HIGH  role {role!r} has wildcard services/targets and is bound to {who}")
                elif role in NODE_ROLES:
                    out.append(f"NOTE  {who} can enroll as {role!r} (open enrollment); check what that role may call")
                else:
                    out.append(f"NOTE  role {role!r} is bound to {who}")
    for name, r in roles.items():
        if is_wild(r.get("allowed_services")) and is_wild(r.get("allowed_targets")):
            out.append(f"INFO  role {name!r} may call every service on every target; confirm who holds it")
    return [f"{path}: {line}" for line in out] or [f"{path}: no open wildcard bindings found"]


def check_binary(path):
    try:
        info = subprocess.run(["go", "version", "-m", path], capture_output=True, text=True, timeout=30)
    except FileNotFoundError:
        return [f"{path}: needs the Go toolchain for `go version -m`"]
    return check_buildinfo(path, info.stdout)


def check_buildinfo(path, text):
    for line in text.splitlines():
        parts = line.split()
        if len(parts) >= 3 and parts[0] in ("mod", "dep") and parts[1] in (OLD_MODULE, NEW_MODULE):
            mod, ver = parts[1], parts[2]
            if mod == NEW_MODULE:
                return [f"{path}: {mod} {ver}: module renamed after all three fixes; not affected by these"]
            if ver == "(devel)":
                return [f"{path}: {mod} built from source (devel); check the commit against the fixes"]
            k = vkey(ver)
            if k is None:
                return [f"{path}: {mod} {ver}: unrecognized version, check by hand"]
            hits = [a for a, fixed in ADVISORIES if k < vkey(fixed)]
            if not hits:
                return [f"{path}: {mod} {ver}: has all three fixes"]
            return [f"{path}: {mod} {ver}: AFFECTED by {a}" for a in hits]
    return [f"{path}: not an Agent Mesh / SAM binary"]


def main():
    for path in sys.argv[1:]:
        try:
            with open(path, "rb") as f:
                head = f.read(1).lstrip()
                f.seek(0)
                if head in (b"{", b""):
                    lines = check_policy(path, json.load(f))
                elif path.endswith(".buildinfo"):
                    lines = check_buildinfo(path, f.read().decode())
                else:
                    lines = check_binary(path)
        except (OSError, ValueError) as e:
            lines = [f"{path}: could not read ({e})"]
        print("\n".join(lines))


if __name__ == "__main__":
    main()

On v0.1.0 and later, read the policy back as an admin from GET /admin/policy or the console. Older control planes don't have that endpoint, so use the JSON your seed job or Helm values posted. For a container image, copy the binary out with docker create and docker cp, or save go version -m output from inside the container to a file ending in .buildinfo.

We ran it on four policy fixtures (the alpha.8 example seed, the same policy after the v0.1.1 rename, the alpha.9 example, and an open-enrollment policy) and on build metadata for three old sam-node versions and one current agentmesh-node:

fx/policy_alpha8_example.json: HIGH  role 'public-mesh' has wildcard services/targets and is bound to every authenticated identity
fx/policy_alpha8_example.json: INFO  role 'public-mesh' may call every service on every target; confirm who holds it
fx/policy_after_v011_migration.json: HIGH  role 'public-mesh' has wildcard services/targets and is bound to every authenticated identity
fx/policy_after_v011_migration.json: INFO  role 'public-mesh' may call every service on every target; confirm who holds it
fx/policy_alpha9_example.json: no open wildcard bindings found
fx/policy_open_enrollment.json: NOTE  every authenticated identity can enroll as 'mesh:role:node' (open enrollment); check what that role may call
fx/policy_open_enrollment.json: NOTE  role 'sre' is bound to bare wildcard 'group:*'
fx/sam-node_v0.1.0-alpha.2.buildinfo: github.com/google/sam v0.1.0-alpha.2: AFFECTED by CVE-2026-102503 (/libp2p-http ingress skips Biscuit checks)
fx/sam-node_v0.1.0-alpha.2.buildinfo: github.com/google/sam v0.1.0-alpha.2: AFFECTED by CVE-2026-103091 (open OIDC enrollment + wildcard default policy)
fx/sam-node_v0.1.0-alpha.2.buildinfo: github.com/google/sam v0.1.0-alpha.2: AFFECTED by GHSA-hp3x-79wr-rx66 (/enroll/status returns an approved Biscuit)
fx/sam-node_v0.1.0-alpha.8.buildinfo: github.com/google/sam v0.1.0-alpha.8: AFFECTED by CVE-2026-103091 (open OIDC enrollment + wildcard default policy)
fx/sam-node_v0.1.0-alpha.8.buildinfo: github.com/google/sam v0.1.0-alpha.8: AFFECTED by GHSA-hp3x-79wr-rx66 (/enroll/status returns an approved Biscuit)
fx/sam-node_v0.1.0-alpha.9.buildinfo: github.com/google/sam v0.1.0-alpha.9: has all three fixes
fx/agentmesh-node_v0.1.1.buildinfo: github.com/google/agentmesh v0.1.1: module renamed after all three fixes; not affected by these

A HIGH line means the stored policy hands everything to anyone who can enroll. A NOTE about open enrollment isn't a vulnerability by itself, but it makes whatever the node role may call reachable by every identity your OIDC provider will vouch for.

If you put MCP servers behind a mesh or a proxy, it's worth seeing what per-tool policy looks like at that boundary. Here's Safeguard's interactive walkthrough of putting an MCP server behind a guardrail:

What to do

  1. Upgrade. Any github.com/google/sam build before 0.1.0-alpha.9 is affected by at least two of the three advisories. Every release under the github.com/google/agentmesh path includes all three fixes.
  2. Export and read your policy. Remove any binding of sam:system:authenticated or mesh:system:authenticated to a role with wildcard services or targets, unless the mesh is meant to be public.
  3. Narrow who can enroll. Bind the node role to specific users, groups or service accounts, and decide whether automatic approval of enrollments is what you want.
  4. Review existing members. Anyone who enrolled while the mesh was open may hold a session of up to 90 days. Check the enrolled nodes in the console and revoke the ones you don't recognize. The project's key rotation guide covers retiring signing keys if you want everything re-issued.
  5. Check your logs for /libp2p-http and /enroll/status traffic from peers you don't know, if you ran alpha.1 or alpha.2, or anything before alpha.9, on a reachable network.

For the wider design questions, our guides to securing MCP servers and A2A protocol security cover authentication and authorization at the tool and agent layers.

The fair view

This is an alpha project that did most things right. The advisories were public by early September, the reporters are credited, the fixes changed the defaults to fail closed rather than relying on documentation alone, and the alpha.9 manifest now tells operators in plain words not to ship the wildcard combination. The bugs themselves are common ones for young networks. One path was authorized and its sibling wasn't. Enrollment was easy because that's what makes a demo work in a minute.

The lesson that carries over to any agent infrastructure is about where the risk sits. Agent Mesh's security is mostly policy, and policy is data you own. A version bump fixes the code that evaluates it. It doesn't review what you told that code to allow.

Check yourself

You upgraded a control plane from 0.1.0-alpha.8 to v0.1.1 and never changed the policy you seeded from the alpha.8 example. Are you exposed to the CVE-2026-103091 attack chain?

Largely, yes. The code fixes add ban checks and change the shipped defaults, but your stored policy still binds every authenticated identity to a role that may call every service on every target, and enrollment allows any role that resolves for the caller. The v0.1.1 migration renames that member to mesh:system:authenticated and keeps the binding. Remove or narrow it.

Why didn't the Biscuit check on the MCP stream protect against CVE-2026-102503?

Because the same services were reachable through a second path. The /libp2p-http ingress handed requests directly to the service handlers, and the only check in front of it refused banned or revoked peers, not unenrolled ones.

Your dependency scanner reports no known vulnerabilities for a sam-node build at 0.1.0-alpha.5. Is it safe?

No. On 19 October none of the three advisories was in OSV, the Go vulnerability database or GitHub's global advisory database, so scanners that read those sources had nothing to match. Version alpha.5 is affected by CVE-2026-103091 and GHSA-hp3x-79wr-rx66. Check the version against the advisories directly.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.