diff --git a/claude-code/README.md b/claude-code/README.md index 15053d8..3bfddcf 100644 --- a/claude-code/README.md +++ b/claude-code/README.md @@ -1,77 +1,166 @@ # AgenTrust for Claude Code -Agent-integrity for the coding agent you already use. This plugin captures, every -session: +**Know your coding agent hasn't changed behind your back.** -- 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)). +Claude Code is not just a model. It is a model plus everything you have wired +around it: skills in `~/.claude/skills`, an allow/deny permission policy, MCP +servers, your `CLAUDE.md` and memory, and the tools it can call. That whole +composition decides what your agent can do to your machine and your code. Any of +it can change without you noticing. -and answers the one question that matters on a developer box: +This plugin answers one question at the start of every session: -> **Is the agent I'm running the one I approved — nothing added, nothing subtracted?** +> **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. +## Why this matters -## What it does +You approved a setup you trust. Then, quietly, things drift: -| 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 | +- A skill you installed ships an update that now runs `curl` to an address you + never saw. +- A dependency's postinstall drops a `SKILL.md` into `~/.claude/skills`. +- A permission gets widened from `Bash(git:*)` to `Bash(*)` during some debugging + session and never gets narrowed back. +- An MCP server you added for one task is still connected weeks later. +- Your `CLAUDE.md` picks up an instruction you didn't write. -The hook is deliberately dependency-free so it never blocks session start. Signing -runs only when you ask for records. +None of these announce themselves. Each one changes what your agent will do on +your next run. This plugin fingerprints the whole composition, stores an approved +baseline, and tells you at session start the moment any of it moves. It is the +difference between "I think my agent is what I set up" and "I can prove it, and +I'd know within one session if it wasn't." -## Install +## Quickstart (about 60 seconds) ```bash -# 1. plugin (hooks + commands) +# 1. add the marketplace and install the plugin (hooks + commands) /plugin marketplace add agentrust-io/integrations /plugin install agentrust-claude-code +``` + +That's the whole install for drift detection. The SessionStart hook is +dependency-free (Python standard library only), so it never blocks a session. + +On your **first** session after install, it records your baseline and tells you: + +``` +AgenTrust: baseline established for this Claude agent (7 skills, 2 MCP on disk). +Future sessions are checked against it. Run /manifest approve to re-baseline. +``` + +The baseline lives at `~/.claude/agentrust/baseline.json`. From then on, every +session is checked against it. + +Signed records (`/trace` and `/manifest approve --sign`) are the only feature +that needs crypto packages. Install them when you want them: -# 2. only for signed records (/trace, /manifest approve --sign) +```bash 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. +## The everyday loop + +You mostly do nothing. You install it, and it stays quiet until something +changes. When it does, one line shows up at session start: + +``` +AgenTrust WARNING: 1 change(s) to your agent since baseline: added skill +pypi-helper. Run /manifest verify for detail, or /manifest approve to accept. +``` + +Two responses, both one command: -## What it captures (and what it does not) +**If the change is a surprise**, look at it. `/manifest verify` re-reads your +setup right now and lays out exactly what moved: -Captured, by fingerprint — never raw content, never secrets: +``` + NOTHING ADDED, NOTHING SUBTRACTED? (vs approved baseline) + -------------------------------------------------------------- + ~ CHANGED permissions: policy_bundle + + ADDED skill: exfil + >> 2 change(s) since baseline. Review above. +``` -- **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 +Now you decide with the facts in front of you: remove the rogue skill, narrow the +permission, or accept it. -It never reads `~/.claude/.credentials.json`, and records skill / tool / MCP -**names** only, never tokens or environment values. +**If you made the change on purpose** (installed a skill you wanted, added an MCP +server for real work), tell the plugin this is the new normal: -## Known gaps (read before relying on it) +``` +/manifest approve +``` -- **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. +That promotes your current setup to the approved baseline. The warnings stop +until something moves again. + +## Commands + +| Command | What it does | Needs crypto packages? | +|---|---|---| +| SessionStart hook | Snapshot the agent from disk, diff against your approved baseline, warn in-session on drift | No | +| `/manifest verify` | Re-snapshot now and show the full diff, including the live tool and MCP roster the agent reports this session | No | +| `/manifest approve` | Make the current composition the approved baseline | No (add `--sign` for records) | +| `/manifest show` | Show the current composition without touching the baseline | No | +| `/trace` | Build and sign the Agent Manifest and TRACE record for this session, explained in plain English | Yes | + +`/manifest verify` always re-reads your setup fresh, so it catches drift that +happens partway through a session, not just at startup. + +## What it captures, and what it does not + +It records **fingerprints, never raw content, never secrets:** + +- **skills**: each `~/.claude/skills/*/SKILL.md` +- **permissions**: `~/.claude/settings.json` (the allow/deny policy) +- **instruction layer**: your `CLAUDE.md` and memory tree +- **tools and MCP servers**: the roster the agent reports, by name only +- **model**: provider, id, version + +It never reads `~/.claude/.credentials.json`. It records skill, tool, and MCP +**names** only, never tokens, never environment values, never file contents. +A changed fingerprint tells you *that* something changed and *which category*, +which is what you need to go look. + +## Signed records: Agent Manifest and TRACE + +When you run `/trace` (or `/manifest approve --sign`), the plugin writes two +signed JSON records: + +- an **Agent Manifest**: what your agent *is*, the full composition above, each + part fingerprinted and Ed25519-signed + ([agent-manifest](https://github.com/agentrust-io/agent-manifest)). +- a **TRACE Trust Record**: what your agent *did* this run, signed and checkable + against the public conformance suite + ([trace-spec](https://github.com/agentrust-io/trace-spec)). + +These are shareable proof a third party can verify without trusting your machine. +Confirm a record with: + +```bash +trace-tests verify --record trace.json --level 0 +# or, if the console script is not on PATH: +python -m trace_tests.cli verify --record trace.json --level 0 +``` + +## Known limits (read before relying on it) + +This plugin is honest about what it is. On a normal developer machine: + +- **Software-only, Level 0.** A dev box has no hardware TEE, so the TRACE record + is software integrity, not silicon-rooted attestation. It is labelled Level 0, + never presented as hardware-attested. +- **The instruction layer is a proxy.** The `system_prompt` fingerprint covers + your `CLAUDE.md` and memory tree, not Claude Code's internal system prompt, + which is not on disk. +- **`policy_language` is modelled as `composite`.** The agent-manifest + `policy_language` enum (`cedar` / `rego` / `yaml-agt` / `composite`) has no + value for host-native permission systems like Claude Code's `settings.json`. + A spec value for host-native permissions is proposed upstream. +- **The hook sees disk, commands see the session.** A shell hook cannot enumerate + the live tool roster, so the SessionStart check compares skills, permissions, + and the instruction layer. The full tool and MCP diff runs in `/manifest + verify`, where the agent supplies the live roster. ## Layout @@ -79,10 +168,15 @@ It never reads `~/.claude/.credentials.json`, and records skill / tool / MCP claude-code/ .claude-plugin/plugin.json plugin manifest hooks/hooks.json SessionStart -> engine/capture.py hook - commands/manifest.md /manifest capture | verify | approve + commands/manifest.md /manifest verify | approve | show 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 ``` + +## License + +Apache-2.0. Part of the [agentrust-io](https://github.com/agentrust-io) +open agent-governance toolchain. diff --git a/claude-code/commands/manifest.md b/claude-code/commands/manifest.md index 01ff9e8..ed65675 100644 --- a/claude-code/commands/manifest.md +++ b/claude-code/commands/manifest.md @@ -1,15 +1,20 @@ --- -description: Capture, verify, or approve the integrity baseline of your Claude Code agent +description: Check, approve, or show 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`. +You are running the AgenTrust agent-integrity command. It answers one question +for the user: is the agent they are running the one they approved, with nothing +added and nothing subtracted since their baseline? + +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 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`: +write the current session's real facts to a temp `live.json`. Use real values you +actually observe this session, never invented ones: ```json { @@ -24,13 +29,20 @@ write the current session's real facts to a temp `live.json`: 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. + `python "${CLAUDE_PLUGIN_ROOT}/engine/capture.py" verify --live-context live.json`. + Verify always re-reads the setup fresh and merges your `live.json`, so it + reflects the agent's state right now, including drift introduced partway + through this session. Show the result and explain any change in plain language, + then ask whether to approve it. +- `approve`: run + `python "${CLAUDE_PLUGIN_ROOT}/engine/capture.py" approve --live-context live.json --sign --out .` + to make the current composition the new approved baseline and write signed + records. Use this when the user confirms the changes are intentional. +- `show`: run + `python "${CLAUDE_PLUGIN_ROOT}/engine/capture.py" 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. +Report the result in plain English. Name the categories that changed (a skill, a +permission, an MCP server, the instruction layer) and what the user should do +about each. 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 index 316b351..bdd1879 100644 --- a/claude-code/commands/trace.md +++ b/claude-code/commands/trace.md @@ -9,22 +9,25 @@ session and explain it in plain English. Engine: 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). +1. Ensure the signing packages are installed (once). Prefer the pinned set: + `pip install -r "${CLAUDE_PLUGIN_ROOT}/requirements.txt"`. +2. Write this session's real facts to `live.json`. Use values you actually + observe, never invented ones: `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). + `python -m trace_tests.cli verify --record trace.json --level 0` if the + console 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), + +- what the agent IS: skills, tools, MCP, model, permissions, each fingerprinted. +- what it DID this run: the TRACE record, software-only and 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). +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 index bd2673f..acf4247 100644 --- a/claude-code/engine/capture.py +++ b/claude-code/engine/capture.py @@ -428,7 +428,13 @@ def cmd_hook(args) -> int: def cmd_verify(args) -> int: base = _load(BASELINE) - snap = _load(LATEST) or snapshot(_live_from(args)) + # Always re-snapshot so verify reflects the agent's CURRENT composition, + # not a cached session-latest.json. Otherwise drift introduced after + # session start (a skill dropped in, a widened permission) would be missed + # and verify would falsely report "nothing added, nothing subtracted". + # The live context supplied by the /manifest command is merged in here. + snap = snapshot(_live_from(args)) + _save(LATEST, snap) if base is None: print("No approved baseline yet. Run /manifest approve to establish one.") return 0 diff --git a/claude-code/tests/test_capture.py b/claude-code/tests/test_capture.py index addd8ca..7fc1a4a 100644 --- a/claude-code/tests/test_capture.py +++ b/claude-code/tests/test_capture.py @@ -68,3 +68,54 @@ def test_snapshot_shape_from_real_home(tmp_path, monkeypatch): assert key in snap assert "tools" in snap["observed"] and "mcp" in snap["observed"] assert snap["hashes"]["tool_catalog"].startswith("sha256:") + + +def _isolate_state(tmp_path, monkeypatch): + """Point the engine's baseline/latest state at a temp dir.""" + state = tmp_path / "agentrust" + monkeypatch.setattr(capture, "STATE_DIR", state) + monkeypatch.setattr(capture, "BASELINE", state / "baseline.json") + monkeypatch.setattr(capture, "LATEST", state / "session-latest.json") + + +class _Args: + live_context = None + out = "." + json = False + sign = False + + +def test_verify_detects_drift_introduced_after_baseline(tmp_path, monkeypatch, capsys): + """verify must re-snapshot, not trust a stale session-latest.json. + + Regression: verify used `_load(LATEST) or snapshot(...)`, so drift added + after session start (a rogue skill, a widened permission) was reported as + "nothing added, nothing subtracted" against the cached snapshot. + """ + _isolate_state(tmp_path, monkeypatch) + + renderable = { + "captured_at": "2026-01-01T00:00:00Z", + "agent_id": "spiffe://claude-code.local/dev/box", + "model": {"provider": "anthropic", "model_id": "claude-x", "version": "1"}, + "allow_rules": [], + "hashes": { + "system_prompt": "sha256:" + "1" * 64, "policy_bundle": "sha256:" + "2" * 64, + "skills_set": "sha256:" + "3" * 64, "tool_catalog": "sha256:" + "4" * 64, + }, + } + clean = _base(skills={"deploy": "sha256:" + "a" * 64}, **renderable) + capture._save(capture.BASELINE, clean) + # A stale latest from an earlier, clean point in the session. + capture._save(capture.LATEST, clean) + + # The agent has since drifted: a rogue skill appeared on disk. + drifted = _base(skills={"deploy": "sha256:" + "a" * 64, "exfil": "sha256:" + "b" * 64}, **renderable) + monkeypatch.setattr(capture, "snapshot", lambda live=None: drifted) + + assert capture.cmd_verify(_Args()) == 0 + out = capsys.readouterr().out + assert "ADDED skill: exfil" in out + assert "1 change(s) since baseline" in out + # the success line must NOT appear when drift is present + assert "Verified: nothing added, nothing subtracted" not in out