From 28d58728cffbf1ddc86c7823273c89b3b8f8689d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 13 Aug 2026 02:08:18 +0000 Subject: [PATCH] split machine-specific stores into mask hooks --- tools/mask-hooks.d/officer-chat-events | 66 ++++++++++++++++++++++++ tools/mask-secret.py | 70 ++++++++++++-------------- user/CLAUDE.md | 15 ++++-- 3 files changed, 109 insertions(+), 42 deletions(-) create mode 100755 tools/mask-hooks.d/officer-chat-events diff --git a/tools/mask-hooks.d/officer-chat-events b/tools/mask-hooks.d/officer-chat-events new file mode 100755 index 0000000..7d88819 --- /dev/null +++ b/tools/mask-hooks.d/officer-chat-events @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Officer platform: mask a secret in `chat_session_events`. + +SPECIFIC TO THIS MACHINE. Officer's sidecar commits every turn — assistant text, +tool inputs and tool outputs — to Postgres, so the web chat can replay a session +after a restart. That makes it a second readable copy of the conversation, and +the one a browser shows. Rows age out after 7 days (`api/chat/retention.ts`); +this masks them now rather than waiting. + +Contract: secret on stdin line 1, mask on line 2. Print one line. Exit non-zero +only if the store exists but could not be updated. +""" + +import os +import subprocess +import sys + +DB = os.environ.get("OFFICER_PGDB", "officer_dev") +HOST = os.environ.get("OFFICER_PGHOST", "127.0.0.1") +USER = os.environ.get("OFFICER_PGUSER", "postgres") +PASSWORD = os.environ.get("OFFICER_PGPASSWORD", "postgres") + + +def main() -> int: + lines = sys.stdin.read().splitlines() + if len(lines) < 2: + print("bad input: expected secret then mask") + return 2 + secret, mask = lines[0], lines[1] + + quote = lambda v: "'" + v.replace("'", "''") + "'" + stmt = ( + "update chat_session_events " + f"set event = replace(event::text, {quote(secret)}, {quote(mask)})::jsonb " + f"where event::text like {quote('%' + secret + '%')};" + ) + + try: + out = subprocess.run( + ["psql", "-h", HOST, "-U", USER, "-d", DB, "-tAc", stmt], + capture_output=True, text=True, timeout=30, + env={**os.environ, "PGPASSWORD": PASSWORD}, + ) + except FileNotFoundError: + print("skipped: psql not installed") + return 0 + except subprocess.TimeoutExpired: + print("skipped: psql timed out") + return 0 + + if out.returncode != 0: + err = out.stderr.strip().splitlines() + # Not this machine, or the platform is not installed here. Not a failure. + if any("does not exist" in e for e in err): + print("skipped: no officer database here") + return 0 + print(f"FAILED: {err[0][:100] if err else 'unknown error'}") + return 1 + + count = out.stdout.strip().split()[-1] if out.stdout.strip() else "0" + print(f"chat_session_events: {count} row(s) masked") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/mask-secret.py b/tools/mask-secret.py index 4431a9d..d473ee5 100755 --- a/tools/mask-secret.py +++ b/tools/mask-secret.py @@ -12,9 +12,14 @@ session is appending to its transcript while this runs, so the file is opened the writer never notices. A `sed -i` would swap the inode out and the running session's appends would vanish into an unlinked file. -Covers both stores a human can actually read back: - - Claude Code transcripts ~/.claude/projects/**/*.jsonl - - Postgres chat_session_events (what the web chat replays) +WHAT THIS OWNS: Claude Code's own stores, on any machine — +`~/.claude/projects/**/*.jsonl`. Nothing else is universal. + +ANYTHING ELSE IS A HOOK. A machine or platform that keeps its own copy of the +chat (a database, a log shipper, a mirror) drops an executable into +`mask-hooks.d/` beside this file, or wherever `MASK_HOOKS_DIR` points. Each hook +receives two lines on stdin — the secret, then the mask — and prints one line +saying what it did. Hooks are specific to a place; this file stays general. One paste lands in a transcript about three times — the `user` message, a `queue-operation` record, and a rolling `last-prompt` cache — plus once more for @@ -27,10 +32,8 @@ import subprocess import sys from pathlib import Path -PROJECTS = Path.home() / ".claude" / "projects" -PGDB = os.environ.get("MASK_PGDB", "officer_dev") -PGHOST = os.environ.get("MASK_PGHOST", "127.0.0.1") -PGUSER = os.environ.get("MASK_PGUSER", "postgres") +PROJECTS = Path(os.environ.get("MASK_PROJECTS_DIR", Path.home() / ".claude" / "projects")) +HOOKS_DIR = Path(os.environ.get("MASK_HOOKS_DIR", Path(__file__).resolve().parent / "mask-hooks.d")) def mask_file(path: Path, secret: bytes, mask: bytes) -> int: @@ -50,29 +53,27 @@ def mask_file(path: Path, secret: bytes, mask: bytes) -> int: return 0 -def mask_postgres(secret: str, mask: str) -> int: - """Replace inside the jsonb payload. Returns rows updated, -1 if unavailable.""" - sql = ( - "update chat_session_events set event = " - "replace(event::text, %(s)s, %(m)s)::jsonb " - "where event::text like %(l)s;" - ) - # psql has no bind params, so quote by doubling any single quotes. The secret - # is hex/base64 in practice, but never build SQL from unchecked input blindly. - q = lambda v: "'" + v.replace("'", "''") + "'" - stmt = sql.replace("%(s)s", q(secret)).replace("%(m)s", q(mask)).replace("%(l)s", q(f"%{secret}%")) - env = {**os.environ, "PGPASSWORD": os.environ.get("MASK_PGPASSWORD", "postgres")} - try: - out = subprocess.run( - ["psql", "-h", PGHOST, "-U", PGUSER, "-d", PGDB, "-tAc", stmt], - capture_output=True, text=True, env=env, timeout=30, - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - return -1 - if out.returncode != 0: - print(f" ! postgres: {out.stderr.strip()[:120]}", file=sys.stderr) - return -1 - return int(out.stdout.strip().split()[-1]) if out.stdout.strip() else 0 +def run_hooks(secret: str, mask: str) -> None: + """Every executable in HOOKS_DIR gets secret+mask on stdin.""" + if not HOOKS_DIR.is_dir(): + return + hooks = sorted(h for h in HOOKS_DIR.iterdir() if h.is_file() and os.access(h, os.X_OK)) + if not hooks: + return + print(f"\nhooks ({HOOKS_DIR}):") + for hook in hooks: + try: + out = subprocess.run( + [str(hook)], input=f"{secret}\n{mask}\n", + capture_output=True, text=True, timeout=60, + ) + line = (out.stdout.strip() or out.stderr.strip() or "(no output)").splitlines()[0] + status = "" if out.returncode == 0 else f" [exit {out.returncode}]" + print(f" {hook.name}: {line}{status}") + except subprocess.TimeoutExpired: + print(f" {hook.name}: TIMED OUT") + except OSError as err: + print(f" {hook.name}: failed to run ({err})") def main() -> int: @@ -81,21 +82,16 @@ def main() -> int: print("refusing: secret under 12 chars would mask too much", file=sys.stderr) return 2 mask = "*" * len(secret) - sb, mb = secret.encode(), mask.encode() total = 0 for path in sorted(PROJECTS.rglob("*.jsonl")): - n = mask_file(path, sb, mb) + n = mask_file(path, secret.encode(), mask.encode()) if n: total += n print(f" {n:>3} {path}") print(f"transcripts: {total} occurrence(s) masked") - rows = mask_postgres(secret, mask) - if rows < 0: - print("postgres: skipped (psql unavailable or errored)") - else: - print(f"postgres: {rows} row(s) updated") + run_hooks(secret, mask) print("\nStill holding the real value, deliberately:") print(" - the tool's own config (that is how it authenticates)") diff --git a/user/CLAUDE.md b/user/CLAUDE.md index 736cf2b..c05f390 100644 --- a/user/CLAUDE.md +++ b/user/CLAUDE.md @@ -89,11 +89,16 @@ chat histories, and that is the copy that matters to him. printf '%s' '' | ~/projects/claude-mds/tools/mask-secret.py -The script covers both readable stores — Claude Code's `~/.claude/projects/**` -transcripts and Postgres `chat_session_events` — and replaces the value with -asterisks of the same length, overwriting in place so the live session's -appends are not lost. Read the header before changing it; the same-length, -same-inode detail is load-bearing. +It masks Claude Code's own transcripts (`~/.claude/projects/**/*.jsonl`) with +asterisks of the same length, overwriting in place so the live session's appends +are not lost. Read the header before changing it; the same-length, same-inode +detail is load-bearing. + +That is all this rule owns. **A machine that keeps its own second copy of the +chat — a database, a mirror, a log shipper — declares it as a hook** in +`tools/mask-hooks.d/`, and the script runs whatever it finds there. Do not add +machine-specific stores to this file; add a hook, and describe it in that +project's own `CLAUDE.md`. **Do not mask a throwaway.** He sometimes sets a real password aside, hands you a temporary one so you can drive an API — Nginx Proxy Manager, a router, a