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
20 changes: 20 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "agentrust",
"description": "AgenTrust integrations for Claude Code: agent-integrity tooling built on Agent Manifest and TRACE.",
"owner": {
"name": "agentrust-io",
"url": "https://github.com/agentrust-io"
},
"plugins": [
{
"name": "agentrust-claude-code",
"source": "./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"
},
"license": "Apache-2.0"
}
]
}
127 changes: 103 additions & 24 deletions claude-code/engine/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,35 +88,73 @@ def _now_iso() -> str:
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"
# is_dir(), not exists(): tolerate ~/.claude/skills being a stray file.
if not sdir.is_dir():
return out
try:
entries = sorted(sdir.iterdir())
except OSError:
return out
for d in entries:
sk = d / "SKILL.md"
try:
if sk.is_file():
out[d.name] = _sha_file(sk)
except OSError:
continue # unreadable skill file: skip it, never crash the hook
return out


def _server_names(mapping: object) -> list[str]:
"""Keys of a config mapping, or [] if it is not a dict (malformed config)."""
return list(mapping.keys()) if isinstance(mapping, dict) else []


def _policy() -> tuple[str, list[str]]:
settings = CLAUDE_HOME / "settings.json"
if not settings.exists():
if not settings.is_file():
return _sha_bytes(b"{}"), []
data = json.loads(settings.read_text(encoding="utf-8"))
allow = data.get("permissions", {}).get("allow", [])
return _sha_file(settings), allow
# Always hash the real file bytes so a hand-edit is detected as drift, even
# if the JSON is malformed. Parsing the allow-list is best-effort: bad JSON
# or an unexpected shape yields an empty list rather than crashing the hook.
try:
policy_hash = _sha_file(settings)
except OSError:
return _sha_bytes(b"{}"), []
allow: list[str] = []
try:
data = json.loads(settings.read_text(encoding="utf-8"))
if isinstance(data, dict):
perms = data.get("permissions")
if isinstance(perms, dict) and isinstance(perms.get("allow"), list):
allow = perms["allow"]
except (json.JSONDecodeError, OSError):
pass
return policy_hash, allow


def _mcp_from_config() -> list[str]:
"""MCP server names declared on disk (global + per-project). Names only."""
"""MCP server names declared on disk (global + per-project). Names only.

Best-effort: a missing, unreadable, malformed, or unexpectedly-shaped
``~/.claude.json`` yields an empty list, never an exception.
"""
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())
if not cfg.is_file():
return []
try:
data = json.loads(cfg.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return []
if not isinstance(data, dict):
return []
servers.update(_server_names(data.get("mcpServers")))
projects = data.get("projects")
if isinstance(projects, dict):
for proj in projects.values():
if isinstance(proj, dict):
servers.update(_server_names(proj.get("mcpServers")))
return sorted(servers)


Expand Down Expand Up @@ -310,8 +348,16 @@ def build_trace(cur: dict) -> dict:


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
try:
from agent_manifest import Ed25519Signer, Ed25519Verifier, Manifest, generate_ed25519
from agentrust_trace import generate_key, sign_record
except ImportError as e:
raise SystemExit(
"Signing needs the crypto packages, which are not installed. Run:\n"
" pip install -r requirements.txt\n"
f"(missing module: {e.name}). Drift detection (snapshot / verify / "
"approve without --sign) does not need them."
)

manifest = build_manifest(cur)
Manifest.model_validate(manifest)
Expand Down Expand Up @@ -377,7 +423,19 @@ def _save(path: Path, obj: dict) -> None:


def _load(path: Path) -> dict | None:
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else None
"""Load a state file, or None if it is absent, unreadable, or corrupt.

A truncated baseline.json (crash mid-write, disk full, racing sessions) must
not brick the hook on every future session. Treating corrupt state as absent
lets the next run re-establish it instead of crashing forever.
"""
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
return data if isinstance(data, dict) else None


def _live_from(args) -> dict | None:
Expand All @@ -396,13 +454,18 @@ def cmd_snapshot(args) -> int:
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."""
def _emit_context(msg: str) -> None:
out = {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": msg}}
print(json.dumps(out))


def _hook_body() -> None:
try:
payload = json.load(sys.stdin) if not sys.stdin.isatty() else {}
except (json.JSONDecodeError, ValueError):
payload = {}
if not isinstance(payload, dict):
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)
Expand All @@ -421,8 +484,24 @@ def cmd_hook(args) -> int:
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))
_emit_context(msg)


def cmd_hook(args) -> int:
"""SessionStart entrypoint. Reads the hook payload on stdin, snapshots,
checks drift against the baseline, and emits SessionStart context.

A SessionStart hook must never block or break the session. Whatever goes
wrong (unreadable config, a bug, an OS error), emit a benign context and
exit 0 rather than dumping a traceback into the user's session.
"""
try:
_hook_body()
except Exception: # noqa: BLE001 -- last-resort guard: the session must start
_emit_context(
"AgenTrust: integrity check skipped this session (could not read the "
"agent configuration). Run /manifest verify to check manually."
)
return 0


Expand Down
69 changes: 69 additions & 0 deletions claude-code/tests/test_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""
from __future__ import annotations

import json
import sys
from pathlib import Path

Expand Down Expand Up @@ -119,3 +120,71 @@ def test_verify_detects_drift_introduced_after_baseline(tmp_path, monkeypatch, c
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


# ---------------------------------------------------------------------------
# Robustness: a SessionStart hook must never crash on malformed input.
# ---------------------------------------------------------------------------
def _setup_home(tmp_path, monkeypatch):
"""Point CLAUDE_HOME and ~ expansion at an isolated temp home."""
home = tmp_path / "home"
claude = home / ".claude"
claude.mkdir(parents=True)
monkeypatch.setattr(capture, "CLAUDE_HOME", claude)
monkeypatch.setattr(capture.os.path, "expanduser", lambda p: str(home))
return home, claude


def test_policy_tolerates_malformed_settings_json(tmp_path, monkeypatch):
_home, claude = _setup_home(tmp_path, monkeypatch)
(claude / "settings.json").write_text("{ not valid json ", encoding="utf-8")
policy_hash, allow = capture._policy()
# No exception; empty allow-list; hash still reflects the real (broken) bytes
# so a hand-edit is detected as drift rather than silently ignored.
assert allow == []
assert policy_hash.startswith("sha256:")
assert policy_hash != capture._sha_bytes(b"{}")


def test_policy_tolerates_nondict_permissions(tmp_path, monkeypatch):
_home, claude = _setup_home(tmp_path, monkeypatch)
(claude / "settings.json").write_text('{"permissions": "all"}', encoding="utf-8")
_hash, allow = capture._policy()
assert allow == []


def test_mcp_tolerates_malformed_and_misshaped_config(tmp_path, monkeypatch):
home, _claude = _setup_home(tmp_path, monkeypatch)
(home / ".claude.json").write_text('{"mcpServers": ["a", "b"]}', encoding="utf-8")
assert capture._mcp_from_config() == []
(home / ".claude.json").write_text("NOT JSON {", encoding="utf-8")
assert capture._mcp_from_config() == []


def test_skills_tolerates_file_where_dir_expected(tmp_path, monkeypatch):
_home, claude = _setup_home(tmp_path, monkeypatch)
(claude / "skills").write_text("i am a file, not a dir", encoding="utf-8")
assert capture._skills() == {}


def test_load_returns_none_on_corrupt_state(tmp_path):
p = tmp_path / "baseline.json"
p.write_text('{ "captured_at": "2026" ', encoding="utf-8") # truncated
assert capture._load(p) is None # treated as absent -> next run re-establishes


def test_hook_never_crashes_and_exits_zero(tmp_path, monkeypatch, capsys):
"""Any failure inside the hook still yields valid SessionStart output and 0."""
_isolate_state(tmp_path, monkeypatch)

def boom(*a, **k):
raise RuntimeError("simulated failure deep in snapshot")

monkeypatch.setattr(capture, "snapshot", boom)
monkeypatch.setattr(capture.sys.stdin, "isatty", lambda: True)

assert capture.cmd_hook(_Args()) == 0
out = capsys.readouterr().out
payload = json.loads(out)
assert payload["hookSpecificOutput"]["hookEventName"] == "SessionStart"
assert "integrity check skipped" in payload["hookSpecificOutput"]["additionalContext"]