From f97b1b69e467a8ad9894aad5f7252a1b663da9e0 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Thu, 16 Jul 2026 09:25:23 -0700 Subject: [PATCH 1/2] fix(marketplace): add repo-root marketplace.json so the plugin can be installed The claude-code README documents `/plugin marketplace add agentrust-io/integrations` + `/plugin install agentrust-claude-code`, but no `.claude-plugin/marketplace.json` existed at the repo root, so both commands failed and nobody could install the plugin. Adds the manifest listing the single plugin (source ./claude-code). Passes `claude plugin validate .`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude-plugin/marketplace.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .claude-plugin/marketplace.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..21571cc --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -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" + } + ] +} From 5245aff87808d2b2f86b805f452b557435b376d4 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Thu, 16 Jul 2026 09:25:24 -0700 Subject: [PATCH 2/2] fix(claude-code): the SessionStart hook must never crash the session Hardens the capture engine so malformed on-disk state degrades gracefully instead of dumping a traceback at session start: - _policy: tolerate malformed settings.json and a non-dict permissions block; still hash the real file bytes so hand-edits are detected as drift. - _mcp_from_config: tolerate malformed or misshaped ~/.claude.json (e.g. mcpServers as a list). - _skills: tolerate ~/.claude/skills being a file, and unreadable SKILL.md. - _load: treat a corrupt baseline/latest as absent so a truncated baseline.json (crash mid-write, disk full, racing sessions) self-heals on the next run instead of bricking every future session. - cmd_hook: wrap the body in a last-resort guard that always emits valid SessionStart output and exits 0. - sign_all: a clear "pip install -r requirements.txt" message when the crypto packages are absent, instead of a raw ModuleNotFoundError. Adds 6 regression tests (13 total). Verified: every crash vector now exits 0 with a benign message, a corrupt baseline self-heals, drift detection still fires, and the full 0.3.0 signing path still passes the TRACE suite. Co-Authored-By: Claude Opus 4.8 (1M context) --- claude-code/engine/capture.py | 127 ++++++++++++++++++++++++------ claude-code/tests/test_capture.py | 69 ++++++++++++++++ 2 files changed, 172 insertions(+), 24 deletions(-) diff --git a/claude-code/engine/capture.py b/claude-code/engine/capture.py index acf4247..962562d 100644 --- a/claude-code/engine/capture.py +++ b/claude-code/engine/capture.py @@ -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) @@ -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) @@ -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: @@ -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) @@ -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 diff --git a/claude-code/tests/test_capture.py b/claude-code/tests/test_capture.py index 7fc1a4a..07c1587 100644 --- a/claude-code/tests/test_capture.py +++ b/claude-code/tests/test_capture.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +import json import sys from pathlib import Path @@ -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"]