From 835b57db68b2028353dc3cced19fa309eb13b86c Mon Sep 17 00:00:00 2001 From: Caio Ribeiro Date: Sat, 23 May 2026 18:02:46 +0000 Subject: [PATCH 1/2] feat: add privacy-safe auto-memory receipts --- README.md | 13 +++++++ scripts/trigger.py | 81 ++++++++++++++++++++++++++++++++++++++++--- tests/test_trigger.py | 59 +++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b93e520..b4de5ba 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,18 @@ 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 +} +``` + +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, short SHA-256 hashes, trigger mode, session hash, and auto-commit/push outcome. 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..dbd06b0 100644 --- a/scripts/trigger.py +++ b/scripts/trigger.py @@ -13,11 +13,13 @@ from __future__ import annotations +import hashlib 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 +71,56 @@ 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 sha256_short(value: str) -> str: + """Return a short SHA-256 hash for receipt fields.""" + return hashlib.sha256(value.encode("utf-8")).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)) + receipt: dict[str, Any] = { + "schema": "auto-memory.receipt.v1", + "event": event, + "timestamp": datetime.now(timezone.utc).isoformat(), + "session_id_hash": sha256_short(session_id) if session_id else None, + "trigger_mode": config.get("triggerMode", "default"), + "active_memory_file": active, + "changed_file_count": len(files), + "changed_file_hashes": [sha256_short(f) for f in sorted(files)], + "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 +185,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 +297,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() == "" From 5943376dff551ac5876e4298a3687acd6c90b5a0 Mon Sep 17 00:00:00 2001 From: Caio Ribeiro Date: Sat, 23 May 2026 20:04:18 +0000 Subject: [PATCH 2/2] fix: harden receipt hashes with HMAC --- README.md | 5 +++-- scripts/trigger.py | 27 ++++++++++++++++++++++----- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b4de5ba..7ee037c 100644 --- a/README.md +++ b/README.md @@ -174,11 +174,12 @@ Teams that need auditability can enable opt-in receipts in `.claude/auto-memory/ ```json { - "receipts": true + "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, short SHA-256 hashes, trigger mode, session hash, and auto-commit/push outcome. 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. +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 diff --git a/scripts/trigger.py b/scripts/trigger.py index dbd06b0..e402b1d 100644 --- a/scripts/trigger.py +++ b/scripts/trigger.py @@ -14,6 +14,7 @@ from __future__ import annotations import hashlib +import hmac import json import os import subprocess @@ -76,9 +77,20 @@ def receipt_file_path(project_dir: str) -> Path: return Path(project_dir) / ".claude" / "auto-memory" / "receipts.jsonl" -def sha256_short(value: str) -> str: - """Return a short SHA-256 hash for receipt fields.""" - return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] +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( @@ -102,15 +114,20 @@ def write_receipt( 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": sha256_short(session_id) if session_id else None, + "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": [sha256_short(f) for f in sorted(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, }