From 81e0f1e9e79794f4eae190fb5ed20ed37e39f9dc Mon Sep 17 00:00:00 2001 From: cyre Date: Sun, 9 Aug 2026 15:07:36 +0800 Subject: [PATCH] fix(claude-code): redact home paths and raw content in episodic logging claude_code_post_tool.py persisted raw absolute paths and raw edit/write content into AGENT_LEARNINGS.jsonl -- a log meant to be shared, diffed, and exported via the data flywheel: - file_path values went into action/reflection unnormalized, baking the operator's home directory into every entry that touched a file. - Edit/MultiEdit reflection used repr(old[:30])/repr(new[:30]) -- literal content excerpts, not just that an edit happened. - The generic _reflection() fallback (for tool types not explicitly handled -- Read, Grep, Glob, WebFetch, etc.) embedded raw json.dumps(tool_input) whenever it was under 80 chars, which is backwards: a short absolute path is exactly the case most likely to leak in full. Adds _normalize_path(), reusing the hook's own existing AGENT_ROOT resolution (no new dependency, no new config) to normalize to project-relative or ~-relative form. Replaces content/diff previews with char counts. Fixes the _reflection() fallback to normalize paths before its length check instead of dumping raw JSON. tests/test_claude_code_hook.py: new TestNoRawContentOrPathsPersisted-style coverage (8 new checks, section 10b) asserting no raw home path or file content survives into a persisted entry, for both Edit and Write -- 62/62 passing, up from 54/54. Fixes #66 --- .agent/harness/hooks/claude_code_post_tool.py | 66 +++++++++++++++---- tests/test_claude_code_hook.py | 60 +++++++++++++++++ 2 files changed, 115 insertions(+), 11 deletions(-) diff --git a/.agent/harness/hooks/claude_code_post_tool.py b/.agent/harness/hooks/claude_code_post_tool.py index ba0633d..6387e88 100644 --- a/.agent/harness/hooks/claude_code_post_tool.py +++ b/.agent/harness/hooks/claude_code_post_tool.py @@ -34,6 +34,7 @@ # UP 3 = .agent/ HERE = os.path.dirname(os.path.abspath(__file__)) AGENT_ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) +PROJECT_ROOT = os.path.dirname(AGENT_ROOT) sys.path.insert(0, os.path.join(AGENT_ROOT, "harness")) sys.path.insert(0, os.path.join(AGENT_ROOT, "tools")) @@ -42,6 +43,29 @@ from hooks.on_failure import on_failure # noqa: E402 +def _normalize_path(value): + """Return a portable path label for anything persisted to episodic memory. + + Episodic entries are meant to be shared, diffed, and eventually exported + via the data flywheel — a raw absolute path bakes the operator's + username into every entry that touches a file. Normalize to a + project-relative or home-relative form instead. + """ + if not isinstance(value, str) or not value: + return "?" + expanded = os.path.abspath(os.path.expanduser(value)) + try: + rel = os.path.relpath(expanded, PROJECT_ROOT) + except ValueError: + rel = None + if rel is not None and not rel.startswith(".."): + return rel + home = os.path.expanduser("~") + if home and expanded.startswith(home + os.sep): + return "~" + expanded[len(home):] + return rel if rel is not None else "" + + # --------------------------------------------------------------------------- # Importance scoring # --------------------------------------------------------------------------- @@ -386,15 +410,15 @@ def _action_label(tool_name: str, tool_input: dict) -> str: or tool_input.get("path") or tool_input.get("new_path") or "?") - return f"edit: {path}" + return f"edit: {_normalize_path(path)}" if tool_name == "Write": path = tool_input.get("file_path") or tool_input.get("path") or "?" - return f"write: {path}" + return f"write: {_normalize_path(path)}" if tool_name == "Read": path = tool_input.get("file_path") or tool_input.get("path") or "?" - return f"read: {path}" + return f"read: {_normalize_path(path)}" if tool_name == "TodoWrite": todos = tool_input.get("todos", []) @@ -435,7 +459,11 @@ def _reflection(tool_name: str, tool_input: dict, 4. Keep under ~200 chars so detail field carries the rest. """ parts = [] - inp_str = json.dumps(tool_input) + _fallback_input = dict(tool_input) if isinstance(tool_input, dict) else {} + for _key in ("file_path", "path", "new_path"): + if isinstance(_fallback_input.get(_key), str): + _fallback_input[_key] = _normalize_path(_fallback_input[_key]) + inp_str = json.dumps(_fallback_input) # --- Bash --- if tool_name == "Bash": @@ -462,13 +490,12 @@ def _reflection(tool_name: str, tool_input: dict, # --- Edit --- elif tool_name in ("Edit", "MultiEdit"): - path = tool_input.get("file_path") or tool_input.get("path") or "?" - old = (tool_input.get("old_string") or "")[:50] - new = (tool_input.get("new_string") or "")[:50] + path = _normalize_path(tool_input.get("file_path") or tool_input.get("path") or "?") + old = tool_input.get("old_string") or "" + new = tool_input.get("new_string") or "" if old and new: parts.append( - f"Edited {path}: replaced {repr(old[:30])} " - f"with {repr(new[:30])}" + f"Edited {path}: {len(old)} chars -> {len(new)} chars" ) else: parts.append(f"Edited {path}") @@ -477,7 +504,7 @@ def _reflection(tool_name: str, tool_input: dict, # --- Write --- elif tool_name == "Write": - path = tool_input.get("file_path") or tool_input.get("path") or "?" + path = _normalize_path(tool_input.get("file_path") or tool_input.get("path") or "?") content = tool_input.get("content") or "" lines = content.count("\n") + 1 if content else 0 parts.append(f"Wrote {path} ({lines} lines)") @@ -521,9 +548,12 @@ def _detail(tool_name: str, tool_input: dict, """ Stored in `detail`. More verbose than reflection. Truncated to 500 chars by log_execution anyway. + + Persists normalized metadata only — never a raw dump of tool_input. + A raw dump embeds file contents, edit diffs, and absolute paths + verbatim into a log meant to be shared, diffed, and exported. """ output = _extract_output(tool_response) - inp_str = json.dumps(tool_input, separators=(",", ":"))[:300] if tool_name == "Bash": cmd = tool_input.get("command", "")[:120] @@ -533,6 +563,20 @@ def _detail(tool_name: str, tool_input: dict, out_snip = output[:200] if output else "" return f"cmd={cmd!r}" + (f" | out={out_snip}" if out_snip else "") + path = tool_input.get("file_path") or tool_input.get("path") + meta = {"tool": tool_name} + if path: + meta["path"] = _normalize_path(path) + content = tool_input.get("content") + if isinstance(content, str): + meta["content_chars"] = len(content) + old = tool_input.get("old_string") + new = tool_input.get("new_string") + if isinstance(old, str) or isinstance(new, str): + meta["old_string_chars"] = len(old or "") + meta["new_string_chars"] = len(new or "") + inp_str = json.dumps(meta, separators=(",", ":")) + return inp_str + (f" | {output[:150]}" if output else "") diff --git a/tests/test_claude_code_hook.py b/tests/test_claude_code_hook.py index 0f1b98c..e434430 100644 --- a/tests/test_claude_code_hook.py +++ b/tests/test_claude_code_hook.py @@ -340,6 +340,65 @@ def test_failure_write(mod): for label, passed in checks: (ok if passed else fail)(f" entry.{label}") +def test_no_raw_content_or_paths_persisted(mod): + section("10b. Privacy — no raw content, no raw absolute paths persisted") + + home_file = os.path.join(os.path.expanduser("~"), "supabase", "secrets.env") + payload = { + "tool_name": "Edit", + "tool_input": { + "file_path": home_file, + "old_string": "STRIPE_SECRET_KEY=sk_live_topsecretvalue12345", + "new_string": "STRIPE_SECRET_KEY=sk_live_rotatedvalue67890", + }, + "tool_response": {"output": "", "exit_code": 0, "error": ""}, + } + rc, entry, stderr = run_hook(payload) + if entry is None: + fail("no entry written for privacy-check Edit case") + return + ok("privacy-check Edit entry written") + + blob = json.dumps(entry) + checks = [ + ("no raw home directory in entry", + os.path.expanduser("~") not in blob), + ("action uses normalized path, not raw home path", + home_file not in entry.get("action", "")), + ("reflection does not contain old secret value", + "sk_live_topsecretvalue12345" not in blob), + ("reflection does not contain new secret value", + "sk_live_rotatedvalue67890" not in blob), + ("detail carries char counts, not the raw strings", + "old_string_chars" in entry.get("detail", "") + or "chars ->" in entry.get("reflection", "")), + ] + for label, passed in checks: + (ok if passed else fail)(f" entry.{label}") + + write_payload = { + "tool_name": "Write", + "tool_input": { + "file_path": os.path.join(os.path.expanduser("~"), "notes", "private.md"), + "content": "API_KEY=super-secret-value-should-not-leak\n", + }, + "tool_response": {"output": "", "exit_code": 0, "error": ""}, + } + rc, entry, stderr = run_hook(write_payload) + if entry is None: + fail("no entry written for privacy-check Write case") + return + blob = json.dumps(entry) + checks = [ + ("Write entry has no raw home path", + os.path.expanduser("~") not in blob), + ("Write entry does not contain file content", + "super-secret-value-should-not-leak" not in blob), + ] + for label, passed in checks: + (ok if passed else fail)(f" entry.{label}") + + def test_dream_cycle(): section("11. Dream cycle produces staged candidates from rich entries") # Use a universally high-stakes command so importance=9 / pain_score=5 @@ -509,6 +568,7 @@ def main(): test_reflection_non_empty(mod) test_full_write(mod) test_failure_write(mod) + test_no_raw_content_or_paths_persisted(mod) test_dream_cycle() test_memory_reflect_pain_flag() test_post_execution_pain_param()