Protect (PR) · Supply chain
Supply chain at the client side layer
deterministic · Outside the model's reasoning
If the agent decides to violate this concern, what stops it at this layer?
What this cell does
MCP server allowlist with hash pinning, Sigstore verification, lockfile pinning, pre-commit deps scan.
Artifacts (4)
mcp-allowlist.jsonview on GitHub{
"version": 1,
"_comment": "Operator-owned. The agent's user MUST NOT have write permission on this file. Update the sha256 fields after every legitimate version bump; mcp-launch refuses to start servers whose binary hash does not match.",
"servers": {
"filesystem": {
"command": "/usr/local/bin/mcp-filesystem",
"args": [],
"sha256": "REPLACE_WITH_ACTUAL_SHA256_OF_BINARY",
"tool_descriptions_sha256": "REPLACE_AFTER_FIRST_HANDSHAKE",
"cosign_certificate": "/etc/agents/mcp-signing/mcp-filesystem.cert",
"permissions": ["read"],
"approved_at": "2026-04-15T00:00:00Z",
"approved_by": "michael@example.com",
"approval_notes": "Read-only filesystem access scoped to /workspace by the launcher."
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github@1.4.2"],
"sha256": "REPLACE_WITH_ACTUAL_SHA256_OF_PINNED_VERSION",
"tool_descriptions_sha256": "REPLACE_AFTER_FIRST_HANDSHAKE",
"cosign_certificate": null,
"permissions": ["issues:read", "pulls:read"],
"approved_at": "2026-04-15T00:00:00Z",
"approved_by": "michael@example.com",
"approval_notes": "Read-only GitHub access; never approve a version that adds write permissions without re-review."
}
},
"policy": {
"_comment": "Settings consumed by mcp-launch and mcp-verify-tools.py.",
"require_signature": false,
"fail_on_unsigned": false,
"fail_on_tool_description_change": true,
"rug_pull_grace_period_seconds": 0
}
}
mcp-launchview on GitHub#!/usr/bin/env bash
# ABOUTME: Verifies an MCP server's binary hash against the allowlist before exec, optionally checks Sigstore signature.
# ABOUTME: Refuses to launch servers not in the allowlist or whose hash differs. Run as the user the MCP server should run as.
set -euo pipefail
ALLOWLIST="${MCP_ALLOWLIST:-/etc/agents/mcp-allowlist.json}"
SERVER_NAME="${1:-}"
if [[ -z "$SERVER_NAME" ]]; then
cat <<'USAGE' >&2
Usage: mcp-launch <server-name> [args...]
Reads /etc/agents/mcp-allowlist.json (or $MCP_ALLOWLIST), looks up the server,
verifies the command's sha256 against the recorded value, optionally verifies
the Sigstore signature, then execs the command with the remaining args.
USAGE
exit 64
fi
if [[ ! -r "$ALLOWLIST" ]]; then
echo "BLOCKED: cannot read MCP allowlist at $ALLOWLIST" >&2
exit 2
fi
EXPECTED_SHA="$(jq -r ".servers[\"$SERVER_NAME\"].sha256 // \"\"" "$ALLOWLIST")"
COMMAND="$(jq -r ".servers[\"$SERVER_NAME\"].command // \"\"" "$ALLOWLIST")"
ARGS_JSON="$(jq -r ".servers[\"$SERVER_NAME\"].args // [] | @json" "$ALLOWLIST")"
COSIGN_CERT="$(jq -r ".servers[\"$SERVER_NAME\"].cosign_certificate // \"\"" "$ALLOWLIST")"
REQUIRE_SIGNATURE="$(jq -r '.policy.require_signature // false' "$ALLOWLIST")"
if [[ -z "$EXPECTED_SHA" || "$EXPECTED_SHA" == "REPLACE_WITH_ACTUAL_SHA256_OF_BINARY"* ]]; then
echo "BLOCKED: $SERVER_NAME has no recorded sha256 (or placeholder still present)" >&2
exit 2
fi
if [[ -z "$COMMAND" ]]; then
echo "BLOCKED: $SERVER_NAME not in allowlist" >&2
exit 2
fi
# Resolve the actual binary. For npx-style commands, the binary to hash is
# the resolved package's main. Operators must record the resolved sha256
# (npm pack --json | jq .integrity), not the wrapper's.
if [[ -x "$COMMAND" ]]; then
ACTUAL_SHA="$(sha256sum "$COMMAND" | awk '{print $1}')"
else
echo "BLOCKED: $SERVER_NAME command $COMMAND is not an executable file" >&2
echo " For npx-style commands, record the resolved package's sha256 and ensure mcp-launch points to the resolved binary." >&2
exit 2
fi
if [[ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]]; then
echo "BLOCKED: $SERVER_NAME sha256 mismatch" >&2
echo " expected: $EXPECTED_SHA" >&2
echo " actual: $ACTUAL_SHA" >&2
echo " Re-approve with: mcp-approve $SERVER_NAME --sha $ACTUAL_SHA" >&2
exit 2
fi
# Optional Sigstore signature verification.
if [[ -n "$COSIGN_CERT" && "$COSIGN_CERT" != "null" ]]; then
if command -v cosign >/dev/null 2>&1; then
if cosign verify-blob \
--signature "${COMMAND}.sig" \
--certificate "$COSIGN_CERT" \
"$COMMAND" >/dev/null 2>&1; then
echo "OK: $SERVER_NAME cosign verified" >&2
else
if [[ "$REQUIRE_SIGNATURE" == "true" ]]; then
echo "BLOCKED: $SERVER_NAME cosign verification failed (required by policy)" >&2
exit 2
else
echo "WARN: $SERVER_NAME cosign verification failed (allowed by policy)" >&2
fi
fi
fi
fi
# Successful pre-flight; exec the server with the remaining args.
shift
exec "$COMMAND" $(echo "$ARGS_JSON" | jq -r '.[]') "$@"
mcp-verify-tools.pyview on GitHub#!/usr/bin/env python3
# ABOUTME: Hashes MCP server tool descriptions in a canonical order and compares to the allowlist for rug-pull defense.
# ABOUTME: Run after the MCP handshake. Mismatch causes the agent runtime to refuse the connection until the operator re-approves.
import argparse
import hashlib
import json
import sys
from pathlib import Path
ALLOWLIST_PATH_DEFAULT = "/etc/agents/mcp-allowlist.json"
def hash_tool_descriptions(tool_list: list) -> str:
"""Stable hash over tool descriptions, ignoring ordering.
The canonical form sorts tools by name, normalizes the input schema by
sorting its keys, and concatenates with explicit separators so a tool
that contains the separator literally cannot collide with a different
tool whose name happens to match.
"""
canonical = sorted(
[
(
t.get("name", ""),
t.get("description", ""),
json.dumps(t.get("inputSchema", {}), sort_keys=True),
)
for t in tool_list
],
key=lambda x: x[0],
)
h = hashlib.sha256()
for name, desc, schema in canonical:
h.update(name.encode())
h.update(b"\x00")
h.update(desc.encode())
h.update(b"\x00")
h.update(schema.encode())
h.update(b"\xff")
return h.hexdigest()
def main():
parser = argparse.ArgumentParser(
description="Hash MCP tool descriptions and compare to allowlist (rug-pull defense)."
)
parser.add_argument("server_name", help="The MCP server name as it appears in the allowlist.")
parser.add_argument(
"--allowlist",
default=ALLOWLIST_PATH_DEFAULT,
help=f"Path to allowlist JSON (default: {ALLOWLIST_PATH_DEFAULT}).",
)
parser.add_argument(
"--tools-from-stdin",
action="store_true",
help="Read the MCP tool list as JSON from stdin (default: true; given for clarity).",
)
args = parser.parse_args()
try:
tools = json.loads(sys.stdin.read())
except json.JSONDecodeError as exc:
print(f"BLOCKED: {args.server_name} sent malformed tool list: {exc}", file=sys.stderr)
sys.exit(2)
if not isinstance(tools, list):
print(
f"BLOCKED: {args.server_name} tool list is not a JSON array",
file=sys.stderr,
)
sys.exit(2)
actual = hash_tool_descriptions(tools)
try:
allowlist = json.loads(Path(args.allowlist).read_text())
except FileNotFoundError:
print(f"BLOCKED: allowlist not found at {args.allowlist}", file=sys.stderr)
sys.exit(2)
server_entry = allowlist.get("servers", {}).get(args.server_name)
if not server_entry:
print(f"BLOCKED: {args.server_name} not in allowlist", file=sys.stderr)
sys.exit(2)
expected = server_entry.get("tool_descriptions_sha256")
fail_on_change = allowlist.get("policy", {}).get("fail_on_tool_description_change", True)
if not expected or expected.startswith("REPLACE_AFTER_FIRST_HANDSHAKE"):
# First handshake; record-and-prompt rather than block.
print(
f"FIRST-USE: {args.server_name} tool descriptions sha256 = {actual}",
file=sys.stderr,
)
print(
"Update mcp-allowlist.json: set servers[" + args.server_name + "].tool_descriptions_sha256 = " + actual,
file=sys.stderr,
)
sys.exit(0)
if actual != expected:
print(f"BLOCKED: {args.server_name} tool descriptions changed (rug-pull?)", file=sys.stderr)
print(f" expected: {expected}", file=sys.stderr)
print(f" actual: {actual}", file=sys.stderr)
print(
f" Re-approve with: mcp-approve {args.server_name} --tool-descriptions-sha {actual}",
file=sys.stderr,
)
if fail_on_change:
sys.exit(2)
print(f"OK: {args.server_name} tool descriptions match allowlist")
if __name__ == "__main__":
main()
pre-commit-deps-scan.yamlview on GitHub# ABOUTME: pre-commit framework hooks adding dependency vulnerability scans. Append to .pre-commit-config.yaml.
# ABOUTME: pre-commit is bypassable with --no-verify; the server-side lockfile-integrity job is the real backstop.
repos:
- repo: https://github.com/pypa/pip-audit
rev: v2.10.1
hooks:
- id: pip-audit
args: ["--strict"]
files: '^(requirements.*\.txt|pyproject\.toml|Pipfile\.lock|poetry\.lock)$'
# Trivy was compromised twice in 2026 (malicious v0.69.4, March 2026) — its own
# release pipeline is part of YOUR supply chain. Pin the rev, and in CI verify
# the binary by digest/signature rather than trusting the tag. See BYPASSES.md.
- repo: https://github.com/aquasecurity/trivy
rev: v0.71.1
hooks:
- id: trivy-fs
args: ["--severity", "HIGH,CRITICAL", "--exit-code", "1"]
- repo: https://github.com/python-poetry/poetry
rev: 2.4.1
hooks:
- id: poetry-check
args: ["--lock"] # poetry 2.x: `poetry check --lock` replaces the removed `poetry lock --check`
- repo: local
hooks:
- id: lockfile-not-edited-by-agent
name: Detect agent-style lockfile edits (heuristic)
# Heuristic: an agent that just edited the manifest also re-resolved
# the lock without running the package manager's recommended command.
# This hook checks that lockfile changes accompany manifest changes
# and that the lockfile generation timestamp is recent. Tune for your
# ecosystem.
language: script
entry: ./scripts/lockfile-sanity-check.sh
files: '(package-lock\.json|requirements.*\.txt|Pipfile\.lock|poetry\.lock|go\.sum|Cargo\.lock)$'
always_run: false
Cell notes
Supply chain / Client-side
Control. MCP server allowlist with manifest hash pinning. Tool-description hashing on first approval (rug-pull defense). Sigstore signature verification before install. Lockfile pinning paired with server-side integrity validation. Pre-commit dependency scan. Reject unsigned MCP servers at handshake.
Strength. Deterministic for the artifacts named in the allowlist. Bypassable if the agent can write to mcp-allowlist.json, package-lock.json, or other lockfiles; through tool-description rug-pull when the MCP server's descriptions are not re-hashed at every handshake; through --no-verify on pre-commit.
Tooling
- -
cosignfor Sigstore signature verification. - -
syftandgrype(or Trivy) for SBOM and vulnerability scanning. - - A skill-scanning tool: SkillCheck, ToxicSkills, SecureClaw, Snyk agent-scan, or
mcp-scanner. - - Lockfile linters:
npm-lockfile-fixed,pip-audit,cargo-audit.
Files in this directory
- -
mcp-allowlist.json, declarative allowlist of approved MCP servers with binarysha256,tool_descriptions_sha256, and per-serverpermissionsscope. Owned by the operator; the agent's user must not have write permission on this file. - -
mcp-launch, wrapper that verifies the binary'ssha256against the allowlist beforeexec, and callscosign verify-blobif a signature is present. Refuses to start if the server is not allowlisted or the hash mismatches. - -
mcp-verify-tools.py, runs after the MCP handshake, hashes the returned tool descriptions in a canonical order, compares totool_descriptions_sha256in the allowlist. Mismatch → block + re-approve flow. This is the rug-pull defense. - -
pre-commit-deps-scan.yaml, extension to.pre-commit-config.yamladdingpip-auditandtrivy-fshooks. The server-side lockfile-integrity job in../server-side/lockfile-integrity.ymlis the backstop when--no-verifyis used.
Verification
# 1. Allowlist enforcement
mcp-launch unknown-server
# expected: "not in allowlist"
# 2. Hash mismatch detection
echo "extra byte" >> /usr/local/bin/mcp-filesystem
mcp-launch filesystem
# expected: sha256 mismatch
git checkout -- /usr/local/bin/mcp-filesystem # revert
# 3. Tool description rug-pull detection
# Modify a tool description in a running MCP server's source; restart;
# verify mcp-verify-tools.py blocks with the expected/actual hashes.
# 4. Pre-commit catches a known CVE
echo "django==1.0.0" >> requirements.txt
git add requirements.txt && git commit -m "test"
# expected: failure at pip-audit
# 5. Cosign verification
cosign verify-blob \
--signature /usr/local/bin/mcp-filesystem.sig \
--certificate /usr/local/bin/mcp-filesystem.cert \
/usr/local/bin/mcp-filesystem
# expected: success against your signing key
Common mistakes
- - Allowlist with no hash pinning. The supply-chain bypass is "trusting npm publication" (postmark-mcp, September 2025).
- - Tool-description hash captured but not re-checked at every handshake. The rug-pull works because day-7's description is different from day-1's.
- - Lockfile pinning without server-side integrity validation. The agent edits
package-lock.jsonand pins a malicious version; without server-side validation, the malicious version is now self-attesting. - - Pre-commit dependency scan that runs only on the operator's machine.
--no-verifyskips it. The same scan must run server-side. - - Skill allowlist that approves whole publishers. ClawHavoc (1,184+ malicious skills) demonstrated that publisher trust does not transfer to individual skills.
Citation
NIST CSF 2.0 PR.PS-02, PR.PS-01, GV.SC-07 (risks from suppliers identified, recorded, prioritized, assessed). NIST AI RMF MAP 4.1, MANAGE 3.1 (third-party risk treatment). OWASP LLM03 (Supply Chain), LLM04 (Data and Model Poisoning). OWASP ASI04 (Agentic Supply Chain Vulnerabilities), ASI06 (Memory & Context Poisoning). NIST SP 800-218 v1.1 PS.2. NIST SP 800-218 Rev. 1 draft (Dec 17, 2025). NIST SP 800-218A (Generative AI Profile of SSDF). OWASP MCP01, MCP03, MCP04.
Primary bypasses
Documented, not hypothetical. A control whose bypass is undocumented is worse than no control, because somebody trusted it.
- lockfile rewrite if agent has FS write
- allowlist edited by agent
Crosswalk
| NIST CSF 2 0 | PR.PS-01, PR.PS-02, GV.SC-07 |
|---|---|
| NIST AI RMF | MAP 4.1, MANAGE 3.1 |
| OWASP LLM | LLM03, LLM04 |
| OWASP AGENTIC | ASI04, ASI06 |
| OTHER | NIST SP 800-218 PS.2, NIST SP 800-218A |
Cite this cell:
https://agenticcovenants.com/protect/supply-chain/client-side/