Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions claude-code/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ __pycache__/
# local capture artifacts
manifest.json
trace.json
verification_key.json
signing_key.json
live.json
35 changes: 28 additions & 7 deletions claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,25 +124,46 @@ 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:
When you run `/trace` (or `/manifest approve --sign`), the plugin writes three
files:

- an **Agent Manifest**: what your agent *is*, the full composition above, each
part fingerprinted and Ed25519-signed
- **`manifest.json`**, 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.json`**, 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)).
- **`verification_key.json`**, the public key for the manifest.

These are shareable proof a third party can verify without trusting your machine.
Confirm a record with:

The manifest is signed with a **persistent** key kept at
`~/.claude/agentrust/signing_key.json`. It is generated once, reused every run,
and its private half never leaves your machine, so every record you produce
carries the same signing identity. `verification_key.json` publishes only the
public half (`{key_id, public_key_b64url}`).

Confirm the TRACE record against the conformance suite:

```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
```

Verify the Agent Manifest on any machine, using only the published public key:

```python
import json
from agent_manifest import VerificationContext, verify_manifest, RevocationStore

manifest = json.load(open("manifest.json"))
vk = json.load(open("verification_key.json"))
ctx = VerificationContext(trusted_keys={vk["key_id"]: vk["public_key_b64url"]})
result = verify_manifest(manifest, ctx, RevocationStore())
print(result.result, result.signature_verified) # VALID True (any tampering -> MISMATCH False)
```

## Known limits (read before relying on it)

This plugin is honest about what it is. On a normal developer machine:
Expand Down
6 changes: 6 additions & 0 deletions claude-code/commands/trace.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ Steps:
actually connected now).
3. Run
`python "${CLAUDE_PLUGIN_ROOT}/engine/capture.py" report --live-context live.json --out .`
This writes `manifest.json`, `trace.json`, and `verification_key.json` (the
public key a third party uses to verify the manifest). The manifest is signed
with the persistent key at `~/.claude/agentrust/signing_key.json`; the private
half never leaves the machine.
4. Optionally confirm the TRACE record passes the suite:
`trace-tests verify --record trace.json --level 0` (or
`python -m trace_tests.cli verify --record trace.json --level 0` if the
Expand All @@ -27,6 +31,8 @@ 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: the TRACE record, software-only and Level 0 on a dev box.
- whether anything changed since their approved baseline.
- that the records are shareable: `manifest.json` verifies on any machine with
`verification_key.json`, and `trace.json` passes the public conformance suite.

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
Expand Down
69 changes: 66 additions & 3 deletions claude-code/engine/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@
from __future__ import annotations

import argparse
import base64
import hashlib
import json
import os
import stat
import sys
import time
import uuid
Expand All @@ -43,6 +45,7 @@
STATE_DIR = CLAUDE_HOME / "agentrust"
BASELINE = STATE_DIR / "baseline.json"
LATEST = STATE_DIR / "session-latest.json"
SIGNING_KEY = STATE_DIR / "signing_key.json"


# --------------------------------------------------------------------------- #
Expand Down Expand Up @@ -349,7 +352,7 @@ def build_trace(cur: dict) -> dict:

def sign_all(cur: dict, outdir: Path) -> tuple[dict, dict]:
try:
from agent_manifest import Ed25519Signer, Ed25519Verifier, Manifest, generate_ed25519
from agent_manifest import Ed25519Signer, Ed25519Verifier, Manifest
from agentrust_trace import generate_key, sign_record
except ImportError as e:
raise SystemExit(
Expand All @@ -359,9 +362,10 @@ def sign_all(cur: dict, outdir: Path) -> tuple[dict, dict]:
"approve without --sign) does not need them."
)

kp = _load_or_create_manifest_keypair()

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"])

Expand All @@ -370,9 +374,66 @@ def sign_all(cur: dict, outdir: Path) -> tuple[dict, dict]:
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")
# Publish the public key so a third party can verify manifest.json without
# trusting this machine: load it into the verifier's trusted_keys as
# {key_id: public_key_b64url}. The private key never leaves ~/.claude.
verification_key = {
"algorithm": "Ed25519",
"key_id": kp.key_id,
"public_key_b64url": kp.public_b64url(),
"note": "load as {key_id: public_key_b64url} into the verifier's trusted_keys",
}
(outdir / "verification_key.json").write_text(
json.dumps(verification_key, indent=2), encoding="utf-8"
)
return manifest, trace


def _load_or_create_manifest_keypair():
"""Return a stable Ed25519 keypair, persisted at ~/.claude/agentrust.

Signing the Agent Manifest with a fresh key every run would make the
signature unverifiable (nobody has the public half) and give the agent a
different identity each session. Instead we persist one key and publish its
public half beside each record, so manifest.json is genuinely third-party
verifiable and records chain to a single identity. The private key stays
local, readable only by the owner where the OS supports it.
"""
from agent_manifest import Ed25519KeyPair, generate_ed25519
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

existing = _load(SIGNING_KEY)
if existing and isinstance(existing.get("private_b64url"), str):
try:
pad = "=" * (-len(existing["private_b64url"]) % 4)
raw = base64.urlsafe_b64decode(existing["private_b64url"] + pad)
priv = Ed25519PrivateKey.from_private_bytes(raw)
return Ed25519KeyPair(priv, priv.public_key())
except (ValueError, TypeError):
pass # corrupt key file: fall through and mint a fresh one

kp = generate_ed25519()
SIGNING_KEY.parent.mkdir(parents=True, exist_ok=True)
SIGNING_KEY.write_text(
json.dumps(
{
"algorithm": "Ed25519",
"key_id": kp.key_id,
"public_key_b64url": kp.public_b64url(),
"private_b64url": kp.private_b64url(),
"created_at": _now_iso(),
},
indent=2,
),
encoding="utf-8",
)
try: # best-effort owner-only permissions (no-op on filesystems without it)
os.chmod(SIGNING_KEY, stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
return kp


# --------------------------------------------------------------------------- #
# report rendering
# --------------------------------------------------------------------------- #
Expand Down Expand Up @@ -409,7 +470,9 @@ def render_report(cur: dict, changes: list[dict] | None, signed: bool) -> str:
L.append("")
if signed:
L += [" Signed records written: manifest.json (agent-manifest, verified),",
" trace.json (TRACE Level 0, software-only).", ""]
" trace.json (TRACE Level 0, software-only),",
" verification_key.json (public key to verify",
" manifest.json on another machine).", ""]
L.append("=" * 66)
return "\n".join(L)

Expand Down
47 changes: 47 additions & 0 deletions claude-code/tests/test_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import sys
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "engine"))

import capture # noqa: E402
Expand Down Expand Up @@ -188,3 +190,48 @@ def boom(*a, **k):
payload = json.loads(out)
assert payload["hookSpecificOutput"]["hookEventName"] == "SessionStart"
assert "integrity check skipped" in payload["hookSpecificOutput"]["additionalContext"]


# ---------------------------------------------------------------------------
# Signing: persistent key + externally-verifiable, tamper-evident manifest.
# These need the crypto packages; skipped cleanly where they are absent.
# ---------------------------------------------------------------------------
def _point_signing_key(tmp_path, monkeypatch):
monkeypatch.setattr(capture, "STATE_DIR", tmp_path / "agentrust")
monkeypatch.setattr(capture, "SIGNING_KEY", tmp_path / "agentrust" / "signing_key.json")


def test_signing_key_is_persisted_and_stable(tmp_path, monkeypatch):
pytest.importorskip("agent_manifest")
pytest.importorskip("cryptography")
_point_signing_key(tmp_path, monkeypatch)

kp1 = capture._load_or_create_manifest_keypair()
assert capture.SIGNING_KEY.is_file()
kp2 = capture._load_or_create_manifest_keypair() # second run must reuse it
assert kp1.key_id == kp2.key_id


def test_manifest_is_externally_verifiable_and_tamper_evident(tmp_path, monkeypatch):
pytest.importorskip("agent_manifest")
pytest.importorskip("cryptography")
from agent_manifest import RevocationStore, VerificationContext, verify_manifest

_point_signing_key(tmp_path, monkeypatch)
out = tmp_path / "records"
cur = capture.snapshot({"model_id": "claude-x", "builtin_tools": ["Bash"], "mcp_servers": ["github"]})
manifest, _trace = capture.sign_all(cur, out)

vk = json.loads((out / "verification_key.json").read_text(encoding="utf-8"))
assert vk["key_id"] == manifest["signature"]["key_id"]
ctx = VerificationContext(trusted_keys={vk["key_id"]: vk["public_key_b64url"]})

# a third party with only the published public key verifies the manifest
good = verify_manifest(manifest, ctx, RevocationStore())
assert good.signature_verified is True

# any post-signing change breaks verification
tampered = json.loads(json.dumps(manifest))
tampered["artifacts"]["policy_bundle"]["hash"] = "sha256:" + "0" * 64
bad = verify_manifest(tampered, ctx, RevocationStore())
assert bad.signature_verified is False