From 2876925b5db05904b06ac311726597ce74eb52ce Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Wed, 15 Jul 2026 13:41:59 -0700 Subject: [PATCH] feat(claude-code): agent-integrity plugin (Agent Manifest + TRACE) Adds a Claude Code integration that, per session, captures what the agent IS (skills, tools, MCP servers, model, permissions, instruction layer) as a signed Agent Manifest and what it DID as a signed TRACE record, and checks nothing was added or subtracted since an approved baseline. - SessionStart hook: stdlib-only snapshot + drift warning (no crypto deps) - /manifest verify|approve and /trace commands - observed-category diff so the disk-only hook never false-flags the live tool roster of a richer baseline - software-only / Level 0; honest Known-gaps section Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- claude-code/.claude-plugin/plugin.json | 11 + claude-code/.gitignore | 7 + claude-code/README.md | 91 +++++ claude-code/commands/manifest.md | 36 ++ claude-code/commands/trace.md | 30 ++ claude-code/engine/capture.py | 479 +++++++++++++++++++++++++ claude-code/hooks/hooks.json | 16 + claude-code/integration.yaml | 15 + claude-code/requirements.txt | 12 + claude-code/tests/test_capture.py | 70 ++++ 11 files changed, 768 insertions(+), 1 deletion(-) create mode 100644 claude-code/.claude-plugin/plugin.json create mode 100644 claude-code/.gitignore create mode 100644 claude-code/README.md create mode 100644 claude-code/commands/manifest.md create mode 100644 claude-code/commands/trace.md create mode 100644 claude-code/engine/capture.py create mode 100644 claude-code/hooks/hooks.json create mode 100644 claude-code/integration.yaml create mode 100644 claude-code/requirements.txt create mode 100644 claude-code/tests/test_capture.py diff --git a/README.md b/README.md index 24cfecc..aa8cf1b 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ TRACE only works as a standard if it is genuinely neutral. Integrations are list | Integration | Vendor | Integrates with | Tier | |---|---|---|---| -| _none yet - be the first_ | | | | +| [claude-code](claude-code/) | agentrust-io | agent-manifest, trace | community | ## Community diff --git a/claude-code/.claude-plugin/plugin.json b/claude-code/.claude-plugin/plugin.json new file mode 100644 index 0000000..c8043be --- /dev/null +++ b/claude-code/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "agentrust-claude-code", + "description": "Agent-integrity for Claude Code: capture what your agent IS (Agent Manifest) and what it DID (TRACE), and check nothing was added or subtracted since your approved baseline.", + "version": "0.1.0", + "author": { + "name": "agentrust-io" + }, + "homepage": "https://github.com/agentrust-io/integrations/tree/main/claude-code", + "repository": "https://github.com/agentrust-io/integrations", + "license": "Apache-2.0" +} diff --git a/claude-code/.gitignore b/claude-code/.gitignore new file mode 100644 index 0000000..a3df02c --- /dev/null +++ b/claude-code/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.pytest_cache/ +# local capture artifacts +manifest.json +trace.json +live.json diff --git a/claude-code/README.md b/claude-code/README.md new file mode 100644 index 0000000..869cabd --- /dev/null +++ b/claude-code/README.md @@ -0,0 +1,91 @@ +# AgenTrust for Claude Code + +Agent-integrity for the coding agent you already use. This plugin captures, every +session: + +- an **Agent Manifest** — what your Claude agent *is*: skills, tools, MCP servers, + model, permission policy, and instruction layer, each fingerprinted and signed + ([agent-manifest](https://github.com/agentrust-io/agent-manifest)). +- a **TRACE Trust Record** — what your agent *did* this run, signed and + conformance-checkable ([trace](https://github.com/agentrust-io/trace-spec)). + +and answers the one question that matters on a developer box: + +> **Is the agent I'm running the one I approved — nothing added, nothing subtracted?** + +A rogue skill dropped into `~/.claude/skills`, a widened permission, an edited +`CLAUDE.md`, or an unexpected MCP server all change a fingerprint, and the next +SessionStart tells you. + +## What it does + +| Surface | What runs | Needs crypto packages? | +|---|---|---| +| **SessionStart hook** | snapshot the agent from disk, diff against your approved baseline, warn in-session on drift | No (stdlib only) | +| `/manifest verify` | full diff including the live tool/MCP roster the agent reports | No | +| `/manifest approve` | make the current composition the approved baseline | No (add `--sign` for records) | +| `/trace` | build + sign the Agent Manifest and TRACE record, explain them in plain English | Yes | + +The hook is deliberately dependency-free so it never blocks session start. Signing +runs only when you ask for records. + +## Install + +```bash +# 1. plugin (hooks + commands) +/plugin marketplace add agentrust-io/integrations +/plugin install agentrust-claude-code + +# 2. only for signed records (/trace, /manifest approve --sign) +pip install -r claude-code/requirements.txt +``` + +First SessionStart establishes your baseline at `~/.claude/agentrust/baseline.json`. +Every later session is checked against it. Run `/manifest approve` whenever you +intentionally change your setup. + +## What it captures (and what it does not) + +Captured, by fingerprint — never raw content, never secrets: + +- **skills** — each `~/.claude/skills/*/SKILL.md` +- **permissions** — `~/.claude/settings.json` (the allow/deny policy) +- **instruction layer** — your `CLAUDE.md` / memory tree +- **tools + MCP servers** — the roster the agent reports at report time +- **model** — provider, id, version + +It never reads `~/.claude/.credentials.json`, and records skill / tool / MCP +**names** only, never tokens or environment values. + +## Known gaps (read before relying on it) + +- **Software-only, Level 0.** A normal dev box has no TEE, so the TRACE record is + software-only integrity, not hardware-rooted attestation. Labelled as such. +- **Instruction layer is a proxy.** The `system_prompt` fingerprint covers your + `CLAUDE.md` and memory, not Claude Code's internal system prompt, which is not + on disk. +- **`policy_language` mismatch.** agent-manifest's `policy_language` enum + (`cedar`/`rego`/`yaml-agt`/`composite`) has no value for host-native agent + permission systems like Claude Code's `settings.json`. Modelled as `composite`; + a spec value for host-native permissions is proposed upstream. +- **Hook visibility.** A shell hook cannot enumerate the live tool roster, so the + SessionStart check compares skills, permissions, and the instruction layer. + The full tool/MCP diff runs in `/manifest verify`, where the agent supplies the + live roster. +- **agent-manifest version.** The manifest builder targets agent-manifest 0.3.x; + PyPI currently publishes 0.2.0. Install agent-manifest from source until 0.3.x + ships. + +## Layout + +``` +claude-code/ + .claude-plugin/plugin.json plugin manifest + hooks/hooks.json SessionStart -> engine/capture.py hook + commands/manifest.md /manifest capture | verify | approve + commands/trace.md /trace report + engine/capture.py capture engine (stdlib hook + signing report) + tests/test_capture.py stdlib-only tests + integration.yaml agentrust-io integration manifest + requirements.txt crypto deps for signing only +``` diff --git a/claude-code/commands/manifest.md b/claude-code/commands/manifest.md new file mode 100644 index 0000000..01ff9e8 --- /dev/null +++ b/claude-code/commands/manifest.md @@ -0,0 +1,36 @@ +--- +description: Capture, verify, or approve the integrity baseline of your Claude Code agent +argument-hint: "[verify | approve | show]" +--- + +You are running the AgenTrust agent-integrity command. The engine is at +`${CLAUDE_PLUGIN_ROOT}/engine/capture.py`. It captures the agent's composition +(skills, tools, MCP servers, model, permissions, instruction layer) and diffs it +against the user's approved baseline at `~/.claude/agentrust/baseline.json`. + +The shell hook cannot see the live tool roster, so YOU enrich it. Before running, +write the current session's real facts to a temp `live.json`: + +```json +{ + "model_id": "", + "model_provider": "anthropic", + "model_version": "", + "builtin_tools": [""], + "mcp_servers": [""] +} +``` + +Then dispatch on `$ARGUMENTS`: + +- `verify` (default): run + `python "${CLAUDE_PLUGIN_ROOT}/engine/capture.py" verify --live-context live.json` + and show the "nothing added, nothing subtracted" result. Explain any change in + plain language and ask whether to approve it. +- `approve`: run `... approve --live-context live.json --sign --out .` to make the + current composition the new approved baseline and write signed records. +- `show`: run `... snapshot --live-context live.json` to display the current + composition without touching the baseline. + +Report the result in plain English. Never claim hardware attestation: on a normal +dev box this is software-only (Level 0) integrity, not silicon-rooted proof. diff --git a/claude-code/commands/trace.md b/claude-code/commands/trace.md new file mode 100644 index 0000000..316b351 --- /dev/null +++ b/claude-code/commands/trace.md @@ -0,0 +1,30 @@ +--- +description: Generate a signed TRACE + Agent Manifest report for this Claude Code session +argument-hint: "" +--- + +You are running the AgenTrust report command. Generate a signed record of THIS +session and explain it in plain English. Engine: +`${CLAUDE_PLUGIN_ROOT}/engine/capture.py`. + +Steps: + +1. Ensure the packages are installed (once): + `pip install agent-manifest agentrust-trace "agentrust-trace-tests>=0.2"`. +2. Write this session's real facts to `live.json` (do NOT invent values): + `model_id`, `model_provider`, `model_version`, `builtin_tools` (your actual + built-in tools), `mcp_servers` (the MCP servers actually connected now). +3. Run + `python "${CLAUDE_PLUGIN_ROOT}/engine/capture.py" report --live-context live.json --out .` +4. Optionally confirm the TRACE record passes the suite: + `trace-tests verify --record trace.json --level 0` (or + `python -m trace_tests.cli verify ...` if the script is not on PATH). + +Then explain the report the user actually cares about: +- what the agent IS (skills, tools, MCP, model, permissions), each fingerprinted, +- what it DID this run (TRACE record, software-only / Level 0 on a dev box), +- whether anything changed since their approved baseline. + +Be honest about scope: no TEE on a normal laptop means Level 0, not hardware +attestation. The "instruction layer" fingerprint covers CLAUDE.md and memory, +not Claude Code's internal system prompt (which is not on disk). diff --git a/claude-code/engine/capture.py b/claude-code/engine/capture.py new file mode 100644 index 0000000..bd2673f --- /dev/null +++ b/claude-code/engine/capture.py @@ -0,0 +1,479 @@ +"""AgenTrust capture engine for Claude Code. + +Introspects the running Claude Code agent and produces, for one session: + + * an Agent Manifest -- what the agent IS: skills, tools, MCP servers, model, + permission policy, and instruction layer, each fingerprinted. Signed with + agent-manifest (Ed25519). + * a TRACE Trust Record -- what the agent DID this run. Signed with + agentrust-trace. Shares the manifest's agent_id and policy hash. + * a plain-English integrity report. + +The design keeps the SessionStart hook cheap and dependency-free: `snapshot`, +`verify`, and `approve` use only the Python standard library. Signing (which +needs agent-manifest / agentrust-trace) runs only in `report` and `approve +--sign`, on demand. + +The core question it answers: "is the agent I am running the one I approved -- +nothing added, nothing subtracted?" + +Safety: hashes files, never stores secrets. Never reads .credentials.json. +Records skill / MCP / tool NAMES only, never tokens or environment values. + +Subcommands: + snapshot capture the agent's composition from disk (stdlib only) + hook SessionStart entrypoint: snapshot + drift check + warn + verify diff the latest snapshot against the approved baseline + approve promote the latest snapshot to the approved baseline + report build + sign the manifest and TRACE record, render the report +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +import time +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path + +CLAUDE_HOME = Path(os.path.expanduser("~")) / ".claude" +STATE_DIR = CLAUDE_HOME / "agentrust" +BASELINE = STATE_DIR / "baseline.json" +LATEST = STATE_DIR / "session-latest.json" + + +# --------------------------------------------------------------------------- # +# hashing (files only, never secrets) +# --------------------------------------------------------------------------- # +def _sha_bytes(b: bytes) -> str: + return "sha256:" + hashlib.sha256(b).hexdigest() + + +def _sha_file(p: Path) -> str: + return _sha_bytes(p.read_bytes()) + + +def _sha_tree(root: Path, pattern: str = "*.md") -> str: + if not root.exists(): + return _sha_bytes(b"") + h = hashlib.sha256() + for f in sorted(root.rglob(pattern)): + if f.is_file(): + h.update(f.relative_to(root).as_posix().encode()) + h.update(b"\0") + h.update(f.read_bytes()) + h.update(b"\0") + return "sha256:" + h.hexdigest() + + +def _uuid7() -> str: + """RFC 9562 UUID v7 (time-ordered) -- required by agent-manifest.""" + ms = int(time.time() * 1000) + b = bytearray(ms.to_bytes(6, "big") + os.urandom(10)) + b[6] = 0x70 | (b[6] & 0x0F) + b[8] = 0x80 | (b[8] & 0x3F) + return str(uuid.UUID(bytes=bytes(b))) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +# --------------------------------------------------------------------------- # +# snapshot: read the real box (stdlib only) +# --------------------------------------------------------------------------- # +def _skills() -> dict[str, str]: + out: dict[str, str] = {} + sdir = CLAUDE_HOME / "skills" + if sdir.exists(): + for d in sorted(sdir.iterdir()): + sk = d / "SKILL.md" + if sk.is_file(): + out[d.name] = _sha_file(sk) + return out + + +def _policy() -> tuple[str, list[str]]: + settings = CLAUDE_HOME / "settings.json" + if not settings.exists(): + return _sha_bytes(b"{}"), [] + data = json.loads(settings.read_text(encoding="utf-8")) + allow = data.get("permissions", {}).get("allow", []) + return _sha_file(settings), allow + + +def _mcp_from_config() -> list[str]: + """MCP server names declared on disk (global + per-project). Names only.""" + servers: set[str] = set() + cfg = Path(os.path.expanduser("~")) / ".claude.json" + if cfg.exists(): + try: + data = json.loads(cfg.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return [] + servers.update((data.get("mcpServers") or {}).keys()) + for proj in (data.get("projects") or {}).values(): + servers.update((proj.get("mcpServers") or {}).keys()) + return sorted(servers) + + +def _identity() -> str: + host = os.environ.get("COMPUTERNAME") or os.environ.get("HOSTNAME") or "localhost" + user = os.environ.get("USERNAME") or os.environ.get("USER") or "user" + return f"spiffe://claude-code.local/{user}/{host}".lower() + + +def snapshot(live: dict | None = None) -> dict: + """Capture the agent's composition. `live` carries session facts the agent + knows at runtime (model, tool roster, connected MCP servers) that a shell + hook cannot see; merged in when present.""" + live = live or {} + skills = _skills() + policy_hash, allow = _policy() + prompt_hash = _sha_tree(CLAUDE_HOME / "projects") # instruction / memory layer + + # Skills, permissions, and the instruction layer are observable from disk in + # any context (including the shell hook). The live tool roster and connected + # MCP servers are runtime state only the agent can report; they are recorded + # -- and diffed -- only when a live context supplies them. `observed` marks + # which categories this snapshot actually measured, so a disk-only hook + # snapshot is never diffed against the live categories of a richer baseline. + observed = ["skills", "policy", "prompt"] + mcp_live = live.get("mcp_servers") + mcp = mcp_live if mcp_live is not None else _mcp_from_config() + builtin = live.get("builtin_tools") or [] + tools = sorted(builtin) + [f"mcp:{s}" for s in sorted(mcp)] + if mcp_live is not None: + observed.append("mcp") + if builtin: + observed.append("tools") + + return { + "captured_at": _now_iso(), + "observed": observed, + "agent_id": _identity(), + "model": { + "provider": live.get("model_provider", "anthropic"), + "model_id": live.get("model_id", "unknown"), + "version": live.get("model_version", "unknown"), + "capability_level": live.get("capability_level"), + }, + "skills": skills, + "policy_hash": policy_hash, + "allow_rules": allow, + "prompt_hash": prompt_hash, + "mcp_servers": mcp, + "tools": tools, + "data_class": live.get("data_class", "internal"), + "hashes": { + "system_prompt": prompt_hash, + "policy_bundle": policy_hash, + "skills_set": _sha_bytes(json.dumps(skills, sort_keys=True).encode()), + "tool_catalog": _sha_bytes(json.dumps(tools, sort_keys=True).encode()), + }, + } + + +# --------------------------------------------------------------------------- # +# diff: nothing added, nothing subtracted +# --------------------------------------------------------------------------- # +def diff(base: dict, cur: dict) -> list[dict]: + """Return a list of {change, what, detail}, change in {added,removed,changed}. + + Only categories BOTH snapshots observed are compared, so a disk-only hook + snapshot never reports the live tool roster of a richer baseline as removed. + """ + out: list[dict] = [] + obs = set(base.get("observed", ["skills", "policy", "prompt"])) & set( + cur.get("observed", ["skills", "policy", "prompt"]) + ) + + if "prompt" in obs and base["hashes"].get("system_prompt") != cur["hashes"].get("system_prompt"): + out.append({"change": "changed", "what": "instruction layer", "detail": "system_prompt"}) + if "policy" in obs and base["hashes"].get("policy_bundle") != cur["hashes"].get("policy_bundle"): + out.append({"change": "changed", "what": "permissions", "detail": "policy_bundle"}) + + if "skills" in obs: + b_sk, c_sk = base.get("skills", {}), cur.get("skills", {}) + for name in sorted(set(c_sk) - set(b_sk)): + out.append({"change": "added", "what": "skill", "detail": name}) + for name in sorted(set(b_sk) - set(c_sk)): + out.append({"change": "removed", "what": "skill", "detail": name}) + for name in sorted(set(b_sk) & set(c_sk)): + if b_sk[name] != c_sk[name]: + out.append({"change": "changed", "what": "skill", "detail": name}) + + if "mcp" in obs: + b_mcp, c_mcp = set(base.get("mcp_servers", [])), set(cur.get("mcp_servers", [])) + for s in sorted(c_mcp - b_mcp): + out.append({"change": "added", "what": "MCP server", "detail": s}) + for s in sorted(b_mcp - c_mcp): + out.append({"change": "removed", "what": "MCP server", "detail": s}) + + if "tools" in obs: + b_t, c_t = set(base.get("tools", [])), set(cur.get("tools", [])) + for t in sorted(c_t - b_t): + out.append({"change": "added", "what": "tool", "detail": t}) + for t in sorted(b_t - c_t): + out.append({"change": "removed", "what": "tool", "detail": t}) + return out + + +# --------------------------------------------------------------------------- # +# signed records (lazy imports -- only when generating a report) +# --------------------------------------------------------------------------- # +def build_manifest(cur: dict) -> dict: + now = _now_iso() + expires = (datetime.now(timezone.utc) + timedelta(days=90)).isoformat( + timespec="seconds" + ).replace("+00:00", "Z") + m = cur["model"] + tools = [ + { + "tool_id": (f"claude-code.mcp.{t[4:]}" if t.startswith("mcp:") + else f"claude-code.builtin.{t}"), + "tool_name": t, + "endpoint_id": (f"spiffe://claude-code.local/mcp/{t[4:]}".lower() + if t.startswith("mcp:") else "spiffe://claude-code.local/host"), + "schema_hash": _sha_bytes(t.encode()), + "description_hash": _sha_bytes(("desc:" + t).encode()), + "version": "1.0.0", + "egress_destinations": ["claude.ai"] if t.startswith("mcp:") else [], + } + for t in cur["tools"] + ] + return { + "@context": "https://agentmanifest.agentrust.io/v0.1/context.json", + "@type": "AgentManifest", + "manifest_id": _uuid7(), + "agent_id": cur["agent_id"], + "version": "0.1", + "issued_at": now, + "expires_at": expires, + "issuer": "spiffe://claude-code.local/self-signed", + "crypto_profile": "standard", + "artifacts": { + "system_prompt": { + "hash": cur["hashes"]["system_prompt"], + "hash_algorithm": "SHA-256", + "version": "1.0.0", + "classification": "internal", + "bound_at": now, + }, + "policy_bundle": { + # policy_language has no host-native value for Claude Code + # permissions; modelled as composite. See README "Known gaps". + "hash": cur["hashes"]["policy_bundle"], + "policy_language": "composite", + "version": "1.0.0", + "enforcement_mode": "enforce", + "scope": cur["allow_rules"][:32] or ["claude-code:permissions"], + "agt_version": "0.0.0-claude-code", + "bound_at": now, + }, + "tool_manifest": { + "catalog_hash": cur["hashes"]["tool_catalog"], + "tools": tools, + "allow_dynamic_registration": True, + "rug_pull_policy": "deny-and-alert", + "bound_at": now, + }, + "model_identity": { + "provider": m["provider"], + "model_id": m["model_id"], + "version": m["version"], + "capability_level": m.get("capability_level"), + "deployment_type": "api", + "model_attestation_type": "provider-asserted", + "bound_at": now, + }, + }, + } + + +def build_trace(cur: dict) -> dict: + return { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": int(time.time()), + "subject": cur["agent_id"], + "model": {k: cur["model"][k] for k in ("provider", "model_id", "version")}, + "runtime": {"platform": "software-only", "measurement": "sha256:" + "0" * 64}, + "policy": {"bundle_hash": cur["hashes"]["policy_bundle"], "enforcement_mode": "enforce"}, + "data_class": cur["data_class"], + "build_provenance": {"slsa_level": 0, "digest": cur["hashes"]["tool_catalog"]}, + "appraisal": {"status": "none", "verifier": "https://claude-code.local"}, + "transparency": "https://registry.agentrust.io/claim/placeholder", + } + + +def sign_all(cur: dict, outdir: Path) -> tuple[dict, dict]: + from agent_manifest import Ed25519Signer, Ed25519Verifier, Manifest, generate_ed25519 + from agentrust_trace import generate_key, sign_record + + manifest = build_manifest(cur) + Manifest.model_validate(manifest) + kp = generate_ed25519() + manifest["signature"] = Ed25519Signer(kp).sign(manifest) + Ed25519Verifier(kp.public_bytes).verify(manifest, manifest["signature"]["signature_value"]) + + trace = sign_record(build_trace(cur), generate_key()) + + outdir.mkdir(parents=True, exist_ok=True) + (outdir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + (outdir / "trace.json").write_text(json.dumps(trace, indent=2), encoding="utf-8") + return manifest, trace + + +# --------------------------------------------------------------------------- # +# report rendering +# --------------------------------------------------------------------------- # +def render_report(cur: dict, changes: list[dict] | None, signed: bool) -> str: + m = cur["model"] + n_builtin = len([t for t in cur["tools"] if not t.startswith("mcp:")]) + L = ["=" * 66, + " AGENT INTEGRITY REPORT -- your Claude Code session", + "=" * 66, "", + f" Agent identity : {cur['agent_id']}", + f" Model : {m['provider']}/{m['model_id']} {m['version']}", + f" Captured : {cur['captured_at']}", "", + " WHAT THIS AGENT IS (agent-manifest -- signed composition)", + " " + "-" * 62, + f" Skills loaded : {len(cur['skills'])} ({', '.join(cur['skills']) or 'none'})", + f" Tools exposed : {n_builtin} built-in + {len(cur['mcp_servers'])} MCP server(s)", + f" MCP servers : {', '.join(cur['mcp_servers']) or 'none on disk'}", + f" Permissions : {len(cur['allow_rules'])} allow-rule(s), enforce mode", "", + " Fingerprints (change here == your agent changed):", + f" instruction layer : {cur['hashes']['system_prompt'][:23]}...", + f" permissions : {cur['hashes']['policy_bundle'][:23]}...", + f" skills set : {cur['hashes']['skills_set'][:23]}...", + f" tool catalog : {cur['hashes']['tool_catalog'][:23]}...", ""] + if changes is not None: + L += [" NOTHING ADDED, NOTHING SUBTRACTED? (vs approved baseline)", + " " + "-" * 62] + if not changes: + L.append(" >> Verified: nothing added, nothing subtracted.") + else: + sym = {"added": "+", "removed": "-", "changed": "~"} + for c in changes: + L.append(f" {sym[c['change']]} {c['change'].upper()} {c['what']}: {c['detail']}") + L.append(f" >> {len(changes)} change(s) since baseline. Review above.") + L.append("") + if signed: + L += [" Signed records written: manifest.json (agent-manifest, verified),", + " trace.json (TRACE Level 0, software-only).", ""] + L.append("=" * 66) + return "\n".join(L) + + +# --------------------------------------------------------------------------- # +# state helpers +# --------------------------------------------------------------------------- # +def _save(path: Path, obj: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(obj, indent=2), encoding="utf-8") + + +def _load(path: Path) -> dict | None: + return json.loads(path.read_text(encoding="utf-8")) if path.exists() else None + + +def _live_from(args) -> dict | None: + if args.live_context: + return json.loads(Path(args.live_context).read_text(encoding="utf-8")) + return None + + +# --------------------------------------------------------------------------- # +# subcommands +# --------------------------------------------------------------------------- # +def cmd_snapshot(args) -> int: + snap = snapshot(_live_from(args)) + _save(LATEST, snap) + print(json.dumps(snap, indent=2) if args.json else render_report(snap, None, False)) + return 0 + + +def cmd_hook(args) -> int: + """SessionStart entrypoint. Reads the hook payload on stdin, snapshots, + checks drift against the baseline, and emits SessionStart context.""" + try: + payload = json.load(sys.stdin) if not sys.stdin.isatty() else {} + except (json.JSONDecodeError, ValueError): + payload = {} + live = {k: payload[k] for k in ("model_id", "model_provider", "model_version") if k in payload} + snap = snapshot(live or None) + _save(LATEST, snap) + + base = _load(BASELINE) + if base is None: + _save(BASELINE, snap) + msg = ("AgenTrust: baseline established for this Claude agent " + f"({len(snap['skills'])} skills, {len(snap['mcp_servers'])} MCP on disk). " + "Future sessions are checked against it. Run /manifest approve to re-baseline.") + else: + changes = diff(base, snap) + if not changes: + msg = "AgenTrust: agent composition unchanged since your approved baseline (nothing added, nothing subtracted)." + else: + detail = "; ".join(f"{c['change']} {c['what']} {c['detail']}" for c in changes[:8]) + msg = (f"AgenTrust WARNING: {len(changes)} change(s) to your agent since baseline: " + f"{detail}. Run /manifest verify for detail, or /manifest approve to accept.") + out = {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": msg}} + print(json.dumps(out)) + return 0 + + +def cmd_verify(args) -> int: + base = _load(BASELINE) + snap = _load(LATEST) or snapshot(_live_from(args)) + if base is None: + print("No approved baseline yet. Run /manifest approve to establish one.") + return 0 + print(render_report(snap, diff(base, snap), False)) + return 0 + + +def cmd_approve(args) -> int: + snap = snapshot(_live_from(args)) + _save(LATEST, snap) + _save(BASELINE, snap) + print(render_report(snap, [], False)) + print(f"\nApproved baseline updated: {BASELINE}") + if args.sign: + _, _ = sign_all(snap, Path(args.out)) + print(f"Signed manifest + trace written to {args.out}") + return 0 + + +def cmd_report(args) -> int: + snap = snapshot(_live_from(args)) + _save(LATEST, snap) + base = _load(BASELINE) + changes = diff(base, snap) if base else None + sign_all(snap, Path(args.out)) + print(render_report(snap, changes, True)) + print(f"\nwrote {args.out}/manifest.json {args.out}/trace.json") + return 0 + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(prog="agentrust-capture", description=__doc__) + sub = ap.add_subparsers(dest="cmd", required=True) + for name in ("snapshot", "hook", "verify", "approve", "report"): + p = sub.add_parser(name) + p.add_argument("--live-context", help="JSON of live session facts (model, tools, mcp)") + p.add_argument("--out", default=".", help="output dir for signed records") + p.add_argument("--json", action="store_true", help="emit raw JSON snapshot") + p.add_argument("--sign", action="store_true", help="(approve) also write signed records") + args = ap.parse_args(argv) + return { + "snapshot": cmd_snapshot, "hook": cmd_hook, "verify": cmd_verify, + "approve": cmd_approve, "report": cmd_report, + }[args.cmd](args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-code/hooks/hooks.json b/claude-code/hooks/hooks.json new file mode 100644 index 0000000..d9a2184 --- /dev/null +++ b/claude-code/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "description": "AgenTrust agent-integrity hook for Claude Code", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "python \"${CLAUDE_PLUGIN_ROOT}/engine/capture.py\" hook", + "timeout": 20 + } + ] + } + ] + } +} diff --git a/claude-code/integration.yaml b/claude-code/integration.yaml new file mode 100644 index 0000000..80f5ffa --- /dev/null +++ b/claude-code/integration.yaml @@ -0,0 +1,15 @@ +name: claude-code +vendor: agentrust-io +integrates_with: + - agent-manifest + - trace +description: Claude Code plugin that emits a signed Agent Manifest and TRACE record per session and detects agent composition drift from an approved baseline. +maintainer: + github: agentrust-io +repository: https://github.com/agentrust-io/integrations +license: Apache-2.0 +tier: community +trace_conformance_level: 0 +tested_against: + agent-manifest: 0.3.0 + agentrust-trace: 0.3.0 diff --git a/claude-code/requirements.txt b/claude-code/requirements.txt new file mode 100644 index 0000000..1901217 --- /dev/null +++ b/claude-code/requirements.txt @@ -0,0 +1,12 @@ +# The SessionStart hook and drift check (snapshot / verify / approve) use only +# the Python standard library -- nothing here is needed for them. +# +# These are required only to generate SIGNED records (/trace, /manifest approve +# --sign): the Agent Manifest and the TRACE Trust Record. +# +# NOTE: the manifest builder targets agent-manifest 0.3.x (repo). PyPI currently +# publishes 0.2.0; 0.3.x is not yet released there. Until it is, install from the +# agent-manifest repo. See README "Known gaps". +agent-manifest>=0.2 +agentrust-trace>=0.3 +agentrust-trace-tests>=0.2 diff --git a/claude-code/tests/test_capture.py b/claude-code/tests/test_capture.py new file mode 100644 index 0000000..addd8ca --- /dev/null +++ b/claude-code/tests/test_capture.py @@ -0,0 +1,70 @@ +"""Tests for the AgenTrust Claude Code capture engine. + +Covers the stdlib-only path (snapshot / diff / observed-category scoping) with no +network or signing dependencies, so it runs in CI without the crypto packages. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "engine")) + +import capture # noqa: E402 + + +def _base(**over): + snap = { + "observed": ["skills", "policy", "prompt", "mcp", "tools"], + "skills": {"trace": "sha256:" + "a" * 64}, + "mcp_servers": ["Slack"], + "tools": ["Bash", "mcp:Slack"], + "hashes": {"system_prompt": "sha256:" + "1" * 64, "policy_bundle": "sha256:" + "2" * 64}, + } + snap.update(over) + return snap + + +def test_identical_snapshots_have_no_diff(): + assert capture.diff(_base(), _base()) == [] + + +def test_added_skill_is_detected(): + cur = _base(skills={"trace": "sha256:" + "a" * 64, "rogue": "sha256:" + "b" * 64}) + out = capture.diff(_base(), cur) + assert {"change": "added", "what": "skill", "detail": "rogue"} in out + + +def test_changed_permissions_detected(): + cur = _base(hashes={"system_prompt": "sha256:" + "1" * 64, "policy_bundle": "sha256:" + "9" * 64}) + out = capture.diff(_base(), cur) + assert any(c["what"] == "permissions" and c["change"] == "changed" for c in out) + + +def test_added_mcp_server_detected(): + cur = _base(mcp_servers=["Slack", "ShadowExfil"], tools=["Bash", "mcp:Slack", "mcp:ShadowExfil"]) + out = capture.diff(_base(), cur) + assert {"change": "added", "what": "MCP server", "detail": "ShadowExfil"} in out + + +def test_disk_only_snapshot_does_not_flag_unobserved_live_roster(): + """A hook snapshot (no tools/mcp observed) must not report the baseline's + live roster as removed -- only skills/policy/prompt are comparable.""" + baseline = _base() # observed everything, has tools + mcp + hook_snap = { + "observed": ["skills", "policy", "prompt"], + "skills": {"trace": "sha256:" + "a" * 64}, + "mcp_servers": [], + "tools": [], + "hashes": {"system_prompt": "sha256:" + "1" * 64, "policy_bundle": "sha256:" + "2" * 64}, + } + assert capture.diff(baseline, hook_snap) == [] + + +def test_snapshot_shape_from_real_home(tmp_path, monkeypatch): + """snapshot() runs and returns the documented keys.""" + snap = capture.snapshot({"model_id": "claude-x", "builtin_tools": ["Bash"], "mcp_servers": ["Slack"]}) + for key in ("agent_id", "observed", "skills", "hashes", "tools", "model"): + assert key in snap + assert "tools" in snap["observed"] and "mcp" in snap["observed"] + assert snap["hashes"]["tool_catalog"].startswith("sha256:")