Generate and verify integrity manifests for AI agent plugins and tools. Detect file drift, review version pinning, and support supply chain provenance work.
Agent plugins and MCP servers have the same supply chain risks as npm packages or container images. However, the ecosystem has no equivalent of npm provenance, Sigstore, or SLSA. The manifests in this skill help detect file drift. Real provenance still requires Sigstore signing and SLSA attestations.
Plugin Directory → Hash All Files (SHA-256) → Generate INTEGRITY.json
↓
Later: Plugin Directory → Re-Hash Files → Compare Against INTEGRITY.json
↓
Match? VERIFIED : TAMPERED
Trust boundary: an unsigned INTEGRITY.json stored inside the plugin only detects accidental drift. Anyone who can change the plugin files can also regenerate the manifest. For real tamper evidence, take two steps:
- Sign the manifest with Sigstore, or record manifest_hash in a protected place outside the plugin directory.
- Treat verification and promotion as failed unless the signature or the recorded value matches.
Treat every result from these patterns as advisory. A person should review the result before relying on it.
- Before promoting a plugin from development to production
- During code review of plugin PRs
- As a CI step to check recorded files against a trusted manifest after review. See the CI caveats below.
- When auditing third-party agent tools or MCP servers
- Building a plugin marketplace with integrity requirements
Create an INTEGRITY.json with SHA-256 hashes of all plugin files. The file map and manifest_hash are deterministic for the same files. The generated_at value changes on each run.
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
EXCLUDE_DIRS = {".git", "__pycache__", "node_modules", ".venv", ".pytest_cache"}
EXCLUDE_FILES = {".DS_Store", "Thumbs.db", "INTEGRITY.json"}
def hash_file(path: Path) -> str:
"""Compute SHA-256 hex digest of a file."""
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest()
def generate_manifest(plugin_dir: str) -> dict:
"""Generate an integrity manifest for a plugin directory."""
root = Path(plugin_dir)
files = {}
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
if path.name in EXCLUDE_FILES:
continue
if any(part in EXCLUDE_DIRS for part in path.relative_to(root).parts):
continue
rel = path.relative_to(root).as_posix()
files[rel] = hash_file(path)
# Chain hash: SHA-256 of all file hashes concatenated in sorted order
chain = hashlib.sha256()
for key in sorted(files.keys()):
chain.update(files[key].encode("ascii"))
manifest = {
"plugin_name": root.name,
"generated_at": datetime.now(timezone.utc).isoformat(),
"algorithm": "sha256",
"file_count": len(files),
"files": files,
"manifest_hash": chain.hexdigest(),
}
return manifest
# Generate and save
manifest = generate_manifest("my-plugin/")
Path("my-plugin/INTEGRITY.json").write_text(
json.dumps(manifest, indent=2) + "\n"
)
print(f"Generated manifest: {manifest['file_count']} files, "
f"hash: {manifest['manifest_hash'][:16]}...")
Output (INTEGRITY.json):
{
"plugin_name": "my-plugin",
"generated_at": "2026-04-01T03:00:00+00:00",
"algorithm": "sha256",
"file_count": 12,
"files": {
".claude-plugin/plugin.json": "a1b2c3d4...",
"README.md": "e5f6a7b8...",
"skills/search/SKILL.md": "c9d0e1f2...",
"agency.json": "3a4b5c6d..."
},
"manifest_hash": "7e8f9a0b1c2d3e4f..."
}
The paths in this output are example values only.
Limits of this pattern:
- Excluded names apply at any depth. Excluded directory names and excluded file names are matched at every level of the tree. Code placed in them is neither hashed nor reported as untracked. Treat any excluded directory found inside a plugin, such as node_modules, as a promotion failure until a person has reviewed it. Verify dependencies separately through lockfile integrity hashes.
- Symbolic links are followed. A link can bring files from outside the plugin into the manifest. Treat any symbolic link, or any link that resolves outside the plugin directory, as a verification failure that needs review. This applies to Patterns 1 and 2 and to CI.
- Third-party plugins. When auditing a third-party plugin, write the manifest to a location outside the plugin directory. Get the user's confirmation before replacing an existing manifest.
Check that current files match the manifest.
# Requires: hash_file() and generate_manifest() from Pattern 1 above
import json
from pathlib import Path
def verify_manifest(plugin_dir: str) -> tuple[bool, list[str]]:
"""Verify plugin files against INTEGRITY.json."""
root = Path(plugin_dir)
manifest_path = root / "INTEGRITY.json"
if not manifest_path.exists():
return False, ["INTEGRITY.json not found"]
manifest = json.loads(manifest_path.read_text())
recorded = manifest.get("files", {})
errors = []
# Check recorded files
for rel_path, expected_hash in recorded.items():
full = root / rel_path
if not full.exists():
errors.append(f"MISSING: {rel_path}")
continue
actual = hash_file(full)
if actual != expected_hash:
errors.append(f"MODIFIED: {rel_path}")
# Check for new untracked files
current = generate_manifest(plugin_dir)
for rel_path in current["files"]:
if rel_path not in recorded:
errors.append(f"UNTRACKED: {rel_path}")
return len(errors) == 0, errors
# Verify
passed, errors = verify_manifest("my-plugin/")
if passed:
print("VERIFIED: All files match manifest")
else:
print(f"FAILED: {len(errors)} issue(s)")
for e in errors:
print(f" {e}")
Output on tampered plugin:
FAILED: 3 issue(s)
MODIFIED: skills/search/SKILL.md
MISSING: agency.json
UNTRACKED: backdoor.py
Treat manifest content as untrusted input. Apply these checks before acting on a manifest:
- Read only files that stay inside the plugin directory. Reject any manifest entry that is an absolute path or points outside the plugin directory, and do this before reading any file.
- Limit the file size before parsing.
- Confirm that algorithm is sha256 and that each files entry maps a path to a sha256 hex digest.
- Treat any parse error or type error as not verified.
Check that agent dependencies use pinned versions.
import re
import json
from pathlib import Path
def audit_versions(config_path: str) -> list[dict]:
"""Audit dependency version pinning in a config file."""
findings = []
path = Path(config_path)
content = path.read_text()
if path.name == "package.json":
data = json.loads(content)
for section in ("dependencies", "devDependencies"):
for pkg, ver in data.get(section, {}).items():
if ver.startswith("^") or ver.startswith("~") or ver == "*" or ver == "latest":
findings.append({
"package": pkg,
"version": ver,
"severity": "HIGH" if ver in ("*", "latest") else "MEDIUM",
"fix": f'Pin to exact: "{pkg}": "{ver.lstrip("^~")}"'
})
elif path.name in ("requirements.txt", "pyproject.toml"):
for line in content.splitlines():
line = line.strip()
if ">=" in line and "<" not in line:
findings.append({
"package": line.split(">=")[0].strip(),
"version": line,
"severity": "MEDIUM",
"fix": f"Add upper bound: {line},<next_major"
})
return findings
These checks are heuristics. They catch only obvious cases. Bare package names, many version ranges, and tags still pass. Keep these points in mind:
- Only an exact version pin counts as pinned.
- Rely on lockfiles with integrity hashes for real pinning.
- For a star or latest finding, resolve a concrete version and pin it. The suggested fix text is a starting point, not a final pin.
Use integrity verification as a gate before promoting plugins. This pattern requires verify_manifest from Pattern 2, plus the functions from Pattern 1.
import json
from pathlib import Path
def promotion_check(plugin_dir: str) -> dict:
"""Check if a plugin is ready for production promotion."""
checks = {}
# 1. Integrity manifest exists and verifies
passed, errors = verify_manifest(plugin_dir)
checks["integrity"] = {
"passed": passed,
"errors": errors
}
# 2. Required files exist
root = Path(plugin_dir)
required = ["README.md"]
missing = [f for f in required if not (root / f).exists()]
# Require at least one plugin manifest (supports both layouts)
manifest_paths = [
root / ".github/plugin/plugin.json",
root / ".claude-plugin/plugin.json",
]
if not any(p.exists() for p in manifest_paths):
missing.append(".github/plugin/plugin.json (or .claude-plugin/plugin.json)")
checks["required_files"] = {
"passed": len(missing) == 0,
"missing": missing
}
# 3. No unpinned dependencies
mcp_path = root / ".mcp.json"
if mcp_path.exists():
config = json.loads(mcp_path.read_text())
unpinned = []
for server in config.get("mcpServers", {}).values():
if isinstance(server, dict):
for arg in server.get("args", []):
if isinstance(arg, str) and "@latest" in arg:
unpinned.append(arg)
checks["pinned_deps"] = {
"passed": len(unpinned) == 0,
"unpinned": unpinned
}
# Overall
all_passed = all(c["passed"] for c in checks.values())
return {"ready": all_passed, "checks": checks}
result = promotion_check("my-plugin/")
if result["ready"]:
print("Plugin is ready for production promotion")
else:
print("Plugin NOT ready:")
for name, check in result["checks"].items():
if not check["passed"]:
print(f" FAILED: {name}")
Limits of this gate:
- The pinning check here flags only the latest tag.
- It runs only when the MCP config file is present.
- Package runner arguments with no version at all are not flagged.
- The integrity check trusts the manifest inside the plugin; see the trust boundary in the Overview.
A passing result therefore does not prove that dependencies are pinned or that files are authentic. A person should review the result before promotion.
Read these caveats before using the example step:
- Recorded files only. The inline step detects missing or modified files that the manifest records. It does not detect new untracked files. For complete coverage, run the full verify_manifest logic from Pattern 2, ideally from a checked-in, reviewed verifier script invoked by path.
- Trusted manifest source. Compare against a manifest from a trusted, reviewed source. A manifest supplied by the change under test can simply be regenerated by that change.
- Static matrix values. Matrix values must be static directory names defined in the workflow, never taken from pull request or user data. Pass the value through the step environment and check it against an allowed list of plugin paths before changing directory. Use read-only job permissions. Run the step for untrusted pull requests only with read-only access.
- Trusted working directory. Run the verifier from a trusted working directory outside the plugin under check, and pass the plugin directory in. A plugin can ship modules that Python would load from the current directory.
- Untrusted file names. Manifest keys are untrusted. Reject or escape keys that contain control characters, line breaks, or double colons before printing them as workflow messages.
Add to your GitHub Actions workflow:
- name: Verify plugin integrity
run: |
PLUGIN_DIR="${{ matrix.plugin || '.' }}"
cd "$PLUGIN_DIR"
python -c "
from pathlib import Path
import json, hashlib, sys
def hash_file(p):
h = hashlib.sha256()
with open(p, 'rb') as f:
for c in iter(lambda: f.read(8192), b''):
h.update(c)
return h.hexdigest()
manifest = json.loads(Path('INTEGRITY.json').read_text())
errors = []
for rel, expected in manifest['files'].items():
p = Path(rel)
if not p.exists():
errors.append(f'MISSING: {rel}')
elif hash_file(p) != expected:
errors.append(f'MODIFIED: {rel}')
if errors:
for e in errors:
print(f'::error::{e}')
sys.exit(1)
print(f'Verified {len(manifest[\"files\"])} files')
"
| Practice | Rationale |
|---|
Generate manifest after code review | Ensures reviewed code matches production code |
Include manifest in the PR | Reviewers can verify what was hashed |
Verify in CI before deploy | Catches post-review changes to recorded files; add the untracked check from Pattern 2 to catch added files |
Anchor the manifest outside the plugin | Sign it with Sigstore or record manifest_hash in a protected place, so a regenerated manifest is detected |
Treat the chain hash as a summary only | It covers file digests only, not file paths or metadata, and Pattern 2 and CI do not check it again |
Review excluded directories | Excluded names such as node_modules are skipped at any depth; verify dependencies separately through lockfile integrity hashes |
Deploy the verified artifact | After verification, package the plugin into an immutable archive or image, record its digest, deploy by that digest, and verify again at install time |
Pin all dependency versions | Unpinned deps = different code on every install |