diff --git a/README.md b/README.md index b93e520..7ee037c 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Claude Code edits code -> Plugin tracks changes -> Isolated agent updates memory - **Isolated processing**: Agent runs in separate context window, doesn't consume main session tokens - **Marker-based updates**: Only modifies AUTO-MANAGED sections, preserves manual content - **Subtree support**: Hierarchical CLAUDE.md for monorepos +- **Opt-in receipts**: Write privacy-safe JSONL proof that memory refreshes were requested/completed without logging raw paths or memory content ## Installation @@ -167,6 +168,19 @@ CLAUDE.md updated - **Agent**: Runs in isolated context window - doesn't consume main session tokens - **Skills**: Progressive disclosure - load only when invoked +### Privacy-safe receipts (optional) + +Teams that need auditability can enable opt-in receipts in `.claude/auto-memory/config.json`: + +```json +{ + "receipts": true, + "receiptHmacKeyEnv": "AUTO_MEMORY_RECEIPT_HMAC_KEY" +} +``` + +When enabled, the Stop/SubagentStop hooks append JSONL records to `.claude/auto-memory/receipts.jsonl` for `auto_memory.update.requested` and `auto_memory.update.completed`. Receipts include counts, trigger mode, active memory file, auto-commit/push outcome, and privacy flags. For stable shareable identifiers, set `AUTO_MEMORY_RECEIPT_HMAC_KEY` (or the env var named by `receiptHmacKeyEnv`) so session/file identifiers are keyed with short HMAC-SHA256 values. If no key is set, receipts still write counts and outcomes but omit session/file hashes instead of falling back to guessable raw SHA-256 hashes. They intentionally set `raw_paths_included=false` and `raw_memory_included=false`; raw file paths, commit messages, prompts, and CLAUDE.md/AGENTS.md content are not logged. + ## CLAUDE.md Format Auto-managed sections use HTML comment markers: diff --git a/scripts/trigger.py b/scripts/trigger.py index 8fabe2e..e402b1d 100644 --- a/scripts/trigger.py +++ b/scripts/trigger.py @@ -13,11 +13,14 @@ from __future__ import annotations +import hashlib +import hmac import json import os import subprocess import sys import time +from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -69,6 +72,72 @@ def dirty_file_path(project_dir: str, session_id: str = "") -> Path: return base / "dirty-files" +def receipt_file_path(project_dir: str) -> Path: + """Return the privacy-safe receipt log path.""" + return Path(project_dir) / ".claude" / "auto-memory" / "receipts.jsonl" + + +def receipt_hmac_key(config: dict[str, Any]) -> str | None: + """Return the configured HMAC key for shareable receipt hashes, if present.""" + env_name = config.get("receiptHmacKeyEnv", "AUTO_MEMORY_RECEIPT_HMAC_KEY") + if not isinstance(env_name, str) or not env_name: + env_name = "AUTO_MEMORY_RECEIPT_HMAC_KEY" + key = os.environ.get(env_name) + return key if key else None + + +def receipt_hash(value: str, key: str | None) -> str | None: + """Return a short keyed hash for receipt fields, or None when no key is set.""" + if not key: + return None + return hmac.new(key.encode("utf-8"), value.encode("utf-8"), hashlib.sha256).hexdigest()[:16] + + +def write_receipt( + project_dir: str, + event: str, + session_id: str, + files: list[str], + config: dict[str, Any], + extra: dict[str, Any] | None = None, +) -> None: + """Append a privacy-safe auto-memory receipt when enabled. + + Receipts are intentionally opt-in and never include raw file paths, + commit messages, prompts, or memory-file contents. They are useful for + teams that want proof that auto-memory requested or completed a memory + refresh without leaking the changed-file list into logs. + """ + if not config.get("receipts", False): + return + + path = receipt_file_path(project_dir) + path.parent.mkdir(parents=True, exist_ok=True) + active = get_active_memory_file(get_memory_files(config)) + key = receipt_hmac_key(config) + hashed_files = [receipt_hash(f, key) for f in sorted(files)] if key else [] + receipt: dict[str, Any] = { + "schema": "auto-memory.receipt.v1", + "event": event, + "timestamp": datetime.now(timezone.utc).isoformat(), + "session_id_hash": receipt_hash(session_id, key) if session_id else None, + "trigger_mode": config.get("triggerMode", "default"), + "active_memory_file": active, + "changed_file_count": len(files), + "changed_file_hashes": hashed_files, + "hash_algorithm": "hmac-sha256-16" if key else "none", + "hmac_key_env": config.get("receiptHmacKeyEnv", "AUTO_MEMORY_RECEIPT_HMAC_KEY"), + "hashes_omitted_reason": None if key else "set AUTO_MEMORY_RECEIPT_HMAC_KEY or receiptHmacKeyEnv for shareable stable hashes", + "raw_paths_included": False, + "raw_memory_included": False, + } + if extra: + receipt.update(extra) + + with open(path, "a", encoding="utf-8") as f: + f.write(json.dumps(receipt, sort_keys=True) + "\n") + + def read_dirty_files(project_dir: str, session_id: str = "") -> list[str]: """Read and deduplicate dirty files, stripping commit context. @@ -133,6 +202,8 @@ def handle_stop(input_data: dict[str, Any], project_dir: str) -> None: # (which means a commit happened but the agent hasn't run yet) _ = trigger_mode # Used for future mode-specific logic + write_receipt(project_dir, "auto_memory.update.requested", session_id, files, config) + output = { "decision": "block", "reason": build_spawn_reason(files, config), @@ -243,10 +314,29 @@ def handle_subagent_stop(input_data: dict[str, Any], project_dir: str) -> None: # Auto-commit/push before clearing dirty files config = load_config(project_dir) - if config.get("autoCommit", False): - if auto_commit_memory_files(project_dir, config): - if config.get("autoPush", False): - auto_push(project_dir) + auto_commit_attempted = bool(config.get("autoCommit", False)) + auto_commit_succeeded = False + auto_push_attempted = False + auto_push_succeeded = False + if auto_commit_attempted: + auto_commit_succeeded = auto_commit_memory_files(project_dir, config) + if auto_commit_succeeded and config.get("autoPush", False): + auto_push_attempted = True + auto_push_succeeded = auto_push(project_dir) + + write_receipt( + project_dir, + "auto_memory.update.completed", + session_id, + files, + config, + { + "auto_commit_attempted": auto_commit_attempted, + "auto_commit_succeeded": auto_commit_succeeded, + "auto_push_attempted": auto_push_attempted, + "auto_push_succeeded": auto_push_succeeded, + }, + ) clear_dirty_files(project_dir, session_id) cleanup_stale_session_files(project_dir) diff --git a/tests/test_trigger.py b/tests/test_trigger.py index f3c6cc7..caf28ff 100644 --- a/tests/test_trigger.py +++ b/tests/test_trigger.py @@ -1084,3 +1084,62 @@ def test_returns_false_when_not_git_repo(self, tmp_path): """Returns False when not in a git repo.""" result = trigger.auto_push(str(tmp_path)) assert result is False + + +class TestReceipts: + """Tests for opt-in privacy-safe receipt logging.""" + + def test_write_receipt_disabled_by_default(self, tmp_path): + """Receipt log is not created unless receipts=true is configured.""" + trigger.write_receipt( + str(tmp_path), + "auto_memory.update.requested", + "sess-001", + ["/repo/src/private.py"], + {"triggerMode": "default"}, + ) + + assert not trigger.receipt_file_path(str(tmp_path)).exists() + + def test_write_receipt_hashes_paths_without_raw_values(self, tmp_path): + """Receipt log stores hashes/counts, not raw file paths or memory bodies.""" + raw_path = "/repo/src/customer-secret-flow.py" + trigger.write_receipt( + str(tmp_path), + "auto_memory.update.requested", + "sess-001", + [raw_path], + {"triggerMode": "gitmode", "memoryFiles": ["AGENTS.md"], "receipts": True}, + ) + + receipt_path = trigger.receipt_file_path(str(tmp_path)) + line = receipt_path.read_text() + receipt = json.loads(line) + + assert receipt["schema"] == "auto-memory.receipt.v1" + assert receipt["event"] == "auto_memory.update.requested" + assert receipt["trigger_mode"] == "gitmode" + assert receipt["active_memory_file"] == "AGENTS.md" + assert receipt["changed_file_count"] == 1 + assert receipt["raw_paths_included"] is False + assert receipt["raw_memory_included"] is False + assert receipt["changed_file_hashes"] == [trigger.sha256_short(raw_path)] + assert "customer-secret-flow.py" not in line + assert "sess-001" not in line + + def test_handle_subagent_stop_writes_completed_receipt(self, tmp_path): + """SubagentStop appends completion receipt before clearing dirty files.""" + config_dir = tmp_path / ".claude" / "auto-memory" + config_dir.mkdir(parents=True) + (config_dir / "config.json").write_text(json.dumps({"receipts": True})) + (config_dir / "dirty-files-sess-001").write_text("/repo/src/a.py\n") + + trigger.handle_subagent_stop({"session_id": "sess-001"}, str(tmp_path)) + + receipt_path = trigger.receipt_file_path(str(tmp_path)) + receipt = json.loads(receipt_path.read_text().splitlines()[-1]) + assert receipt["event"] == "auto_memory.update.completed" + assert receipt["changed_file_count"] == 1 + assert receipt["auto_commit_attempted"] is False + assert receipt["raw_paths_included"] is False + assert (config_dir / "dirty-files-sess-001").read_text() == ""