AID-H-028.006: Client Distribution, Plugin & Update Integrity Verification
Pillars: infra
Lifecycle: building, operation
Scope boundary
Owns authenticity, integrity, provenance, update-channel security, and rollback protection for the MCP client binary, its plugins or extensions, and updates before installation or execution.
AID-H-003.001: AID-H-028.006 specializes artifact verification for the MCP client distribution and update path; AID-H-003.001 makes the broader admission decision for exact third-party components consumed by build or deployment. A valid client signature is an input to supply-chain admission, not a general component approval.
AID-H-023: AID-H-028.006 verifies the distributed client, plugin, or update artifact; AID-H-023 hardens the publisher identity and release workflow that produced a package. Trusted publishing and consumer-side artifact verification are distinct links in the chain.
AID-H-003.010: AID-H-028.006 verifies authenticity, integrity, provenance, and rollback protection before an MCP client, plugin, or update executes; AID-H-003.010 decides whether and when deployed AI software requires vulnerability remediation and verifies fleet completion. An authentic update can remain vulnerable or undeployed, while remediation policy never permits an unverified update.
Implementation guidance
- AID-H-028.006-G001: Verify signatures or trusted identities for client binaries and plugins before installation or first execution.
- AID-H-028.006-G002: Protect the update channel with signed metadata, rollback protection, and pinned update sources.
- AID-H-028.006-G003: Continuously inventory installed client plugins and compare them against an approved allowlist with version and publisher constraints.
AID-H-028.006-G001
Concept:
Require a signature or trusted publisher identity for every distributable artifact. Do not install or load unsigned artifacts unless there is a tightly controlled break-glass exception.
Example cosign verification for a downloaded artifact
cosign verify-blob \
--bundle client.sigstore.json \
--certificate-identity build-bot@example.com \
--certificate-oidc-issuer https://github.com/login/oauth \
client-binary.zip
Operational notes: On macOS and Windows, also verify platform-native signatures such as notarization or Authenticode during install. Store trusted publisher identities in a reviewed allowlist.
Production implementation
At installer and execution admission, hash the exact bytes, verify the policy-approved signature, publisher identity and provenance, reject tag-only or unsigned artifacts and bind the admitted digest to the installed path.
Independent verification
A separately credentialed verifier independently reads back or replays downloaded bytes, signature/provenance and installed-file digest with tampered, unsigned, wrong-publisher, stale-signature and post-install mutation fixtures.
AID-H-028.006-G002
Concept:
The client should not trust an update just because it came from a URL. Use signed update metadata and reject version rollback or source drift.
Example checks your updater should enforce
{
"trusted_update_roots": ["https://updates.example.com/tuf/"],
"minimum_allowed_version": "3.4.0",
"require_signed_targets": true,
"reject_rollback": true
}
Operational notes: TUF is a strong fit for update metadata because it separates roles, supports key rotation, and helps detect rollback. Even if you use another updater, keep the same properties: signed metadata, monotonic version checks, pinned source, and staged rollout.
Production implementation
At update resolution, verify threshold-signed metadata, root/timestamp/snapshot/targets versions, target digest and length, approved source and rollback floor; install atomically and refuse stale, frozen, mixed-generation or unlisted targets.
Independent verification
A separately credentialed verifier independently reads back or replays metadata chain, target bytes, rollback counter and installed version with expired, freeze, rollback, mix-and-match, mirror and interrupted-install fixtures.
AID-H-028.006-G003
Concept:
Validate the plugin population the client loader will actually use, not a plugin-authored inventory file. The host-specific adapter must take a complete pre-activation snapshot, copy each discovered package into a protected immutable staging area, and keep plugin execution disabled until this gate returns PASS. A signature-verified allowlist binds the loader key, publisher, exact version, and SHA-256 of every approved package; the digest is the primary identity. If policy permits version ranges, resolve the range to approved exact versions and digests in the policy service before this validator runs.
Reusable pre-load validator
# File: client_plugin_policy/admission.py
from __future__ import annotations
import hashlib
import os
import stat
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol, Sequence
@dataclass(frozen=True)
class PluginRule:
loader_key: str
publisher: str
version: str
sha256: str
required: bool = False
@dataclass(frozen=True)
class PluginPolicy:
revision: str
trusted_snapshot_roots: Sequence[str]
rules: Sequence[PluginRule]
@dataclass(frozen=True)
class PluginCandidate:
loader_key: str
publisher: str
version: str
snapshot_path: str
@dataclass(frozen=True)
class PluginSnapshot:
snapshot_id: str
phase: str
population_complete: bool
activation_disabled: bool
loader_population: Sequence[str]
candidates: Sequence[PluginCandidate]
class VerifiedPolicyProvider(Protocol):
def load_verified(self) -> PluginPolicy:
"""Verify policy signature, schema, validity window, and revision."""
raise RuntimeError("protocol-only method")
class HostPluginInventory(Protocol):
def snapshot_before_activation(self) -> PluginSnapshot:
"""Enumerate the loader population and snapshot exact package bytes."""
raise RuntimeError("protocol-only method")
def keep_disabled_and_readback(self, loader_keys: Sequence[str]) -> str:
"""Disable rejected plugins and return the effective loader-state receipt."""
raise RuntimeError("protocol-only method")
class EvidenceSink(Protocol):
def write_and_readback(self, report: dict) -> str:
raise RuntimeError("protocol-only method")
def _under_trusted_root(path: Path, roots: Sequence[str]) -> bool:
resolved = path.resolve(strict=True)
for raw_root in roots:
root = Path(raw_root).resolve(strict=True)
try:
resolved.relative_to(root)
return True
except ValueError:
pass
return False
def _hash_regular_snapshot(path_text: str, roots: Sequence[str]) -> str:
path = Path(path_text)
if not _under_trusted_root(path, roots):
raise RuntimeError("plugin_snapshot_outside_trusted_root")
before = path.lstat()
if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode):
raise RuntimeError("plugin_snapshot_not_regular_file")
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(path, flags)
try:
opened = os.fstat(descriptor)
if (
opened.st_dev != before.st_dev or opened.st_ino != before.st_ino
or opened.st_size != before.st_size or opened.st_mtime_ns != before.st_mtime_ns
):
raise RuntimeError("plugin_snapshot_changed_before_read")
digest = hashlib.sha256()
while True:
block = os.read(descriptor, 1024 * 1024)
if not block:
break
digest.update(block)
after = os.fstat(descriptor)
if (
after.st_size != opened.st_size
or after.st_mtime_ns != opened.st_mtime_ns
):
raise RuntimeError("plugin_snapshot_changed_during_read")
finally:
os.close(descriptor)
current = path.lstat()
if (
current.st_dev != before.st_dev or current.st_ino != before.st_ino
or current.st_size != before.st_size or current.st_mtime_ns != before.st_mtime_ns
):
raise RuntimeError("plugin_snapshot_replaced_after_read")
return digest.hexdigest()
def evaluate_plugins(policy: PluginPolicy, snapshot: PluginSnapshot) -> dict:
if (
not policy.revision or not policy.trusted_snapshot_roots
or snapshot.phase != "PRE_ACTIVATION"
or not snapshot.population_complete
or not snapshot.activation_disabled
or not snapshot.snapshot_id
):
raise RuntimeError("incomplete_or_unsafe_plugin_snapshot")
rules: dict[str, PluginRule] = {}
for rule in policy.rules:
if (
not rule.loader_key or rule.loader_key in rules
or len(rule.sha256) != 64
or set(rule.sha256) - set("0123456789abcdef")
or not rule.publisher or not rule.version
):
raise RuntimeError("invalid_or_duplicate_plugin_policy_rule")
rules[rule.loader_key] = rule
loader_keys = list(snapshot.loader_population)
candidates = list(snapshot.candidates)
candidate_keys = [item.loader_key for item in candidates]
candidate_paths = [str(Path(item.snapshot_path).resolve(strict=True)) for item in candidates]
if (
len(loader_keys) != len(set(loader_keys))
or len(candidate_keys) != len(set(candidate_keys))
or len(candidate_paths) != len(set(candidate_paths))
or set(loader_keys) != set(candidate_keys)
):
raise RuntimeError("loader_population_and_snapshot_do_not_match")
findings: list[dict[str, str]] = []
verified: list[dict[str, str]] = []
for candidate in candidates:
actual_digest = _hash_regular_snapshot(
candidate.snapshot_path, policy.trusted_snapshot_roots
)
rule = rules.get(candidate.loader_key)
reason = None
if rule is None:
reason = "plugin_not_allowlisted"
elif candidate.publisher != rule.publisher:
reason = "publisher_mismatch"
elif candidate.version != rule.version:
reason = "version_mismatch"
elif actual_digest != rule.sha256:
reason = "artifact_digest_mismatch"
if reason:
findings.append({
"loader_key": candidate.loader_key,
"reason": reason,
"actual_sha256": actual_digest,
})
else:
verified.append({
"loader_key": candidate.loader_key,
"version": candidate.version,
"sha256": actual_digest,
})
installed = set(candidate_keys)
for rule in rules.values():
if rule.required and rule.loader_key not in installed:
findings.append({
"loader_key": rule.loader_key,
"reason": "required_plugin_missing",
"actual_sha256": "",
})
return {
"schema_version": 1,
"policy_revision": policy.revision,
"snapshot_id": snapshot.snapshot_id,
"population_count": len(candidates),
"verified": sorted(verified, key=lambda item: item["loader_key"]),
"findings": sorted(
findings, key=lambda item: (item["loader_key"], item["reason"])
),
"decision": "PASS" if not findings else "FAIL",
}
def enforce_plugin_gate(
policy_provider: VerifiedPolicyProvider,
inventory: HostPluginInventory,
evidence_sink: EvidenceSink,
) -> dict:
policy = policy_provider.load_verified()
snapshot = inventory.snapshot_before_activation()
try:
report = evaluate_plugins(policy, snapshot)
except Exception:
inventory.keep_disabled_and_readback(snapshot.loader_population)
raise
if report["decision"] != "PASS":
disable_receipt = inventory.keep_disabled_and_readback(
tuple(item["loader_key"] for item in report["findings"])
)
report["disable_receipt"] = disable_receipt
evidence_sink.write_and_readback(report)
if report["decision"] != "PASS":
raise RuntimeError("plugin_admission_denied")
return report
Production implementation
Implement HostPluginInventory for the actual client loader: enumerate every loader search root and built-in extension source, reject duplicate identities, snapshot package bytes before imports or hooks execute, and prove activation is disabled. The adapter—not the plugin—extracts loader identity, version, and publisher evidence. For scheduled post-start scans, quarantine or restart to a pre-activation state before declaring enforcement; a scan that cannot reconcile the complete loader population is ERROR, never PASS. Keep signature verification, endpoint management, and fleet quarantine inside the existing policy and endpoint-control adapters.
Independent verification
Replay startup and scheduled scans with an unknown, renamed, duplicated, downgraded, republished, digest-swapped, symlinked, concurrently replaced, hidden search-path, already-loaded, offline, and incomplete-population plugin. Confirm that the loader remains disabled, the evidence receipt covers the exact snapshot and policy revision, and only a complete byte-bound population produces PASS.
Open-source tools
- Cosign
- python-tuf
- in-toto
- slsa-verifier
- GnuPG
- Notation
Commercial / hosted tools
- Apple Notarization (macOS application notarization and signing checks)
- SignTool (Windows SDK; Authenticode signing and verification)
- JFrog Artifactory
- Sonatype Nexus Repository
Threat mappings
MITRE ATLAS
- AML.T0010 AI Supply Chain Compromise (tampered client binary, plugin, or updater)
- AML.T0010.001 AI Supply Chain Compromise: AI Software (malicious client SDK or dependency)
- AML.T0074 Masquerading (malicious plugins masquerading as legitimate extensions)
- AML.T0109 AI Supply Chain Rug Pull
MAESTRO
- Supply Chain Attacks (Cross-Layer) (compromised client distribution or update channel)
- Compromised Framework Components (L3) (malicious plugin or dependency)
OWASP LLM Top 10 2026
- LLM04:2026 Supply Chain
OWASP ML Top 10 2023
- ML06:2023 AI Supply Chain Attacks (client-side software supply chain compromise)
OWASP Top 10 for Agentic Applications 2026
- ASI04:2026 Agentic Supply Chain Vulnerabilities (client binary, plugin, or updater compromise)
NIST Adversarial Machine Learning 2025
- N/A
Cisco Integrated AI Security and Safety Framework
- AISubtech-9.3.1 Malicious Package / Tool Injection
- AISubtech-9.3.2 Dependency Name Squatting (Tools / Servers)
- AISubtech-9.3.3 Dependency Replacement / Rug Pull
- AITech-9.3 Dependency / Plugin Compromise
Google Secure AI Framework 2.0 - Risks
- IIC: Insecure Integrated Component
Databricks AI Security Framework 3.0
- Agents - Tools MCP Client 13.35: Update and Patch Management
- Model 7.3: ML Supply chain vulnerabilities