Agentic Covenants

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

  • - cosign for Sigstore signature verification.
  • - syft and grype (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 binary sha256, tool_descriptions_sha256, and per-server permissions scope. Owned by the operator; the agent's user must not have write permission on this file.
  • - mcp-launch, wrapper that verifies the binary's sha256 against the allowlist before exec, and calls cosign verify-blob if 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 to tool_descriptions_sha256 in the allowlist. Mismatch → block + re-approve flow. This is the rug-pull defense.
  • - pre-commit-deps-scan.yaml, extension to .pre-commit-config.yaml adding pip-audit and trivy-fs hooks. The server-side lockfile-integrity job in ../server-side/lockfile-integrity.yml is the backstop when --no-verify is 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.json and 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-verify skips 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 0PR.PS-01, PR.PS-02, GV.SC-07
NIST AI RMFMAP 4.1, MANAGE 3.1
OWASP LLMLLM03, LLM04
OWASP AGENTICASI04, ASI06
OTHERNIST SP 800-218 PS.2, NIST SP 800-218A