Skip to content
Open
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
66 changes: 55 additions & 11 deletions .agent/harness/hooks/claude_code_post_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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 "<external>"


# ---------------------------------------------------------------------------
# Importance scoring
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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", [])
Expand Down Expand Up @@ -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":
Expand All @@ -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}")
Expand All @@ -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)")
Expand Down Expand Up @@ -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]
Expand All @@ -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 "")


Expand Down
60 changes: 60 additions & 0 deletions tests/test_claude_code_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down